1"""Model loading and bigram scoring utilities.
2
3Note: ``from __future__ import annotations`` is intentionally omitted because
4this module is compiled with mypyc, which does not support PEP 563 string
5annotations.
6"""
7
8import array
9import functools
10import hashlib
11import importlib.resources
12import math
13import operator
14import sys
15import warnings
16
17from chardet import _kernel
18from chardet._kernel import dot_packed, pack_profile
19from chardet.models._format import (
20 langnorm_from_table,
21 parse_langnorm_bin,
22 parse_models_bin,
23 parse_rowmax_bin,
24 rowmax_from_table,
25)
26from chardet.registry import REGISTRY, lookup_encoding
27
28#: Shared empty packed buffers for profiles scored through another path.
29#: Safe to share because nothing ever writes to a profile's packed
30#: buffers --- dot_packed only reads them, and pack_profile always
31#: returns fresh arrays. Rebuilding these per profile allocated four
32#: throwaway arrays on the focused-profile path, which the surrounding
33#: code exists to keep allocation-free.
34_EMPTY_PACKED = (array.array("i"), array.array("i"))
35
36#: Whether _kernel was compiled into an extension for this install.
37#: The packed buffers only pay off when it was: read from C they are two
38#: contiguous int32 arrays, but read from the interpreter every element
39#: access boxes an int, which is slower than the dense list it replaced.
40#: A build with mypyc but no Cython would otherwise get the packed layout
41#: with an interpreted loop -- measured 3.5x slower than either path
42#: alone -- so profiles keep the dense table instead when it is absent.
43#:
44#: This flag makes two scoring paths and two storage shapes live in one
45#: file, and **no single test run exercises both**: a compiled run never
46#: takes the dense branch, an interpreted run never takes the packed one.
47#: Running the suite against both builds is therefore load-bearing rather
48#: than thorough --- it is the only thing standing between these branches
49#: and silently diverging. Change either one and check the other.
50_KERNEL_COMPILED = _kernel.__file__.endswith((".so", ".pyd"))
51
52#: Whether interpreted dense profiles score through ``operator.itemgetter``.
53#: On CPython that moves the per-bigram gather and multiply-add into C,
54#: about 1.7x faster per model than the Python loop. PyPy's JIT runs the
55#: plain loop about 4x faster than the itemgetter form, so it keeps the loop.
56_GATHER_DOT = sys.implementation.name == "cpython"
57
58# 256-entry membership table for whitespace bytes whose runs training
59# collapsed — native byte indexing under mypyc, used in the bigram-profile
60# hot loop. Covers the ASCII whitespace bytes (space, tab, LF, VT, FF, CR)
61# plus NBSP (0xA0): training's run collapse operates on decoded text where
62# regex \s matches all of these, so Latin models carry no run weight for
63# them and an uncollapsed input run would match only unrelated models
64# where the byte happens to be a letter. 0x85 (NEL in the ISO family) is
65# deliberately absent: it decodes to the ellipsis in windows-1252, whose
66# models legitimately carry ellipsis-run weight.
67_ASCII_WHITESPACE_TABLE = bytes(
68 1 if b in (0x20, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0xA0) else 0 for b in range(256)
69)
70
71# Encodings that map to exactly one language, derived from the registry.
72# Keyed by canonical name only — callers always use canonical names.
73_SINGLE_LANG_MAP: dict[str, str] = {}
74for _enc in REGISTRY.values():
75 if len(_enc.languages) == 1:
76 _SINGLE_LANG_MAP[_enc.name] = _enc.languages[0]
77
78
79@functools.cache
80def _load_models_data() -> tuple[dict[str, bytes], dict[str, float]]:
81 """Load and parse models.bin, returning (models, norms).
82
83 Cached: only reads from disk on first call.
84 """
85 ref = importlib.resources.files("chardet.models").joinpath("models.bin")
86 data = ref.read_bytes()
87
88 if not data:
89 warnings.warn(
90 "chardet models.bin is empty — statistical detection disabled; "
91 "reinstall chardet to fix",
92 RuntimeWarning,
93 stacklevel=2,
94 )
95 return {}, {}
96
97 return parse_models_bin(data)
98
99
100def load_models() -> dict[str, bytes]:
101 """Load all bigram models from the bundled models.bin file.
102
103 Each model is a bytes object of length 65536 (256*256).
104 Index: (b1 << 8) | b2 -> weight (0-255).
105
106 :returns: A dict mapping model key strings to 65536-byte lookup tables.
107 """
108 return _load_models_data()[0]
109
110
111def _build_enc_index(
112 models: dict[str, bytes],
113) -> dict[str, list[tuple[str | None, bytes, str]]]:
114 """Build a grouped index from a models dict.
115
116 :param models: Mapping of ``"lang/encoding"`` keys to 65536-byte tables.
117 :returns: Mapping of encoding name to ``[(lang, model, model_key), ...]``.
118 """
119 index: dict[str, list[tuple[str | None, bytes, str]]] = {}
120 for key, model in models.items():
121 lang, enc = key.split("/", 1)
122 index.setdefault(enc, []).append((lang, model, key))
123
124 # Resolve aliases: if a model key uses a non-canonical name,
125 # copy the entry under the canonical name.
126 for enc_name in list(index):
127 canonical = lookup_encoding(enc_name)
128 if canonical is not None and canonical not in index:
129 index[canonical] = index[enc_name]
130
131 return index
132
133
134@functools.cache
135def get_enc_index() -> dict[str, list[tuple[str | None, bytes, str]]]:
136 """Return a pre-grouped index mapping encoding name -> [(lang, model, model_key), ...]."""
137 return _build_enc_index(load_models())
138
139
140def infer_language(encoding: str) -> str | None:
141 """Return the language for a single-language encoding, or None.
142
143 :param encoding: The canonical encoding name.
144 :returns: An ISO 639-1 language code, or ``None`` if the encoding is
145 multi-language.
146 """
147 return _SINGLE_LANG_MAP.get(encoding)
148
149
150def has_model_variants(encoding: str) -> bool:
151 """Return True if the encoding has language variants in the model index.
152
153 :param encoding: The canonical encoding name.
154 :returns: ``True`` if bigram models exist for this encoding.
155 """
156 return encoding in get_enc_index()
157
158
159def _get_model_norms() -> dict[str, float]:
160 """Return cached L2 norms for all models, keyed by model key string."""
161 return _load_models_data()[1]
162
163
164@functools.cache
165def get_rowmax() -> dict[str, bytes]:
166 """Return per-model row-maximum tables for upper-bound prescreening.
167
168 For each model, entry ``b1`` of its 256-byte table holds the maximum
169 weight in the model's row for lead byte ``b1``. Because every bigram
170 weight is bounded by its row maximum, a dot product against the row
171 maxima (256 terms) upper-bounds the dot product against the full table
172 (65536 terms) — statistical scoring uses this to rule out candidate
173 models without scoring them fully.
174
175 Loads the precomputed ``rowmax.bin`` (written by
176 ``chardet.models._format.write_model_artifacts`` in the same model
177 order as ``models.bin``). The file starts with a ``CRM1`` magic and
178 the SHA-256 of the ``models.bin`` it was derived from: a stale or
179 mismatched file would silently under-estimate row maxima and break
180 the upper bound that pruning depends on, so anything that does not match
181 the *current* ``models.bin`` byte-for-byte is rejected and the tables
182 are derived from the models directly (slower, but always correct).
183 """
184 models = load_models()
185 files = importlib.resources.files("chardet.models")
186 try:
187 data = files.joinpath("rowmax.bin").read_bytes()
188 models_digest = hashlib.sha256(
189 files.joinpath("models.bin").read_bytes()
190 ).digest()
191 except (FileNotFoundError, OSError):
192 data = b""
193 models_digest = b""
194 # models preserves models.bin header order, matching rowmax.bin.
195 tables = parse_rowmax_bin(data, models_digest, list(models))
196 if tables is not None:
197 return tables
198 if models:
199 warnings.warn(
200 "chardet rowmax.bin is missing or does not match models.bin; "
201 "deriving row maxima from the models (slower startup)",
202 RuntimeWarning,
203 stacklevel=2,
204 )
205 return {key: rowmax_from_table(table) for key, table in models.items()}
206
207
208@functools.cache
209def get_language_norms() -> dict[str, float]:
210 """Return per-model norms for language scoring, keyed by model key.
211
212 The language tiers score in an IDF-weighted space (see
213 :meth:`BigramProfile.for_language`), where a model's length is its
214 norm after each weight is scaled by the square root of its bigram's
215 IDF. Loads the precomputed ``langnorm.bin`` (written by
216 ``chardet.models._format.write_model_artifacts`` in the same model
217 order as ``models.bin``) and, like :func:`get_rowmax`, rejects a file
218 whose embedded digest does not match the current ``models.bin``:
219 a stale norm table would tilt every language comparison. The
220 fallback derives the norms from the tables directly, which is correct
221 but costs a pass over every model.
222 """
223 models = load_models()
224 files = importlib.resources.files("chardet.models")
225 try:
226 data = files.joinpath("langnorm.bin").read_bytes()
227 models_digest = hashlib.sha256(
228 files.joinpath("models.bin").read_bytes()
229 ).digest()
230 except (FileNotFoundError, OSError):
231 data = b""
232 models_digest = b""
233 norms = parse_langnorm_bin(data, models_digest, list(models))
234 if norms is not None:
235 return norms
236 if models:
237 warnings.warn(
238 "chardet langnorm.bin is missing or does not match models.bin; "
239 "deriving language norms from the models (slower startup)",
240 RuntimeWarning,
241 stacklevel=2,
242 )
243 idf = get_idf_weights()
244 return {key: langnorm_from_table(table, idf) for key, table in models.items()}
245
246
247#: The root-IDF scale table carries this factor so its entries fill a byte:
248#: ``round(16 * sqrt(idf))`` runs from 16 to 255. The language profile's
249#: norm carries the same factor, so it cancels out of every score.
250_LANGUAGE_SCALE_FACTOR = 16
251
252
253@functools.cache
254def get_language_scale() -> bytes:
255 """Return the 65536-byte root-IDF table the language profile multiplies by.
256
257 Entry ``i`` is ``round(16 * sqrt(idf[i]))``. The language tiers
258 compare an IDF-weighted profile against models weighted by the square
259 root of IDF; folding the model-side factor into the profile, as the
260 product of this table and the IDF table per occurrence, leaves the
261 model tables untouched, so the same scoring loop serves both metrics
262 and only the norm differs (see :func:`get_language_norms`). A byte
263 table keeps the profile walk on native indexing: a list of the
264 products costs about as much again as the walk itself.
265 """
266 idf = get_idf_weights()
267 roots = bytes(round(_LANGUAGE_SCALE_FACTOR * math.sqrt(v)) for v in range(256))
268 return bytes(roots[v] for v in idf)
269
270
271@functools.cache
272def get_idf_weights() -> bytes:
273 """Return a 65536-byte IDF weight table for bigram profile construction.
274
275 Loads a precomputed table from ``idf.bin`` (generated at training time).
276 For each bigram index, the weight reflects how discriminative that bigram
277 is across all models:
278
279 - Bigrams in every model (common ASCII) → weight 1 (minimal signal)
280 - Bigrams in one model → weight 255 (maximum signal)
281 - Bigrams not in any model → weight 1 (unknown, treat as neutral)
282 """
283 ref = importlib.resources.files("chardet.models").joinpath("idf.bin")
284 data = ref.read_bytes()
285 if len(data) != 65536:
286 warnings.warn(
287 f"chardet idf.bin has wrong size ({len(data)}), "
288 "falling back to uniform weights",
289 RuntimeWarning,
290 stacklevel=2,
291 )
292 return b"\x01" * 65536
293 return data
294
295
296class BigramProfile:
297 """Pre-computed bigram frequency distribution for a data sample.
298
299 Computing this once and reusing it across all models reduces per-model
300 scoring from O(n) to O(distinct_bigrams).
301
302 Each bigram is weighted by its IDF (inverse document frequency) across all
303 models — bigrams unique to few models get high weight, bigrams common to
304 all models get weight 1. ``nonzero`` lists the indices carrying weight,
305 in first-encounter order. :meth:`for_language` builds the same profile
306 under the language tiers' heavier weighting instead.
307
308 The weights are reachable two ways, and which one a profile fills
309 depends on how it was built:
310
311 * ``idx_arr``/``val_arr`` — parallel ``array('i')`` buffers, what
312 :func:`score_with_profile` reads for a streaming profile. Filled by
313 the streaming constructor only.
314 * ``values`` — a plain list parallel to ``nonzero``, filled by
315 :meth:`from_weighted_freq` for the small focused profiles confusion
316 resolution builds, which are scored inline instead.
317 * ``gather``/``dense_vals`` — an :func:`operator.itemgetter` over
318 ``nonzero`` and the weights it picks out of the dense table, filled
319 for a streaming profile only when the kernel is not compiled and
320 the interpreter is CPython.
321 ``row_freq`` aggregates the weights by lead byte (256 entries) and
322 ``nonzero_rows`` lists the lead bytes with non-zero total; together with
323 per-model row maxima (:func:`get_rowmax`) they let statistical scoring
324 compute a cheap upper bound on a model's score.
325
326 **Input limit.** Weights are packed as int32, which holds any value an
327 input of at most 16 MB can produce (``255`` per occurrence against a
328 2.1-billion ceiling). Detection truncates to ``max_bytes`` long before
329 that; construct a profile directly from a larger buffer and packing
330 raises :exc:`OverflowError`.
331 """
332
333 __slots__ = (
334 "dense_vals",
335 "freq",
336 "gather",
337 "idx_arr",
338 "input_norm",
339 "nonzero",
340 "nonzero_rows",
341 "row_freq",
342 "val_arr",
343 "values",
344 "weight_sum",
345 )
346
347 def __init__(self, data: bytes) -> None:
348 """Compute the bigram frequency distribution for *data*.
349
350 Each bigram is weighted by its IDF (inverse document frequency) across
351 all loaded models. Bigrams unique to few models get high weight;
352 bigrams common to all models get weight 1.
353
354 :param data: The raw byte data to profile.
355 """
356 total_bigrams = len(data) - 1
357 if total_bigrams <= 0:
358 # Empty lists, not [0]*65536: a no-op profile allocates nothing.
359 self.freq: list[int] = []
360 self.nonzero: list[int] = []
361 self.values: list[int] = []
362 self.idx_arr, self.val_arr = _EMPTY_PACKED
363 self.row_freq: list[int] = []
364 self.nonzero_rows: list[int] = []
365 self.weight_sum: int = 0
366 self.input_norm: float = 0.0
367 return
368
369 idf = get_idf_weights()
370 freq: list[int] = [0] * 65536
371 nonzero: list[int] = []
372 w_sum = 0
373 for i in range(total_bigrams):
374 b1 = data[i]
375 b2 = data[i + 1]
376 # Skip repeated-whitespace bigrams (equivalent to collapsing
377 # whitespace runs, which training does before counting): padding
378 # and indentation carry no encoding signal, and models trained
379 # on lossy transcodes can carry spurious weight for them.
380 if b1 == b2 and _ASCII_WHITESPACE_TABLE[b1]:
381 continue
382 idx = (b1 << 8) | b2
383 w = idf[idx]
384 if freq[idx] == 0:
385 nonzero.append(idx)
386 freq[idx] += w
387 w_sum += w
388 # No ``values``: this path already has the dense table, and
389 # materializing a parallel list over every nonzero bigram costs
390 # more than the sparse constructor's allocation saves.
391 self._finish(freq, nonzero, [], w_sum)
392
393 @classmethod
394 def for_language(cls, data: bytes) -> "BigramProfile":
395 """Build the profile the language tiers score with.
396
397 Same bigram walk as the streaming constructor, but each occurrence
398 carries IDF times :func:`get_language_scale` (a byte-quantized
399 root of IDF) rather than IDF alone. Scored against
400 :func:`get_language_norms`, this is the cosine between the
401 IDF-weighted input and models weighted by the square root of IDF.
402 The plain cosine lets one high-IDF bigram that
403 two languages share, such as the UTF-8 pair for ``é``, outweigh
404 every bigram that tells them apart: it rates the model by the share
405 that bigram takes of its *unweighted* length, so an accent-heavy
406 language wins French text on the strength of ``é`` alone. Weighting
407 the model side too charges each model for its rare bigrams, and the
408 common bigrams decide again.
409
410 :param data: The raw byte data to profile.
411 :returns: A profile for use with ``language_weighting=True``.
412 """
413 profile = cls(b"")
414 total_bigrams = len(data) - 1
415 if total_bigrams <= 0:
416 return profile
417 idf = get_idf_weights()
418 freq: list[int] = [0] * 65536
419 nonzero: list[int] = []
420 for i in range(total_bigrams):
421 b1 = data[i]
422 b2 = data[i + 1]
423 if b1 == b2 and _ASCII_WHITESPACE_TABLE[b1]:
424 continue
425 idx = (b1 << 8) | b2
426 if freq[idx] == 0:
427 nonzero.append(idx)
428 freq[idx] += idf[idx]
429 # The walk above is the streaming constructor's, under IDF alone.
430 # The model-side factor (root IDF, scaled to a byte) is applied once
431 # per distinct bigram rather than once per occurrence, and the
432 # profile's own length is taken under IDF with the same scale
433 # factor so it cancels: the quotient is then the cosine between the
434 # IDF-weighted input and the root-IDF-weighted model.
435 scale = get_language_scale()
436 w_sum = 0
437 norm_sq = 0
438 for idx in nonzero:
439 v = freq[idx]
440 norm_sq += v * v
441 w = v * scale[idx]
442 freq[idx] = w
443 w_sum += w
444 profile._finish(freq, nonzero, [], w_sum)
445 profile.input_norm = _LANGUAGE_SCALE_FACTOR * math.sqrt(norm_sq)
446 return profile
447
448 def _finish(
449 self,
450 freq: list[int],
451 nonzero: list[int],
452 values: list[int],
453 weight_sum: int,
454 ) -> None:
455 """Store the frequency data and derive the norm and row aggregates.
456
457 Single finalization path shared by both constructors so pruning
458 fields cannot silently diverge between them.
459
460 Exactly one of *freq* and *values* carries the weights, never both.
461 The streaming constructor passes the dense *freq* table it had to
462 build and leaves *values* empty; the sparse constructor fills
463 *values* parallel to *nonzero* and passes no table, which is what
464 lets it skip a 65536-entry allocation per call.
465
466 Neither is retained. *freq* is read here to derive the norm, the row
467 aggregates and the packed buffers, and is then dropped -- scoring
468 reads ``idx_arr``/``val_arr`` or ``values``, never the dense table,
469 so keeping it alive would pin 512 KB per profile for nothing.
470 """
471 self.nonzero = nonzero
472 self.values = values
473 self.weight_sum = weight_sum
474 norm_sq = 0
475 row_freq: list[int] = [0] * 256
476 if values:
477 for i in range(len(nonzero)):
478 v = values[i]
479 norm_sq += v * v
480 row_freq[nonzero[i] >> 8] += v
481 else:
482 for idx in nonzero:
483 v = freq[idx]
484 norm_sq += v * v
485 row_freq[idx >> 8] += v
486 self.input_norm = math.sqrt(norm_sq)
487 self.row_freq = row_freq
488 self.nonzero_rows = [b1 for b1 in range(256) if row_freq[b1]]
489 # Dense profiles are scored through the packed kernel when it is
490 # compiled; sparse ones (a median of eight bigrams) are scored inline
491 # either way and skip the packing.
492 if values or not _KERNEL_COMPILED:
493 self.idx_arr, self.val_arr = _EMPTY_PACKED
494 else:
495 self.idx_arr, self.val_arr = pack_profile(nonzero, freq)
496 # Retained only when scoring will read it -- see _KERNEL_COMPILED.
497 self.freq = [] if (values or _KERNEL_COMPILED) else freq
498 # See _GATHER_DOT. Skipped below two bigrams, where itemgetter
499 # returns a bare value rather than a tuple.
500 self.gather: operator.itemgetter[tuple[int, ...]] | None = None
501 self.dense_vals: tuple[int, ...] = ()
502 if self.freq and len(nonzero) > 1 and _GATHER_DOT:
503 gather = operator.itemgetter(*nonzero)
504 self.gather = gather
505 self.dense_vals = gather(freq)
506
507 @classmethod
508 def from_weighted_freq(cls, weighted_freq: dict[int, int]) -> "BigramProfile":
509 """Create a BigramProfile from pre-computed weighted frequencies.
510
511 Computes ``weight_sum`` and ``input_norm`` from *weighted_freq* to
512 ensure consistency between the stored fields.
513
514 Deliberately does not build the dense 65536-entry ``freq`` table
515 the streaming constructor uses. Callers here pass a handful of
516 bigrams — confusion resolution's focused profiles hold a median of
517 eight — and allocating a 65536-element list per call cost about
518 24us, which measured as roughly 40% of the whole bigram-rescore
519 stage. Scoring reads ``nonzero``/``values``, so the table is
520 never needed.
521
522 :param weighted_freq: Mapping of bigram index to weighted count.
523 :returns: A new :class:`BigramProfile` instance.
524 """
525 profile = cls(b"")
526 nonzero: list[int] = []
527 values: list[int] = []
528 w_sum = 0
529 for idx, count in weighted_freq.items():
530 if count:
531 nonzero.append(idx)
532 values.append(count)
533 w_sum += count
534 profile._finish([], nonzero, values, w_sum)
535 return profile
536
537
538def score_with_profile(
539 profile: BigramProfile,
540 model: "bytes | bytearray | memoryview",
541 model_key: str = "",
542 *,
543 language_weighting: bool = False,
544) -> float:
545 """Score a pre-computed bigram profile against a single model using cosine similarity.
546
547 ``bytearray``/``memoryview`` tables are accepted for compatibility but
548 copied to ``bytes`` first: the narrow type lets mypyc compile the
549 dot-product loop with native byte indexing (and keeps compiled and
550 pure-Python installs accepting the same argument types).
551
552 With *language_weighting* the profile must come from
553 :meth:`BigramProfile.for_language` and the model is normalized by its
554 :func:`get_language_norms` entry, which together give the cosine in
555 the language tiers' IDF-weighted space.
556 """
557 if not isinstance(model, bytes):
558 model = bytes(model)
559 if profile.input_norm == 0.0:
560 return 0.0
561 norms = get_language_norms() if language_weighting else _get_model_norms()
562 model_norm = norms.get(model_key) if model_key else None
563 if model_norm is None:
564 if language_weighting:
565 model_norm = langnorm_from_table(model, get_idf_weights())
566 else:
567 sq_sum = 0
568 for i in range(65536):
569 v = model[i]
570 if v:
571 sq_sum += v * v
572 model_norm = math.sqrt(sq_sum)
573 if model_norm == 0.0:
574 return 0.0
575 nonzero = profile.nonzero
576 values = profile.values
577 if values:
578 # Focused confusion profiles hold a median of eight bigrams, so this
579 # branch stays inline: a call into _kernel would cost about what the
580 # loop does. It measured 1.3% of compiled runtime.
581 dot = 0
582 for i in range(len(nonzero)):
583 dot += model[nonzero[i]] * values[i]
584 elif _KERNEL_COMPILED:
585 dot = dot_packed(profile.idx_arr, profile.val_arr, model)
586 else:
587 # No compiled kernel: the dense table beats packed buffers here,
588 # because a list returns a cached int where array('i') boxes a new one.
589 gather = profile.gather
590 if gather is not None:
591 dot = sum(map(operator.mul, gather(model), profile.dense_vals))
592 else:
593 dot = 0
594 freq = profile.freq
595 for idx in nonzero:
596 dot += model[idx] * freq[idx]
597 return dot / (model_norm * profile.input_norm)
598
599
600#: ADR-0005's hand-audited rare-language set, shared with the encoding-side
601#: arbitration gate in ``pipeline.postprocess``. One set, two gates: the
602#: deployment evidence that justifies membership is recorded in the ADR and
603#: any new genuine specimen forces a re-audit of both.
604RARE_LANGUAGES: frozenset[str] = frozenset({"gd", "cy", "ga", "br"})
605
606#: The ANSI/ASCII-art pseudo-language. Never a valid demotion target for
607#: the thin-rare band: swapping a rare label for "zxx" would tell the
608#: orchestrator the text has no linguistic content at all.
609ART_LANGUAGE = "zxx"
610
611#: The thin-margin band for ``demote_thin_rare``: inputs shorter than this
612#: are where bigram cosines stop discriminating (apostrophe-rich English
613#: snippets score as Gaelic). The smallest genuine rare-language files in
614#: the test corpus are 253 bytes (legacy path) and 560 bytes (fill path),
615#: 2x and 4.4x above this gate; the callers in ``pipeline.language`` own
616#: the length judgment because only they know the pre-transcoding size.
617_THIN_RARE_MAX_BYTES = 128
618
619#: Maximum lead over the best prevalent-language variant for a rare win on
620#: a short input to count as noise, measured in the language tiers' weighted
621#: cosine. The calibrated English mislabel snippet wins as Gaelic by 0.039
622#: and the pinned Irish casualty by 0.053. This is NOT a safety floor:
623#: genuine gd/cy/ga snippets measure 0.14+ at 60 characters, but short
624#: Breton and Irish can sit inside the band and are the accepted casualty
625#: class per ADR-0005's addendum — widening the margin widens that class.
626_THIN_RARE_MARGIN = 0.06
627
628
629def score_best_language(
630 data: bytes,
631 encoding: str,
632 profile: BigramProfile | None = None,
633 *,
634 demote_thin_rare: bool = False,
635 language_weighting: bool = False,
636) -> tuple[float, str | None]:
637 """Score data against all language variants of an encoding.
638
639 Returns (best_score, best_language). Uses a pre-grouped index for O(L)
640 lookup where L is the number of language variants for the encoding.
641
642 If *profile* is provided, it is reused instead of recomputing the bigram
643 frequency distribution from *data*.
644
645 :param data: The raw byte data to score.
646 :param encoding: The canonical encoding name to match against.
647 :param profile: Optional pre-computed :class:`BigramProfile` to reuse.
648 :param demote_thin_rare: Pass true only when the caller has judged the
649 *original* input thin (under :data:`_THIN_RARE_MAX_BYTES` before
650 any transcoding). A :data:`RARE_LANGUAGES` winner that leads the
651 best prevalent-language variant by less than the measured noise
652 band is then reported under the prevalent language instead. The
653 length judgment deliberately lives with the caller: this function
654 may receive transcoded bytes or a profile without its source data,
655 so ``len(data)`` here is not a reliable proxy for input size.
656 Language-fill callers pass this; encoding-ranking callers must not,
657 so that candidate ordering stays byte-identical.
658 :param language_weighting: Score in the language tiers' IDF-weighted
659 space (see :meth:`BigramProfile.for_language`). A *profile* passed
660 alongside must have been built by that constructor. The
661 encoding-ranking caller leaves this off for the same reason as
662 *demote_thin_rare*: its scores feed the confidence and ordering of
663 encodings, and the weighted cosine lives on a different scale.
664 :returns: A ``(score, language)`` tuple. The score is always the best
665 cosine similarity across variants; the language matches it except
666 when ``demote_thin_rare`` fires, in which case the label is the
667 best prevalent-language variant's while the score remains the
668 rare winner's.
669 """
670 if not data and profile is None:
671 return 0.0, None
672
673 index = get_enc_index()
674 variants = index.get(encoding)
675 if variants is None:
676 return 0.0, None
677
678 if profile is None:
679 profile = (
680 BigramProfile.for_language(data)
681 if language_weighting
682 else BigramProfile(data)
683 )
684
685 best_score = 0.0
686 best_lang: str | None = None
687 best_prevalent = 0.0
688 best_prevalent_lang: str | None = None
689 for lang, model, model_key in variants:
690 s = score_with_profile(
691 profile, model, model_key, language_weighting=language_weighting
692 )
693 if s > best_score:
694 best_score = s
695 best_lang = lang
696 if (
697 demote_thin_rare
698 and lang not in RARE_LANGUAGES
699 and lang != ART_LANGUAGE
700 and s > best_prevalent
701 ):
702 best_prevalent = s
703 best_prevalent_lang = lang
704
705 if (
706 demote_thin_rare
707 and best_lang is not None
708 and best_lang in RARE_LANGUAGES
709 and best_prevalent_lang is not None
710 and best_score - best_prevalent < _THIN_RARE_MARGIN
711 ):
712 best_lang = best_prevalent_lang
713
714 return best_score, best_lang