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        // Swedish function words and common auxiliaries. Several overlap with English or German
186        // (`i`, `kan`, `har`), so the distinctive words below are what lets Swedish text survive an
187        // ASCII-normalising ticket pipeline without treating one stray Nordic letter as the only
188        // hint. ASCII-normalised variants sit beside the diacritic forms.
189        m.insert(
190            "sv",
191            [
192                "jag",
193                "är",
194                "och",
195                "inte",
196                "att",
197                "från",
198                "till",
199                "behöver",
200                "får",
201                "skulle",
202                "ska",
203                "vill",
204                "måste",
205                "också",
206                "dessa",
207                "detta",
208                "säger",
209                "upp",
210                "utan",
211                "mitt",
212                "min",
213                "om",
214                "kommer",
215                "här",
216                "två",
217                "vi",
218                "nästa",
219                "gör",
220                "göra",
221                "hjälp",
222                "hjälpa",
223                "mig",
224                "återbetalning",
225                "återbetala",
226                "faktura",
227                "gång",
228                "gånger",
229                "hittar",
230                "inställningen",
231                "inställningarna",
232                "lösenord",
233                "när",
234                "öppnar",
235                "spårningen",
236                "aterbetalning",
237                "aterbetala",
238                "behover",
239                "fel",
240                "ganger",
241                "hjalp",
242                "hjalpa",
243                "installningen",
244                "installningarna",
245                "kraschar",
246                "kvittot",
247                "losenord",
248                "nar",
249                "oppnar",
250                "paket",
251                "skicka",
252                "sparningen",
253                "tva",
254                "uppdaterats",
255                "blivit",
256                "debiterade",
257                "appen",
258            ]
259            .into_iter()
260            .collect(),
261        );
262        m.insert(
263            "ro",
264            [
265                "și", "să", "este", "sunt", "care", "pentru", "din", "dar", "după", "până", "fără",
266                "ale", "lui", "în", "fost", "acum", "vreau", "trebuie", "foarte", "acest",
267                "această", "acesta", "aceasta", "mi", "ți", "vă", "nu",
268            ]
269            .into_iter()
270            .collect(),
271        );
272        m.insert(
273            "bn",
274            [
275                "ami",
276                "amar",
277                "amake",
278                "amra",
279                "amader",
280                "apni",
281                "apnar",
282                "apnake",
283                "apnara",
284                "tumi",
285                "tomar",
286                "tomake",
287                "tomra",
288                "tader",
289                "ota",
290                "eita",
291                "oita",
292                "ekta",
293                "ei",
294                "oi",
295                "ki",
296                "keno",
297                "kivabe",
298                "kibhabe",
299                "kothay",
300                "kokhon",
301                "kobe",
302                "koto",
303                "kintu",
304                "jodi",
305                "tahole",
306                "ar",
307                "theke",
308                "jonno",
309                "sathe",
310                "shathe",
311                "diye",
312                "niye",
313                "moddhe",
314                "kore",
315                "korte",
316                "korchi",
317                "korsi",
318                "korbo",
319                "korechi",
320                "koreche",
321                "korun",
322                "koren",
323                "korlam",
324                "hobe",
325                "hoyeche",
326                "hoise",
327                "hocche",
328                "hoyni",
329                "chai",
330                "chaina",
331                "lagbe",
332                "parchi",
333                "parbo",
334                "parchina",
335                "peyechi",
336                "paini",
337                "dite",
338                "dilam",
339                "diyechi",
340                "nai",
341                "khub",
342                "onek",
343                "ekhon",
344                "akhon",
345                "ekhono",
346                "abar",
347                "ekbar",
348                "duibar",
349                "ajke",
350                "kalke",
351                "taka",
352                "bhalo",
353                "valo",
354                "kharap",
355                "shomossa",
356                "somossa",
357                "dhonnobad",
358                "bhai",
359                "shob",
360                "keu",
361                "kichu",
362                "bolte",
363                "bolun",
364                "parben",
365                "asbe",
366                "jabe",
367                "pabo",
368                "ferot",
369                "dorkar",
370                "hoye",
371                "geche",
372                "gese",
373            ]
374            .into_iter()
375            .collect(),
376        );
377        m.insert(
378            "az",
379            [
380                "və", "ve", "bir", "bu", "üçün", "ucun", "ilə", "ile", "olan", "olub", "olmasa",
381                "var", "yox", "yoxdur", "mən", "sən", "biz", "siz", "onlar", "daha", "çox", "cox",
382                "hər", "nə", "kimi", "görə", "sonra", "əgər", "eger", "deyil", "lakin", "amma",
383                "ancaq", "artıq", "artiq", "də", "isə", "həm", "yalnız", "yalniz",
384            ]
385            .into_iter()
386            .collect(),
387        );
388        m
389    })
390}
391
392// Letters that ordinary English does not use.
393const DIACRITICS: &str = concat!(
394    "àâäãáåçéèêëíìîïñóòôöõøúùûüýÿßæœ", // Western European
395    "ăâîșțşţ",                         // Romanian
396    "ąćęłńśźż",                        // Polish
397    "čďěňřšťůž",                       // Czech / Slovak
398    "őű",                              // Hungarian
399    "ğı",                              // Turkish (text is lowercased before matching)
400    "āēģīķļņūž",                       // Baltic
401    "đ",                               // Serbo-Croatian / Vietnamese
402    "ə",                               // Azerbaijani
403);
404
405fn non_en_diacritics() -> &'static HashSet<char> {
406    static S: OnceLock<HashSet<char>> = OnceLock::new();
407    S.get_or_init(|| DIACRITICS.chars().collect())
408}
409
410/// Short support fragments (two or three words) that identify Swedish on their own, below the
411/// four-token minimum the general guess needs. Generic words such as `fel`, `hjälp`, `paket` and
412/// `appen` are deliberately not sufficient here. ASCII-normalised variants sit beside the diacritic
413/// forms (upstream `_SHORT_SWEDISH_WORDS`).
414fn short_swedish_words() -> &'static HashSet<&'static str> {
415    static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
416    S.get_or_init(|| {
417        [
418            "åtkomst",
419            "atkomst",
420            "lösenord",
421            "losenord",
422            "fakturan",
423            "betalningen",
424            "inloggningen",
425            "glömt",
426            "glomt",
427            "behöver",
428            "behover",
429            "återbetalning",
430            "aterbetalning",
431            "kvitto",
432            "kvittot",
433            "spårningen",
434            "sparningen",
435            "inställningen",
436            "installningen",
437            "felmeddelande",
438            "abonnemanget",
439        ]
440        .into_iter()
441        .collect()
442    })
443}
444
445/// Danish is not in `_STOP`, but several of its common words also occur in the Swedish list. These
446/// words alone must not name a Danish sentence as Swedish; they stay useful score hits when a more
447/// distinctive Swedish word is present. Folded into [`shared_words`] (upstream
448/// `_NORDIC_OVERLAP_WORDS`).
449fn nordic_overlap_words() -> &'static HashSet<&'static str> {
450    static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
451    S.get_or_init(|| {
452        [
453            "hej", "ja", "nej", "jo", "tack", "mig", "min", "om", "kommer", "får", "skulle", "vi",
454        ]
455        .into_iter()
456        .collect()
457    })
458}
459
460/// Words that more than one list claims, plus the Nordic (Swedish/Danish) overlap words.
461fn shared_words() -> &'static HashSet<&'static str> {
462    static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
463    S.get_or_init(|| {
464        let mut counts: HashMap<&'static str, usize> = HashMap::new();
465        for words in stop().values() {
466            for w in words {
467                *counts.entry(*w).or_insert(0) += 1;
468            }
469        }
470        let mut shared: HashSet<&'static str> = counts
471            .into_iter()
472            .filter(|(_, n)| *n > 1)
473            .map(|(w, _)| w)
474            .collect();
475        shared.extend(nordic_overlap_words().iter().copied());
476        shared
477    })
478}
479
480/// English function words no other list holds (`in`, `is`, `as`, `was` are shared with German,
481/// Dutch and Portuguese). They alone carry the English rescue of [`latin_profile`] (upstream #350).
482fn en_only_words() -> &'static HashSet<&'static str> {
483    static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
484    S.get_or_init(|| {
485        let en = &stop()["en"];
486        let shared = shared_words();
487        en.iter().copied().filter(|w| !shared.contains(w)).collect()
488    })
489}
490
491/// Function words that are also ordinary English words: `come` (Italian), `son` (Spanish), `do`
492/// (Portuguese), `care` (Romanian), `todo` (a to-do list), `per`, `plus`, and `im` (German `im`,
493/// and how `I'm` is written without the apostrophe). For those a repeat is evidence of the foreign
494/// language exactly once — "do more, do less" repeats an English word — so counting both hits let
495/// one such word clear the `best >= 2` bar and send plain English to the multilingual checkpoint.
496/// Every other word keeps counting occurrences (`der`, `los`, `sa` are nobody's English). Dutch
497/// `van` is deliberately left out. Built as the fixed set intersected with the non-English stop
498/// lists, matching upstream `_EN_COLLISION_WORDS`.
499fn en_collision_words() -> &'static HashSet<&'static str> {
500    static S: OnceLock<HashSet<&'static str>> = OnceLock::new();
501    S.get_or_init(|| {
502        const CANDIDATES: [&str; 8] = ["come", "son", "do", "care", "todo", "im", "per", "plus"];
503        let mut non_en: HashSet<&'static str> = HashSet::new();
504        for (lg, sw) in stop() {
505            if *lg == "en" {
506                continue;
507            }
508            non_en.extend(sw.iter().copied());
509        }
510        CANDIDATES
511            .into_iter()
512            .filter(|w| non_en.contains(w))
513            .collect()
514    })
515}
516
517/// Whether plain-English function words outvote a marginal diacritic rate (upstream #337/#350).
518///
519/// The rate is measured over every character, so one accented loanword or proper noun in a short
520/// sentence clears [`NON_EN_DIACRITIC_RATE`] outright. English still wins when it shows at least
521/// two distinct function words no other list holds and at most one word carrying a non-English
522/// letter: one loanword is not a non-English vocabulary, while the odd word a Danish or Swedish
523/// sentence picks up (`i`, `at`, `for`, `have`) is not an English sentence either. A rate at or
524/// above [`ENGLISH_RESCUE_DIACRITIC_RATE`] vetoes regardless.
525fn english_rescued_by_words(words: &[&str], diac_rate: f64) -> bool {
526    if diac_rate >= ENGLISH_RESCUE_DIACRITIC_RATE {
527        return false;
528    }
529    let word_set: HashSet<&str> = words.iter().copied().collect();
530    let en_only = en_only_words();
531    if word_set.iter().filter(|w| en_only.contains(*w)).count() < 2 {
532        return false;
533    }
534    let diac = non_en_diacritics();
535    word_set
536        .iter()
537        .filter(|w| w.chars().any(|ch| diac.contains(&ch)))
538        .count()
539        <= 1
540}
541
542fn word_re() -> &'static Regex {
543    static R: OnceLock<Regex> = OnceLock::new();
544    R.get_or_init(|| Regex::new(r"[^\W\d_]+").unwrap())
545}
546
547// A token whose dot or @ joins word characters is an identifier, not prose. Upstream added a
548// `(?<![\w-])` look-behind for a ReDoS fix; the `regex` crate is already linear-time and lacks
549// look-behind, and the look-behind removes no match (a leftmost match can only begin at a run
550// start, since `[\w-]` and `[.@]` are disjoint), so the match set here is identical.
551fn identifier_re() -> &'static Regex {
552    static R: OnceLock<Regex> = OnceLock::new();
553    R.get_or_init(|| Regex::new(r"[\w-]*(?:[.@][\w-]+)+").unwrap())
554}
555
556/// A combining mark (Python `unicodedata.combining(ch) != 0`): Mn / Mc / Me.
557fn is_combining(ch: char) -> bool {
558    matches!(
559        get_general_category(ch),
560        GeneralCategory::NonspacingMark
561            | GeneralCategory::SpacingMark
562            | GeneralCategory::EnclosingMark
563    )
564}
565
566/// `detect_script` / `script_profile` Latin test (`cp < 0x02B0`).
567fn is_latin_detect(cp: u32) -> bool {
568    cp < 0x02B0
569        || (0x1E00..=0x1EFF).contains(&cp)
570        || (0xFF21..=0xFF3A).contains(&cp)
571        || (0xFF41..=0xFF5A).contains(&cp)
572}
573
574/// `_script_of` Latin test (`cp < 0x0250` — deliberately distinct from `detect_script`).
575fn is_latin_script_of(cp: u32) -> bool {
576    cp < 0x0250
577        || (0x1E00..=0x1EFF).contains(&cp)
578        || (0xFF21..=0xFF3A).contains(&cp)
579        || (0xFF41..=0xFF5A).contains(&cp)
580}
581
582fn in_ranges(cp: u32, ranges: &[(u32, u32)]) -> bool {
583    ranges.iter().any(|&(lo, hi)| lo <= cp && cp <= hi)
584}
585
586/// Round to 4 decimals, half-to-even (Python `round(x, 4)`).
587fn round4(x: f64) -> f64 {
588    round_half_even(x * 10000.0) / 10000.0
589}
590
591/// Round to the nearest integer, half-to-even (Python `round(x)`); valid for `x >= 0`.
592fn round_half_even(x: f64) -> f64 {
593    let floor = x.floor();
594    let diff = x - floor;
595    if diff < 0.5 {
596        floor
597    } else if diff > 0.5 {
598        floor + 1.0
599    } else {
600        // exactly .5 -> round to even
601        if (floor as i64) % 2 == 0 {
602            floor
603        } else {
604            floor + 1.0
605        }
606    }
607}
608
609// --- text flattening -------------------------------------------------------
610
611fn iter_text(v: &Value, depth: usize, out: &mut Vec<String>) {
612    if depth > 6 {
613        return;
614    }
615    match v {
616        Value::Null => {}
617        Value::String(s) => out.push(s.clone()),
618        Value::Array(a) => {
619            for x in a {
620                iter_text(x, depth + 1, out);
621            }
622        }
623        Value::Object(m) => {
624            for x in m.values() {
625                iter_text(x, depth + 1, out);
626            }
627        }
628        _ => {}
629    }
630}
631
632/// Flatten a state into the text used for detection (keys are ignored). `max_chars` default 4000.
633pub fn state_text(state: &Value, max_chars: usize) -> String {
634    let mut leaves: Vec<String> = Vec::new();
635    iter_text(state, 0, &mut leaves);
636    // Bound the join work before truncation: only `max_chars` are ever returned, so stop
637    // materializing leaves once the budget is spent (parity with upstream `state_text`).
638    let mut parts: Vec<String> = Vec::new();
639    let mut budget = max_chars as isize;
640    for leaf in leaves {
641        if budget <= 0 {
642            break;
643        }
644        let leaf_len = leaf.chars().count() as isize;
645        if leaf_len > budget {
646            parts.push(leaf.chars().take(budget as usize).collect());
647            break;
648        }
649        parts.push(leaf);
650        // Account for the joining space without materializing the full text first.
651        budget -= leaf_len + 1;
652    }
653    parts.join(" ").chars().take(max_chars).collect()
654}
655
656// --- script detection ------------------------------------------------------
657
658/// Dominant script of `text`: `"latin"`, `"han"`, ... or `"unknown"` if there are no letters.
659pub fn detect_script(text: &str) -> String {
660    let mut counts: IndexMap<&'static str, i64> = IndexMap::new();
661    let mut latin: i64 = 0;
662    for ch in text.chars() {
663        if !ch.is_alphabetic() {
664            continue;
665        }
666        let cp = ch as u32;
667        if is_latin_detect(cp) {
668            latin += 1;
669            continue;
670        }
671        let mut found: Option<&'static str> = None;
672        for (name, ranges) in SCRIPT_RANGES {
673            if in_ranges(cp, ranges) {
674                found = Some(name);
675                break;
676            }
677        }
678        let key = found.unwrap_or("other");
679        *counts.entry(key).or_insert(0) += 1;
680    }
681    counts.insert("latin", latin);
682    let total: i64 = counts.values().sum();
683    if total == 0 {
684        return "unknown".to_string();
685    }
686    // Python `max(..., key=value)` returns the first item on a tie (insertion order).
687    let mut best: &str = "";
688    let mut best_n: i64 = i64::MIN;
689    for (k, v) in &counts {
690        if *v > best_n {
691            best_n = *v;
692            best = k;
693        }
694    }
695    best.to_string()
696}
697
698/// Fraction of alphabetic characters belonging to each detected script (nonzero entries only).
699pub fn script_profile(text: &str) -> IndexMap<String, f64> {
700    let mut counts: IndexMap<&'static str, i64> = IndexMap::new();
701    counts.insert("latin", 0);
702    for ch in text.chars() {
703        if !ch.is_alphabetic() {
704            continue;
705        }
706        let cp = ch as u32;
707        if is_latin_detect(cp) {
708            *counts.get_mut("latin").unwrap() += 1;
709            continue;
710        }
711        let mut found: Option<&'static str> = None;
712        for (name, ranges) in SCRIPT_RANGES {
713            if in_ranges(cp, ranges) {
714                found = Some(name);
715                break;
716            }
717        }
718        let key = found.unwrap_or("other");
719        *counts.entry(key).or_insert(0) += 1;
720    }
721    let total: i64 = counts.values().sum();
722    let mut out: IndexMap<String, f64> = IndexMap::new();
723    if total == 0 {
724        return out;
725    }
726    for (k, v) in &counts {
727        if *v != 0 {
728            out.insert((*k).to_string(), *v as f64 / total as f64);
729        }
730    }
731    out
732}
733
734fn script_of(ch: char) -> Option<&'static str> {
735    let cp = ch as u32;
736    if is_latin_script_of(cp) {
737        return None;
738    }
739    for (name, ranges) in SCRIPT_RANGES {
740        if in_ranges(cp, ranges) {
741            return Some(name);
742        }
743    }
744    None
745}
746
747/// Non-Latin runs that read as words rather than as annotation inside English prose.
748fn non_latin_words(text: &str) -> Vec<String> {
749    let mut runs: Vec<String> = Vec::new();
750    let mut cur = String::new();
751    let mut script: Option<&'static str> = None;
752    for ch in text.chars() {
753        if is_combining(ch) {
754            continue;
755        }
756        let s = script_of(ch);
757        if s.is_some() && s == script {
758            cur.push(ch);
759            continue;
760        }
761        if !cur.is_empty() {
762            runs.push(std::mem::take(&mut cur));
763        }
764        match s {
765            Some(_) => {
766                cur = ch.to_string();
767                script = s;
768            }
769            None => {
770                cur = String::new();
771                script = None;
772            }
773        }
774    }
775    if !cur.is_empty() {
776        runs.push(cur);
777    }
778    runs.into_iter()
779        .filter(|w| {
780            w.chars().count() >= 2 && !w.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
781        })
782        .collect()
783}
784
785// --- Latin-script language guess ------------------------------------------
786
787/// Evidence behind the Latin-script language guess.
788#[derive(Debug, Clone)]
789pub struct LatinProfile {
790    pub language: Option<String>,
791    pub english_hits: usize,
792    pub diacritic_rate: f64,
793    pub looks_non_english: bool,
794}
795
796pub fn latin_profile(text: &str) -> LatinProfile {
797    // Order matches Python: identifier-strip the ORIGINAL text, replace 'İ'->'i', lower, then findall.
798    let stripped = identifier_re().replace_all(text, " ");
799    let normalized = stripped.replace('\u{0130}', "i").to_lowercase();
800    let words: Vec<&str> = word_re()
801        .find_iter(&normalized)
802        .map(|m| m.as_str())
803        .collect();
804
805    let lowered = text.to_lowercase();
806    let diac_set = non_en_diacritics();
807    let diac = lowered.chars().filter(|ch| diac_set.contains(ch)).count();
808    let len = lowered.chars().count();
809    let diac_rate = diac as f64 / (std::cmp::max(1, len) as f64);
810    let non_english = diac_rate >= NON_EN_DIACRITIC_RATE;
811
812    // A shared Swedish/Danish marker with no English-only word present leans the undecided cases
813    // toward the multilingual checkpoint (upstream `nordic_overlap`).
814    let word_set_all: HashSet<&str> = words.iter().copied().collect();
815    let en_only = en_only_words();
816    let nordic_overlap = word_set_all
817        .iter()
818        .any(|w| nordic_overlap_words().contains(*w))
819        && !word_set_all.iter().any(|w| en_only.contains(*w));
820
821    // Short support fragments: two or three distinctive Swedish words name Swedish outright.
822    if words.len() > 1
823        && words.len() < 4
824        && word_set_all
825            .iter()
826            .any(|w| short_swedish_words().contains(*w))
827    {
828        return LatinProfile {
829            language: Some("sv".to_string()),
830            english_hits: 0,
831            diacritic_rate: diac_rate,
832            looks_non_english: non_english,
833        };
834    }
835
836    if words.len() < 4 {
837        return LatinProfile {
838            language: None,
839            english_hits: 0,
840            diacritic_rate: diac_rate,
841            looks_non_english: non_english || nordic_overlap,
842        };
843    }
844
845    let stop = stop();
846    let shared = shared_words();
847
848    // Per-language hit counts: a collision word counts once however often it repeats, every other
849    // word counts its occurrences (upstream `_EN_COLLISION_WORDS` / `Counter` scoring).
850    let mut counts: HashMap<&str, i64> = HashMap::new();
851    for w in &words {
852        *counts.entry(*w).or_insert(0) += 1;
853    }
854    let collision = en_collision_words();
855    let mut scores: IndexMap<&'static str, i64> = IndexMap::new();
856    for (lg, sw) in stop {
857        let s: i64 = counts
858            .iter()
859            .filter(|(w, _)| sw.contains(**w))
860            .map(|(w, n)| if collision.contains(*w) { 1 } else { *n })
861            .sum();
862        scores.insert(lg, s);
863    }
864    let en = scores.get("en").copied().unwrap_or(0);
865
866    // Only a language that matched at least one word no other list claims may be named.
867    let mut best_lg: Option<&'static str> = None;
868    let mut best: i64 = 0;
869    for (lg, sw) in stop {
870        if *lg == "en" {
871            continue;
872        }
873        let has_evidence = word_set_all
874            .iter()
875            .any(|w| sw.contains(*w) && !shared.contains(*w));
876        if !has_evidence {
877            continue;
878        }
879        let s = *scores.get(lg).unwrap();
880        if s > best {
881            best = s;
882            best_lg = Some(lg);
883        }
884    }
885
886    let mut language: Option<String> = None;
887    // Mirrors the elif ladder in laya/lang.py.
888    if best_lg.is_some() && best >= std::cmp::max(2, en + 2) {
889        // a non-English language needs a clear margin over English function words
890        language = best_lg.map(|s| s.to_string());
891    } else if best_lg == Some("sv")
892        && word_set_all.contains("inte")
893        && word_set_all.contains("kan")
894        && matches!(
895            words.first().copied(),
896            Some("kan") | Some("jag") | Some("vi")
897        )
898        && en <= 1
899    {
900        // Short login requests such as "kan inte logga in" carry a distinctive Swedish phrase but
901        // also one English-shaped token (`in`). Do not treat `kan` alone as Swedish: it is common
902        // in Danish and Norwegian too.
903        language = best_lg.map(|s| s.to_string());
904    } else if best_lg.is_some() && non_english && best >= std::cmp::max(2, en) {
905        // Needs two hits here too. One shared function word named a language on diacritics alone.
906        language = best_lg.map(|s| s.to_string());
907    } else if en > 0 && (!non_english || english_rescued_by_words(&words, diac_rate)) {
908        language = Some("en".to_string());
909    }
910
911    let looks_non_english = non_english || (language.is_none() && nordic_overlap);
912    LatinProfile {
913        language,
914        english_hits: en as usize,
915        diacritic_rate: diac_rate,
916        looks_non_english,
917    }
918}
919
920/// Best-effort language code for Latin-script text, or `None` when undecided.
921pub fn guess_latin_language(text: &str) -> Option<String> {
922    latin_profile(text).language
923}
924
925// --- mixed-language segment detection --------------------------------------
926
927// A line carrying code syntax — `=`, `;`, braces, brackets or a call `name(` — is skipped, so a
928// program pasted into an English request does not count as a foreign segment (`os.path`,
929// `round(el, 2)`, `non_english` all read as function words when split into tokens).
930fn code_line_re() -> &'static Regex {
931    static R: OnceLock<Regex> = OnceLock::new();
932    R.get_or_init(|| Regex::new(r"[=;{}\[\]]|\w\(").unwrap())
933}
934
935// A whitespace token holding a letter/digit, a joiner (`.`, `_`, `/`, `\`) and another
936// letter/digit is an identifier or slash/dot compound (`Nav/Com`, `OS/2`, `C:\DOS`) and is
937// dropped whole. Fixed length on purpose: an open-ended form backtracks quadratically.
938fn joined_re() -> &'static Regex {
939    static R: OnceLock<Regex> = OnceLock::new();
940    R.get_or_init(|| Regex::new(r"[^\W_][._/\\][^\W_]").unwrap())
941}
942
943// A run of two or more letters. An all-caps run inside mixed-case text is an acronym or code
944// (`MON`, `EST`, `COM`), not a foreign word, and is blanked; a fully capitalised segment keeps
945// its words (a customer shouting in Portuguese is still Portuguese).
946fn letter_run_re() -> &'static Regex {
947    static R: OnceLock<Regex> = OnceLock::new();
948    R.get_or_init(|| Regex::new(r"[^\W\d_]{2,}").unwrap())
949}
950
951/// Python `str.isupper()` for a run of cased letters: at least one cased character and no
952/// lowercase one. (The runs here are letters-only, so every character is cased.)
953fn is_all_upper(s: &str) -> bool {
954    let mut has_cased = false;
955    for c in s.chars() {
956        if c.is_lowercase() {
957            return false;
958        }
959        if c.is_uppercase() {
960            has_cased = true;
961        }
962    }
963    has_cased
964}
965
966/// Whether the non-Latin letters of a Latin-plurality text are enough to carry the state.
967/// Mirrors `_non_latin_carries` in `laya/lang.py`.
968fn non_latin_carries(non_latin: f64, n_non_latin: i64) -> bool {
969    non_latin >= NON_LATIN_FRACTION
970        || (non_latin >= NON_LATIN_MIN_FRACTION && n_non_latin >= NON_LATIN_MIN_LETTERS)
971}
972
973// The minimum matched-stopword length for an all-caps line's evidence to count as more than
974// acronym-shaped, and the minimum token length at which a run is a word rather than an acronym.
975// Three corpora decide these two numbers upstream; see `_SHOUTED_MIN_STOPWORD` / `_SHOUTED_MIN_WORD`
976// in `laya/lang.py`.
977const SHOUTED_MIN_STOPWORD: usize = 4;
978const SHOUTED_MIN_WORD: usize = 5;
979
980/// True for a *cased* segment written entirely in capitals: at least one uppercase character and
981/// no lowercase one. A caseless script (Devanagari, CJK) has no lowercase either, so the `isupper`
982/// half keeps it off the caps bar. Mirrors `_shouted` in `laya/lang.py`.
983fn is_shouted(text: &str) -> bool {
984    text.chars().any(|c| c.is_uppercase()) && !text.chars().any(|c| c.is_lowercase())
985}
986
987/// Whether an all-caps line's evidence for `lang` is more than acronym-shaped tokens: it needs one
988/// word-length token, and then either non-English diacritics or a matched stopword long enough to
989/// not be an acronym. Mirrors `_shouted_evidence` in `laya/lang.py`.
990fn shouted_evidence(tokens: &[&str], lang: &str, diacritic_rate: f64) -> bool {
991    if !tokens.iter().any(|t| t.chars().count() >= SHOUTED_MIN_WORD) {
992        return false;
993    }
994    if diacritic_rate >= NON_EN_DIACRITIC_RATE {
995        return true;
996    }
997    let Some(sw) = stop().get(lang) else {
998        return false;
999    };
1000    tokens.iter().any(|t| {
1001        let lw = t.to_lowercase();
1002        lw.chars().count() >= SHOUTED_MIN_STOPWORD && sw.contains(lw.as_str())
1003    })
1004}
1005
1006/// Language code for one non-code line, or `None` when it does not name a foreign language.
1007///
1008/// Same evidence bar as [`non_english_segment`]: at least four words, a language named by
1009/// [`latin_profile`], and two *different* words of that language. Acronyms and slash compounds are
1010/// not words. A segment in all capitals keeps its acronym-shaped tokens — shouting is not a foreign
1011/// language — so it must also clear [`shouted_evidence`]. Mirrors `_named_prose_language` in
1012/// `laya/lang.py`.
1013fn named_prose_language(segment: &str) -> Option<String> {
1014    if segment.trim().is_empty() || code_line_re().is_match(segment) {
1015        return None;
1016    }
1017    let joined = joined_re();
1018    let prose = segment
1019        .split_whitespace()
1020        .filter(|tok| !joined.is_match(tok))
1021        .collect::<Vec<_>>()
1022        .join(" ");
1023    let shouted = is_shouted(&prose);
1024    let prose = if !shouted {
1025        letter_run_re()
1026            .replace_all(&prose, |caps: &regex::Captures| {
1027                let m = &caps[0];
1028                if is_all_upper(m) {
1029                    " ".to_string()
1030                } else {
1031                    m.to_string()
1032                }
1033            })
1034            .into_owned()
1035    } else {
1036        prose
1037    };
1038    let tokens: Vec<&str> = word_re().find_iter(&prose).map(|m| m.as_str()).collect();
1039    if tokens.len() < 4 {
1040        return None;
1041    }
1042    let prof = latin_profile(&prose);
1043    let lang = match prof.language {
1044        Some(ref l) if l != "en" => l.clone(),
1045        _ => return None,
1046    };
1047    if shouted && !shouted_evidence(&tokens, &lang, prof.diacritic_rate) {
1048        return None;
1049    }
1050    let sw = stop().get(lang.as_str())?;
1051    let lowered: HashSet<String> = tokens.iter().map(|w| w.to_lowercase()).collect();
1052    if lowered.iter().filter(|w| sw.contains(w.as_str())).count() < 2 {
1053        return None;
1054    }
1055    Some(lang)
1056}
1057
1058/// First line or field that, read on its own, is named a non-English language, else `None`.
1059///
1060/// Returns `(language, segment)`. A segment needs the evidence a whole state needs — at least four
1061/// words and a language named by [`latin_profile`] — and, because one line carries far less text,
1062/// two things more: the words that name the language must be two *different* ones, and acronyms
1063/// and slash compounds are not words. Reads at most `max_chars` characters in all. Mirrors
1064/// `_non_english_segment` in `laya/lang.py`.
1065fn non_english_segment(state: &Value, max_chars: usize) -> Option<(String, String)> {
1066    let mut leaves: Vec<String> = Vec::new();
1067    iter_text(state, 0, &mut leaves);
1068    let mut seen = 0usize;
1069    for leaf in &leaves {
1070        for seg in leaf.split('\n') {
1071            if seen >= max_chars {
1072                return None;
1073            }
1074            let seg: String = seg.chars().take(max_chars - seen).collect();
1075            seen += seg.chars().count();
1076            if let Some(lang) = named_prose_language(&seg) {
1077                return Some((lang, seg.trim().to_string()));
1078            }
1079        }
1080    }
1081    None
1082}
1083
1084// --- top-level analysis ----------------------------------------------------
1085
1086/// Result of [`analyse`]. Field names/semantics mirror the Python dict keys.
1087#[derive(Debug, Clone)]
1088pub struct Analysis {
1089    pub script: String,
1090    pub script_profile: IndexMap<String, f64>,
1091    pub language: Option<String>,
1092    pub is_english: bool,
1093    pub language_undecided: bool,
1094    pub diacritic_rate: f64,
1095    pub non_latin_fraction: f64,
1096    /// The line or field that made a mostly-English state non-English, else `None`.
1097    pub mixed_segment: Option<String>,
1098}
1099
1100/// Detection result for one already-flattened string. Does not look for a foreign line inside
1101/// mostly-English text — [`analyse`] does that, because it needs the original state and not only
1102/// the joined window. Mirrors `_analyse_text` in `laya/lang.py`.
1103fn analyse_text(text: &str) -> Analysis {
1104    let prof = script_profile(text);
1105    let mut script = detect_script(text);
1106
1107    let non_latin = if !prof.is_empty() {
1108        round4(1.0 - prof.get("latin").copied().unwrap_or(0.0))
1109    } else {
1110        0.0
1111    };
1112    let sum_alpha = text.chars().filter(|c| c.is_alphabetic()).count() as f64;
1113    let n_non_latin = round_half_even(non_latin * sum_alpha) as i64;
1114
1115    if script == "latin"
1116        && !non_latin_words(text).is_empty()
1117        && non_latin_carries(non_latin, n_non_latin)
1118    {
1119        // reclassify to the dominant non-latin script (first max on a tie, insertion order)
1120        let mut best: Option<&str> = None;
1121        let mut best_v = f64::NEG_INFINITY;
1122        for (k, v) in &prof {
1123            if k == "latin" {
1124                continue;
1125            }
1126            if *v > best_v {
1127                best_v = *v;
1128                best = Some(k);
1129            }
1130        }
1131        if let Some(b) = best {
1132            script = b.to_string();
1133        }
1134    }
1135
1136    if script == "unknown" {
1137        return Analysis {
1138            script: "unknown".to_string(),
1139            script_profile: prof,
1140            language: None,
1141            is_english: true,
1142            language_undecided: true,
1143            diacritic_rate: 0.0,
1144            non_latin_fraction: 0.0,
1145            mixed_segment: None,
1146        };
1147    }
1148    if script != "latin" {
1149        return Analysis {
1150            script,
1151            script_profile: prof,
1152            language: None,
1153            is_english: false,
1154            language_undecided: true,
1155            diacritic_rate: 0.0,
1156            non_latin_fraction: non_latin,
1157            mixed_segment: None,
1158        };
1159    }
1160
1161    let prof_lat = latin_profile(text);
1162    let mut lang = prof_lat.language.clone();
1163    // The acronym bar belongs here too, not only in the segment scan. A state that is nothing but
1164    // an acronym line — or one diluted by fewer English lines than it takes to tip the verdict —
1165    // never reached the segment scan and was named foreign outright (`MON DES EST LA` routed
1166    // multilingual on its own). Vetoing here leaves the text undecided, so `looks_non_english`
1167    // still decides it. Neither bar applies when the state is carried by a caseless script. See
1168    // `_analyse_text` in `laya/lang.py`.
1169    if matches!(lang.as_deref(), Some(l) if l != "en") && !non_latin_carries(non_latin, n_non_latin)
1170    {
1171        if is_shouted(text) {
1172            let tokens: Vec<&str> = word_re().find_iter(text).map(|m| m.as_str()).collect();
1173            let ok = {
1174                let l = lang.as_deref().unwrap();
1175                shouted_evidence(&tokens, l, prof_lat.diacritic_rate)
1176            };
1177            if !ok {
1178                lang = None;
1179            }
1180        } else {
1181            // Mixed-case text: re-take the verdict without the all-caps runs, so acronyms do not
1182            // vote. Only acronym-shaped runs are blanked — a run of `SHOUTED_MIN_WORD` letters is a
1183            // word, and a word is not blanked out of its own sentence (emphasis capitals, and
1184            // monotonicity). `looks_non_english` and `diacritic_rate` stay measured on the original.
1185            let blank_run = |caps: &regex::Captures| {
1186                let m = &caps[0];
1187                if is_all_upper(m) {
1188                    " ".to_string()
1189                } else {
1190                    m.to_string()
1191                }
1192            };
1193            let caps: Vec<&str> = letter_run_re()
1194                .find_iter(text)
1195                .map(|m| m.as_str())
1196                .filter(|m| is_all_upper(m))
1197                .collect();
1198            if !caps.is_empty() && !caps.iter().any(|c| c.chars().count() >= SHOUTED_MIN_WORD) {
1199                let blanked = letter_run_re().replace_all(text, blank_run);
1200                if blanked.as_ref() != text {
1201                    lang = latin_profile(blanked.as_ref()).language;
1202                }
1203            }
1204        }
1205    }
1206    let undecided = lang.is_none();
1207    let english = lang.as_deref() == Some("en") || (undecided && !prof_lat.looks_non_english);
1208    Analysis {
1209        script: "latin".to_string(),
1210        script_profile: prof,
1211        language: lang,
1212        is_english: english,
1213        language_undecided: undecided,
1214        diacritic_rate: round4(prof_lat.diacritic_rate),
1215        non_latin_fraction: non_latin,
1216        mixed_segment: None,
1217    }
1218}
1219
1220/// A string value that is itself not safe for the English checkpoint, else `None`.
1221///
1222/// A one-word name (`José`) and a capitalised non-Latin name stay out: the same rules
1223/// [`latin_profile`] and [`non_latin_words`] already use, so a name field cannot pull an English
1224/// ticket onto the multilingual checkpoint. Code lines, acronyms and slash compounds stay out too,
1225/// matching [`non_english_segment`], so a pasted traceback is not a message. Each line is capped at
1226/// 4000 characters; unlike the segment scan, a long earlier field does not consume the budget of
1227/// the next one (upstream #384). Mirrors `_leaf_non_english` in `laya/lang.py`.
1228fn leaf_non_english(leaf: &str) -> Option<Analysis> {
1229    let mut best: Option<Analysis> = None;
1230    let mut best_n: i64 = -1;
1231    for line in leaf.split('\n') {
1232        // A line this short cannot be selected, so skip it before the slice, `trim`, `_CODE_LINE`
1233        // and a full `analyse_text` pass. Each branch below needs four `word_re` tokens or
1234        // NON_LATIN_MIN_LETTERS letters; four tokens need three separators between them (seven
1235        // characters) and ten letters need ten. Counted on the raw line in code points, matching
1236        // upstream `len(line) < 7`.
1237        if line.chars().count() < 7 {
1238            continue;
1239        }
1240        let sample: String = line.chars().take(4000).collect();
1241        if sample.trim().is_empty() || code_line_re().is_match(&sample) {
1242            continue;
1243        }
1244        let det = analyse_text(&sample);
1245        if det.is_english {
1246            continue;
1247        }
1248        // A named language still has to survive `named_prose_language` (acronyms and slash
1249        // compounds are not words), but a veto is not a verdict of English: a language named and
1250        // then disbelieved falls through to the diacritic branch, the same evidential position as
1251        // one never named, so its non-English letters can still carry it. That is also why
1252        // `language_undecided` is no longer required below. Mirrors `_leaf_non_english`.
1253        let named = matches!(det.language.as_deref(), Some(l) if l != "en")
1254            && named_prose_language(&sample).is_some();
1255        if !named {
1256            if det.script != "latin" && det.script != "unknown" {
1257                let n_alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as i64;
1258                if non_latin_words(&sample).is_empty() || n_alpha < NON_LATIN_MIN_LETTERS {
1259                    continue;
1260                }
1261            } else if !(det.diacritic_rate >= NON_EN_DIACRITIC_RATE
1262                && word_re().find_iter(&sample).count() >= 4)
1263            {
1264                continue;
1265            }
1266        }
1267        let n_alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as i64;
1268        if n_alpha > best_n {
1269            best_n = n_alpha;
1270            best = Some(det);
1271        }
1272    }
1273    best
1274}
1275
1276pub fn analyse(state: &Value) -> Analysis {
1277    let mut result = analyse_text(&state_text(state, 4000));
1278    // A mostly-English state can hide a customer's non-English line behind a longer English stack
1279    // trace or form template. The whole reads as English, but the English checkpoint cannot read
1280    // the customer's part, so a state that would go to English is checked line by line and field by
1281    // field. A single line has no other part to outvote it and was just read whole.
1282    if result.script == "latin" && result.is_english {
1283        let mut leaves: Vec<String> = Vec::new();
1284        iter_text(state, 0, &mut leaves);
1285        if leaves.len() > 1 || leaves.iter().any(|l| l.contains('\n')) {
1286            if let Some((seg_lang, seg)) = non_english_segment(state, 4000) {
1287                result.language = Some(seg_lang);
1288                result.is_english = false;
1289                result.language_undecided = false;
1290                result.mixed_segment = Some(seg);
1291            }
1292        }
1293    }
1294    // A plain string was just read whole. A structured state can still hide a message past the
1295    // segment cap, or in a script `latin_profile` does not name: read every string value on its
1296    // own, and let one non-English value decide (upstream #384).
1297    if matches!(state, Value::String(_) | Value::Null) || !result.is_english {
1298        return result;
1299    }
1300    let mut leaves: Vec<String> = Vec::new();
1301    iter_text(state, 0, &mut leaves);
1302    let mut best: Option<Analysis> = None;
1303    let mut best_n: i64 = -1;
1304    for leaf in &leaves {
1305        let Some(det) = leaf_non_english(leaf) else {
1306            continue;
1307        };
1308        let capped: String = leaf.chars().take(4000).collect();
1309        let n_alpha = capped.chars().filter(|c| c.is_alphabetic()).count() as i64;
1310        if n_alpha > best_n {
1311            best_n = n_alpha;
1312            best = Some(det);
1313        }
1314    }
1315    if let Some(det) = best {
1316        result.language = det.language;
1317        result.is_english = false;
1318        result.language_undecided = det.language_undecided;
1319    }
1320    result
1321}
1322
1323/// True when the English checkpoint can be expected to read this state.
1324pub fn is_english(state: &Value) -> bool {
1325    analyse(state).is_english
1326}
1327
1328#[cfg(test)]
1329mod tests {
1330    use super::*;
1331    use serde_json::json;
1332
1333    fn s(t: &str) -> Value {
1334        Value::String(t.to_string())
1335    }
1336
1337    #[test]
1338    fn detects_devanagari_as_non_latin() {
1339        assert_eq!(detect_script("मुझसे दो बार शुल्क लिया गया"), "devanagari");
1340    }
1341
1342    #[test]
1343    fn routes_english_latin_to_english() {
1344        let a = analyse(&s("Please refund the duplicate charge"));
1345        assert_eq!(a.script, "latin");
1346        assert!(a.is_english);
1347        assert_eq!(a.language.as_deref(), Some("en"));
1348    }
1349
1350    #[test]
1351    fn routes_german_latin_to_non_english() {
1352        assert!(!is_english(&s("Der Kunde wurde zweimal belastet")));
1353    }
1354
1355    #[test]
1356    fn collision_word_repeat_counts_once() {
1357        // `do` is Portuguese but also ordinary English; repeating it four times used to score
1358        // Portuguese 4 and route plain English to the multilingual checkpoint. Counting a collision
1359        // word once keeps this English (upstream `_EN_COLLISION_WORDS`).
1360        assert_eq!(
1361            latin_profile("do do do do please refund me")
1362                .language
1363                .as_deref(),
1364            Some("en")
1365        );
1366        // A non-collision function word still counts every occurrence: `der` twice stays German.
1367        assert_eq!(
1368            latin_profile("reduzieren der helligkeit der lichter")
1369                .language
1370                .as_deref(),
1371            Some("de")
1372        );
1373    }
1374
1375    #[test]
1376    fn detects_french_multilingual_signals() {
1377        let a = analyse(&s("Je voudrais un remboursement pour la commande"));
1378        assert!(!a.is_english);
1379        assert_eq!(a.language.as_deref(), Some("fr"));
1380    }
1381
1382    #[test]
1383    fn unknown_no_letters_is_english_and_undecided() {
1384        let a = analyse(&s("123 !!!"));
1385        assert_eq!(a.script, "unknown");
1386        assert!(a.is_english);
1387        assert!(a.language_undecided);
1388    }
1389
1390    #[test]
1391    fn cjk_han_is_not_english() {
1392        let a = analyse(&s("请退还重复的费用"));
1393        assert_eq!(a.script, "han");
1394        assert!(!a.is_english);
1395    }
1396
1397    #[test]
1398    fn hindi_devanagari_is_not_english() {
1399        let a = analyse(&s("मुझसे दो बार शुल्क लिया गया"));
1400        assert_eq!(a.script, "devanagari");
1401        assert!(!a.is_english);
1402    }
1403
1404    #[test]
1405    fn romanized_bangla_detected() {
1406        let a = analyse(&s("ami ekta refund chai amar order er jonno"));
1407        assert_eq!(a.script, "latin");
1408        assert_eq!(a.language.as_deref(), Some("bn"));
1409        assert!(!a.is_english);
1410    }
1411
1412    #[test]
1413    fn url_only_not_misdetected_as_foreign() {
1414        // Without identifier stripping, four "com" tokens would score Portuguese/Italian.
1415        let a = analyse(&s("acme.com foo.com bar.com test.com"));
1416        assert_eq!(a.language, None);
1417        assert!(a.is_english);
1418    }
1419
1420    #[test]
1421    fn all_caps_acronym_line_is_not_foreign_prose() {
1422        // A line of nothing but acronyms and place names is shouted, not foreign prose (upstream
1423        // fix/lang-allcaps-acronyms): no long matched stopword and no non-English diacritic, so it
1424        // shows no more than acronym-shaped evidence and stays English.
1425        let a = analyse(&s("MON DES EST LA"));
1426        assert_eq!(a.language, None);
1427        assert!(a.is_english);
1428        let a = analyse(&s("STORES LOS ANGELES LAS VEGAS EL PASO CLOSED"));
1429        assert_eq!(a.language, None);
1430        assert!(a.is_english);
1431    }
1432
1433    #[test]
1434    fn shouted_line_with_diacritics_is_kept_foreign() {
1435        // A shouted line carried by non-English letters still leaves the English checkpoint.
1436        assert!(!analyse(&s("WIE SPÄT IST ES IN KÖLN")).is_english);
1437        // The ASCII spelling has no diacritic to fall through to and only a three-letter matched
1438        // stopword, so it routes English — a documented upper-cased casualty, not a regression.
1439        assert!(is_english(&s("WIE SPAET IST ES IN KOELN")));
1440    }
1441
1442    #[test]
1443    fn emphasis_capitals_do_not_erase_the_language() {
1444        // A caps run of `SHOUTED_MIN_WORD` letters is a word, not an acronym, so it is not blanked
1445        // out of its own mixed-case sentence; the shouted words still name the language.
1446        assert_eq!(
1447            analyse(&s("sag mir das HEUTIGE DATUM")).language.as_deref(),
1448            Some("de")
1449        );
1450        assert_eq!(
1451            analyse(&s("quiero cancelar mi PEDIDO POR FAVOR"))
1452                .language
1453                .as_deref(),
1454            Some("es")
1455        );
1456    }
1457
1458    #[test]
1459    fn english_line_above_acronym_line_reads_english() {
1460        // The all-caps acronyms no longer vote in the whole-state verdict, so one English line
1461        // above an acronym line carries the state (the segment scan vetoes the acronym line).
1462        let a = analyse(&s("Please refund my order\nMON DES EST LA"));
1463        assert!(a.is_english);
1464        assert_eq!(a.language.as_deref(), Some("en"));
1465    }
1466
1467    #[test]
1468    fn script_profile_latin_first_and_fractions() {
1469        let p = script_profile("abc देव");
1470        // latin inserted first; both scripts present.
1471        let keys: Vec<&String> = p.keys().collect();
1472        assert_eq!(keys[0], "latin");
1473        assert!(p.contains_key("devanagari"));
1474        let sum: f64 = p.values().sum();
1475        assert!((sum - 1.0).abs() < 1e-9);
1476    }
1477
1478    #[test]
1479    fn nested_state_is_flattened() {
1480        let state = json!({"a": "Please refund", "b": ["the", "duplicate", "charge"]});
1481        assert!(is_english(&state));
1482    }
1483
1484    #[test]
1485    fn round4_is_half_to_even() {
1486        assert_eq!(round_half_even(2.5), 2.0);
1487        assert_eq!(round_half_even(3.5), 4.0);
1488    }
1489
1490    #[test]
1491    fn mixed_field_makes_state_non_english() {
1492        // A mostly-English state (the English log is longer) whose own field reads as Portuguese
1493        // must not go to the English checkpoint (upstream #207).
1494        let state = json!({
1495            "customer": "Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes",
1496            "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"
1497        });
1498        let a = analyse(&state);
1499        assert_eq!(a.language.as_deref(), Some("pt"));
1500        assert!(!a.is_english);
1501        assert_eq!(
1502            a.mixed_segment.as_deref(),
1503            Some("Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes")
1504        );
1505    }
1506
1507    #[test]
1508    fn mixed_segment_ignores_code_lines() {
1509        // A pasted stack trace reads as function words when split (`os.path`, `round(el, 2)`,
1510        // `non_english`); a line carrying code syntax must not count as a foreign segment.
1511        let a = analyse(&s(
1512            "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",
1513        ));
1514        assert!(a.is_english);
1515        assert_eq!(a.language.as_deref(), Some("en"));
1516        assert_eq!(a.mixed_segment, None);
1517    }
1518
1519    #[test]
1520    fn single_line_never_reports_mixed_segment() {
1521        // A single line has no other part to be outvoted by, so it is judged whole, never split.
1522        let a = analyse(&s("Please refund the duplicate charge on my account today"));
1523        assert_eq!(a.mixed_segment, None);
1524    }
1525
1526    #[test]
1527    fn one_loanword_keeps_plain_english() {
1528        // A single accented loanword/proper noun clears the diacritic floor over a short sentence,
1529        // but two English-only function words and at most one accented word rescue it (#337).
1530        let a = analyse(&s(
1531            "Please send José the invoice and the refund for his order today",
1532        ));
1533        assert_eq!(a.language.as_deref(), Some("en"));
1534        assert!(a.is_english);
1535        let a = analyse(&s(
1536            "I would like a refund for the résumé service I paid for twice this month",
1537        ));
1538        assert_eq!(a.language.as_deref(), Some("en"));
1539        assert!(a.is_english);
1540    }
1541
1542    #[test]
1543    fn loanword_rescue_is_bounded() {
1544        // A diacritic rate at/above 0.06 is a real non-English vocabulary, not a loanword: no rescue.
1545        let a = analyse(&s(
1546            "We met at the café for a naïve chat about the résumé and the soirée plans",
1547        ));
1548        assert!(!a.is_english);
1549        // More than one word carries a non-English letter (Swedish two/gånger): rescue denied (#350).
1550        let a = analyse(&s(
1551            "Jag har blivit debiterad två gånger och vill ha pengarna tillbaka nu",
1552        ));
1553        assert!(!a.is_english);
1554    }
1555
1556    #[test]
1557    fn per_leaf_detection_finds_hidden_non_english_field() {
1558        // A German field behind an English field longer than the 4000-char segment-scan budget: the
1559        // joined window reads English and the segment scan never reaches it, so only reading each
1560        // string value on its own catches it (#384). No mixed_segment on this path.
1561        let big_en =
1562            "please help me with the account issue and the refund for the order ".repeat(80);
1563        let state = json!({
1564            "a_log": big_en,
1565            "z_customer": "Mein Konto wurde zweimal belastet und ich brauche dringend Hilfe",
1566        });
1567        let a = analyse(&state);
1568        assert_eq!(a.language.as_deref(), Some("de"));
1569        assert!(!a.is_english);
1570        assert_eq!(a.mixed_segment, None);
1571    }
1572}