Skip to main content

laya/
lang.rs

1//! Dependency-free language/script detection used to route between Laya checkpoints.
2//!
3//! Faithful port of `laya/lang.py`. Routing needs one decision: *is this English Latin text, or
4//! is it something the English checkpoint cannot read?* Script detection is exact; the Latin-script
5//! language guess is a stopword/diacritic heuristic and is explicitly best-effort.
6//!
7//! This is the source-of-truth port of the Python module (which carries `bn`/`az` stopword lists
8//! and the `_non_latin_words` reclassification step that the older TypeScript reference dropped).
9
10use std::collections::{HashMap, HashSet};
11use std::sync::OnceLock;
12
13use indexmap::IndexMap;
14use regex::Regex;
15use serde_json::Value;
16use unicode_general_category::{get_general_category, GeneralCategory};
17
18// Unicode blocks that the English (ModernBERT-large, 50k English BPE) checkpoint cannot read.
19const SCRIPT_RANGES: &[(&str, &[(u32, u32)])] = &[
20    ("greek", &[(0x0370, 0x03FF), (0x1F00, 0x1FFF)]),
21    (
22        "cyrillic",
23        &[(0x0400, 0x052F), (0x2DE0, 0x2DFF), (0xA640, 0xA69F)],
24    ),
25    ("armenian", &[(0x0530, 0x058F)]),
26    ("hebrew", &[(0x0590, 0x05FF)]),
27    (
28        "arabic",
29        &[
30            (0x0600, 0x06FF),
31            (0x0750, 0x077F),
32            (0x08A0, 0x08FF),
33            (0xFB50, 0xFDFF),
34            (0xFE70, 0xFEFF),
35        ],
36    ),
37    ("devanagari", &[(0x0900, 0x097F), (0xA8E0, 0xA8FF)]),
38    ("bengali", &[(0x0980, 0x09FF)]),
39    ("gurmukhi", &[(0x0A00, 0x0A7F)]),
40    ("gujarati", &[(0x0A80, 0x0AFF)]),
41    ("oriya", &[(0x0B00, 0x0B7F)]),
42    ("tamil", &[(0x0B80, 0x0BFF)]),
43    ("telugu", &[(0x0C00, 0x0C7F)]),
44    ("kannada", &[(0x0C80, 0x0CFF)]),
45    ("malayalam", &[(0x0D00, 0x0D7F)]),
46    ("sinhala", &[(0x0D80, 0x0DFF)]),
47    ("thai", &[(0x0E00, 0x0E7F)]),
48    ("lao", &[(0x0E80, 0x0EFF)]),
49    ("tibetan", &[(0x0F00, 0x0FFF)]),
50    ("myanmar", &[(0x1000, 0x109F)]),
51    ("georgian", &[(0x10A0, 0x10FF)]),
52    ("ethiopic", &[(0x1200, 0x137F)]),
53    ("khmer", &[(0x1780, 0x17FF)]),
54    (
55        "hangul",
56        &[(0x1100, 0x11FF), (0x3130, 0x318F), (0xAC00, 0xD7AF)],
57    ),
58    (
59        "kana",
60        &[(0x3040, 0x309F), (0x30A0, 0x30FF), (0x31F0, 0x31FF)],
61    ),
62    (
63        "han",
64        &[(0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF)],
65    ),
66];
67
68/// A diacritic rate above this is taken as evidence the text is not English.
69pub const NON_EN_DIACRITIC_RATE: f64 = 0.02;
70
71/// Non-Latin text is not for the English checkpoint even when Latin letters are the plurality.
72pub const NON_LATIN_FRACTION: f64 = 0.2;
73pub const NON_LATIN_MIN_FRACTION: f64 = 0.1;
74pub const NON_LATIN_MIN_LETTERS: i64 = 10;
75
76/// Function words per language, in the exact insertion order of the Python `_STOP` dict.
77fn stop() -> &'static IndexMap<&'static str, HashSet<&'static str>> {
78    static S: OnceLock<IndexMap<&'static str, HashSet<&'static str>>> = OnceLock::new();
79    S.get_or_init(|| {
80        let mut m: IndexMap<&'static str, HashSet<&'static str>> = IndexMap::new();
81        m.insert(
82            "en",
83            [
84                "the", "and", "is", "are", "was", "were", "to", "of", "in", "for", "with", "that",
85                "this", "it", "you", "have", "has", "not", "but", "on", "at", "be", "as", "from",
86                "will", "can", "would", "there", "their", "what", "which", "please", "we", "i",
87            ]
88            .into_iter()
89            .collect(),
90        );
91        m.insert(
92            "fr",
93            [
94                "le", "la", "les", "des", "une", "est", "pour", "dans", "que", "qui", "avec",
95                "sur", "pas", "plus", "nous", "vous", "être", "cette", "mais", "sont", "ont",
96                "aux", "ce", "et", "du", "au", "ou", "je", "tu", "il", "elle", "ils", "elles",
97                "mon", "ton", "ma", "ta", "sa", "mes", "tes", "ses", "ces", "deux", "trois",
98                "très", "bien", "tout", "tous", "toute", "fait", "veux", "veut", "peux", "peut",
99                "dois", "doit", "merci", "bonjour", "jour", "jours", "mois", "fois", "quand",
100                "comment", "pourquoi", "alors", "donc",
101            ]
102            .into_iter()
103            .collect(),
104        );
105        m.insert(
106            "de",
107            [
108                "der", "die", "das", "und", "ist", "ein", "eine", "den", "dem", "nicht", "mit",
109                "für", "auf", "von", "zu", "sich", "auch", "werden", "wurde", "haben", "sind",
110                "oder", "aber", "ich", "wir", "mir", "mich", "dir", "dich", "uns", "mein", "meine",
111                "meinen", "meinem", "meiner", "diese", "dieser", "diesen", "dieses", "einen",
112                "einem", "einer", "wie", "wo", "wann", "welche", "im", "zum", "zur", "aus", "bei",
113                "nach", "noch", "bitte", "heute", "jetzt", "kann", "kannst", "habe", "gibt",
114                "wird",
115                // shared with English on purpose: counted for English alone, they outvoted short German
116                "in", "was",
117            ]
118            .into_iter()
119            .collect(),
120        );
121        m.insert(
122            "es",
123            [
124                "el", "los", "las", "que", "por", "con", "para", "una", "es", "se", "del", "como",
125                "pero", "son", "está", "este", "esta", "todo", "más", "muy", "hay", "sus", "la",
126                "un", "y", "al", "lo", "le", "les", "su", "mi", "tu", "nos", "ni", "dos", "tres",
127                "fue", "fueron", "ser", "tiene", "tienen", "tengo", "puede", "pueden", "quiero",
128                "necesito", "hemos", "han", "sobre", "entre", "cuando", "donde", "porque",
129                "aunque", "también", "ya", "eso", "esto", "esa", "ese", "nada", "algo", "aquí",
130                "hoy", "gracias",
131            ]
132            .into_iter()
133            .collect(),
134        );
135        m.insert(
136            "pt",
137            [
138                "os", "as", "que", "em", "um", "uma", "para", "com", "não", "é", "se", "do", "da",
139                "dos", "das", "mas", "são", "está", "este", "esta", "muito", "pelo", "pela", "o",
140                "e", "na", "nas", "nos", "ao", "aos", "por", "foi", "era", "ser", "sou", "tem",
141                "tenho", "pode", "podem", "quero", "preciso", "eu", "meu", "minha", "seu", "sua",
142                "isso", "isto", "aqui", "ali", "como", "quando", "onde", "porque", "mais", "já",
143                "ainda", "agora", "hoje", "ontem", "dois", "três", "tudo", "nada", "obrigado",
144                "olá", "você", "vocês", "voce", "voces", "vc", "vcs", "nao", "sao", "ja", "até",
145                "tá", "pra", "gostaria", "obrigada", "também", "tambem", "estou", "estamos",
146                "meus", "minhas", "nosso", "nossa", "consigo", "cadê", "boa", "tarde", "noite",
147                "depois", "antes", "então", "entao", "ninguém", "ninguem", "alguém", "alguem",
148                "nenhum", "nenhuma", "estava", "ficou", "fiz", "deu",
149            ]
150            .into_iter()
151            .collect(),
152        );
153        m.insert(
154            "it",
155            [
156                "il", "lo", "gli", "che", "di", "per", "con", "non", "è", "si", "del", "della",
157                "sono", "questo", "questa", "anche", "come", "più", "sono", "nella", "alla", "la",
158                "le", "un", "uno", "una", "e", "ed", "o", "da", "su", "tra", "fra", "mi", "ci",
159                "ne", "ho", "hai", "ha", "abbiamo", "avete", "hanno", "era", "stato", "stata",
160                "devo", "deve", "devono", "voglio", "vorrei", "mio", "mia", "tuo", "sua", "quando",
161                "dove", "perche", "molto", "poco", "sempre", "mai", "già", "ancora", "adesso",
162                "oggi", "ieri", "grazie", "ciao", "scusa", "nel", "nell", "negli", "sul", "sulla",
163                "sulle", "dal", "dalla", "dallo", "dagli", "dei", "delle", "dello", "degli",
164                "agli", "alle", "col",
165            ]
166            .into_iter()
167            .collect(),
168        );
169        m.insert(
170            "nl",
171            [
172                "het", "een", "van", "is", "op", "te", "dat", "niet", "met", "voor", "zijn", "aan",
173                "door", "maar", "ook", "worden", "deze", "naar", "wordt",
174            ]
175            .into_iter()
176            .collect(),
177        );
178        m.insert(
179            "ro",
180            [
181                "și", "să", "este", "sunt", "care", "pentru", "din", "dar", "după", "până", "fără",
182                "ale", "lui", "în", "fost", "acum", "vreau", "trebuie", "foarte", "acest",
183                "această", "acesta", "aceasta", "mi", "ți", "vă", "nu",
184            ]
185            .into_iter()
186            .collect(),
187        );
188        m.insert(
189            "bn",
190            [
191                "ami",
192                "amar",
193                "amake",
194                "amra",
195                "amader",
196                "apni",
197                "apnar",
198                "apnake",
199                "apnara",
200                "tumi",
201                "tomar",
202                "tomake",
203                "tomra",
204                "tader",
205                "ota",
206                "eita",
207                "oita",
208                "ekta",
209                "ei",
210                "oi",
211                "ki",
212                "keno",
213                "kivabe",
214                "kibhabe",
215                "kothay",
216                "kokhon",
217                "kobe",
218                "koto",
219                "kintu",
220                "jodi",
221                "tahole",
222                "ar",
223                "theke",
224                "jonno",
225                "sathe",
226                "shathe",
227                "diye",
228                "niye",
229                "moddhe",
230                "kore",
231                "korte",
232                "korchi",
233                "korsi",
234                "korbo",
235                "korechi",
236                "koreche",
237                "korun",
238                "koren",
239                "korlam",
240                "hobe",
241                "hoyeche",
242                "hoise",
243                "hocche",
244                "hoyni",
245                "chai",
246                "chaina",
247                "lagbe",
248                "parchi",
249                "parbo",
250                "parchina",
251                "peyechi",
252                "paini",
253                "dite",
254                "dilam",
255                "diyechi",
256                "nai",
257                "khub",
258                "onek",
259                "ekhon",
260                "akhon",
261                "ekhono",
262                "abar",
263                "ekbar",
264                "duibar",
265                "ajke",
266                "kalke",
267                "taka",
268                "bhalo",
269                "valo",
270                "kharap",
271                "shomossa",
272                "somossa",
273                "dhonnobad",
274                "bhai",
275                "shob",
276                "keu",
277                "kichu",
278                "bolte",
279                "bolun",
280                "parben",
281                "asbe",
282                "jabe",
283                "pabo",
284                "ferot",
285                "dorkar",
286                "hoye",
287                "geche",
288                "gese",
289            ]
290            .into_iter()
291            .collect(),
292        );
293        m.insert(
294            "az",
295            [
296                "və", "ve", "bir", "bu", "üçün", "ucun", "ilə", "ile", "olan", "olub", "olmasa",
297                "var", "yox", "yoxdur", "mən", "sən", "biz", "siz", "onlar", "daha", "çox", "cox",
298                "hər", "nə", "kimi", "görə", "sonra", "əgər", "eger", "deyil", "lakin", "amma",
299                "ancaq", "artıq", "artiq", "də", "isə", "həm", "yalnız", "yalniz",
300            ]
301            .into_iter()
302            .collect(),
303        );
304        m
305    })
306}
307
308// Letters that ordinary English does not use.
309const DIACRITICS: &str = concat!(
310    "àâäãáåçéèêëíìîïñóòôöõøúùûüýÿßæœ", // Western European
311    "ăâîșțşţ",                         // Romanian
312    "ąćęłńśźż",                        // Polish
313    "čďěňřšťůž",                       // Czech / Slovak
314    "őű",                              // Hungarian
315    "ğı",                              // Turkish (text is lowercased before matching)
316    "āēģīķļņūž",                       // Baltic
317    "đ",                               // Serbo-Croatian / Vietnamese
318    "ə",                               // Azerbaijani
319);
320
321fn non_en_diacritics() -> &'static HashSet<char> {
322    static S: OnceLock<HashSet<char>> = OnceLock::new();
323    S.get_or_init(|| DIACRITICS.chars().collect())
324}
325
326/// Words that more than one list claims.
327fn shared_words() -> &'static HashSet<&'static str> {
328    static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
329    S.get_or_init(|| {
330        let mut counts: HashMap<&'static str, usize> = HashMap::new();
331        for words in stop().values() {
332            for w in words {
333                *counts.entry(*w).or_insert(0) += 1;
334            }
335        }
336        counts
337            .into_iter()
338            .filter(|(_, n)| *n > 1)
339            .map(|(w, _)| w)
340            .collect()
341    })
342}
343
344fn word_re() -> &'static Regex {
345    static R: OnceLock<Regex> = OnceLock::new();
346    R.get_or_init(|| Regex::new(r"[^\W\d_]+").unwrap())
347}
348
349// A token whose dot or @ joins word characters is an identifier, not prose. Upstream added a
350// `(?<![\w-])` look-behind for a ReDoS fix; the `regex` crate is already linear-time and lacks
351// look-behind, and the look-behind removes no match (a leftmost match can only begin at a run
352// start, since `[\w-]` and `[.@]` are disjoint), so the match set here is identical.
353fn identifier_re() -> &'static Regex {
354    static R: OnceLock<Regex> = OnceLock::new();
355    R.get_or_init(|| Regex::new(r"[\w-]*(?:[.@][\w-]+)+").unwrap())
356}
357
358/// A combining mark (Python `unicodedata.combining(ch) != 0`): Mn / Mc / Me.
359fn is_combining(ch: char) -> bool {
360    matches!(
361        get_general_category(ch),
362        GeneralCategory::NonspacingMark
363            | GeneralCategory::SpacingMark
364            | GeneralCategory::EnclosingMark
365    )
366}
367
368/// `detect_script` / `script_profile` Latin test (`cp < 0x02B0`).
369fn is_latin_detect(cp: u32) -> bool {
370    cp < 0x02B0
371        || (0x1E00..=0x1EFF).contains(&cp)
372        || (0xFF21..=0xFF3A).contains(&cp)
373        || (0xFF41..=0xFF5A).contains(&cp)
374}
375
376/// `_script_of` Latin test (`cp < 0x0250` — deliberately distinct from `detect_script`).
377fn is_latin_script_of(cp: u32) -> bool {
378    cp < 0x0250
379        || (0x1E00..=0x1EFF).contains(&cp)
380        || (0xFF21..=0xFF3A).contains(&cp)
381        || (0xFF41..=0xFF5A).contains(&cp)
382}
383
384fn in_ranges(cp: u32, ranges: &[(u32, u32)]) -> bool {
385    ranges.iter().any(|&(lo, hi)| lo <= cp && cp <= hi)
386}
387
388/// Round to 4 decimals, half-to-even (Python `round(x, 4)`).
389fn round4(x: f64) -> f64 {
390    round_half_even(x * 10000.0) / 10000.0
391}
392
393/// Round to the nearest integer, half-to-even (Python `round(x)`); valid for `x >= 0`.
394fn round_half_even(x: f64) -> f64 {
395    let floor = x.floor();
396    let diff = x - floor;
397    if diff < 0.5 {
398        floor
399    } else if diff > 0.5 {
400        floor + 1.0
401    } else {
402        // exactly .5 -> round to even
403        if (floor as i64) % 2 == 0 {
404            floor
405        } else {
406            floor + 1.0
407        }
408    }
409}
410
411// --- text flattening -------------------------------------------------------
412
413fn iter_text(v: &Value, depth: usize, out: &mut Vec<String>) {
414    if depth > 6 {
415        return;
416    }
417    match v {
418        Value::Null => {}
419        Value::String(s) => out.push(s.clone()),
420        Value::Array(a) => {
421            for x in a {
422                iter_text(x, depth + 1, out);
423            }
424        }
425        Value::Object(m) => {
426            for x in m.values() {
427                iter_text(x, depth + 1, out);
428            }
429        }
430        _ => {}
431    }
432}
433
434/// Flatten a state into the text used for detection (keys are ignored). `max_chars` default 4000.
435pub fn state_text(state: &Value, max_chars: usize) -> String {
436    let mut leaves: Vec<String> = Vec::new();
437    iter_text(state, 0, &mut leaves);
438    // Bound the join work before truncation: only `max_chars` are ever returned, so stop
439    // materializing leaves once the budget is spent (parity with upstream `state_text`).
440    let mut parts: Vec<String> = Vec::new();
441    let mut budget = max_chars as isize;
442    for leaf in leaves {
443        if budget <= 0 {
444            break;
445        }
446        let leaf_len = leaf.chars().count() as isize;
447        if leaf_len > budget {
448            parts.push(leaf.chars().take(budget as usize).collect());
449            break;
450        }
451        parts.push(leaf);
452        // Account for the joining space without materializing the full text first.
453        budget -= leaf_len + 1;
454    }
455    parts.join(" ").chars().take(max_chars).collect()
456}
457
458// --- script detection ------------------------------------------------------
459
460/// Dominant script of `text`: `"latin"`, `"han"`, ... or `"unknown"` if there are no letters.
461pub fn detect_script(text: &str) -> String {
462    let mut counts: IndexMap<&'static str, i64> = IndexMap::new();
463    let mut latin: i64 = 0;
464    for ch in text.chars() {
465        if !ch.is_alphabetic() {
466            continue;
467        }
468        let cp = ch as u32;
469        if is_latin_detect(cp) {
470            latin += 1;
471            continue;
472        }
473        let mut found: Option<&'static str> = None;
474        for (name, ranges) in SCRIPT_RANGES {
475            if in_ranges(cp, ranges) {
476                found = Some(name);
477                break;
478            }
479        }
480        let key = found.unwrap_or("other");
481        *counts.entry(key).or_insert(0) += 1;
482    }
483    counts.insert("latin", latin);
484    let total: i64 = counts.values().sum();
485    if total == 0 {
486        return "unknown".to_string();
487    }
488    // Python `max(..., key=value)` returns the first item on a tie (insertion order).
489    let mut best: &str = "";
490    let mut best_n: i64 = i64::MIN;
491    for (k, v) in &counts {
492        if *v > best_n {
493            best_n = *v;
494            best = k;
495        }
496    }
497    best.to_string()
498}
499
500/// Fraction of alphabetic characters belonging to each detected script (nonzero entries only).
501pub fn script_profile(text: &str) -> IndexMap<String, f64> {
502    let mut counts: IndexMap<&'static str, i64> = IndexMap::new();
503    counts.insert("latin", 0);
504    for ch in text.chars() {
505        if !ch.is_alphabetic() {
506            continue;
507        }
508        let cp = ch as u32;
509        if is_latin_detect(cp) {
510            *counts.get_mut("latin").unwrap() += 1;
511            continue;
512        }
513        let mut found: Option<&'static str> = None;
514        for (name, ranges) in SCRIPT_RANGES {
515            if in_ranges(cp, ranges) {
516                found = Some(name);
517                break;
518            }
519        }
520        let key = found.unwrap_or("other");
521        *counts.entry(key).or_insert(0) += 1;
522    }
523    let total: i64 = counts.values().sum();
524    let mut out: IndexMap<String, f64> = IndexMap::new();
525    if total == 0 {
526        return out;
527    }
528    for (k, v) in &counts {
529        if *v != 0 {
530            out.insert((*k).to_string(), *v as f64 / total as f64);
531        }
532    }
533    out
534}
535
536fn script_of(ch: char) -> Option<&'static str> {
537    let cp = ch as u32;
538    if is_latin_script_of(cp) {
539        return None;
540    }
541    for (name, ranges) in SCRIPT_RANGES {
542        if in_ranges(cp, ranges) {
543            return Some(name);
544        }
545    }
546    None
547}
548
549/// Non-Latin runs that read as words rather than as annotation inside English prose.
550fn non_latin_words(text: &str) -> Vec<String> {
551    let mut runs: Vec<String> = Vec::new();
552    let mut cur = String::new();
553    let mut script: Option<&'static str> = None;
554    for ch in text.chars() {
555        if is_combining(ch) {
556            continue;
557        }
558        let s = script_of(ch);
559        if s.is_some() && s == script {
560            cur.push(ch);
561            continue;
562        }
563        if !cur.is_empty() {
564            runs.push(std::mem::take(&mut cur));
565        }
566        match s {
567            Some(_) => {
568                cur = ch.to_string();
569                script = s;
570            }
571            None => {
572                cur = String::new();
573                script = None;
574            }
575        }
576    }
577    if !cur.is_empty() {
578        runs.push(cur);
579    }
580    runs.into_iter()
581        .filter(|w| {
582            w.chars().count() >= 2 && !w.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
583        })
584        .collect()
585}
586
587// --- Latin-script language guess ------------------------------------------
588
589/// Evidence behind the Latin-script language guess.
590#[derive(Debug, Clone)]
591pub struct LatinProfile {
592    pub language: Option<String>,
593    pub english_hits: usize,
594    pub diacritic_rate: f64,
595    pub looks_non_english: bool,
596}
597
598pub fn latin_profile(text: &str) -> LatinProfile {
599    // Order matches Python: identifier-strip the ORIGINAL text, replace 'İ'->'i', lower, then findall.
600    let stripped = identifier_re().replace_all(text, " ");
601    let normalized = stripped.replace('\u{0130}', "i").to_lowercase();
602    let words: Vec<&str> = word_re()
603        .find_iter(&normalized)
604        .map(|m| m.as_str())
605        .collect();
606
607    let lowered = text.to_lowercase();
608    let diac_set = non_en_diacritics();
609    let diac = lowered.chars().filter(|ch| diac_set.contains(ch)).count();
610    let len = lowered.chars().count();
611    let diac_rate = diac as f64 / (std::cmp::max(1, len) as f64);
612    let non_english = diac_rate >= NON_EN_DIACRITIC_RATE;
613
614    if words.len() < 4 {
615        return LatinProfile {
616            language: None,
617            english_hits: 0,
618            diacritic_rate: diac_rate,
619            looks_non_english: non_english,
620        };
621    }
622
623    let stop = stop();
624    let shared = shared_words();
625
626    // Per-language raw hit counts.
627    let mut scores: IndexMap<&'static str, i64> = IndexMap::new();
628    for (lg, sw) in stop {
629        let s = words.iter().filter(|w| sw.contains(*w)).count() as i64;
630        scores.insert(lg, s);
631    }
632    let en = scores.get("en").copied().unwrap_or(0);
633
634    let word_set: HashSet<&str> = words.iter().copied().collect();
635
636    // Only a language that matched at least one word no other list claims may be named.
637    let mut best_lg: Option<&'static str> = None;
638    let mut best: i64 = 0;
639    for (lg, sw) in stop {
640        if *lg == "en" {
641            continue;
642        }
643        let has_evidence = word_set
644            .iter()
645            .any(|w| sw.contains(*w) && !shared.contains(*w));
646        if !has_evidence {
647            continue;
648        }
649        let s = *scores.get(lg).unwrap();
650        if s > best {
651            best = s;
652            best_lg = Some(lg);
653        }
654    }
655
656    let mut language: Option<String> = None;
657    if let Some(bl) = best_lg {
658        // A non-English language needs a clear margin over English function words, or (with
659        // non-English letters present) at least a two-hit tie. Mirrors the elif ladder in
660        // laya/lang.py, with the two "name best_lg" branches combined.
661        if best >= std::cmp::max(2, en + 2) || (non_english && best >= std::cmp::max(2, en)) {
662            language = Some(bl.to_string());
663        } else if en > 0 && !non_english {
664            language = Some("en".to_string());
665        }
666    } else if en > 0 && !non_english {
667        language = Some("en".to_string());
668    }
669
670    LatinProfile {
671        language,
672        english_hits: en as usize,
673        diacritic_rate: diac_rate,
674        looks_non_english: non_english,
675    }
676}
677
678/// Best-effort language code for Latin-script text, or `None` when undecided.
679pub fn guess_latin_language(text: &str) -> Option<String> {
680    latin_profile(text).language
681}
682
683// --- mixed-language segment detection --------------------------------------
684
685// A line carrying code syntax — `=`, `;`, braces, brackets or a call `name(` — is skipped, so a
686// program pasted into an English request does not count as a foreign segment (`os.path`,
687// `round(el, 2)`, `non_english` all read as function words when split into tokens).
688fn code_line_re() -> &'static Regex {
689    static R: OnceLock<Regex> = OnceLock::new();
690    R.get_or_init(|| Regex::new(r"[=;{}\[\]]|\w\(").unwrap())
691}
692
693// A whitespace token holding a letter/digit, a joiner (`.`, `_`, `/`, `\`) and another
694// letter/digit is an identifier or slash/dot compound (`Nav/Com`, `OS/2`, `C:\DOS`) and is
695// dropped whole. Fixed length on purpose: an open-ended form backtracks quadratically.
696fn joined_re() -> &'static Regex {
697    static R: OnceLock<Regex> = OnceLock::new();
698    R.get_or_init(|| Regex::new(r"[^\W_][._/\\][^\W_]").unwrap())
699}
700
701// A run of two or more letters. An all-caps run inside mixed-case text is an acronym or code
702// (`MON`, `EST`, `COM`), not a foreign word, and is blanked; a fully capitalised segment keeps
703// its words (a customer shouting in Portuguese is still Portuguese).
704fn letter_run_re() -> &'static Regex {
705    static R: OnceLock<Regex> = OnceLock::new();
706    R.get_or_init(|| Regex::new(r"[^\W\d_]{2,}").unwrap())
707}
708
709/// Python `str.isupper()` for a run of cased letters: at least one cased character and no
710/// lowercase one. (The runs here are letters-only, so every character is cased.)
711fn is_all_upper(s: &str) -> bool {
712    let mut has_cased = false;
713    for c in s.chars() {
714        if c.is_lowercase() {
715            return false;
716        }
717        if c.is_uppercase() {
718            has_cased = true;
719        }
720    }
721    has_cased
722}
723
724/// First line or field that, read on its own, is named a non-English language, else `None`.
725///
726/// Returns `(language, segment)`. A segment needs the evidence a whole state needs — at least four
727/// words and a language named by [`latin_profile`] — and, because one line carries far less text,
728/// two things more: the words that name the language must be two *different* ones, and acronyms
729/// and slash compounds are not words. Reads at most `max_chars` characters in all. Mirrors
730/// `_non_english_segment` in `laya/lang.py`.
731fn non_english_segment(state: &Value, max_chars: usize) -> Option<(String, String)> {
732    let mut leaves: Vec<String> = Vec::new();
733    iter_text(state, 0, &mut leaves);
734    let stop = stop();
735    let mut seen = 0usize;
736    for leaf in &leaves {
737        for seg in leaf.split('\n') {
738            if seen >= max_chars {
739                return None;
740            }
741            let seg: String = seg.chars().take(max_chars - seen).collect();
742            seen += seg.chars().count();
743            if code_line_re().is_match(&seg) {
744                continue;
745            }
746            let joined = joined_re();
747            let prose = seg
748                .split_whitespace()
749                .filter(|tok| !joined.is_match(tok))
750                .collect::<Vec<_>>()
751                .join(" ");
752            let prose = if prose.chars().any(|c| c.is_lowercase()) {
753                letter_run_re()
754                    .replace_all(&prose, |caps: &regex::Captures| {
755                        let m = &caps[0];
756                        if is_all_upper(m) {
757                            " ".to_string()
758                        } else {
759                            m.to_string()
760                        }
761                    })
762                    .into_owned()
763            } else {
764                prose
765            };
766            let tokens: Vec<&str> = word_re().find_iter(&prose).map(|m| m.as_str()).collect();
767            if tokens.len() < 4 {
768                continue;
769            }
770            let lang = match latin_profile(&prose).language {
771                Some(l) if l != "en" => l,
772                _ => continue,
773            };
774            if let Some(sw) = stop.get(lang.as_str()) {
775                let lowered: HashSet<String> = tokens.iter().map(|w| w.to_lowercase()).collect();
776                let hits = lowered.iter().filter(|w| sw.contains(w.as_str())).count();
777                if hits >= 2 {
778                    return Some((lang, seg.trim().to_string()));
779                }
780            }
781        }
782    }
783    None
784}
785
786// --- top-level analysis ----------------------------------------------------
787
788/// Result of [`analyse`]. Field names/semantics mirror the Python dict keys.
789#[derive(Debug, Clone)]
790pub struct Analysis {
791    pub script: String,
792    pub script_profile: IndexMap<String, f64>,
793    pub language: Option<String>,
794    pub is_english: bool,
795    pub language_undecided: bool,
796    pub diacritic_rate: f64,
797    pub non_latin_fraction: f64,
798    /// The line or field that made a mostly-English state non-English, else `None`.
799    pub mixed_segment: Option<String>,
800}
801
802pub fn analyse(state: &Value) -> Analysis {
803    let text = state_text(state, 4000);
804    let prof = script_profile(&text);
805    let mut script = detect_script(&text);
806
807    let non_latin = if !prof.is_empty() {
808        round4(1.0 - prof.get("latin").copied().unwrap_or(0.0))
809    } else {
810        0.0
811    };
812    let sum_alpha = text.chars().filter(|c| c.is_alphabetic()).count() as f64;
813    let n_non_latin = round_half_even(non_latin * sum_alpha) as i64;
814
815    if script == "latin"
816        && !non_latin_words(&text).is_empty()
817        && (non_latin >= NON_LATIN_FRACTION
818            || (non_latin >= NON_LATIN_MIN_FRACTION && n_non_latin >= NON_LATIN_MIN_LETTERS))
819    {
820        // reclassify to the dominant non-latin script (first max on a tie, insertion order)
821        let mut best: Option<&str> = None;
822        let mut best_v = f64::NEG_INFINITY;
823        for (k, v) in &prof {
824            if k == "latin" {
825                continue;
826            }
827            if *v > best_v {
828                best_v = *v;
829                best = Some(k);
830            }
831        }
832        if let Some(b) = best {
833            script = b.to_string();
834        }
835    }
836
837    if script == "unknown" {
838        return Analysis {
839            script: "unknown".to_string(),
840            script_profile: prof,
841            language: None,
842            is_english: true,
843            language_undecided: true,
844            diacritic_rate: 0.0,
845            non_latin_fraction: 0.0,
846            mixed_segment: None,
847        };
848    }
849    if script != "latin" {
850        return Analysis {
851            script,
852            script_profile: prof,
853            language: None,
854            is_english: false,
855            language_undecided: true,
856            diacritic_rate: 0.0,
857            non_latin_fraction: non_latin,
858            mixed_segment: None,
859        };
860    }
861
862    let prof_lat = latin_profile(&text);
863    let mut lang = prof_lat.language.clone();
864    let mut undecided = lang.is_none();
865    let mut english = lang.as_deref() == Some("en") || (undecided && !prof_lat.looks_non_english);
866    // A mostly-English state can hide a customer's non-English line behind a longer English stack
867    // trace or form template. The whole reads as English, but the English checkpoint cannot read
868    // the customer's part, so a state that would go to English is checked line by line and field
869    // by field. A single line has no other part to outvote it and was just read whole.
870    let mut mixed: Option<String> = None;
871    let mut leaves: Vec<String> = Vec::new();
872    iter_text(state, 0, &mut leaves);
873    if english && (leaves.len() > 1 || leaves.iter().any(|l| l.contains('\n'))) {
874        if let Some((seg_lang, seg)) = non_english_segment(state, 4000) {
875            lang = Some(seg_lang);
876            mixed = Some(seg);
877            english = false;
878            undecided = false;
879        }
880    }
881    Analysis {
882        script: "latin".to_string(),
883        script_profile: prof,
884        language: lang,
885        is_english: english,
886        language_undecided: undecided,
887        diacritic_rate: round4(prof_lat.diacritic_rate),
888        non_latin_fraction: non_latin,
889        mixed_segment: mixed,
890    }
891}
892
893/// True when the English checkpoint can be expected to read this state.
894pub fn is_english(state: &Value) -> bool {
895    analyse(state).is_english
896}
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901    use serde_json::json;
902
903    fn s(t: &str) -> Value {
904        Value::String(t.to_string())
905    }
906
907    #[test]
908    fn detects_devanagari_as_non_latin() {
909        assert_eq!(detect_script("मुझसे दो बार शुल्क लिया गया"), "devanagari");
910    }
911
912    #[test]
913    fn routes_english_latin_to_english() {
914        let a = analyse(&s("Please refund the duplicate charge"));
915        assert_eq!(a.script, "latin");
916        assert!(a.is_english);
917        assert_eq!(a.language.as_deref(), Some("en"));
918    }
919
920    #[test]
921    fn routes_german_latin_to_non_english() {
922        assert!(!is_english(&s("Der Kunde wurde zweimal belastet")));
923    }
924
925    #[test]
926    fn detects_french_multilingual_signals() {
927        let a = analyse(&s("Je voudrais un remboursement pour la commande"));
928        assert!(!a.is_english);
929        assert_eq!(a.language.as_deref(), Some("fr"));
930    }
931
932    #[test]
933    fn unknown_no_letters_is_english_and_undecided() {
934        let a = analyse(&s("123 !!!"));
935        assert_eq!(a.script, "unknown");
936        assert!(a.is_english);
937        assert!(a.language_undecided);
938    }
939
940    #[test]
941    fn cjk_han_is_not_english() {
942        let a = analyse(&s("请退还重复的费用"));
943        assert_eq!(a.script, "han");
944        assert!(!a.is_english);
945    }
946
947    #[test]
948    fn hindi_devanagari_is_not_english() {
949        let a = analyse(&s("मुझसे दो बार शुल्क लिया गया"));
950        assert_eq!(a.script, "devanagari");
951        assert!(!a.is_english);
952    }
953
954    #[test]
955    fn romanized_bangla_detected() {
956        let a = analyse(&s("ami ekta refund chai amar order er jonno"));
957        assert_eq!(a.script, "latin");
958        assert_eq!(a.language.as_deref(), Some("bn"));
959        assert!(!a.is_english);
960    }
961
962    #[test]
963    fn url_only_not_misdetected_as_foreign() {
964        // Without identifier stripping, four "com" tokens would score Portuguese/Italian.
965        let a = analyse(&s("acme.com foo.com bar.com test.com"));
966        assert_eq!(a.language, None);
967        assert!(a.is_english);
968    }
969
970    #[test]
971    fn script_profile_latin_first_and_fractions() {
972        let p = script_profile("abc देव");
973        // latin inserted first; both scripts present.
974        let keys: Vec<&String> = p.keys().collect();
975        assert_eq!(keys[0], "latin");
976        assert!(p.contains_key("devanagari"));
977        let sum: f64 = p.values().sum();
978        assert!((sum - 1.0).abs() < 1e-9);
979    }
980
981    #[test]
982    fn nested_state_is_flattened() {
983        let state = json!({"a": "Please refund", "b": ["the", "duplicate", "charge"]});
984        assert!(is_english(&state));
985    }
986
987    #[test]
988    fn round4_is_half_to_even() {
989        assert_eq!(round_half_even(2.5), 2.0);
990        assert_eq!(round_half_even(3.5), 4.0);
991    }
992
993    #[test]
994    fn mixed_field_makes_state_non_english() {
995        // A mostly-English state (the English log is longer) whose own field reads as Portuguese
996        // must not go to the English checkpoint (upstream #207).
997        let state = json!({
998            "customer": "Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes",
999            "log": "The server returned an internal error and the request was retried three times before it finally failed on the second attempt with a timeout on the database connection pool that was exhausted"
1000        });
1001        let a = analyse(&state);
1002        assert_eq!(a.language.as_deref(), Some("pt"));
1003        assert!(!a.is_english);
1004        assert_eq!(
1005            a.mixed_segment.as_deref(),
1006            Some("Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes")
1007        );
1008    }
1009
1010    #[test]
1011    fn mixed_segment_ignores_code_lines() {
1012        // A pasted stack trace reads as function words when split (`os.path`, `round(el, 2)`,
1013        // `non_english`); a line carrying code syntax must not count as a foreign segment.
1014        let a = analyse(&s(
1015            "Please refund the duplicate charge on my account.\nTraceback: os.path failed in round(el, 2) at non_english line 42 of the payment module during the retry",
1016        ));
1017        assert!(a.is_english);
1018        assert_eq!(a.language.as_deref(), Some("en"));
1019        assert_eq!(a.mixed_segment, None);
1020    }
1021
1022    #[test]
1023    fn single_line_never_reports_mixed_segment() {
1024        // A single line has no other part to be outvoted by, so it is judged whole, never split.
1025        let a = analyse(&s("Please refund the duplicate charge on my account today"));
1026        assert_eq!(a.mixed_segment, None);
1027    }
1028}