Repository navigation
Expand file tree
/
Copy pathtext_util.py
More file actions
3936 lines (3365 loc) · 173 KB
/
Copy pathtext_util.py
File metadata and controls
3936 lines (3365 loc) · 173 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
text_util.py
Purpose:
Provides the core text-processing utilities for the ALTO OCR post-processing pipeline.
This includes functions for detecting OCR noise, calculating character/symbol density,
scoring word "weirdness", and running text chunks through a GPU-accelerated Perplexity model.
Categories Outputted:
- Empty : A blank line.
- Non-text : Lines that are too short, lack letters, or are purely numbers/symbols.
- Trash : Severe OCR corruption, high symbol density, gibberish, or failed language ID.
- Noisy : Partially degraded text (e.g., isolated strange symbols, mid-word uppercase).
- Clear : Structurally sound text with low perplexity.
Those five, and only those five, are what this module writes into `lines[].categ`.
They are bound to the CATEG_* label constants below (collected in
CATEGORIES_EMITTED) and cross-checked at import time against the hub registry,
atrium_vocab.LINE_CATEGORY_ORIGINATORS["alto-postprocess"]. The same block's other
authorised originator, digital-convert, emits a DISJOINT set ({Garbage, Inverted});
that is deliberate and is not drift to reconcile.
"""
import configparser
import functools
import itertools
import os
import re
import sys
import unicodedata
from collections.abc import Mapping
from contextlib import contextmanager
from pathlib import Path
from types import MappingProxyType
# The empty lexicon, shared so that "no table configured" and "table unreadable"
# return the same object rather than two equal-but-distinct ones.
_EMPTY_LEXICON: Mapping[str, int] = MappingProxyType({})
# ---------------------------------------------------------------------------
# Line-category labels (hub registry: atrium_vocab.LINE_CATEGORY_ORIGINATORS)
# ---------------------------------------------------------------------------
# The five strings below are the ONLY values this module ever writes into
# `lines[].categ`. They are spelled out here as literals, not derived from the
# registry, and that direction is deliberate:
#
# * these strings ARE the emitted contract. Deriving them would make a stale or
# re-ordered vendored copy of atrium_vocab.py silently change what the
# pipeline outputs -- the registry is a description of behaviour, not a knob
# that steers it;
# * `lines[].categ` has a SECOND authorised originator (`digital-convert`,
# atrium-digital-convert) which emits {Garbage, Inverted}. The two sets are
# disjoint ON PURPOSE -- one is an OCR verdict over a rendered image, the
# other a decode-sanity verdict over an embedded text layer -- so "align the
# two" is never the fix for a disagreement found here.
#
# The registry's role is therefore advisory only: the check below reports a drift
# between this file and the hub declaration and then gets out of the way. See
# defect V-1 in the hub's docs/skos_strategy.md for why a consumer that filters
# this field must handle BOTH sets.
#
# Naming note: the CATEG_*_SCORE_MAX / CATEG_GARBAGE_DENSITY_HIGH constants further
# down are quality-score THRESHOLDS, not labels. Same prefix, different kind. And
# "Process", which pre_filter_line() also returns, is NOT one of these: it is the
# routing sentinel meaning "no verdict yet, score this line" (classify_TEXT.py gates
# on `cat != "Process"`), and it never reaches lines[].categ.
CATEG_EMPTY = "Empty"
CATEG_NON_TEXT = "Non-text"
CATEG_TRASH = "Trash"
CATEG_NOISY = "Noisy"
CATEG_CLEAR = "Clear"
#: Every value `determine_category()` / `categorize_line()` can return, sorted so a
#: comparison against the registry is order-independent. Consumers that need the
#: set (service/text_api.py's `lines[]` projection documents it) should read this
#: rather than re-typing the strings.
CATEGORIES_EMITTED: tuple = tuple(sorted((CATEG_CLEAR, CATEG_EMPTY, CATEG_NOISY, CATEG_NON_TEXT, CATEG_TRASH)))
# Advisory consistency check, NOT a gate. atrium_vocab is a vendored hub-canonical
# file and is legitimately absent in some execution contexts (a bare `text_util.py`
# copied next to a notebook, an image built before the vendor step). A missing
# registry must never break the pipeline, so this follows the house idiom used by
# atrium_document.py's origin check: abstain with a NOTE on stderr, never fatal.
# Silence here means agreement; nothing is printed on the happy path.
try:
from atrium_vocab import LINE_CATEGORY_ORIGINATORS as _VOCAB_LINE_CATEGORY_ORIGINATORS
except ImportError: # registry not vendored here - abstain, do not guess
_VOCAB_LINE_CATEGORY_ORIGINATORS = None
else:
_declared = tuple(sorted(_VOCAB_LINE_CATEGORY_ORIGINATORS.get("alto-postprocess", ())))
if _declared and _declared != CATEGORIES_EMITTED:
print(
"[text_util] NOTE - line-category drift: this module emits "
f"{list(CATEGORIES_EMITTED)} but atrium_vocab declares {list(_declared)} for "
"originator 'alto-postprocess'. Emission is unchanged; reconcile the registry "
"or this file (see defect V-1 in the hub's docs/skos_strategy.md).",
file=sys.stderr,
)
del _declared
# ---------------------------------------------------------------------------
# Ablation Kill-Switch (Part B)
# Inject rule names here via override_constants to disable them for ablation sweeps.
# ---------------------------------------------------------------------------
DISABLED_RULES: frozenset = frozenset()
# ---------------------------------------------------------------------------
# Rule-Fire Coverage Instrumentation (Increment B5)
# When RULE_FIRE_COUNTS is not None (i.e. inside a rule_fire_capture() block),
# every _fire(name) call increments the counter for that rule. Outside a capture
# block _fire() is a no-op so there is zero overhead during normal production runs.
# ---------------------------------------------------------------------------
RULE_FIRE_COUNTS: dict | None = None
def _fire(name: str) -> None:
"""Register a single rule execution against the active capture context."""
if RULE_FIRE_COUNTS is not None:
RULE_FIRE_COUNTS[name] = RULE_FIRE_COUNTS.get(name, 0) + 1
@contextmanager
def rule_fire_capture():
"""Context manager that enables rule-fire counting for the enclosed block.
Yields the live counts dict so callers can inspect it after (or during)
the run.
"""
global RULE_FIRE_COUNTS
prev, RULE_FIRE_COUNTS = RULE_FIRE_COUNTS, {}
try:
yield RULE_FIRE_COUNTS
finally:
RULE_FIRE_COUNTS = prev
# ---------------------------------------------------------------------------
# Configuration & Regular Expressions
# ---------------------------------------------------------------------------
# (12-factor III) Config resolution order, highest precedence first:
#
# 1. environment ATRIUM_<SECTION>_<KEY> e.g. ATRIUM_TEXT_UTILS_SHORT_PPL_CAP=900
# 2. the INI file named by LANGID_CONFIG (default: setup/config.txt)
# 3. the in-code default
#
# The env layer exists because the file was previously the ONLY way to change a
# value: LANGID_CONFIG names a *path*, not a value, so a deploy could not move a
# single threshold without editing a file inside its image or bind-mounting a
# replacement. The section is part of the variable name because keys are not
# unique across sections (WORKERS_MAX appears in both EXTRACT and CLASSIFY).
#
# Values are still read at import time, so an override must be set before the
# process starts. Derived constants (ROT_GHOSTLIST, _LANG_DIACRITICS) are built
# once from these and are not rebuilt by override_constants().
ENV_PREFIX = "ATRIUM_"
_config = configparser.RawConfigParser()
# Anchored to this file, NOT to the working directory. The default used to be the
# relative "setup/config.txt", so a process started anywhere but the repo root
# found nothing and ran every constant on its in-code default after one stderr
# line -- while tools/recategorize_from_csv.py resolved the SAME file absolutely
# via `_ROOT`, giving one process two different configurations.
#
# Measured 2026-09-10: 0 of 84 scalar constants currently differ between the file
# and the in-code defaults, so this is behaviour-neutral today. That is exactly
# why it is worth landing now -- the next round of runs exists to CHANGE those
# constants, and the bug goes live the moment setup/config.txt stops being a
# mirror of the defaults. A cluster job launched from a scheduler's working
# directory would then silently score with the old values.
_DEFAULT_CONFIG_PATH = Path(__file__).resolve().parent / "setup" / "config.txt"
_config_path = Path(os.getenv("LANGID_CONFIG", str(_DEFAULT_CONFIG_PATH)))
if _config_path.exists():
_config.read(_config_path, encoding="utf-8")
elif os.getenv("LANGID_CONFIG"):
# Explicitly pointed somewhere that does not exist. Previously this fell
# through to the in-code defaults in silence, so a typo'd path ran the whole
# collection on defaults and looked exactly like a successful run.
raise FileNotFoundError(
f"LANGID_CONFIG points at {_config_path}, which does not exist. "
f"Unset it to use the bundled setup/config.txt, or correct the path."
)
else:
# Running outside the repo root with no explicit path. Legitimate for a
# library import, but the operator should know the defaults are in force.
print(
f"[config] {_config_path} not found - every constant is using its in-code default. "
f"Set LANGID_CONFIG to silence this.",
file=sys.stderr,
)
def _env_override(section, key):
"""Return the raw env value for a section/key, or None if unset."""
return os.getenv(f"{ENV_PREFIX}{section}_{key}")
def _get_float(section, key, default):
raw = _env_override(section, key)
if raw is not None:
try:
return float(raw)
except ValueError as exc:
raise ValueError(f"{ENV_PREFIX}{section}_{key}={raw!r} is not a float") from exc
return _config.getfloat(section, key, fallback=default) if _config.has_section(section) else default
def _get_str(section, key, default):
raw = _env_override(section, key)
if raw is not None:
return raw
return _config.get(section, key, fallback=default) if _config.has_section(section) else default
def _get_int(section, key, default):
raw = _env_override(section, key)
if raw is not None:
try:
return int(raw)
except ValueError as exc:
raise ValueError(f"{ENV_PREFIX}{section}_{key}={raw!r} is not an int") from exc
return _config.getint(section, key, fallback=default) if _config.has_section(section) else default
def _get_csv_set(section, key, default):
"""Parse a comma-separated config value into a frozenset of stripped tokens."""
raw = _get_str(section, key, default)
return frozenset(t.strip() for t in raw.split(",") if t.strip())
# ---------------------------------------------------------------------------
# The hand-maintained word lists (#30)
# ---------------------------------------------------------------------------
#
# Defined HERE, high in the module, because the constants that read it are
# themselves module-level and are evaluated in source order. Everything below
# needs only `_get_str`, `os`, `Path`, `functools` and `MappingProxyType`, all of
# which exist by this point.
# (#30) Where the hand-maintained word lists live. This is the one file in the
# repository meant to be edited by the people who know the material rather than
# by the people who deploy the code: units, reference labels, section headings,
# and -- the section that did not exist before -- ordinary open-class words this
# archive uses that the program keeps getting wrong.
#
# Every section is a VETO. Nothing in that file can make the program convict a
# line; it can only stop it. Adding a word that was never at risk does nothing.
#
# EMPTY = fall back to the values compiled in below, which is exactly the
# behaviour that shipped before the file existed. A file that omits a section
# falls back for that section alone, so deleting a section cannot silently empty
# a list production depends on.
WORD_LISTS_PATH = _get_str("TEXT_UTILS", "WORD_LISTS_PATH", "setup/word_lists.txt").strip()
if WORD_LISTS_PATH and not os.path.isabs(WORD_LISTS_PATH):
# Anchored to this module, not to the working directory -- the same fix
# 3.plan.md records for the config path itself, made here before it bites.
WORD_LISTS_PATH = str(Path(__file__).resolve().parent / WORD_LISTS_PATH)
#: The sections `setup/word_lists.txt` may define, and the in-code fallback for
#: each. The fallback is what the module used before the file existed, so an empty
#: `WORD_LISTS_PATH` — or a file that simply omits a section — is byte-identical to
#: the behaviour that shipped before 2026-09-22.
#:
#: `allowed` has no fallback and defaults to empty: it is the new open-class layer
#: (#30), and an archive that has not written one has none.
_WORD_LIST_SECTIONS: tuple[str, ...] = ("allowed", "neutral", "notation_labels", "header_labels")
@functools.lru_cache(maxsize=4)
def _read_word_lists(path: str, mtime: float) -> Mapping[str, frozenset]:
"""Parse the hand-maintained section file, keyed on path+mtime so edits are seen.
Format, and it is deliberately the plainest thing that can hold several lists:
``[section]`` opens a section, one token per line, ``#`` starts a comment
anywhere on a line, blanks are skipped, and everything is case-folded. A
section that is absent or empty is simply absent from the result, and the
caller falls back to its in-code default — so deleting a section cannot
silently empty a list that production depends on.
A file that cannot be read degrades to ``{}`` rather than raising. This is
configuration, not input: a missing file must not stop the pipeline, and the
fallbacks are the values that shipped for a year before the file existed.
Cached on (path, mtime) rather than zero-argument, like ``_read_token_lexicon``
above and for the same reason — a zero-argument cache would freeze a flag and
is exactly the class of bug ``_CACHES_FROM_FLAG`` exists to close (#30 D28).
"""
out: dict[str, set] = {}
section: str | None = None
try:
with open(path, encoding="utf-8") as handle:
for raw in handle:
line = raw.split("#", 1)[0].strip()
if not line:
continue
if line.startswith("[") and line.endswith("]"):
section = line[1:-1].strip().lower()
out.setdefault(section, set())
continue
if section is None:
continue
out[section].add(line.lower())
except OSError:
return MappingProxyType({})
return MappingProxyType({k: frozenset(v) for k, v in out.items() if v})
def word_list(section: str, fallback: frozenset, override: str = "") -> frozenset:
"""One section of the hand-maintained file, with the config key still on top.
PRECEDENCE, and the middle layer is the new one:
1. an explicit config/env value -- `ATRIUM_TEXT_UTILS_<KEY>`, or the key
in whatever `LANGID_CONFIG` points at;
2. the `[section]` in `setup/word_lists.txt`;
3. the in-code default.
Layer 1 exists because moving a list into the file would otherwise TAKE AWAY
an operator's ability to override it -- `tests/test_config_constants.py`'s
tier-1 round-trip caught exactly that, by pointing `LANGID_CONFIG` at an
alternate config and finding `ROT_WHITELIST` no longer followed it. The keys
therefore stay in `setup/config.txt`, but EMPTY: empty means "not overridden,
use the file", so the member list lives in one place while the override path
stays open.
Read at CALL time, not at import, so an operator editing the file does not
have to restart a long-running service to see the change -- the mtime key on
the cache above is what makes that cheap.
"""
override = (override or "").strip()
if override:
return frozenset(t.strip() for t in override.split(",") if t.strip())
path = (WORD_LISTS_PATH or "").strip()
if not path:
return fallback
try:
mtime = os.path.getmtime(path)
except OSError:
return fallback
return _read_word_lists(path, mtime).get(section.lower(), fallback)
# (#30) The two [CLASSIFY] language fallbacks, named once.
#
# They used to be spelled out at three call sites -- here, classify_TEXT.main()
# and tools/recategorize_from_csv._load_lang_config() -- and they drifted: the
# offline copy was missing `slk` after the shipped config gained it, so a
# Slovak line reached the guards at TRUST_TIER_UNKNOWN (0.50) offline and
# TRUST_TIER_TRUSTED (0.85) in production. That is the same trust-tier class of
# divergence already fixed three times in this repository's test harnesses.
# Import these rather than retyping the strings.
DEFAULT_EXPECTED_LANGS = "ces,deu,eng"
DEFAULT_TRUSTED_FOREIGN_LANGS = "deu,eng,fra,pol,ita,slk"
COMMON_LANGS = [lang.strip() for lang in DEFAULT_EXPECTED_LANGS.split(",") if lang.strip()]
if _config.has_section("CLASSIFY") and _config.has_option("CLASSIFY", "EXPECTED_LANGS"):
COMMON_LANGS = [lang.strip() for lang in _config.get("CLASSIFY", "EXPECTED_LANGS").split(",") if lang.strip()]
_TRUSTED_FOREIGN_LANG_BASES: frozenset = frozenset(
lang.strip()
for lang in _get_str("CLASSIFY", "TRUSTED_FOREIGN_LANGS", DEFAULT_TRUSTED_FOREIGN_LANGS).split(",")
if lang.strip()
)
def _lang_base(lang_code: str) -> str:
return lang_code.split("_")[0]
CZ_DIACS = frozenset(_get_str("TEXT_UTILS", "CZ_DIACS", "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ"))
METADATA_MARKERS = frozenset(
_get_str("TEXT_UTILS", "METADATA_MARKERS", "Tb.,č.neg,neg.,obr.,obr ,neg ,Tb ,č. neg,č.neg.,č.,str.,Datum").split(
","
)
)
VOWEL_CHARS = frozenset(_get_str("TEXT_UTILS", "VOWEL_CHARS", "aeiouyáéíóúýěůäöüAEIOUYÁÉÍÓÚÝĚŮÄÖÜ"))
ROTATABLE_CHARS = frozenset(_get_str("TEXT_UTILS", "ROTATABLE_CHARS", "pbqdnuwmoxszeyv"))
WQX_CHARS = frozenset(_get_str("TEXT_UTILS", "WQX_CHARS", "wqxWQX"))
NONTEXT_MARKERS = _get_csv_set("TEXT_UTILS", "NONTEXT_MARKERS", "IVerc")
REMAP_KEEP_SCORE_LANGS = _get_csv_set("CLASSIFY", "REMAP_KEEP_SCORE_LANGS", "slk")
def has_cz_diacs(text: str) -> bool:
"""True if *text* contains at least one Czech diacritic glyph."""
return any(ch in CZ_DIACS for ch in text)
_EXPECTED_LANGS_BASES: frozenset = frozenset(_lang_base(lng) for lng in COMMON_LANGS)
PERPLEXITY_THRESHOLD_MAX = _get_float("TEXT_UTILS", "PERPLEXITY_THRESHOLD_MAX", 1000.0)
SHORT_PPL_CAP = _get_float("TEXT_UTILS", "SHORT_PPL_CAP", 850.0)
LANG_SCORE_ROUGH = _get_float("TEXT_UTILS", "LANG_SCORE_ROUGH", 0.45)
# Core signal weights
QS_WEIGHT_VOWEL = _get_float("TEXT_UTILS", "QS_WEIGHT_VOWEL", 0.07)
QS_WEIGHT_LANG = _get_float("TEXT_UTILS", "QS_WEIGHT_LANG", 0.05)
QS_WEIGHT_GIBBERISH = _get_float("TEXT_UTILS", "QS_WEIGHT_GIBBERISH", 0.04)
QS_WEIGHT_FUSED = _get_float("TEXT_UTILS", "QS_WEIGHT_FUSED", 0.03)
QS_LENGTH_MAX = _get_float("TEXT_UTILS", "QS_LENGTH_MAX", 100.0)
QS_WEIGHT_VALID_WORD = _get_float("TEXT_UTILS", "QS_WEIGHT_VALID_WORD", 0.35)
QS_WEIGHT_WEIRD = _get_float("TEXT_UTILS", "QS_WEIGHT_WEIRD", 0.18)
QS_WEIGHT_PERPLEXITY = _get_float("TEXT_UTILS", "QS_WEIGHT_PERPLEXITY", 0.08)
QS_WEIGHT_LENGTH = _get_float("TEXT_UTILS", "QS_WEIGHT_LENGTH", 0.02)
QS_WEIGHT_GARBAGE = _get_float("TEXT_UTILS", "QS_WEIGHT_GARBAGE", 0.18)
CATEG_TRASH_SCORE_MAX = _get_float("TEXT_UTILS", "CATEG_TRASH_SCORE_MAX", 0.55)
CATEG_NOISY_SCORE_MAX = _get_float("TEXT_UTILS", "CATEG_NOISY_SCORE_MAX", 0.80)
CATEG_GARBAGE_DENSITY_HIGH = _get_float("TEXT_UTILS", "CATEG_GARBAGE_DENSITY_HIGH", 0.35)
QS_GARBAGE_NORM_MAX = _get_float("TEXT_UTILS", "QS_GARBAGE_NORM_MAX", 0.35)
# Inverted / 180°-rotated scan detection
ROT_RATIO_INVERTED_MIN = _get_float("TEXT_UTILS", "ROT_RATIO_INVERTED_MIN", 0.55)
WEIRD_RATIO_INVERTED_MIN = _get_float("TEXT_UTILS", "WEIRD_RATIO_INVERTED_MIN", 0.35)
PPL_INVERTED_MIN = _get_float("TEXT_UTILS", "PPL_INVERTED_MIN", 200.0)
ROT_HIGH_LANG_CONF = _get_float("TEXT_UTILS", "ROT_HIGH_LANG_CONF", 0.90)
LOWPPL_CLEAR_MAX = _get_float("TEXT_UTILS", "LOWPPL_CLEAR_MAX", 35.0)
HARD_SWEEP_LANG_MAX = _get_float("TEXT_UTILS", "HARD_SWEEP_LANG_MAX", 0.45)
HARD_SWEEP_PPL_MIN = _get_float("TEXT_UTILS", "HARD_SWEEP_PPL_MIN", 1000.0)
GHOST_DOMINATED_MIN_RATIO = _get_float("TEXT_UTILS", "GHOST_DOMINATED_MIN_RATIO", 0.5)
WORD_W_PENALTY = _get_float("TEXT_UTILS", "WORD_W_PENALTY", 0.20)
INVERTED_RUN_MIN = _get_int("TEXT_UTILS", "INVERTED_RUN_MIN", 4)
INVERTED_PAGE_MAJORITY = _get_float("TEXT_UTILS", "INVERTED_PAGE_MAJORITY", 0.60)
SURROUNDED_TRASH_QS_MARGIN = _get_float("TEXT_UTILS", "SURROUNDED_TRASH_QS_MARGIN", 0.15)
PAGE_GARBAGE_CLEAR_MAX = _get_float("TEXT_UTILS", "PAGE_GARBAGE_CLEAR_MAX", 0.05)
PAGE_GARBAGE_LANG_MAX = _get_float("TEXT_UTILS", "PAGE_GARBAGE_LANG_MAX", 0.50)
PAGE_GARBAGE_MEDIAN_QS_MAX = _get_float("TEXT_UTILS", "PAGE_GARBAGE_MEDIAN_QS_MAX", 0.55)
PAGE_GARBAGE_NOISY_QS_MAX = _get_float("TEXT_UTILS", "PAGE_GARBAGE_NOISY_QS_MAX", 0.80)
PAGE_CLEAN_CLEAR_MIN = _get_float("TEXT_UTILS", "PAGE_CLEAN_CLEAR_MIN", 0.60)
PAGE_CLEAN_MEDIAN_QS_MIN = _get_float("TEXT_UTILS", "PAGE_CLEAN_MEDIAN_QS_MIN", 0.80)
PAGE_CLEAN_RECOVER_QS_MIN = _get_float("TEXT_UTILS", "PAGE_CLEAN_RECOVER_QS_MIN", 0.45)
TRASH_REASONS = frozenset({"trash_threshold", "trash_hard_sweep", "trash_inverted"})
MOSTLY_READABLE_VALID_MIN = _get_float("TEXT_UTILS", "MOSTLY_READABLE_VALID_MIN", 0.85)
SHORT_NOISY_QS_PENALTY = _get_float("TEXT_UTILS", "SHORT_NOISY_QS_PENALTY", 0.20)
LANG_SCORE_REMAP = _get_float("TEXT_UTILS", "LANG_SCORE_REMAP", 0.75)
LANG_SCORE_REMAP_FAR = _get_float("TEXT_UTILS", "LANG_SCORE_REMAP_FAR", 0.50)
LANG_REMAP_ALWAYS = _get_str("TEXT_UTILS", "LANG_REMAP_ALWAYS", "true").strip().lower() in (
"true",
"1",
"yes",
"on",
)
SINGLE_CHAR_ALLOWED = _get_str("TEXT_UTILS", "SINGLE_CHAR_ALLOWED", "aAiIuUvVzZkKsS")
# ── Page-relative perplexity blend (default OFF) ────────────────────────────
# SHORT_PPL_CAP flattens perplexity to a constant for wc <= 2, and the capped
# value is what is both scored AND stored -- the raw LM number is discarded and
# is unrecoverable from a delivered CSV. That leaves short lines with no usable
# perplexity at all: 850 is below HARD_SWEEP_PPL_MIN (1000), PPL_EXTREME_MIN
# (3000) and PPL_GARBAGE_ABSOLUTE (30000), so no perplexity rule can fire on a
# one- or two-token line.
#
# Simply uncapping does not fix it: both surviving perplexity routes gate on LOW
# language-ID confidence, while garbage tokens score high (oueussd at 0.9163)
# and real domain words score low (malakofauna at 0.56). Uncapped, real notation
# is demoted around perplexity 3000 while oueussd survives to 30000.
#
# The blend instead reads a short line's perplexity RELATIVE to the long lines on
# its own page, which is the comparison a human makes. It is a page-consistency
# prior, not a garbage detector: on a mixed page it pulls a garbage token toward
# its clean neighbours. Off by default until calibrated on a real ARUP/ARUB run.
PAGE_PPL_BLEND_ENABLE = _get_str("TEXT_UTILS", "PAGE_PPL_BLEND_ENABLE", "false").strip().lower() in (
"true",
"1",
"yes",
"on",
)
# Geometric blend weight on the line's own perplexity. Log-space is where the
# average has to happen: perplexities span six orders of magnitude here (II/C
# measures ~6e7), so an arithmetic blend is dominated by the tail.
PAGE_PPL_BLEND_WEIGHT = _get_float("TEXT_UTILS", "PAGE_PPL_BLEND_WEIGHT", 0.5)
# Minimum word count for a line to count toward the page reference.
PAGE_PPL_LONG_MIN_WC = _get_int("TEXT_UTILS", "PAGE_PPL_LONG_MIN_WC", 4)
# Minimum number of such lines before a page reference is trusted at all.
PAGE_PPL_MIN_LONG_LINES = _get_int("TEXT_UTILS", "PAGE_PPL_MIN_LONG_LINES", 3)
SHORT_VALID_WORDS_DEFAULT = frozenset(
"a,i,k,o,s,u,v,z,se,si,po,na,za,ze,do,od,ke,ku,ve,ní,mi,ti,by,je,to,co,ač,my,ty,on,ji,jí,už,až".split(",")
)
#: Hand-editable in setup/word_lists.txt [short_valid] since 2026-09-22.
SHORT_VALID_WORDS = word_list("short_valid", SHORT_VALID_WORDS_DEFAULT, _get_str("TEXT_UTILS", "SHORT_VALID_WORDS", ""))
REPEAT_ALLOWED_CHARS = _get_str("TEXT_UTILS", "REPEAT_ALLOWED_CHARS", "oOuU")
REPEATED_DOUBLE_MIN = _get_int("TEXT_UTILS", "REPEATED_DOUBLE_MIN", 2)
VOWEL_RATIO_LOW = _get_float("TEXT_UTILS", "VOWEL_RATIO_LOW", 0.20)
VOWEL_RATIO_HIGH = _get_float("TEXT_UTILS", "VOWEL_RATIO_HIGH", 0.70)
ACADEMIC_TITLES = _get_csv_set(
"TEXT_UTILS",
"ACADEMIC_TITLES",
"PhDr,MUDr,JUDr,MVDr,RNDr,PaedDr,CSc,DrSc,Ing,Mgr,Bc,PhD,DiS,prof,doc",
)
LDL_ALLOWED_FOLLOW = frozenset(_get_str("TEXT_UTILS", "LDL_ALLOWED_FOLLOW", ".,/:%-;?)="))
LDL_UNITS_DEFAULT: frozenset = frozenset("m,cm,mm,g,kg,km,ha,l,ml".split(","))
#: Hand-editable in setup/word_lists.txt [ldl_units] since 2026-09-22.
LDL_UNITS: frozenset = word_list("ldl_units", LDL_UNITS_DEFAULT, _get_str("TEXT_UTILS", "LDL_UNITS", ""))
SHORT_EXCEPTION_TOKENS_DEFAULT = frozenset("mm,cm,m,g,kg,km,ha,l,ml,tb,neg,obr,str,č,čneg".split(","))
#: Hand-editable in setup/word_lists.txt [short_exception] since 2026-09-22.
SHORT_EXCEPTION_TOKENS = word_list(
"short_exception", SHORT_EXCEPTION_TOKENS_DEFAULT, _get_str("TEXT_UTILS", "SHORT_EXCEPTION_TOKENS", "")
)
HEADLINE_MAX_WORDS = _get_int("TEXT_UTILS", "HEADLINE_MAX_WORDS", 8)
HEADLINE_MAX_DIGITS = _get_int("TEXT_UTILS", "HEADLINE_MAX_DIGITS", 2)
GARBAGE_KEEP_CHARS = frozenset(_get_str("TEXT_UTILS", "GARBAGE_KEEP_CHARS", "")) | {" "}
FUSED_VOWEL_RUN_MIN = _get_int("TEXT_UTILS", "FUSED_VOWEL_RUN_MIN", 3)
WX_REPEAT_MIN = _get_int("TEXT_UTILS", "WX_REPEAT_MIN", 2)
ISOLATED_CHAR_RATIO_MAX = _get_float("TEXT_UTILS", "ISOLATED_CHAR_RATIO_MAX", 0.40)
ISOLATED_CHAR_MIN_TOKENS = _get_int("TEXT_UTILS", "ISOLATED_CHAR_MIN_TOKENS", 3)
# ── rule_short_garbage shape witness (issue #30) ────────────────────────────
# OFF by default, like PAGE_PPL_BLEND_ENABLE above: the predicate ships defined,
# unit-tested and measurable, and changes no category until someone turns it on
# against a gold set. See _has_shape_garbage_evidence() for what it tests and,
# just as importantly, what it deliberately does not.
SHORT_GARBAGE_WITNESS_ENABLE = _get_str("TEXT_UTILS", "SHORT_GARBAGE_WITNESS_ENABLE", "false").strip().lower() in (
"true",
"1",
"yes",
"on",
)
# (#30 D47) The witness floor: a short line the shape witness convicts is never
# `Clear`. Where the cascade would answer `Clear` for such a line it answers
# `Noisy` instead (reason `noisy_threshold`, rule `rule_short_garbage_witness_floor`).
# INDEPENDENT of SHORT_GARBAGE_WITNESS_ENABLE: that flag lets the witness return
# `Trash` at gate 6, behind the signal half of `rule_short_garbage` (which reaches
# ~3% of the witness's population, digest AA4); this one acts after the whole
# cascade and never returns `Trash`. A BOOLEAN, not a category name, because the
# answer is fixed by Q1 -- convicted means damaged, not necessarily illegible --
# and `ab_constant_eval` varies numbers and booleans only. SHIPS OFF: it changes
# no category until stage 13 has measured it on both sidecars.
SHORT_GARBAGE_WITNESS_FLOOR = _get_str("TEXT_UTILS", "SHORT_GARBAGE_WITNESS_FLOOR", "false").strip().lower() in (
"true",
"1",
"yes",
"on",
)
# (#30 D35) Rules whose `_fire()` is gated by a CONFIG FLAG rather than by their
# own predicate, and the flag that gates each. A rule listed here reports
# `fire_count == 0` on every corpus while its flag is off -- not because it has
# no population, but because the branch is unreachable by configuration.
#
# This exists because `tools/rule_coverage_report.py` could not tell the two
# apart. Its `_classify()` reads `fire_count == 0` and returns DEAD, whose own
# docstring says the rule "is unreachable dead code and can be permanently
# deleted ... because deletion provably changes nothing". For
# `rule_short_garbage_witness` that is false in the most expensive possible way:
# the 2026-09-21 stage-6 sweep classified it DEAD and recommended retirement,
# while stage 08f had measured the same predicate reaching 100,824 lines across
# both collections and stage 08b had flipped the flag and passed the adoption
# gate (McNemar p = 0.01294). `RULE_COVERAGE.md` called `fire_count == 0` the
# "config-independent" retirement criterion; it is exactly not that.
#
# The taxonomy is not new -- `tests/test_pipeline_parity.py::UNREACHABLE_RULES`
# already separates "unreachable BY CONFIGURATION" from gate shadowing
# (`rule_mid_uppercase`, shadowed by gate 7). It lived only in a test, so no
# tool could read it. This is that distinction, sited next to the flag it is
# about, so a future flag-gated rule has one obvious place to declare itself.
#
# Values are the NAME of the module-level flag, resolved at read time rather
# than captured, so `override_constants()` is visible to readers of this map.
CONFIG_GATED_RULES: dict[str, str] = {
"rule_short_garbage_witness": "SHORT_GARBAGE_WITNESS_ENABLE",
"rule_short_garbage_witness_floor": "SHORT_GARBAGE_WITNESS_FLOOR",
"rule_domain_notation_categ": "DOMAIN_NOTATION_CATEG",
}
def rule_is_config_gated_off(rule: str) -> bool:
"""Is ``rule``'s fire site currently unreachable because its flag is off?
Veto only, and deliberately narrow: it answers "can this rule fire at all in
the configuration now in force", never "does this rule matter". A rule that
is not in ``CONFIG_GATED_RULES`` always returns False, so the default answer
is the honest one.
"""
flag = CONFIG_GATED_RULES.get(rule)
if flag is None:
return False
return not bool(globals().get(flag, False))
SHORT_GARBAGE_WITNESS_MIN_ALPHA = _get_int("TEXT_UTILS", "SHORT_GARBAGE_WITNESS_MIN_ALPHA", 4)
SHORT_GARBAGE_WITNESS_VARIETY_MIN_ALPHA = _get_int("TEXT_UTILS", "SHORT_GARBAGE_WITNESS_VARIETY_MIN_ALPHA", 7)
SHORT_GARBAGE_WITNESS_VARIETY_MAX = _get_float("TEXT_UTILS", "SHORT_GARBAGE_WITNESS_VARIETY_MAX", 0.50)
SHORT_GARBAGE_WITNESS_TRIPLE_MAX_ALPHA = _get_int("TEXT_UTILS", "SHORT_GARBAGE_WITNESS_TRIPLE_MAX_ALPHA", 8)
# (#30) The witness's OWN vowel-run length, decoupled from FUSED_VOWEL_RUN_MIN.
# It defaults to that value, so the shipped behaviour is unchanged -- but
# detect_fused_words() feeds `fused_ratio` in the quality score and the
# `fused_words` CSV column, so the two knobs steering each other meant the only
# clause that discriminates on the #30 population could not be tuned without
# moving scores on every line in the corpus. The trade at 3 vs 4 is measured on
# gold (stage 10d): errors 503 = 503, `Clear`-loss 38 = 38, fixes 2 / breaks 2,
# p = 1, `Trash`-recall 34 -> 32/180. A global 4 buys nothing and costs recall,
# so this stays 3 and the D44 split below is the answer.
SHORT_GARBAGE_WITNESS_VOWEL_RUN_MIN = _get_int("TEXT_UTILS", "SHORT_GARBAGE_WITNESS_VOWEL_RUN_MIN", FUSED_VOWEL_RUN_MIN)
# (#30 D44) The vowel-run clause is a fact about CZECH PHONOTACTICS, not about
# scanning. Czech has no triphthongs, so three vowels in a row is good evidence of
# damage -- in Czech. German and French have them natively, which is why the
# clause reaches `Dauerleihe` (*aue*, permanent loan) and `FEUILLETON` (*eui*),
# both scanned perfectly correctly, and why @david-spacil's answer on 2026-09-22
# was to split by language rather than to blunt the threshold:
#
# "For Czech, 3+ vowels in a row is a good rule -- Czech has no triphthongs.
# For German and French it does damage. So either split by language, or try 4+."
#
# Blunting it to 4 everywhere was measured and is the worse trade: it releases
# `J. Vysoean` (*Vysočan*), `POSTKRANIAINY SKELET`, `lenaye` and `noienm k.`,
# which are damage, to spare two German words -- 3.5 lines that currently agree
# with `Trash` given up per at-risk line spared (stage 10b).
#
# So: 3 vowels convict in any language EXCEPT these, and 4 convict everywhere,
# including these. Set EXEMPT_LANGS empty to get a single global threshold back.
# The language is the RAW FastText label, not the stored `lang` column -- see the
# note on `determine_category`'s `lang` parameter.
SHORT_GARBAGE_WITNESS_VOWEL_RUN_EXEMPT_LANGS: frozenset = _get_csv_set(
"TEXT_UTILS", "SHORT_GARBAGE_WITNESS_VOWEL_RUN_EXEMPT_LANGS", "deu,fra"
)
# The run length required in an exempt language. 4 rather than "never": a German
# line can still be scanned into `oueussd`, and four vowels in a row is not a word
# in any of these languages either.
SHORT_GARBAGE_WITNESS_VOWEL_RUN_MIN_EXEMPT = _get_int("TEXT_UTILS", "SHORT_GARBAGE_WITNESS_VOWEL_RUN_MIN_EXEMPT", 4)
# (#30 D43) What category a recognised web or e-mail address gets.
#
# @david-spacil, 2026-09-22, on what the five categories mean: "`Trash` =
# illegible. Anything legible is `Clear`, easily decipherable is `Noisy` --
# regardless of how useful the line is to us." `http://www.arub.cz` is scanned
# perfectly correctly on 5,309 lines, so by that definition it is `Clear`. D33
# moved it Trash -> Noisy, the right direction and one step short.
#
# This is a CATEGORY NAME rather than an on/off switch on purpose. The question
# "what is a legible URL worth?" has had three different answers in this issue --
# `Trash` (before D33), `Noisy` (D33), `Clear` (W4) -- and a fourth is arguable
# (`Non-text`, since the README says that category "may be checked for
# identifiers of finds/sites"). Which one the archive wants is @DanaKriv's and
# @david-spacil's to settle, not a code change per answer. Set the key, re-score,
# compare.
#
# SHIPS EMPTY = the route is off and behaviour is exactly as before: the address
# falls through the cascade and lands wherever the ordinary rules put it.
# Any of the five category names arms it.
DOMAIN_NOTATION_CATEG = _get_str("TEXT_UTILS", "DOMAIN_NOTATION_CATEG", "").strip()
#: The `reason` each category is reported under, so the three threshold columns in
#: DOC_LINE_CATEG keep meaning what they say instead of all reading False on a
#: novel reason string. `Non-text` and `Empty` have no threshold column, and their
#: reasons are the ones the pre-filter already uses.
_DOMAIN_NOTATION_REASON: dict[str, str] = {
CATEG_CLEAR: "clear_threshold",
CATEG_NOISY: "noisy_threshold",
CATEG_TRASH: "trash_threshold",
CATEG_NON_TEXT: "non_text",
CATEG_EMPTY: "empty",
}
if DOMAIN_NOTATION_CATEG and DOMAIN_NOTATION_CATEG not in _DOMAIN_NOTATION_REASON:
raise ValueError(
f"DOMAIN_NOTATION_CATEG={DOMAIN_NOTATION_CATEG!r} is not one of "
f"{sorted(_DOMAIN_NOTATION_REASON)}. Leave it empty to keep the shipped behaviour, in "
"which a recognised address is categorised by the ordinary cascade."
)
# (#30 D14) The lexical signal for the residue. A path to a token/document-frequency
# table built by tools/build_token_lexicon.py. EMPTY BY DEFAULT: with no table the
# veto is inert and the predicate is byte-identical to the shape-only version, so
# this key changes nothing until an operator points it at a built table.
SHORT_GARBAGE_LEXICON_PATH = _get_str("TEXT_UTILS", "SHORT_GARBAGE_LEXICON_PATH", "").strip()
SHORT_GARBAGE_LEXICON_MIN_DF = _get_int("TEXT_UTILS", "SHORT_GARBAGE_LEXICON_MIN_DF", 3)
# (#30 D40, removed 2026-09-22) The de-gemination guard used to live here --
# SHORT_GARBAGE_LEXICON_GEMINATE_RATIO and _MAX_DF, a pair that let the witness
# convict a doubled-initial token ALTHOUGH the lexicon attested it. Removed on
# @david-spacil's answer ("if the dictionary already covers all eight, it looks
# redundant"), and because three of the eight tokens it was fitted to -- `ssuti`,
# `ssutí`, `ssutě` -- turned out to be an old spelling of `suť`, so the gap the
# ratio sat in had real language on BOTH sides of it. Zero of 42,853 rows in the
# full-scale witness queue carried any of the eight.
# (#30, 2026-09-19) Feed the corpus lexicon to `compute_valid_ratio` as its
# `word_set`. SHIPS FALSE: `valid_word_ratio` feeds `compute_quality_score` and
# every threshold under it, so this moves scores on every line in the corpus --
# the largest change this issue has proposed. Off, it is byte-identical.
QUALITY_VOCABULARY_ENABLE = _get_str("TEXT_UTILS", "QUALITY_VOCABULARY_ENABLE", "false").strip().lower() in (
"true",
"1",
"yes",
"on",
)
# (#30, 2026-09-19) Strip bullet and marker glyphs alongside punctuation. The full
# table ends in `♦zkoumaná`, `✓stopy`, `•nevelká` -- a marker fused to a real
# word, which corrupts the lexicon at build time AND hides the real word at
# lookup time. SHIPS FALSE because it changes tokenisation for every line.
STRIP_SYMBOL_GLYPHS = _get_str("TEXT_UTILS", "STRIP_SYMBOL_GLYPHS", "false").strip().lower() in (
"true",
"1",
"yes",
"on",
)
#: The glyphs STRIP_SYMBOL_GLYPHS adds. Markers and bullets only -- no character
#: that can carry meaning inside an excavation code.
_SYMBOL_GLYPHS = "♦✓■□●○•▪▫★☆†‡§¶–—―‹›«»„“”‘’"
# (#30 D14) The lexicon used as EVIDENCE rather than as a veto: a token with no
# attestation anywhere in the collection convicts. This is the only mechanism in
# this module that can reach `edelite` -- the phonotactically legal residue -- and
# it is the only part of the witness that can ADD a conviction, so it is gated
# separately and ships false. Double-gated in practice: it is read only when
# SHORT_GARBAGE_WITNESS_ENABLE is also true, and only when a table is configured.
# Measured on gold as stage 5c and REJECTED: +44 `Trash` catches for +21 `Clear`
# losses, `Clear`-loss 42 -> 63 (docs/issue30/issue30_gold_ab_findings.md § 6). An
# unattested token is not the same thing as a non-word, and rare real vocabulary
# is the failure mode.
SHORT_GARBAGE_LEXICON_CONVICT = _get_str("TEXT_UTILS", "SHORT_GARBAGE_LEXICON_CONVICT", "false").strip().lower() in (
"true",
"1",
"yes",
"on",
)
def _warn_uncoupled_witness() -> None:
"""Emit the configuration advisories once at import.
Defined here, evaluated after the keys exist. Same house idiom as the
atrium_vocab check at the top of this module: a NOTE on stderr, never fatal,
and silence on the happy path -- which includes the shipped configuration,
where the witness flag is false and no lexicon is configured.
A tuple of one, deliberately: it held two until the de-gemination guard's
scale advisory went with the guard (#30 D40), and the next configuration
advisory belongs in it rather than in a second bespoke call site.
"""
for warning in (uncoupled_witness_warning,):
message = warning()
if message:
print(f"[text_util] NOTE - {message}", file=sys.stderr)
SYM_LET_DIG_NONTEXT = _get_str("TEXT_UTILS", "SYM_LET_DIG_NONTEXT", "true").strip().lower() in (
"true",
"1",
"yes",
"on",
)
ALLOWED_INTERNAL: frozenset = frozenset(_get_str("TEXT_UTILS", "ALLOWED_INTERNAL", ".-,+()\"'/—–:%;?!/"))
_STRIP_CHARS: str = _get_str("TEXT_UTILS", "STRIP_CHARS", ".,;:!?()[]\"'/\\")
# (#30 D27) Extended here rather than at the 28 call sites, so the builder and the
# predicate cannot drift apart -- the divergence this repository has been bitten by
# four times. Off by default: with the flag false this line is a no-op and
# tokenisation is byte-identical to every table already built.
if STRIP_SYMBOL_GLYPHS:
_STRIP_CHARS = _STRIP_CHARS + _SYMBOL_GLYPHS
RE_TRASH_MULTI_SYMBOL: re.Pattern = re.compile(r"[^\w\s]{2,}")
RE_TRASH_LDL: re.Pattern = re.compile(r"[a-zA-Z][^a-zA-Z\s]+[a-zA-Z]")
RE_NON_TEXT: re.Pattern = re.compile(r"^[\d\s\-\u2013\u2014/:.,()%]+$")
RE_GARBAGE_CLUSTERS: re.Pattern = re.compile(r"[~=]|[\u00C0-\u017F]{2,}|[A-Z]=[A-Z]")
RE_ROMAN_NUMERAL: re.Pattern = re.compile(r"^[IVXLCDMivxlcdm]+\.?$")
RE_STAMP: re.Pattern = re.compile(r"^(?:[A-Za-z]+)?[\W_]*\d{2,4}\s*/\s*\d{2,4}[\W_]*$")
RE_ARCHIVE_CODE: re.Pattern = re.compile(r"^[A-Za-z]{1,3}\d{3,}(?:/\d+)?$")
RE_ALPHANUM_TOKEN: re.Pattern = re.compile(r"^[A-Za-z0-9]{5,}$")
RE_ARCHIVE_REF_SPACED: re.Pattern = re.compile(r"^[A-Za-záčďéěíňóřšťůúýžÁČĎÉĚÍŇÓŘŠŤŮÚÝŽ]{1,5}[\s.\-]+\d{1,}")
_RE_SPACED_CAPS: re.Pattern = re.compile(
r"(?<!\S)"
r"([A-ZÁČĎÉĚÍŇÓŘŠŤŮÚÝŽ] ){3,}"
r"[A-ZÁČĎÉĚÍŇÓŘŠŤŮÚÝŽ]"
r"(?!\S)"
)
def _collapse_spaced_caps(m: re.Match) -> str:
letters = m.group(0).replace(" ", "")
return letters[0].upper() + letters[1:].lower()
DEU_DIACS = frozenset(_get_str("TEXT_UTILS", "DEU_DIACS", "äöüßÄÖÜ"))
_LANG_DIACRITICS: dict[str, frozenset] = {
"ces": CZ_DIACS,
"deu": DEU_DIACS,
}
DIACRITIC_INFER_THRESHOLD = _get_float("TEXT_UTILS", "DIACRITIC_INFER_THRESHOLD", 0.07)
PPL_EXTREME_MIN = _get_float("TEXT_UTILS", "PPL_EXTREME_MIN", 3000.0)
EXTREME_LANG_CONF = _get_float("TEXT_UTILS", "EXTREME_LANG_CONF", 0.85)
LOWPPL_CZECH_CLEAR_MAX = _get_float("TEXT_UTILS", "LOWPPL_CZECH_CLEAR_MAX", 180.0)
CZECH_CLEAR_GARBAGE_MAX = _get_float("TEXT_UTILS", "CZECH_CLEAR_GARBAGE_MAX", 0.15)
ANCHOR_MIN_WORDS = _get_int("TEXT_UTILS", "ANCHOR_MIN_WORDS", 2)
ANCHOR_WORD_LEN = _get_int("TEXT_UTILS", "ANCHOR_WORD_LEN", 3)
ANCHOR_VOWEL_RATIO = _get_float("TEXT_UTILS", "ANCHOR_VOWEL_RATIO", 0.10)
SUSPICIOUS_ROT_RATIO = _get_float("TEXT_UTILS", "SUSPICIOUS_ROT_RATIO", 0.65)
SUSPICIOUS_WQX_RATIO = _get_float("TEXT_UTILS", "SUSPICIOUS_WQX_RATIO", 0.15)
INVERTED_WEIRD_PENALTY = _get_float("TEXT_UTILS", "INVERTED_WEIRD_PENALTY", 0.45)
PPL_GARBAGE_ABSOLUTE = _get_float("TEXT_UTILS", "PPL_GARBAGE_ABSOLUTE", 30000.0)
GHOST_HITS_INVERTED_MIN = _get_int("TEXT_UTILS", "GHOST_HITS_INVERTED_MIN", 1)
TRAILING_FILL_CHARS = (
_get_str("TEXT_UTILS", "TRAILING_FILL_CHARS", "\\x20._:-<\\u2013\\u2014")
.encode("latin-1", "backslashreplace")
.decode("unicode_escape")
)
# ---------------------------------------------------------------------------
# Lexicon Integration for Rotation/Inversion detection
# ---------------------------------------------------------------------------
_MIRROR_GLYPH = {
"b": "d",
"d": "b",
"p": "q",
"q": "p",
"a": "a",
"e": "e",
"i": "i",
"l": "l",
"m": "m",
"n": "n",
"o": "o",
"s": "s",
"t": "t",
"u": "u",
"v": "v",
"w": "w",
"x": "x",
"y": "y",
"z": "z",
}
_ROTATE_GLYPH = {
"b": "q",
"q": "b",
"d": "p",
"p": "d",
"h": "y",
"n": "u",
"u": "n",
"m": "w",
"w": "m",
"y": "h",
"a": "e",
"e": "a",
"i": "!",
"l": "l",
"o": "o",
"s": "s",
"x": "x",
"z": "z",
}
def _transform_word(w: str, glyph_map: dict) -> str | None:
out = []
for ch in w:
img = glyph_map.get(ch)
if img is None:
return None
out.append(img)
return "".join(reversed(out))
ROT_WHITELIST_DEFAULT: frozenset = frozenset(
"po,pod,do,od,on,ony,by,bez,ne,nebo,ven,den,zde,se,ve,mez,pouze,bude".split(",")
)
#: Hand-editable in setup/word_lists.txt [rot_whitelist] since 2026-09-22.
ROT_WHITELIST: frozenset = word_list(
"rot_whitelist", ROT_WHITELIST_DEFAULT, _get_str("TEXT_UTILS", "ROT_WHITELIST", "")
)
_GHOST_REAL_WORD_COLLISIONS_DEFAULT: frozenset = frozenset({"no", "bo"})
#: Hand-editable in setup/word_lists.txt [ghost_collisions] since 2026-09-22.
_GHOST_REAL_WORD_COLLISIONS: frozenset = word_list(
"ghost_collisions", _GHOST_REAL_WORD_COLLISIONS_DEFAULT, _get_str("TEXT_UTILS", "GHOST_WORD_COLLISIONS", "")
)
def _build_ghostlist() -> frozenset:
ghosts = set()
for w in ROT_WHITELIST:
for img in (_transform_word(w, _MIRROR_GLYPH), _transform_word(w, _ROTATE_GLYPH)):
if img:
ghosts.add(img)
return frozenset(ghosts - ROT_WHITELIST - _GHOST_REAL_WORD_COLLISIONS)
ROT_GHOSTLIST: frozenset = _build_ghostlist()
def analyze_rotation_signals(text: str) -> tuple[bool, bool]:
words = [w.lower() for w in re.split(r"\W+", text) if w]
if not words:
return has_cz_diacs(text), False
real_hits = sum(1 for w in words if w in ROT_WHITELIST)
ghost_hits = sum(1 for w in words if w in ROT_GHOSTLIST)
is_upright_czech = has_cz_diacs(text) or real_hits > 0
ghost_share = ghost_hits / len(words)
ghost_dominated = ghost_hits > 0 and ghost_share >= GHOST_DOMINATED_MIN_RATIO
return is_upright_czech, ghost_dominated
def ghost_word_share(text: str) -> tuple[int, float]:
words = [w.lower() for w in re.split(r"\W+", text) if w]
if not words:
return 0, 0.0
ghost_hits = sum(1 for w in words if w in ROT_GHOSTLIST)
return ghost_hits, ghost_hits / len(words)
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _is_mid_uppercase(core: str) -> bool:
if len(core) < 2 or core.isupper():
return False
if core.rstrip(".") in ACADEMIC_TITLES:
return False
caps_run = sum(1 for _ in itertools.takewhile(str.isupper, core))
if caps_run >= 2 and any(c.islower() for c in core[caps_run:]):
return True
for i in range(1, len(core)):
if core[i].isupper() and core[i - 1].islower():
return True
return False
def _has_starting_uppercase(core: str) -> bool:
if len(core) < 2 or core.isupper():
return False
if core.rstrip(".") in ACADEMIC_TITLES:
return False
return core[0].isupper() and core[1].isupper()
def _split_subtokens(word: str) -> list[str]:
return [p for p in re.split(r"[.\-\u2013]", word) if p]
def remap_lang(
label: str, score: float, known_bases: frozenset, default_lang: str, remap_floor: float = LANG_SCORE_REMAP
) -> tuple[str, float]:
base = _lang_base(label)
if base in known_bases:
return label, score
suffix = label[len(base) :]
new_label = default_lang + suffix
if base in REMAP_KEEP_SCORE_LANGS:
return new_label, score
cap = remap_floor if suffix == "_Latn" else LANG_SCORE_REMAP_FAR
if LANG_REMAP_ALWAYS or score > cap:
return new_label, cap
return new_label, score
def compute_garbage_density(text: str) -> float:
if not text:
return 0.0
noise_chars = sum(1 for c in text if not c.isalnum() and c not in GARBAGE_KEEP_CHARS)
return noise_chars / len(text)
def _has_repeated_run(core: str) -> bool:
if len(core) < 4:
return False
for ch in set(core):
if ch.isdigit():
continue
if ch * 3 in core:
return True
if ch in REPEAT_ALLOWED_CHARS:
continue
if ch * 2 in core and core.count(ch) >= REPEATED_DOUBLE_MIN:
return True
if (core.count(ch) / len(core) >= 0.30) and core.count(ch) >= 3:
return True
return False
def _trailing_alpha_run(token: str, start: int) -> str:
j = start
while j < len(token) and token[j].isalpha():
j += 1
return token[start:j]