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