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/// Language code for one non-code line, or `None` when it does not name a foreign language.
967///
968/// Same evidence bar as [`non_english_segment`]: at least four words, a language named by
969/// [`latin_profile`], and two *different* words of that language. Acronyms and slash compounds are
970/// not words. Mirrors `_named_prose_language` in `laya/lang.py`.
971fn named_prose_language(segment: &str) -> Option<String> {
972    if segment.trim().is_empty() || code_line_re().is_match(segment) {
973        return None;
974    }
975    let joined = joined_re();
976    let prose = segment
977        .split_whitespace()
978        .filter(|tok| !joined.is_match(tok))
979        .collect::<Vec<_>>()
980        .join(" ");
981    let prose = if prose.chars().any(|c| c.is_lowercase()) {
982        letter_run_re()
983            .replace_all(&prose, |caps: &regex::Captures| {
984                let m = &caps[0];
985                if is_all_upper(m) {
986                    " ".to_string()
987                } else {
988                    m.to_string()
989                }
990            })
991            .into_owned()
992    } else {
993        prose
994    };
995    let tokens: Vec<&str> = word_re().find_iter(&prose).map(|m| m.as_str()).collect();
996    if tokens.len() < 4 {
997        return None;
998    }
999    let lang = match latin_profile(&prose).language {
1000        Some(l) if l != "en" => l,
1001        _ => return None,
1002    };
1003    let sw = stop().get(lang.as_str())?;
1004    let lowered: HashSet<String> = tokens.iter().map(|w| w.to_lowercase()).collect();
1005    if lowered.iter().filter(|w| sw.contains(w.as_str())).count() < 2 {
1006        return None;
1007    }
1008    Some(lang)
1009}
1010
1011/// First line or field that, read on its own, is named a non-English language, else `None`.
1012///
1013/// Returns `(language, segment)`. A segment needs the evidence a whole state needs — at least four
1014/// words and a language named by [`latin_profile`] — and, because one line carries far less text,
1015/// two things more: the words that name the language must be two *different* ones, and acronyms
1016/// and slash compounds are not words. Reads at most `max_chars` characters in all. Mirrors
1017/// `_non_english_segment` in `laya/lang.py`.
1018fn non_english_segment(state: &Value, max_chars: usize) -> Option<(String, String)> {
1019    let mut leaves: Vec<String> = Vec::new();
1020    iter_text(state, 0, &mut leaves);
1021    let mut seen = 0usize;
1022    for leaf in &leaves {
1023        for seg in leaf.split('\n') {
1024            if seen >= max_chars {
1025                return None;
1026            }
1027            let seg: String = seg.chars().take(max_chars - seen).collect();
1028            seen += seg.chars().count();
1029            if let Some(lang) = named_prose_language(&seg) {
1030                return Some((lang, seg.trim().to_string()));
1031            }
1032        }
1033    }
1034    None
1035}
1036
1037// --- top-level analysis ----------------------------------------------------
1038
1039/// Result of [`analyse`]. Field names/semantics mirror the Python dict keys.
1040#[derive(Debug, Clone)]
1041pub struct Analysis {
1042    pub script: String,
1043    pub script_profile: IndexMap<String, f64>,
1044    pub language: Option<String>,
1045    pub is_english: bool,
1046    pub language_undecided: bool,
1047    pub diacritic_rate: f64,
1048    pub non_latin_fraction: f64,
1049    /// The line or field that made a mostly-English state non-English, else `None`.
1050    pub mixed_segment: Option<String>,
1051}
1052
1053/// Detection result for one already-flattened string. Does not look for a foreign line inside
1054/// mostly-English text — [`analyse`] does that, because it needs the original state and not only
1055/// the joined window. Mirrors `_analyse_text` in `laya/lang.py`.
1056fn analyse_text(text: &str) -> Analysis {
1057    let prof = script_profile(text);
1058    let mut script = detect_script(text);
1059
1060    let non_latin = if !prof.is_empty() {
1061        round4(1.0 - prof.get("latin").copied().unwrap_or(0.0))
1062    } else {
1063        0.0
1064    };
1065    let sum_alpha = text.chars().filter(|c| c.is_alphabetic()).count() as f64;
1066    let n_non_latin = round_half_even(non_latin * sum_alpha) as i64;
1067
1068    if script == "latin"
1069        && !non_latin_words(text).is_empty()
1070        && (non_latin >= NON_LATIN_FRACTION
1071            || (non_latin >= NON_LATIN_MIN_FRACTION && n_non_latin >= NON_LATIN_MIN_LETTERS))
1072    {
1073        // reclassify to the dominant non-latin script (first max on a tie, insertion order)
1074        let mut best: Option<&str> = None;
1075        let mut best_v = f64::NEG_INFINITY;
1076        for (k, v) in &prof {
1077            if k == "latin" {
1078                continue;
1079            }
1080            if *v > best_v {
1081                best_v = *v;
1082                best = Some(k);
1083            }
1084        }
1085        if let Some(b) = best {
1086            script = b.to_string();
1087        }
1088    }
1089
1090    if script == "unknown" {
1091        return Analysis {
1092            script: "unknown".to_string(),
1093            script_profile: prof,
1094            language: None,
1095            is_english: true,
1096            language_undecided: true,
1097            diacritic_rate: 0.0,
1098            non_latin_fraction: 0.0,
1099            mixed_segment: None,
1100        };
1101    }
1102    if script != "latin" {
1103        return Analysis {
1104            script,
1105            script_profile: prof,
1106            language: None,
1107            is_english: false,
1108            language_undecided: true,
1109            diacritic_rate: 0.0,
1110            non_latin_fraction: non_latin,
1111            mixed_segment: None,
1112        };
1113    }
1114
1115    let prof_lat = latin_profile(text);
1116    let lang = prof_lat.language.clone();
1117    let undecided = lang.is_none();
1118    let english = lang.as_deref() == Some("en") || (undecided && !prof_lat.looks_non_english);
1119    Analysis {
1120        script: "latin".to_string(),
1121        script_profile: prof,
1122        language: lang,
1123        is_english: english,
1124        language_undecided: undecided,
1125        diacritic_rate: round4(prof_lat.diacritic_rate),
1126        non_latin_fraction: non_latin,
1127        mixed_segment: None,
1128    }
1129}
1130
1131/// A string value that is itself not safe for the English checkpoint, else `None`.
1132///
1133/// A one-word name (`José`) and a capitalised non-Latin name stay out: the same rules
1134/// [`latin_profile`] and [`non_latin_words`] already use, so a name field cannot pull an English
1135/// ticket onto the multilingual checkpoint. Code lines, acronyms and slash compounds stay out too,
1136/// matching [`non_english_segment`], so a pasted traceback is not a message. Each line is capped at
1137/// 4000 characters; unlike the segment scan, a long earlier field does not consume the budget of
1138/// the next one (upstream #384). Mirrors `_leaf_non_english` in `laya/lang.py`.
1139fn leaf_non_english(leaf: &str) -> Option<Analysis> {
1140    let mut best: Option<Analysis> = None;
1141    let mut best_n: i64 = -1;
1142    for line in leaf.split('\n') {
1143        // A line this short cannot be selected, so skip it before the slice, `trim`, `_CODE_LINE`
1144        // and a full `analyse_text` pass. Each branch below needs four `word_re` tokens or
1145        // NON_LATIN_MIN_LETTERS letters; four tokens need three separators between them (seven
1146        // characters) and ten letters need ten. Counted on the raw line in code points, matching
1147        // upstream `len(line) < 7`.
1148        if line.chars().count() < 7 {
1149            continue;
1150        }
1151        let sample: String = line.chars().take(4000).collect();
1152        if sample.trim().is_empty() || code_line_re().is_match(&sample) {
1153            continue;
1154        }
1155        let det = analyse_text(&sample);
1156        if det.is_english {
1157            continue;
1158        }
1159        if matches!(det.language.as_deref(), Some(l) if l != "en") {
1160            if named_prose_language(&sample).is_none() {
1161                continue;
1162            }
1163        } else if det.script != "latin" && det.script != "unknown" {
1164            let n_alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as i64;
1165            if non_latin_words(&sample).is_empty() || n_alpha < NON_LATIN_MIN_LETTERS {
1166                continue;
1167            }
1168        } else if !(det.language_undecided
1169            && det.diacritic_rate >= NON_EN_DIACRITIC_RATE
1170            && word_re().find_iter(&sample).count() >= 4)
1171        {
1172            continue;
1173        }
1174        let n_alpha = sample.chars().filter(|c| c.is_alphabetic()).count() as i64;
1175        if n_alpha > best_n {
1176            best_n = n_alpha;
1177            best = Some(det);
1178        }
1179    }
1180    best
1181}
1182
1183pub fn analyse(state: &Value) -> Analysis {
1184    let mut result = analyse_text(&state_text(state, 4000));
1185    // A mostly-English state can hide a customer's non-English line behind a longer English stack
1186    // trace or form template. The whole reads as English, but the English checkpoint cannot read
1187    // the customer's part, so a state that would go to English is checked line by line and field by
1188    // field. A single line has no other part to outvote it and was just read whole.
1189    if result.script == "latin" && result.is_english {
1190        let mut leaves: Vec<String> = Vec::new();
1191        iter_text(state, 0, &mut leaves);
1192        if leaves.len() > 1 || leaves.iter().any(|l| l.contains('\n')) {
1193            if let Some((seg_lang, seg)) = non_english_segment(state, 4000) {
1194                result.language = Some(seg_lang);
1195                result.is_english = false;
1196                result.language_undecided = false;
1197                result.mixed_segment = Some(seg);
1198            }
1199        }
1200    }
1201    // A plain string was just read whole. A structured state can still hide a message past the
1202    // segment cap, or in a script `latin_profile` does not name: read every string value on its
1203    // own, and let one non-English value decide (upstream #384).
1204    if matches!(state, Value::String(_) | Value::Null) || !result.is_english {
1205        return result;
1206    }
1207    let mut leaves: Vec<String> = Vec::new();
1208    iter_text(state, 0, &mut leaves);
1209    let mut best: Option<Analysis> = None;
1210    let mut best_n: i64 = -1;
1211    for leaf in &leaves {
1212        let Some(det) = leaf_non_english(leaf) else {
1213            continue;
1214        };
1215        let capped: String = leaf.chars().take(4000).collect();
1216        let n_alpha = capped.chars().filter(|c| c.is_alphabetic()).count() as i64;
1217        if n_alpha > best_n {
1218            best_n = n_alpha;
1219            best = Some(det);
1220        }
1221    }
1222    if let Some(det) = best {
1223        result.language = det.language;
1224        result.is_english = false;
1225        result.language_undecided = det.language_undecided;
1226    }
1227    result
1228}
1229
1230/// True when the English checkpoint can be expected to read this state.
1231pub fn is_english(state: &Value) -> bool {
1232    analyse(state).is_english
1233}
1234
1235#[cfg(test)]
1236mod tests {
1237    use super::*;
1238    use serde_json::json;
1239
1240    fn s(t: &str) -> Value {
1241        Value::String(t.to_string())
1242    }
1243
1244    #[test]
1245    fn detects_devanagari_as_non_latin() {
1246        assert_eq!(detect_script("मुझसे दो बार शुल्क लिया गया"), "devanagari");
1247    }
1248
1249    #[test]
1250    fn routes_english_latin_to_english() {
1251        let a = analyse(&s("Please refund the duplicate charge"));
1252        assert_eq!(a.script, "latin");
1253        assert!(a.is_english);
1254        assert_eq!(a.language.as_deref(), Some("en"));
1255    }
1256
1257    #[test]
1258    fn routes_german_latin_to_non_english() {
1259        assert!(!is_english(&s("Der Kunde wurde zweimal belastet")));
1260    }
1261
1262    #[test]
1263    fn collision_word_repeat_counts_once() {
1264        // `do` is Portuguese but also ordinary English; repeating it four times used to score
1265        // Portuguese 4 and route plain English to the multilingual checkpoint. Counting a collision
1266        // word once keeps this English (upstream `_EN_COLLISION_WORDS`).
1267        assert_eq!(
1268            latin_profile("do do do do please refund me")
1269                .language
1270                .as_deref(),
1271            Some("en")
1272        );
1273        // A non-collision function word still counts every occurrence: `der` twice stays German.
1274        assert_eq!(
1275            latin_profile("reduzieren der helligkeit der lichter")
1276                .language
1277                .as_deref(),
1278            Some("de")
1279        );
1280    }
1281
1282    #[test]
1283    fn detects_french_multilingual_signals() {
1284        let a = analyse(&s("Je voudrais un remboursement pour la commande"));
1285        assert!(!a.is_english);
1286        assert_eq!(a.language.as_deref(), Some("fr"));
1287    }
1288
1289    #[test]
1290    fn unknown_no_letters_is_english_and_undecided() {
1291        let a = analyse(&s("123 !!!"));
1292        assert_eq!(a.script, "unknown");
1293        assert!(a.is_english);
1294        assert!(a.language_undecided);
1295    }
1296
1297    #[test]
1298    fn cjk_han_is_not_english() {
1299        let a = analyse(&s("请退还重复的费用"));
1300        assert_eq!(a.script, "han");
1301        assert!(!a.is_english);
1302    }
1303
1304    #[test]
1305    fn hindi_devanagari_is_not_english() {
1306        let a = analyse(&s("मुझसे दो बार शुल्क लिया गया"));
1307        assert_eq!(a.script, "devanagari");
1308        assert!(!a.is_english);
1309    }
1310
1311    #[test]
1312    fn romanized_bangla_detected() {
1313        let a = analyse(&s("ami ekta refund chai amar order er jonno"));
1314        assert_eq!(a.script, "latin");
1315        assert_eq!(a.language.as_deref(), Some("bn"));
1316        assert!(!a.is_english);
1317    }
1318
1319    #[test]
1320    fn url_only_not_misdetected_as_foreign() {
1321        // Without identifier stripping, four "com" tokens would score Portuguese/Italian.
1322        let a = analyse(&s("acme.com foo.com bar.com test.com"));
1323        assert_eq!(a.language, None);
1324        assert!(a.is_english);
1325    }
1326
1327    #[test]
1328    fn script_profile_latin_first_and_fractions() {
1329        let p = script_profile("abc देव");
1330        // latin inserted first; both scripts present.
1331        let keys: Vec<&String> = p.keys().collect();
1332        assert_eq!(keys[0], "latin");
1333        assert!(p.contains_key("devanagari"));
1334        let sum: f64 = p.values().sum();
1335        assert!((sum - 1.0).abs() < 1e-9);
1336    }
1337
1338    #[test]
1339    fn nested_state_is_flattened() {
1340        let state = json!({"a": "Please refund", "b": ["the", "duplicate", "charge"]});
1341        assert!(is_english(&state));
1342    }
1343
1344    #[test]
1345    fn round4_is_half_to_even() {
1346        assert_eq!(round_half_even(2.5), 2.0);
1347        assert_eq!(round_half_even(3.5), 4.0);
1348    }
1349
1350    #[test]
1351    fn mixed_field_makes_state_non_english() {
1352        // A mostly-English state (the English log is longer) whose own field reads as Portuguese
1353        // must not go to the English checkpoint (upstream #207).
1354        let state = json!({
1355            "customer": "Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes",
1356            "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"
1357        });
1358        let a = analyse(&state);
1359        assert_eq!(a.language.as_deref(), Some("pt"));
1360        assert!(!a.is_english);
1361        assert_eq!(
1362            a.mixed_segment.as_deref(),
1363            Some("Eu preciso de ajuda com a minha conta pois fui cobrado duas vezes")
1364        );
1365    }
1366
1367    #[test]
1368    fn mixed_segment_ignores_code_lines() {
1369        // A pasted stack trace reads as function words when split (`os.path`, `round(el, 2)`,
1370        // `non_english`); a line carrying code syntax must not count as a foreign segment.
1371        let a = analyse(&s(
1372            "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",
1373        ));
1374        assert!(a.is_english);
1375        assert_eq!(a.language.as_deref(), Some("en"));
1376        assert_eq!(a.mixed_segment, None);
1377    }
1378
1379    #[test]
1380    fn single_line_never_reports_mixed_segment() {
1381        // A single line has no other part to be outvoted by, so it is judged whole, never split.
1382        let a = analyse(&s("Please refund the duplicate charge on my account today"));
1383        assert_eq!(a.mixed_segment, None);
1384    }
1385
1386    #[test]
1387    fn one_loanword_keeps_plain_english() {
1388        // A single accented loanword/proper noun clears the diacritic floor over a short sentence,
1389        // but two English-only function words and at most one accented word rescue it (#337).
1390        let a = analyse(&s(
1391            "Please send José the invoice and the refund for his order today",
1392        ));
1393        assert_eq!(a.language.as_deref(), Some("en"));
1394        assert!(a.is_english);
1395        let a = analyse(&s(
1396            "I would like a refund for the résumé service I paid for twice this month",
1397        ));
1398        assert_eq!(a.language.as_deref(), Some("en"));
1399        assert!(a.is_english);
1400    }
1401
1402    #[test]
1403    fn loanword_rescue_is_bounded() {
1404        // A diacritic rate at/above 0.06 is a real non-English vocabulary, not a loanword: no rescue.
1405        let a = analyse(&s(
1406            "We met at the café for a naïve chat about the résumé and the soirée plans",
1407        ));
1408        assert!(!a.is_english);
1409        // More than one word carries a non-English letter (Swedish two/gånger): rescue denied (#350).
1410        let a = analyse(&s(
1411            "Jag har blivit debiterad två gånger och vill ha pengarna tillbaka nu",
1412        ));
1413        assert!(!a.is_english);
1414    }
1415
1416    #[test]
1417    fn per_leaf_detection_finds_hidden_non_english_field() {
1418        // A German field behind an English field longer than the 4000-char segment-scan budget: the
1419        // joined window reads English and the segment scan never reaches it, so only reading each
1420        // string value on its own catches it (#384). No mixed_segment on this path.
1421        let big_en =
1422            "please help me with the account issue and the refund for the order ".repeat(80);
1423        let state = json!({
1424            "a_log": big_en,
1425            "z_customer": "Mein Konto wurde zweimal belastet und ich brauche dringend Hilfe",
1426        });
1427        let a = analyse(&state);
1428        assert_eq!(a.language.as_deref(), Some("de"));
1429        assert!(!a.is_english);
1430        assert_eq!(a.mixed_segment, None);
1431    }
1432}