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/// Function words that are also ordinary English words: `come` (Italian), `son` (Spanish), `do`
363/// (Portuguese), `care` (Romanian), `todo` (a to-do list), `per`, `plus`, and `im` (German `im`,
364/// and how `I'm` is written without the apostrophe). For those a repeat is evidence of the foreign
365/// language exactly once — "do more, do less" repeats an English word — so counting both hits let
366/// one such word clear the `best >= 2` bar and send plain English to the multilingual checkpoint.
367/// Every other word keeps counting occurrences (`der`, `los`, `sa` are nobody's English). Dutch
368/// `van` is deliberately left out. Built as the fixed set intersected with the non-English stop
369/// lists, matching upstream `_EN_COLLISION_WORDS`.
370fn en_collision_words() -> &'static HashSet<&'static str> {
371    static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
372    S.get_or_init(|| {
373        const CANDIDATES: [&str; 8] = ["come", "son", "do", "care", "todo", "im", "per", "plus"];
374        let mut non_en: HashSet<&'static str> = HashSet::new();
375        for (lg, sw) in stop() {
376            if *lg == "en" {
377                continue;
378            }
379            non_en.extend(sw.iter().copied());
380        }
381        CANDIDATES
382            .into_iter()
383            .filter(|w| non_en.contains(w))
384            .collect()
385    })
386}
387
388/// Whether plain-English function words outvote a marginal diacritic rate (upstream #337/#350).
389///
390/// The rate is measured over every character, so one accented loanword or proper noun in a short
391/// sentence clears [`NON_EN_DIACRITIC_RATE`] outright. English still wins when it shows at least
392/// two distinct function words no other list holds and at most one word carrying a non-English
393/// letter: one loanword is not a non-English vocabulary, while the odd word a Danish or Swedish
394/// sentence picks up (`i`, `at`, `for`, `have`) is not an English sentence either. A rate at or
395/// above [`ENGLISH_RESCUE_DIACRITIC_RATE`] vetoes regardless.
396fn english_rescued_by_words(words: &[&str], diac_rate: f64) -> bool {
397    if diac_rate >= ENGLISH_RESCUE_DIACRITIC_RATE {
398        return false;
399    }
400    let word_set: HashSet<&str> = words.iter().copied().collect();
401    let en_only = en_only_words();
402    if word_set.iter().filter(|w| en_only.contains(*w)).count() < 2 {
403        return false;
404    }
405    let diac = non_en_diacritics();
406    word_set
407        .iter()
408        .filter(|w| w.chars().any(|ch| diac.contains(&ch)))
409        .count()
410        <= 1
411}
412
413fn word_re() -> &'static Regex {
414    static R: OnceLock<Regex> = OnceLock::new();
415    R.get_or_init(|| Regex::new(r"[^\W\d_]+").unwrap())
416}
417
418// A token whose dot or @ joins word characters is an identifier, not prose. Upstream added a
419// `(?<![\w-])` look-behind for a ReDoS fix; the `regex` crate is already linear-time and lacks
420// look-behind, and the look-behind removes no match (a leftmost match can only begin at a run
421// start, since `[\w-]` and `[.@]` are disjoint), so the match set here is identical.
422fn identifier_re() -> &'static Regex {
423    static R: OnceLock<Regex> = OnceLock::new();
424    R.get_or_init(|| Regex::new(r"[\w-]*(?:[.@][\w-]+)+").unwrap())
425}
426
427/// A combining mark (Python `unicodedata.combining(ch) != 0`): Mn / Mc / Me.
428fn is_combining(ch: char) -> bool {
429    matches!(
430        get_general_category(ch),
431        GeneralCategory::NonspacingMark
432            | GeneralCategory::SpacingMark
433            | GeneralCategory::EnclosingMark
434    )
435}
436
437/// `detect_script` / `script_profile` Latin test (`cp < 0x02B0`).
438fn is_latin_detect(cp: u32) -> bool {
439    cp < 0x02B0
440        || (0x1E00..=0x1EFF).contains(&cp)
441        || (0xFF21..=0xFF3A).contains(&cp)
442        || (0xFF41..=0xFF5A).contains(&cp)
443}
444
445/// `_script_of` Latin test (`cp < 0x0250` — deliberately distinct from `detect_script`).
446fn is_latin_script_of(cp: u32) -> bool {
447    cp < 0x0250
448        || (0x1E00..=0x1EFF).contains(&cp)
449        || (0xFF21..=0xFF3A).contains(&cp)
450        || (0xFF41..=0xFF5A).contains(&cp)
451}
452
453fn in_ranges(cp: u32, ranges: &[(u32, u32)]) -> bool {
454    ranges.iter().any(|&(lo, hi)| lo <= cp && cp <= hi)
455}
456
457/// Round to 4 decimals, half-to-even (Python `round(x, 4)`).
458fn round4(x: f64) -> f64 {
459    round_half_even(x * 10000.0) / 10000.0
460}
461
462/// Round to the nearest integer, half-to-even (Python `round(x)`); valid for `x >= 0`.
463fn round_half_even(x: f64) -> f64 {
464    let floor = x.floor();
465    let diff = x - floor;
466    if diff < 0.5 {
467        floor
468    } else if diff > 0.5 {
469        floor + 1.0
470    } else {
471        // exactly .5 -> round to even
472        if (floor as i64) % 2 == 0 {
473            floor
474        } else {
475            floor + 1.0
476        }
477    }
478}
479
480// --- text flattening -------------------------------------------------------
481
482fn iter_text(v: &Value, depth: usize, out: &mut Vec<String>) {
483    if depth > 6 {
484        return;
485    }
486    match v {
487        Value::Null => {}
488        Value::String(s) => out.push(s.clone()),
489        Value::Array(a) => {
490            for x in a {
491                iter_text(x, depth + 1, out);
492            }
493        }
494        Value::Object(m) => {
495            for x in m.values() {
496                iter_text(x, depth + 1, out);
497            }
498        }
499        _ => {}
500    }
501}
502
503/// Flatten a state into the text used for detection (keys are ignored). `max_chars` default 4000.
504pub fn state_text(state: &Value, max_chars: usize) -> String {
505    let mut leaves: Vec<String> = Vec::new();
506    iter_text(state, 0, &mut leaves);
507    // Bound the join work before truncation: only `max_chars` are ever returned, so stop
508    // materializing leaves once the budget is spent (parity with upstream `state_text`).
509    let mut parts: Vec<String> = Vec::new();
510    let mut budget = max_chars as isize;
511    for leaf in leaves {
512        if budget <= 0 {
513            break;
514        }
515        let leaf_len = leaf.chars().count() as isize;
516        if leaf_len > budget {
517            parts.push(leaf.chars().take(budget as usize).collect());
518            break;
519        }
520        parts.push(leaf);
521        // Account for the joining space without materializing the full text first.
522        budget -= leaf_len + 1;
523    }
524    parts.join(" ").chars().take(max_chars).collect()
525}
526
527// --- script detection ------------------------------------------------------
528
529/// Dominant script of `text`: `"latin"`, `"han"`, ... or `"unknown"` if there are no letters.
530pub fn detect_script(text: &str) -> String {
531    let mut counts: IndexMap<&'static str, i64> = IndexMap::new();
532    let mut latin: i64 = 0;
533    for ch in text.chars() {
534        if !ch.is_alphabetic() {
535            continue;
536        }
537        let cp = ch as u32;
538        if is_latin_detect(cp) {
539            latin += 1;
540            continue;
541        }
542        let mut found: Option<&'static str> = None;
543        for (name, ranges) in SCRIPT_RANGES {
544            if in_ranges(cp, ranges) {
545                found = Some(name);
546                break;
547            }
548        }
549        let key = found.unwrap_or("other");
550        *counts.entry(key).or_insert(0) += 1;
551    }
552    counts.insert("latin", latin);
553    let total: i64 = counts.values().sum();
554    if total == 0 {
555        return "unknown".to_string();
556    }
557    // Python `max(..., key=value)` returns the first item on a tie (insertion order).
558    let mut best: &str = "";
559    let mut best_n: i64 = i64::MIN;
560    for (k, v) in &counts {
561        if *v > best_n {
562            best_n = *v;
563            best = k;
564        }
565    }
566    best.to_string()
567}
568
569/// Fraction of alphabetic characters belonging to each detected script (nonzero entries only).
570pub fn script_profile(text: &str) -> IndexMap<String, f64> {
571    let mut counts: IndexMap<&'static str, i64> = IndexMap::new();
572    counts.insert("latin", 0);
573    for ch in text.chars() {
574        if !ch.is_alphabetic() {
575            continue;
576        }
577        let cp = ch as u32;
578        if is_latin_detect(cp) {
579            *counts.get_mut("latin").unwrap() += 1;
580            continue;
581        }
582        let mut found: Option<&'static str> = None;
583        for (name, ranges) in SCRIPT_RANGES {
584            if in_ranges(cp, ranges) {
585                found = Some(name);
586                break;
587            }
588        }
589        let key = found.unwrap_or("other");
590        *counts.entry(key).or_insert(0) += 1;
591    }
592    let total: i64 = counts.values().sum();
593    let mut out: IndexMap<String, f64> = IndexMap::new();
594    if total == 0 {
595        return out;
596    }
597    for (k, v) in &counts {
598        if *v != 0 {
599            out.insert((*k).to_string(), *v as f64 / total as f64);
600        }
601    }
602    out
603}
604
605fn script_of(ch: char) -> Option<&'static str> {
606    let cp = ch as u32;
607    if is_latin_script_of(cp) {
608        return None;
609    }
610    for (name, ranges) in SCRIPT_RANGES {
611        if in_ranges(cp, ranges) {
612            return Some(name);
613        }
614    }
615    None
616}
617
618/// Non-Latin runs that read as words rather than as annotation inside English prose.
619fn non_latin_words(text: &str) -> Vec<String> {
620    let mut runs: Vec<String> = Vec::new();
621    let mut cur = String::new();
622    let mut script: Option<&'static str> = None;
623    for ch in text.chars() {
624        if is_combining(ch) {
625            continue;
626        }
627        let s = script_of(ch);
628        if s.is_some() && s == script {
629            cur.push(ch);
630            continue;
631        }
632        if !cur.is_empty() {
633            runs.push(std::mem::take(&mut cur));
634        }
635        match s {
636            Some(_) => {
637                cur = ch.to_string();
638                script = s;
639            }
640            None => {
641                cur = String::new();
642                script = None;
643            }
644        }
645    }
646    if !cur.is_empty() {
647        runs.push(cur);
648    }
649    runs.into_iter()
650        .filter(|w| {
651            w.chars().count() >= 2 && !w.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
652        })
653        .collect()
654}
655
656// --- Latin-script language guess ------------------------------------------
657
658/// Evidence behind the Latin-script language guess.
659#[derive(Debug, Clone)]
660pub struct LatinProfile {
661    pub language: Option<String>,
662    pub english_hits: usize,
663    pub diacritic_rate: f64,
664    pub looks_non_english: bool,
665}
666
667pub fn latin_profile(text: &str) -> LatinProfile {
668    // Order matches Python: identifier-strip the ORIGINAL text, replace 'İ'->'i', lower, then findall.
669    let stripped = identifier_re().replace_all(text, " ");
670    let normalized = stripped.replace('\u{0130}', "i").to_lowercase();
671    let words: Vec<&str> = word_re()
672        .find_iter(&normalized)
673        .map(|m| m.as_str())
674        .collect();
675
676    let lowered = text.to_lowercase();
677    let diac_set = non_en_diacritics();
678    let diac = lowered.chars().filter(|ch| diac_set.contains(ch)).count();
679    let len = lowered.chars().count();
680    let diac_rate = diac as f64 / (std::cmp::max(1, len) as f64);
681    let non_english = diac_rate >= NON_EN_DIACRITIC_RATE;
682
683    if words.len() < 4 {
684        return LatinProfile {
685            language: None,
686            english_hits: 0,
687            diacritic_rate: diac_rate,
688            looks_non_english: non_english,
689        };
690    }
691
692    let stop = stop();
693    let shared = shared_words();
694
695    // Per-language hit counts: a collision word counts once however often it repeats, every other
696    // word counts its occurrences (upstream `_EN_COLLISION_WORDS` / `Counter` scoring).
697    let mut counts: HashMap<&str, i64> = HashMap::new();
698    for w in &words {
699        *counts.entry(*w).or_insert(0) += 1;
700    }
701    let collision = en_collision_words();
702    let mut scores: IndexMap<&'static str, i64> = IndexMap::new();
703    for (lg, sw) in stop {
704        let s: i64 = counts
705            .iter()
706            .filter(|(w, _)| sw.contains(**w))
707            .map(|(w, n)| if collision.contains(*w) { 1 } else { *n })
708            .sum();
709        scores.insert(lg, s);
710    }
711    let en = scores.get("en").copied().unwrap_or(0);
712
713    let word_set: HashSet<&str> = words.iter().copied().collect();
714
715    // Only a language that matched at least one word no other list claims may be named.
716    let mut best_lg: Option<&'static str> = None;
717    let mut best: i64 = 0;
718    for (lg, sw) in stop {
719        if *lg == "en" {
720            continue;
721        }
722        let has_evidence = word_set
723            .iter()
724            .any(|w| sw.contains(*w) && !shared.contains(*w));
725        if !has_evidence {
726            continue;
727        }
728        let s = *scores.get(lg).unwrap();
729        if s > best {
730            best = s;
731            best_lg = Some(lg);
732        }
733    }
734
735    let mut language: Option<String> = None;
736    if let Some(bl) = best_lg {
737        // A non-English language needs a clear margin over English function words, or (with
738        // non-English letters present) at least a two-hit tie. Mirrors the elif ladder in
739        // laya/lang.py, with the two "name best_lg" branches combined.
740        if best >= std::cmp::max(2, en + 2) || (non_english && best >= std::cmp::max(2, en)) {
741            language = Some(bl.to_string());
742        } else if en > 0 && (!non_english || english_rescued_by_words(&words, diac_rate)) {
743            language = Some("en".to_string());
744        }
745    } else if en > 0 && (!non_english || english_rescued_by_words(&words, diac_rate)) {
746        language = Some("en".to_string());
747    }
748
749    LatinProfile {
750        language,
751        english_hits: en as usize,
752        diacritic_rate: diac_rate,
753        looks_non_english: non_english,
754    }
755}
756
757/// Best-effort language code for Latin-script text, or `None` when undecided.
758pub fn guess_latin_language(text: &str) -> Option<String> {
759    latin_profile(text).language
760}
761
762// --- mixed-language segment detection --------------------------------------
763
764// A line carrying code syntax — `=`, `;`, braces, brackets or a call `name(` — is skipped, so a
765// program pasted into an English request does not count as a foreign segment (`os.path`,
766// `round(el, 2)`, `non_english` all read as function words when split into tokens).
767fn code_line_re() -> &'static Regex {
768    static R: OnceLock<Regex> = OnceLock::new();
769    R.get_or_init(|| Regex::new(r"[=;{}\[\]]|\w\(").unwrap())
770}
771
772// A whitespace token holding a letter/digit, a joiner (`.`, `_`, `/`, `\`) and another
773// letter/digit is an identifier or slash/dot compound (`Nav/Com`, `OS/2`, `C:\DOS`) and is
774// dropped whole. Fixed length on purpose: an open-ended form backtracks quadratically.
775fn joined_re() -> &'static Regex {
776    static R: OnceLock<Regex> = OnceLock::new();
777    R.get_or_init(|| Regex::new(r"[^\W_][._/\\][^\W_]").unwrap())
778}
779
780// A run of two or more letters. An all-caps run inside mixed-case text is an acronym or code
781// (`MON`, `EST`, `COM`), not a foreign word, and is blanked; a fully capitalised segment keeps
782// its words (a customer shouting in Portuguese is still Portuguese).
783fn letter_run_re() -> &'static Regex {
784    static R: OnceLock<Regex> = OnceLock::new();
785    R.get_or_init(|| Regex::new(r"[^\W\d_]{2,}").unwrap())
786}
787
788/// Python `str.isupper()` for a run of cased letters: at least one cased character and no
789/// lowercase one. (The runs here are letters-only, so every character is cased.)
790fn is_all_upper(s: &str) -> bool {
791    let mut has_cased = false;
792    for c in s.chars() {
793        if c.is_lowercase() {
794            return false;
795        }
796        if c.is_uppercase() {
797            has_cased = true;
798        }
799    }
800    has_cased
801}
802
803/// Language code for one non-code line, or `None` when it does not name a foreign language.
804///
805/// Same evidence bar as [`non_english_segment`]: at least four words, a language named by
806/// [`latin_profile`], and two *different* words of that language. Acronyms and slash compounds are
807/// not words. Mirrors `_named_prose_language` in `laya/lang.py`.
808fn named_prose_language(segment: &str) -> Option<String> {
809    if segment.trim().is_empty() || code_line_re().is_match(segment) {
810        return None;
811    }
812    let joined = joined_re();
813    let prose = segment
814        .split_whitespace()
815        .filter(|tok| !joined.is_match(tok))
816        .collect::<Vec<_>>()
817        .join(" ");
818    let prose = if prose.chars().any(|c| c.is_lowercase()) {
819        letter_run_re()
820            .replace_all(&prose, |caps: &regex::Captures| {
821                let m = &caps[0];
822                if is_all_upper(m) {
823                    " ".to_string()
824                } else {
825                    m.to_string()
826                }
827            })
828            .into_owned()
829    } else {
830        prose
831    };
832    let tokens: Vec<&str> = word_re().find_iter(&prose).map(|m| m.as_str()).collect();
833    if tokens.len() < 4 {
834        return None;
835    }
836    let lang = match latin_profile(&prose).language {
837        Some(l) if l != "en" => l,
838        _ => return None,
839    };
840    let sw = stop().get(lang.as_str())?;
841    let lowered: HashSet<String> = tokens.iter().map(|w| w.to_lowercase()).collect();
842    if lowered.iter().filter(|w| sw.contains(w.as_str())).count() < 2 {
843        return None;
844    }
845    Some(lang)
846}
847
848/// First line or field that, read on its own, is named a non-English language, else `None`.
849///
850/// Returns `(language, segment)`. A segment needs the evidence a whole state needs — at least four
851/// words and a language named by [`latin_profile`] — and, because one line carries far less text,
852/// two things more: the words that name the language must be two *different* ones, and acronyms
853/// and slash compounds are not words. Reads at most `max_chars` characters in all. Mirrors
854/// `_non_english_segment` in `laya/lang.py`.
855fn non_english_segment(state: &Value, max_chars: usize) -> Option<(String, String)> {
856    let mut leaves: Vec<String> = Vec::new();
857    iter_text(state, 0, &mut leaves);
858    let mut seen = 0usize;
859    for leaf in &leaves {
860        for seg in leaf.split('\n') {
861            if seen >= max_chars {
862                return None;
863            }
864            let seg: String = seg.chars().take(max_chars - seen).collect();
865            seen += seg.chars().count();
866            if let Some(lang) = named_prose_language(&seg) {
867                return Some((lang, seg.trim().to_string()));
868            }
869        }
870    }
871    None
872}
873
874// --- top-level analysis ----------------------------------------------------
875
876/// Result of [`analyse`]. Field names/semantics mirror the Python dict keys.
877#[derive(Debug, Clone)]
878pub struct Analysis {
879    pub script: String,
880    pub script_profile: IndexMap<String, f64>,
881    pub language: Option<String>,
882    pub is_english: bool,
883    pub language_undecided: bool,
884    pub diacritic_rate: f64,
885    pub non_latin_fraction: f64,
886    /// The line or field that made a mostly-English state non-English, else `None`.
887    pub mixed_segment: Option<String>,
888}
889
890/// Detection result for one already-flattened string. Does not look for a foreign line inside
891/// mostly-English text — [`analyse`] does that, because it needs the original state and not only
892/// the joined window. Mirrors `_analyse_text` in `laya/lang.py`.
893fn analyse_text(text: &str) -> Analysis {
894    let prof = script_profile(text);
895    let mut script = detect_script(text);
896
897    let non_latin = if !prof.is_empty() {
898        round4(1.0 - prof.get("latin").copied().unwrap_or(0.0))
899    } else {
900        0.0
901    };
902    let sum_alpha = text.chars().filter(|c| c.is_alphabetic()).count() as f64;
903    let n_non_latin = round_half_even(non_latin * sum_alpha) as i64;
904
905    if script == "latin"
906        && !non_latin_words(text).is_empty()
907        && (non_latin >= NON_LATIN_FRACTION
908            || (non_latin >= NON_LATIN_MIN_FRACTION && n_non_latin >= NON_LATIN_MIN_LETTERS))
909    {
910        // reclassify to the dominant non-latin script (first max on a tie, insertion order)
911        let mut best: Option<&str> = None;
912        let mut best_v = f64::NEG_INFINITY;
913        for (k, v) in &prof {
914            if k == "latin" {
915                continue;
916            }
917            if *v > best_v {
918                best_v = *v;
919                best = Some(k);
920            }
921        }
922        if let Some(b) = best {
923            script = b.to_string();
924        }
925    }
926
927    if script == "unknown" {
928        return Analysis {
929            script: "unknown".to_string(),
930            script_profile: prof,
931            language: None,
932            is_english: true,
933            language_undecided: true,
934            diacritic_rate: 0.0,
935            non_latin_fraction: 0.0,
936            mixed_segment: None,
937        };
938    }
939    if script != "latin" {
940        return Analysis {
941            script,
942            script_profile: prof,
943            language: None,
944            is_english: false,
945            language_undecided: true,
946            diacritic_rate: 0.0,
947            non_latin_fraction: non_latin,
948            mixed_segment: None,
949        };
950    }
951
952    let prof_lat = latin_profile(text);
953    let lang = prof_lat.language.clone();
954    let undecided = lang.is_none();
955    let english = lang.as_deref() == Some("en") || (undecided && !prof_lat.looks_non_english);
956    Analysis {
957        script: "latin".to_string(),
958        script_profile: prof,
959        language: lang,
960        is_english: english,
961        language_undecided: undecided,
962        diacritic_rate: round4(prof_lat.diacritic_rate),
963        non_latin_fraction: non_latin,
964        mixed_segment: None,
965    }
966}
967
968/// A string value that is itself not safe for the English checkpoint, else `None`.
969///
970/// A one-word name (`José`) and a capitalised non-Latin name stay out: the same rules
971/// [`latin_profile`] and [`non_latin_words`] already use, so a name field cannot pull an English
972/// ticket onto the multilingual checkpoint. Code lines, acronyms and slash compounds stay out too,
973/// matching [`non_english_segment`], so a pasted traceback is not a message. Each line is capped at
974/// 4000 characters; unlike the segment scan, a long earlier field does not consume the budget of
975/// the next one (upstream #384). Mirrors `_leaf_non_english` in `laya/lang.py`.
976fn leaf_non_english(leaf: &str) -> Option<Analysis> {
977    let mut best: Option<Analysis> = None;
978    let mut best_n: i64 = -1;
979    for line in leaf.split('\n') {
980        // A line this short cannot be selected, so skip it before the slice, `trim`, `_CODE_LINE`
981        // and a full `analyse_text` pass. Each branch below needs four `word_re` tokens or
982        // NON_LATIN_MIN_LETTERS letters; four tokens need three separators between them (seven
983        // characters) and ten letters need ten. Counted on the raw line in code points, matching
984        // upstream `len(line) < 7`.
985        if line.chars().count() < 7 {
986            continue;
987        }
988        let sample: String = line.chars().take(4000).collect();
989        if sample.trim().is_empty() || code_line_re().is_match(&sample) {
990            continue;
991        }
992        let det = analyse_text(&sample);
993        if det.is_english {
994            continue;
995        }
996        if matches!(det.language.as_deref(), Some(l) if l != "en") {
997            if named_prose_language(&sample).is_none() {
998                continue;
999            }
1000        } else if det.script != "latin" && det.script != "unknown" {
1001            let n_alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as i64;
1002            if non_latin_words(&sample).is_empty() || n_alpha < NON_LATIN_MIN_LETTERS {
1003                continue;
1004            }
1005        } else if !(det.language_undecided
1006            && det.diacritic_rate >= NON_EN_DIACRITIC_RATE
1007            && word_re().find_iter(&sample).count() >= 4)
1008        {
1009            continue;
1010        }
1011        let n_alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as i64;
1012        if n_alpha > best_n {
1013            best_n = n_alpha;
1014            best = Some(det);
1015        }
1016    }
1017    best
1018}
1019
1020pub fn analyse(state: &Value) -> Analysis {
1021    let mut result = analyse_text(&state_text(state, 4000));
1022    // A mostly-English state can hide a customer's non-English line behind a longer English stack
1023    // trace or form template. The whole reads as English, but the English checkpoint cannot read
1024    // the customer's part, so a state that would go to English is checked line by line and field by
1025    // field. A single line has no other part to outvote it and was just read whole.
1026    if result.script == "latin" && result.is_english {
1027        let mut leaves: Vec<String> = Vec::new();
1028        iter_text(state, 0, &mut leaves);
1029        if leaves.len() > 1 || leaves.iter().any(|l| l.contains('\n')) {
1030            if let Some((seg_lang, seg)) = non_english_segment(state, 4000) {
1031                result.language = Some(seg_lang);
1032                result.is_english = false;
1033                result.language_undecided = false;
1034                result.mixed_segment = Some(seg);
1035            }
1036        }
1037    }
1038    // A plain string was just read whole. A structured state can still hide a message past the
1039    // segment cap, or in a script `latin_profile` does not name: read every string value on its
1040    // own, and let one non-English value decide (upstream #384).
1041    if matches!(state, Value::String(_) | Value::Null) || !result.is_english {
1042        return result;
1043    }
1044    let mut leaves: Vec<String> = Vec::new();
1045    iter_text(state, 0, &mut leaves);
1046    let mut best: Option<Analysis> = None;
1047    let mut best_n: i64 = -1;
1048    for leaf in &leaves {
1049        let Some(det) = leaf_non_english(leaf) else {
1050            continue;
1051        };
1052        let capped: String = leaf.chars().take(4000).collect();
1053        let n_alpha = capped.chars().filter(|c| c.is_alphabetic()).count() as i64;
1054        if n_alpha > best_n {
1055            best_n = n_alpha;
1056            best = Some(det);
1057        }
1058    }
1059    if let Some(det) = best {
1060        result.language = det.language;
1061        result.is_english = false;
1062        result.language_undecided = det.language_undecided;
1063    }
1064    result
1065}
1066
1067/// True when the English checkpoint can be expected to read this state.
1068pub fn is_english(state: &Value) -> bool {
1069    analyse(state).is_english
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074    use super::*;
1075    use serde_json::json;
1076
1077    fn s(t: &str) -> Value {
1078        Value::String(t.to_string())
1079    }
1080
1081    #[test]
1082    fn detects_devanagari_as_non_latin() {
1083        assert_eq!(detect_script("मुझसे दो बार शुल्क लिया गया"), "devanagari");
1084    }
1085
1086    #[test]
1087    fn routes_english_latin_to_english() {
1088        let a = analyse(&s("Please refund the duplicate charge"));
1089        assert_eq!(a.script, "latin");
1090        assert!(a.is_english);
1091        assert_eq!(a.language.as_deref(), Some("en"));
1092    }
1093
1094    #[test]
1095    fn routes_german_latin_to_non_english() {
1096        assert!(!is_english(&s("Der Kunde wurde zweimal belastet")));
1097    }
1098
1099    #[test]
1100    fn collision_word_repeat_counts_once() {
1101        // `do` is Portuguese but also ordinary English; repeating it four times used to score
1102        // Portuguese 4 and route plain English to the multilingual checkpoint. Counting a collision
1103        // word once keeps this English (upstream `_EN_COLLISION_WORDS`).
1104        assert_eq!(
1105            latin_profile("do do do do please refund me")
1106                .language
1107                .as_deref(),
1108            Some("en")
1109        );
1110        // A non-collision function word still counts every occurrence: `der` twice stays German.
1111        assert_eq!(
1112            latin_profile("reduzieren der helligkeit der lichter")
1113                .language
1114                .as_deref(),
1115            Some("de")
1116        );
1117    }
1118
1119    #[test]
1120    fn detects_french_multilingual_signals() {
1121        let a = analyse(&s("Je voudrais un remboursement pour la commande"));
1122        assert!(!a.is_english);
1123        assert_eq!(a.language.as_deref(), Some("fr"));
1124    }
1125
1126    #[test]
1127    fn unknown_no_letters_is_english_and_undecided() {
1128        let a = analyse(&s("123 !!!"));
1129        assert_eq!(a.script, "unknown");
1130        assert!(a.is_english);
1131        assert!(a.language_undecided);
1132    }
1133
1134    #[test]
1135    fn cjk_han_is_not_english() {
1136        let a = analyse(&s("请退还重复的费用"));
1137        assert_eq!(a.script, "han");
1138        assert!(!a.is_english);
1139    }
1140
1141    #[test]
1142    fn hindi_devanagari_is_not_english() {
1143        let a = analyse(&s("मुझसे दो बार शुल्क लिया गया"));
1144        assert_eq!(a.script, "devanagari");
1145        assert!(!a.is_english);
1146    }
1147
1148    #[test]
1149    fn romanized_bangla_detected() {
1150        let a = analyse(&s("ami ekta refund chai amar order er jonno"));
1151        assert_eq!(a.script, "latin");
1152        assert_eq!(a.language.as_deref(), Some("bn"));
1153        assert!(!a.is_english);
1154    }
1155
1156    #[test]
1157    fn url_only_not_misdetected_as_foreign() {
1158        // Without identifier stripping, four "com" tokens would score Portuguese/Italian.
1159        let a = analyse(&s("acme.com foo.com bar.com test.com"));
1160        assert_eq!(a.language, None);
1161        assert!(a.is_english);
1162    }
1163
1164    #[test]
1165    fn script_profile_latin_first_and_fractions() {
1166        let p = script_profile("abc देव");
1167        // latin inserted first; both scripts present.
1168        let keys: Vec<&String> = p.keys().collect();
1169        assert_eq!(keys[0], "latin");
1170        assert!(p.contains_key("devanagari"));
1171        let sum: f64 = p.values().sum();
1172        assert!((sum - 1.0).abs() < 1e-9);
1173    }
1174
1175    #[test]
1176    fn nested_state_is_flattened() {
1177        let state = json!({"a": "Please refund", "b": ["the", "duplicate", "charge"]});
1178        assert!(is_english(&state));
1179    }
1180
1181    #[test]
1182    fn round4_is_half_to_even() {
1183        assert_eq!(round_half_even(2.5), 2.0);
1184        assert_eq!(round_half_even(3.5), 4.0);
1185    }
1186
1187    #[test]
1188    fn mixed_field_makes_state_non_english() {
1189        // A mostly-English state (the English log is longer) whose own field reads as Portuguese
1190        // must not go to the English checkpoint (upstream #207).
1191        let state = json!({
1192            "customer": "Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes",
1193            "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"
1194        });
1195        let a = analyse(&state);
1196        assert_eq!(a.language.as_deref(), Some("pt"));
1197        assert!(!a.is_english);
1198        assert_eq!(
1199            a.mixed_segment.as_deref(),
1200            Some("Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes")
1201        );
1202    }
1203
1204    #[test]
1205    fn mixed_segment_ignores_code_lines() {
1206        // A pasted stack trace reads as function words when split (`os.path`, `round(el, 2)`,
1207        // `non_english`); a line carrying code syntax must not count as a foreign segment.
1208        let a = analyse(&s(
1209            "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",
1210        ));
1211        assert!(a.is_english);
1212        assert_eq!(a.language.as_deref(), Some("en"));
1213        assert_eq!(a.mixed_segment, None);
1214    }
1215
1216    #[test]
1217    fn single_line_never_reports_mixed_segment() {
1218        // A single line has no other part to be outvoted by, so it is judged whole, never split.
1219        let a = analyse(&s("Please refund the duplicate charge on my account today"));
1220        assert_eq!(a.mixed_segment, None);
1221    }
1222
1223    #[test]
1224    fn one_loanword_keeps_plain_english() {
1225        // A single accented loanword/proper noun clears the diacritic floor over a short sentence,
1226        // but two English-only function words and at most one accented word rescue it (#337).
1227        let a = analyse(&s(
1228            "Please send José the invoice and the refund for his order today",
1229        ));
1230        assert_eq!(a.language.as_deref(), Some("en"));
1231        assert!(a.is_english);
1232        let a = analyse(&s(
1233            "I would like a refund for the résumé service I paid for twice this month",
1234        ));
1235        assert_eq!(a.language.as_deref(), Some("en"));
1236        assert!(a.is_english);
1237    }
1238
1239    #[test]
1240    fn loanword_rescue_is_bounded() {
1241        // A diacritic rate at/above 0.06 is a real non-English vocabulary, not a loanword: no rescue.
1242        let a = analyse(&s(
1243            "We met at the café for a naïve chat about the résumé and the soirée plans",
1244        ));
1245        assert!(!a.is_english);
1246        // More than one word carries a non-English letter (Swedish two/gånger): rescue denied (#350).
1247        let a = analyse(&s(
1248            "Jag har blivit debiterad två gånger och vill ha pengarna tillbaka nu",
1249        ));
1250        assert!(!a.is_english);
1251    }
1252
1253    #[test]
1254    fn per_leaf_detection_finds_hidden_non_english_field() {
1255        // A German field behind an English field longer than the 4000-char segment-scan budget: the
1256        // joined window reads English and the segment scan never reaches it, so only reading each
1257        // string value on its own catches it (#384). No mixed_segment on this path.
1258        let big_en =
1259            "please help me with the account issue and the refund for the order ".repeat(80);
1260        let state = json!({
1261            "a_log": big_en,
1262            "z_customer": "Mein Konto wurde zweimal belastet und ich brauche dringend Hilfe",
1263        });
1264        let a = analyse(&state);
1265        assert_eq!(a.language.as_deref(), Some("de"));
1266        assert!(!a.is_english);
1267        assert_eq!(a.mixed_segment, None);
1268    }
1269}