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