File size: 37,481 Bytes
62966ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0f0a7bf
62966ff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0f0a7bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6c308d3
 
 
 
 
 
 
 
0f0a7bf
6c308d3
 
0f0a7bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6c308d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0f0a7bf
 
 
 
 
 
 
 
6c308d3
0f0a7bf
 
6c308d3
0f0a7bf
 
 
 
6c308d3
 
 
 
 
 
 
 
 
 
 
 
 
 
0f0a7bf
 
 
 
6c308d3
 
 
 
 
0f0a7bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a5d025b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0f0a7bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6c308d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
076f34c
 
 
 
 
 
64c585d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
076f34c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0f0a7bf
 
 
6c308d3
 
 
 
 
 
 
 
 
 
 
 
 
 
0f0a7bf
6c308d3
0f0a7bf
 
 
 
6c308d3
 
 
0f0a7bf
 
6c308d3
076f34c
64c585d
076f34c
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
"""
postprocess_v2 — tầng hậu xử lý nâng cấp cho VOCR pipeline.

Cải tiến so với CharNgramLM (trigram + add-epsilon) trong vocr_pipeline.py:
  1. Char 5-gram với stupid backoff (5→4→3→2→1), điểm CHUẨN HÓA THEO ĐỘ DÀI
     (per-char avg logprob) — không còn thiên vị candidate ngắn.
  2. Kiểm tra ÂM TIẾT tiếng Việt: âm tiết không có trong từ vựng corpus
     ("qưân", "hoc", "trưòng") bị phạt — đúng loại lỗi dấu thanh phổ biến nhất.
  3. Bigram mức âm tiết (word-level) — phân định dấu thanh theo ngữ cảnh
     ("chữa bệnh" vs "chửa bệnh").
  4. Re-rank CÓ ĐIỀU KIỆN: khi CTC đã rất chắc chắn (top1 >= 0.90 và bỏ xa
     top2) thì giữ nguyên — tránh over-correction.

API tương thích chỗ gọi cũ: ViLM().train_from_file(path) ; .rerank(candidates, alpha)
Corpus nhận cả 2 định dạng dòng: "path<TAB>text" hoặc text thuần.
"""
import math
import re
import unicodedata
from collections import Counter

_PUNC = set(".,;:!?-–—_()[]{}\"'“”‘’/\\@#$%&*+=<>~`|«»…0123456789")


def _nfc(s):
    return unicodedata.normalize('NFC', s)


def _syllables(text):
    """Tách chuỗi thành âm tiết (token chữ), bỏ số và dấu câu."""
    out = []
    for tok in text.lower().split():
        tok = tok.strip(''.join(_PUNC & set(tok)))
        if tok and not any(c in _PUNC for c in tok):
            out.append(tok)
    return out


class ViLM:
    """LM ký tự + âm tiết cho tiếng Việt, huấn luyện từ corpus text."""

    def __init__(self, n=5, min_syllable_freq=20):
        self.n = n
        self.min_syllable_freq = min_syllable_freq
        self.char_counts = [Counter() for _ in range(n + 1)]  # index = order
        self.syl_freq = Counter()
        self.syl_bigram = Counter()
        self.vocab = set()
        self.trained = False

    # ---------------- huấn luyện ----------------
    def train_from_file(self, filepath):
        n_lines = 0
        with open(filepath, 'r', encoding='utf-8') as f:
            for line in f:
                line = line.rstrip('\n')
                if not line:
                    continue
                text = line.split('\t', 1)[1] if '\t' in line else line
                self._add_line(_nfc(text))
                n_lines += 1
        self.vocab = {s for s, c in self.syl_freq.items()
                      if c >= self.min_syllable_freq}
        self.trained = n_lines > 0
        return n_lines

    def _add_line(self, text):
        t = '\x02' * (self.n - 1) + text + '\x03'
        for order in range(1, self.n + 1):
            cnt = self.char_counts[order]
            for i in range(len(t) - order + 1):
                cnt[t[i:i + order]] += 1
        syls = _syllables(text)
        self.syl_freq.update(syls)
        for a, b in zip(syls, syls[1:]):
            self.syl_bigram[(a, b)] += 1

    # ---------------- chấm điểm ----------------
    def char_logprob(self, text):
        """Per-char avg logprob, stupid backoff (hệ số 0.4 mỗi bậc lùi)."""
        if not self.trained or not text:
            return 0.0
        t = '\x02' * (self.n - 1) + _nfc(text) + '\x03'
        total_chars = self.char_counts[1]
        n_uni = sum(total_chars.values()) or 1
        lp, n_scored = 0.0, 0
        for i in range(self.n - 1, len(t)):
            score = None
            backoff = 1.0
            for order in range(self.n, 0, -1):
                ctx_start = i - order + 1
                gram = t[ctx_start:i + 1]
                c_gram = self.char_counts[order].get(gram, 0)
                if c_gram > 0:
                    if order == 1:
                        score = backoff * (c_gram / n_uni)
                    else:
                        c_ctx = self.char_counts[order - 1].get(gram[:-1], 0)
                        if c_ctx > 0:
                            score = backoff * (c_gram / c_ctx)
                    if score:
                        break
                backoff *= 0.4
            lp += math.log(score if score else 1e-9)
            n_scored += 1
        return lp / max(n_scored, 1)

    def syllable_score(self, text):
        """(tỷ lệ âm tiết hợp lệ, per-syllable bigram logprob)."""
        syls = _syllables(text)
        if not syls:
            return 1.0, 0.0
        valid = sum(1 for s in syls if s in self.vocab or len(s) <= 1)
        bg = 0.0
        for a, b in zip(syls, syls[1:]):
            c_bg = self.syl_bigram.get((a, b), 0)
            c_a = self.syl_freq.get(a, 0)
            if c_bg > 0 and c_a > 0:
                bg += math.log(c_bg / c_a)
            elif self.syl_freq.get(b, 0) > 0:
                n_syl = sum(self.syl_freq.values()) or 1
                bg += math.log(0.4 * self.syl_freq[b] / n_syl)
            else:
                bg += math.log(1e-7)
        n_bg = max(len(syls) - 1, 1)
        return valid / len(syls), bg / n_bg

    def lm_score(self, text):
        """Điểm LM tổng hợp, đã chuẩn hóa độ dài. Thang ~[-12, 0]."""
        if not text:
            return -12.0
        char_lp = self.char_logprob(text)              # ~[-9, 0]
        valid_frac, syl_bg = self.syllable_score(text)  # [0,1], ~[-16, 0]
        return 0.45 * char_lp + 0.25 * (syl_bg / 2.0) + 3.0 * (valid_frac - 1.0)

    # ---------------- re-rank ----------------
    def rerank(self, candidates, alpha=0.7,
               skip_top1_score=0.90, skip_margin=0.30):
        """Re-rank top-K của CTC. Giữ schema output cũ (ctc_score/lm_score/combined).

        Gating: nếu CTC top1 >= skip_top1_score và bỏ xa top2 >= skip_margin
        thì tin CTC, không cho LM can thiệp (chống over-correction).
        """
        if not candidates or not self.trained:
            return candidates
        cands = sorted(candidates, key=lambda c: c.get('score', 0), reverse=True)
        top1 = cands[0].get('score', 0)
        top2 = cands[1].get('score', 0) if len(cands) > 1 else 0.0
        gated = top1 >= skip_top1_score and (top1 - top2) >= skip_margin

        # softmax LM trong nhóm candidate (temperature 0.25) — so sánh tương đối,
        # tín hiệu LM không bị nén như thang tuyệt đối
        TAU = 0.25
        lms = [self.lm_score(c.get('text', '')) if c.get('text') else -12.0
               for c in cands]
        m = max(lms)
        exps = [math.exp((v - m) / TAU) for v in lms]
        z = sum(exps) or 1.0
        lm_soft = [e / z for e in exps]

        scored = []
        for c, lm01 in zip(cands, lm_soft):
            combined = alpha * c.get('score', 0) + (1 - alpha) * lm01
            scored.append({'text': c.get('text', ''),
                           'ctc_score': round(c.get('score', 0), 4),
                           'lm_score': round(lm01, 4),
                           'combined': round(combined, 4)})
        if not gated:
            scored.sort(key=lambda x: x['combined'], reverse=True)
        return scored


# ==================================================================== #
#  Tầng sửa TEXT — chạy trên chuỗi greedy, KHÔNG cần n-best từ beam.
#
#  Đo trên 3000 dòng crop test thật của rec v11 (train_rect/eval_report.py):
#      v11 greedy            acc 0.2337  CER micro 10.66%  trung vị 4.55%
#      + khử lặp             acc 0.2770  CER micro 10.19%  trung vị 3.39%
#      + R2 gộp số lặp       acc 0.2797  CER micro 10.19%  trung vị 3.39%
#      + sửa dấu thanh       acc 0.2877  CER micro 10.17%  trung vị 3.39%
#  Tổng +5.40 điểm acc, không train lại model.
#
#  R3 (chuẩn hóa space + dấu câu) ĐÃ THỬ VÀ LOẠI: acc 0.2797 -> 0.2737,
#  CER micro 10.19% -> 10.36%. Label giữ nguyên space/dấu câu như ảnh gốc
#  nên "chuẩn hóa" chúng là đi lệch khỏi ground truth.
#
#  Nguồn: train_rect/fix_repeat_vilm.py + fix_tone_vilm.py
# ==================================================================== #

_TONE_MARKS = {'̀', '́', '̃', '̉', '̣'}   # huyền sắc ngã hỏi nặng
_MOD_MARKS = {'̂', '̆', '̛'}                        # mũ, trăng, móc — GIỮ
_VOWEL_BASE = set('aeiouy')
_WORD_RE = re.compile(r"[^\W\d_]+", re.UNICODE)
_DIGIT_RUN_RE = re.compile(r"(\d)\1+")
_NUM_TOKEN_RE = re.compile(r"\d+")

_tone_cache = {}


def _base_char(c):
    """Chữ gốc, bỏ mọi dấu (thanh lẫn mũ/móc), lowercase.

    `đ` (U+0111) là NGOẠI LỆ phải quy về `d`: nó là một chữ cái riêng, NFD KHÔNG
    tách được thành d + dấu, khác với ă â ê ô ơ ư vốn tách bình thường. Không quy
    về d thì cặp d↔đ đứng liền lọt khỏi bộ khử lặp — gặp trên đầu ra thật:
    'Đạt dđược' không bị bắt trong khi 'đđầu' thì bắt được. Với OCR tiếng Việt,
    d và đ đứng cạnh nhau gần như luôn là lỗi lặp của CTC, không phải chữ thật.
    """
    d = unicodedata.normalize('NFD', c)
    base = ''.join(ch for ch in d if unicodedata.category(ch) != 'Mn').lower()
    return 'd' if base == 'đ' else base


def _tone_variants(word):
    """Biến thể chỉ khác `word` ở DẤU THANH; giữ nguyên mũ/móc và hoa/thường."""
    hit = _tone_cache.get(word)
    if hit is not None:
        return hit
    d = unicodedata.normalize('NFD', word)
    chars = [c for c in d if c not in _TONE_MARKS]
    out = {unicodedata.normalize('NFC', ''.join(chars))}
    for i, c in enumerate(chars):
        if c.lower() not in _VOWEL_BASE:
            continue
        j = i + 1
        while j < len(chars) and chars[j] in _MOD_MARKS:
            j += 1
        head, tail = ''.join(chars[:j]), ''.join(chars[j:])
        for t in _TONE_MARKS:
            out.add(unicodedata.normalize('NFC', head + t + tail))
    out.discard(word)
    if len(_tone_cache) < 200_000:
        _tone_cache[word] = out
    return out


def _dup_positions(s, digit_min_len=0):
    """Vị trí i mà s[i], s[i+1] cùng chữ gốc. Chữ số MẶC ĐỊNH KHÔNG XÉT.

    digit_min_len=0 nghĩa là bỏ qua hoàn toàn chữ số. Lý do đổi từ 4 sang 0:
    không có tín hiệu nào trong chuỗi phân biệt được lặp do CTC với chữ số THẬT.
    Ngưỡng >=4 vẫn phá số thật, gặp trên production và kiểm lại thấy hàng loạt:
        'năm 1993'            -> 'năm 193'
        'số hiệu 5500'        -> 'số hiệu 500'
        'dân số 1000000 người'-> 'dân số 1000 người'   (1 triệu thành 1 nghìn)
        '19990000222'         -> '1902'
    Và nó KHÔNG đều tay: 1955/1900/1977 thoát còn 1993 bị phá, vì ứng viên do LM
    chọn nên phụ thuộc ngữ cảnh — kiểu sai ngẫu nhiên còn tệ hơn sai đều, vì
    không biết chỗ nào đã hỏng. Với tài liệu lịch sử, SỐ CHÍNH LÀ NỘI DUNG
    (năm tháng, số trang, dân số, thống kê), nên thà sót lỗi lặp còn hơn sửa hỏng.
    Cái giá: mất khoảng 0.27đ accuracy mà phép gộp số từng mang lại.

    Với CHỮ SỐ chỉ xét khi token số chứa nó dài >= digit_min_len. Không có chặn
    này thì tầng khử lặp cũng ăn mất số thật giống hệt collapse_digit_runs bản
    đầu: 'S553'->'S53', '211.'->'21.'. Đo trên 3000 dòng: không chặn cho acc
    0.3130 nhưng làm sai 2 dòng số; chặn >=4 cho 0.3113 và KHÔNG làm sai dòng
    số nào; chặn >=5 tụt xuống 0.3057. Chọn >=4: mất 0.17đ acc để không bao giờ
    sửa hỏng năm/số trang/số tiền — đánh đổi đúng cho văn bản lịch sử.
    """
    spans = [(m.start(), m.end(), len(m.group()), m.group()) for m in _NUM_TOKEN_RE.finditer(s)]

    def num_len(i):
        for a, b, L, _ in spans:
            if a <= i < b:
                return L
        return 0

    def in_year(i):
        """Token chứa vị trí i có trông như một NĂM không (4 chữ số, 1000-2099)?

        Ngưỡng digit_min_len=4 vốn để bảo vệ số thật, nhưng năm tháng tiếng Việt
        ĐÚNG 4 chữ số và rất hay lặp chữ số (1993, 1955, 1900, 2000). Gặp thật ở
        production: 'năm 1993 chúng tôi' -> 'năm 193'. Tệ hơn nữa là nó KHÔNG đều
        tay — 1955/1900/1977 thoát, chỉ 1993 bị phá — vì ứng viên do LM chọn nên
        phụ thuộc ngữ cảnh. Với sách sử, sai năm nặng hơn sót một lỗi lặp nhiều.
        """
        for a, b, L, tok in spans:
            if a <= i < b:
                return L == 4 and tok.isdigit() and 1000 <= int(tok) <= 2099
        return False

    pos = []
    for i in range(len(s) - 1):
        a, b = s[i], s[i + 1]
        if a.isdigit():
            # digit_min_len falsy (0/None) = BỎ QUA chữ số hoàn toàn.
            # Lưu ý: viết `num_len(i) >= 0` thì luôn đúng, thành ra gộp TẤT CẢ —
            # phải kiểm digit_min_len trước.
            if digit_min_len and a == b and num_len(i) >= digit_min_len \
                    and not in_year(i):
                pos.append(i)
            continue
        ba, bb = _base_char(a), _base_char(b)
        if ba and ba == bb and ba.isalpha():
            pos.append(i)
    return pos


def fix_repeat(text, lm, margin=0.0, max_edits=12):
    """Khử lỗi LẶP ký tự của CTC: 'nguười'->'người', 'khôòng'->'không'.

    margin 0.0 và KHÔNG chèn space là cấu hình đo được tốt nhất (+4.33đ acc);
    phép chèn space làm giảm 1 điểm nên đã bỏ.
    """
    if not lm.trained or not text:
        return text
    cur = text
    for _ in range(max_edits):
        pos = _dup_positions(cur)
        if not pos:
            break
        best, best_score = cur, lm.lm_score(cur) + margin
        for i in pos:
            for cand in (cur[:i] + cur[i + 1:], cur[:i + 1] + cur[i + 2:]):
                if not cand or cand == cur:
                    continue
                sc = lm.lm_score(cand)
                if sc > best_score:
                    best, best_score = cand, sc
        if best == cur:
            break
        cur = best
    return cur


def collapse_digit_runs(text, min_len=5):
    """Gộp chữ số lặp do CTC, CHỈ bên trong token số dài bất thường ('46111'->'461').

    Bản đầu áp regex cho mọi chữ số lặp và đó là sai: đo được tốt 31 dòng nhưng
    PHÁ ĐÚNG 31 dòng vì xóa cả số THẬT — '1000'->'10', '5.000'->'5.0',
    '1.000.000'->'1.0.0', '119'->'19', '266.'->'26.', 'năm 1888'->'năm 18'.
    Với văn bản sử thì sai một con số năm hay số tiền là lỗi nội dung nghiêm
    trọng, dù CER chỉ nhích.

    Chặn bằng ĐỘ DÀI TOKEN: số thật trong nguồn này hầu hết <= 4 chữ số (năm,
    số trang, số hiệu), nên chỉ đụng token >= min_len chữ số. Đo trên 3000 dòng:
    R2 cũ acc 0.3150 / CER micro 9.97% / phá 130; bản này 0.3130 / 9.96% / phá 109;
    bỏ hẳn 0.3123 / 9.97% / phá 109. Tức bản này giữ được một nửa lợi ích mà
    không còn phá số thật.
    """
    return _NUM_TOKEN_RE.sub(
        lambda m: _DIGIT_RUN_RE.sub(r"\1", m.group()) if len(m.group()) >= min_len
        else m.group(), text)


def _oov_frac(line, lm):
    w = [m.group().lower() for m in _WORD_RE.finditer(line)]
    if not w:
        return 0.0
    return sum(1 for x in w if x not in lm.vocab) / len(w)


def fix_tone(text, lm, margin=4.5, ctx_thresh=100, max_edits=2, max_oov=0.05):
    """Sửa dấu thanh sai theo ngữ cảnh: 'sữa chữa'->'sửa chữa'.

    Hai điểm khác fix_repeat, đều lấy từ đo đạc thực tế:

    1. margin là ngưỡng trên TỔNG logprob, không phải lm_score trung bình.
       lm_score chuẩn hóa per-char nên cùng một phép đổi cho delta lớn ở dòng ngắn
       và nhỏ ở dòng dài; ngưỡng per-char 0.05 phá 'thạnh vượng'->'thành vương'
       trên dòng 50 ký tự. Đổi dấu thanh không đổi độ dài chuỗi NFC nên nhân len().

    2. GUARD CẤP DÒNG (max_oov) quan trọng hơn mọi tham số khác. Không có nó,
       lãi ròng là -146 dòng (109 tốt lên / 255 xấu đi) và CER micro TĂNG;
       có nó thì +26 (40/-14) và CER giảm. Lý do: dòng hỏng nặng có lm_score
       thấp sẵn nên phép đổi nào cũng "cải thiện" điểm mà không về được đáp án.
       Lãi ròng theo mức hỏng dòng: +12 (d=1-2), -18 (d=3-5), -133 (d>=6).
    """
    if not lm.trained or not text:
        return text

    cur = text
    cur_score = lm.lm_score(cur)
    n = max(len(cur), 1)
    if _oov_frac(cur, lm) > max_oov:
        return cur

    for _ in range(max_edits):
        toks = [(m.start(), m.end(), m.group()) for m in _WORD_RE.finditer(cur)]
        lows = [t[2].lower() for t in toks]
        best, best_gain, best_score = None, margin, cur_score
        for i, (s, e, w) in enumerate(toks):
            wl = lows[i]
            if wl in lm.vocab:
                # chỉ xét âm tiết LẠC NGỮ CẢNH — đổi dấu thanh thường vẫn cho âm tiết
                # có thật ('sữa'/'sửa') nên không gate được bằng "ngoài từ vựng"
                left = lm.syl_bigram.get((lows[i - 1], wl), 0) if i > 0 else 0
                right = lm.syl_bigram.get((wl, lows[i + 1]), 0) if i + 1 < len(toks) else 0
                if len(toks) > 1 and left + right >= ctx_thresh:
                    continue
            for cw in _tone_variants(w):
                if cw.lower() not in lm.vocab:
                    continue
                cand = cur[:s] + cw + cur[e:]
                sc = lm.lm_score(cand)
                gain = (sc - cur_score) * n
                if gain > best_gain:
                    best, best_gain, best_score = cand, gain, sc
        if best is None:
            break
        cur, cur_score = best, best_score
    return cur


# ĐÃ THỬ VÀ LOẠI: PMI âm tiết (log P(a,b)/(P(a)P(b))) làm tín hiệu collocation.
# Trên 6 cặp dễ nhầm PMI phân biệt đúng 6/6, kể cả hai ca mà tần suất thô chọn sai
# ('lạ quá' PMI 2.58 vs 'là quá' 0.64; 'thạnh vượng' 5.65 vs 'thành vương' 1.06).
# Nhưng đưa vào hệ thì không được gì: cộng vào điểm (trọng số 0.1/0.25/0.5/1.0) làm
# acc tụt 0.3113 -> 0.3110/0.3003/0.2800/0.2550; dùng làm bộ lọc PMI cục bộ cho
# 0.3110 với min_count=5, chênh trong nhiễu. Lý do: gate hiện tại (ctx_thresh=100,
# max_oov=0.05, margin trên tổng logprob) đã chặn sẵn đúng những ca PMI định cứu.
# Muốn khá hơn phải có nguồn tín hiệu NGOÀI corpus này, không phải thống kê lại nó.


def _split_candidates(word, lm, min_part=2):
    """Ứng viên thay thế cho token ngoài từ vựng: TÁCH hoặc RÚT GỌN.

    a) TÁCH    'lạitới' -> 'lại tới'   (cả hai nửa đều là âm tiết có thật)
    b) RÚT GỌN 'vàvà' -> 'và', 'NhNhà' -> 'Nhà', 'Tuyuy' -> 'Tuy'

    (b) không phải tùy chọn. Nếu chỉ có (a), phép tách PHÁ 191 dòng vì nó hợp
    thức hóa lỗi lặp mức CỤM thành hai âm tiết hợp lệ ('vàvà' -> 'và và').
    fix_repeat chỉ gỡ lặp MỘT ký tự liền kề nên lớp lặp cụm lọt xuống đây.
    Đo trên 3000 dòng: chỉ-tách cho lãi ròng -67 và CER tăng; tách+rút gọn
    cho lãi +139, acc +2.20đ, CER micro 10.17% -> 10.00%.
    """
    out = []
    wl = word.lower()
    for i in range(1, len(word)):
        a, b = word[:i], word[i:]
        al, bl = wl[:i], wl[i:]
        if len(a) >= min_part and len(b) >= min_part and al in lm.vocab and bl in lm.vocab:
            out.append(a + ' ' + b)
        if al == bl and al in lm.vocab:
            out.append(a)
        elif al in lm.vocab and al.endswith(bl):
            out.append(a)
        elif bl in lm.vocab and bl.startswith(al):
            out.append(b)
    return list(dict.fromkeys(out))


def _d_stroke_variants(word):
    """Biến thể chỉ khác `word` ở NÉT GẠCH của đ/d, giữ nguyên hoa/thường.

    Chỉ đổi MỘT vị trí mỗi lần: đổi nhiều chỗ cùng lúc gần như luôn sai.
    """
    out = []
    for i, c in enumerate(word):
        if c == 'd':   r = 'đ'
        elif c == 'đ': r = 'd'
        elif c == 'D': r = 'Đ'
        elif c == 'Đ': r = 'D'
        else: continue
        out.append(word[:i] + r + word[i + 1:])
    return out


def fix_d_stroke(text, lm, margin=4.5, ctx_thresh=100, max_edits=2, max_oov=0.05):
    """ĐÃ ĐO VÀ KHÔNG ĐƯA VÀO CHUỖI — giữ lại để khỏi ai thử lại.

    Sửa nhầm nét gạch của đ/d theo ngữ cảnh: 'di qua' <-> 'đi qua'.

    Đo trên 3000 dòng test, áp SAU chuỗi hiện có (acc 0.3117, CER micro 9.91%):
        margin 4.5, ctx 100 : acc 0.3130  CER 9.91%   30 dòng đổi, 12 tốt / 16 XẤU
        margin 2.0, ctx 100 : acc 0.3127  CER 9.91%   61 dòng đổi, 23 tốt / 34 XẤU
        margin 1.0, ctx 200 : acc 0.3123  CER 9.92%   78 dòng đổi, 24 tốt / 48 XẤU
    Mọi ngưỡng đều LỖ theo số dòng, dù acc nhích lên — đúng cái bẫy của bản
    fix_vocab không chốt: acc tăng trong khi chất lượng giảm.

    Vì sao thất bại dù lỗi RẤT hay gặp: khai thác lỗi thật cho thấy d->đ 55 lần
    và đ->d 33 lần, tổng 88, nhiều hơn mọi cặp dấu thanh đơn lẻ. Nhưng cả 'di'
    lẫn 'đi', 'dân' lẫn 'đân' đều cần ngữ cảnh, mà mô hình ngôn ngữ n-gram không
    đủ tín hiệu để quyết chiều nào — y hệt giới hạn của fix_tone (ước lượng
    +5.4đ, thực tế chỉ +0.80đ). Biết lỗi hay gặp KHÔNG có nghĩa là sửa được.

    Vì sao cần riêng một phép: khai thác lỗi thật trên 3000 dòng test (căn chỉnh
    nhãn với đầu ra đã hậu xử lý) cho thấy đây là lỗi TRONG-TỪ hay gặp nhất mà
    chuỗi hiện có KHÔNG nhắm tới: 'd'->'đ' 55 lần, 'đ'->'d' 33 lần, tổng 88 —
    nhiều hơn mọi cặp dấu thanh đơn lẻ (ề↔ể chỉ 24+23). Nét gạch của đ mảnh nên
    hay mất khi in mờ, hoặc bị bịa thêm từ vệt bẩn.

    KHÔNG dùng regex được: cả 'di' lẫn 'đi', 'dân' lẫn 'đân' đều phải xét ngữ
    cảnh. Nên dùng đúng khuôn của fix_tone — sinh ứng viên hẹp, chấm bằng mô hình
    ngôn ngữ, gate bằng bigram âm tiết và ngưỡng trên TỔNG logprob.
    """
    if not text or lm is None or not getattr(lm, 'trained', False):
        return text
    cur = text
    cur_score = lm.lm_score(cur)
    n = max(len(cur), 1)
    if _oov_frac(cur, lm) > max_oov:
        return cur

    for _ in range(max_edits):
        toks = [(m.start(), m.end(), m.group()) for m in _WORD_RE.finditer(cur)]
        lows = [t[2].lower() for t in toks]
        best, best_gain, best_score = None, margin, cur_score
        for i, (st, e, w) in enumerate(toks):
            if 'd' not in w.lower() and 'đ' not in w.lower():
                continue
            wl = lows[i]
            if wl in lm.vocab:
                left = lm.syl_bigram.get((lows[i - 1], wl), 0) if i > 0 else 0
                right = lm.syl_bigram.get((wl, lows[i + 1]), 0) if i + 1 < len(toks) else 0
                if len(toks) > 1 and left + right >= ctx_thresh:
                    continue
            for cw in _d_stroke_variants(w):
                if cw.lower() not in lm.vocab:
                    continue
                cand = cur[:st] + cw + cur[e:]
                sc = lm.lm_score(cand)
                gain = (sc - cur_score) * n
                if gain > best_gain:
                    best, best_gain, best_score = cand, gain, sc
        if best is None:
            break
        cur, cur_score = best, best_score
    return cur


def fix_split(text, lm, margin=0.02, max_edits=2, max_oov=0.30, min_part=2):
    """Tách từ dính và gỡ lặp mức cụm.

    Dùng lm_score per-char (không phải tổng logprob như fix_tone): phép tách
    LÀM ĐỔI độ dài chuỗi nên tổng logprob luôn giảm, chỉ điểm đã chuẩn hóa
    theo độ dài mới so sánh được giữa hai chuỗi khác độ dài.
    """
    if not lm.trained or not text:
        return text
    cur = text
    cur_score = lm.lm_score(cur)

    toks = [m.group().lower() for m in _WORD_RE.finditer(cur)]
    if toks and sum(1 for t in toks if t not in lm.vocab) / len(toks) > max_oov:
        return cur

    for _ in range(max_edits):
        best, best_gain, best_score = None, margin, cur_score
        for m in _WORD_RE.finditer(cur):
            w = m.group()
            if w.lower() in lm.vocab:
                continue
            for repl in _split_candidates(w, lm, min_part):
                cand = cur[:m.start()] + repl + cur[m.end():]
                sc = lm.lm_score(cand)
                if sc - cur_score > best_gain:
                    best, best_gain, best_score = cand, sc - cur_score, sc
        if best is None:
            break
        cur, cur_score = best, best_score
    return cur


_PUNCT_PAIR = re.compile(r"(?<![.])([.,:;–—])([.,:;–—])(?![.])")


def fix_punct(text, lm, margin=0.0, max_edits=2):
    """Gộp hai dấu câu dính liền do CTC nhả thừa: ',.'->',', '..'->'.', '::'->':'.

    Giữ dấu nào thì không có quy tắc cố định (đo được: ',.'->',' giữ dấu trước,
    nhưng ',;'->';' giữ dấu sau), nên sinh cả hai ứng viên rồi để ViLM chọn.
    Chuỗi '...' được loại khỏi phạm vi bằng lookaround.

    PHẠM VI HẸP CÓ CHỦ ĐÍCH. Trong 119 dòng chỉ-sai-dấu-câu còn lại sau 4 tầng,
    chỉ 31 dòng thuộc dạng này (1.03đ acc). 88 dòng còn lại là dấu '.' thừa ở
    CUỐI dòng sau chữ cái — không đụng tới được: 757 dòng pred kết thúc bằng '.'
    thì 509 dòng GT cũng kết thúc bằng '.', nên xóa theo luật sẽ phá 509 dòng
    để cứu tối đa 248. Đó cũng là lý do R3 (chuẩn hóa space + dấu câu) từng
    làm acc tụt 0.2797 -> 0.2737.
    """
    if not lm.trained or not text:
        return text
    cur = text
    cur_score = lm.lm_score(cur)
    for _ in range(max_edits):
        best, best_gain, best_score = None, margin, cur_score
        for m in _PUNCT_PAIR.finditer(cur):
            i = m.start()
            for cand in (cur[:i] + cur[i + 1:], cur[:i + 1] + cur[i + 2:]):
                sc = lm.lm_score(cand)
                gain = (sc - cur_score) * len(cur)
                if gain > best_gain:
                    best, best_gain, best_score = cand, gain, sc
        if best is None:
            break
        cur, cur_score = best, best_score
    return cur


_WORD_RE_VOCAB = re.compile(r"[^\W\d_]+", re.UNICODE)
_ALPHA_CACHE = {}


def _vocab_alphabet(vocab):
    """Bảng chữ cái suy ra từ chính từ điển — đúng phạm vi cần sinh biến thể."""
    k = id(vocab)
    a = _ALPHA_CACHE.get(k)
    if a is None:
        a = sorted({c for w in vocab for c in w})
        _ALPHA_CACHE[k] = a
    return a


def _lev1_in_vocab(w, vocab):
    """Mọi từ trong từ điển cách `w` đúng 1 phép sửa (xóa/thay/chèn).

    Sinh biến thể rồi tra tập hợp, thay vì duyệt cả từ điển: O(len(w) x alphabet)
    thay cho O(|vocab|), và KHÔNG cần thêm thư viện ngoài vào image production.
    """
    out = set()
    for i in range(len(w)):
        c = w[:i] + w[i + 1:]
        if c and c in vocab:
            out.add(c)
    alpha = _vocab_alphabet(vocab)
    for i in range(len(w)):
        pre, post = w[:i], w[i + 1:]
        for ch in alpha:
            if ch != w[i]:
                c = pre + ch + post
                if c in vocab:
                    out.add(c)
    for i in range(len(w) + 1):
        pre, post = w[:i], w[i:]
        for ch in alpha:
            c = pre + ch + post
            if c in vocab:
                out.add(c)
    return out


def fix_vocab(text, lm, min_len=2):
    """Sửa âm tiết NGOÀI từ điển về từ trong từ điển cách đúng 1 phép sửa.

    CHỐT CHẶN QUAN TRỌNG: chỉ sửa khi có ĐÚNG MỘT ứng viên. Không có chốt này thì
    phép sửa chọn bừa giữa các ứng viên đồng hạng và lỗ nặng — đo trên 3000 dòng:
        không chốt : acc 0.3130 nhưng CER micro 9.98% -> 10.27%, 151 tốt / 444 XẤU
        có chốt    : acc 0.3117, CER micro 9.91%,      119 tốt /  18 xấu  (6.6:1)
    Ví dụ điển hình: 'dđược' cách 1 với CẢ 'được' lẫn 'dược' — chọn bừa ra 'dược'.
    """
    if not text or lm is None or not getattr(lm, 'trained', False):
        return text
    vocab = getattr(lm, 'vocab', None)
    if not vocab:
        return text
    out, last = [], 0
    for m in _WORD_RE_VOCAB.finditer(text):
        w = m.group()
        out.append(text[last:m.start()])
        last = m.end()
        lw = w.lower()
        if lw in vocab or len(lw) < min_len:
            out.append(w)
            continue
        cands = _lev1_in_vocab(lw, vocab)
        if len(cands) != 1:            # 0 ứng viên, hoặc đồng hạng -> KHÔNG đụng
            out.append(w)
            continue
        r = cands.pop()
        out.append(r.upper() if w.isupper() else (r.capitalize() if w[:1].isupper() else r))
    out.append(text[last:])
    return "".join(out)


# chữ cái Việt (gồm cả dải tiền kết hợp U+1EXX), KHÔNG gồm chữ số
_VN_LET = r'a-zA-ZÀ-ỹ'
# dấu câu dính liền chữ kế tiếp: 'Nxb.Văn' -> 'Nxb. Văn'
_STUCK_PUNCT = re.compile(rf'(\w)([,;:.])([{_VN_LET}][{_VN_LET}])')


# Âm đầu tiếng Việt, xếp DÀI TRƯỚC để 'ngh' không bị 'ng'/'n' nuốt mất.
_ONSETS = ["ngh", "tr", "th", "ph", "nh", "ng", "kh", "gi", "gh", "ch", "qu",
           "b", "c", "d", "đ", "g", "h", "k", "l", "m", "n", "p", "r", "s",
           "t", "v", "x"]
_RIME_CACHE = {}


def _syl_splits(w):
    """Mọi cách tách (âm đầu, vần) có thể của một âm tiết."""
    yield "", w
    for o in _ONSETS:
        if w.startswith(o) and len(w) > len(o):
            yield o, w[len(o):]


def _rimes(vocab):
    """Kho vần hợp lệ, SUY RA TỪ TỪ ĐIỂN chứ không tự chế luật cấu âm.

    Tự viết bảng vần tiếng Việt bằng tay là chỗ rất dễ sót (vần hiếm, lối viết
    cũ, âm đệm) và mỗi chỗ sót thành một báo động giả. Rút thẳng từ 5310 âm tiết
    trong từ điển thì kho vần đầy đủ theo đúng định nghĩa.
    """
    k = id(vocab)
    if k not in _RIME_CACHE:
        _RIME_CACHE[k] = {r for v in vocab for _, r in _syl_splits(v)}
    return _RIME_CACHE[k]


def _impossible(wl, vocab):
    """True nếu `wl` KHÔNG THỂ là âm tiết tiếng Việt (không cách tách nào hợp lệ)."""
    if wl in vocab:
        return False
    return not any(r in _rimes(vocab) for _, r in _syl_splits(wl))


def _pho_cands(wl, vocab):
    c = set(_lev1_in_vocab(wl, vocab))
    h = len(wl) // 2
    if len(wl) % 2 == 0 and wl[:h] == wl[h:] and wl[:h] in vocab:
        c.add(wl[:h])                      # 'làlà' -> 'là': lặp CẢ ÂM TIẾT, Lev-1 với tới không được
    return c


def _keep_case(w, c):
    if w.isupper():
        return c.upper()
    if w[:1].isupper():
        return c[:1].upper() + c[1:]
    return c


def fix_phono(text, lm, margin=4.5, max_edits=3):
    """Sửa từ PHẠM LUẬT CẤU ÂM tiếng Việt: 'yêy' -> 'yêu', 'địh' -> 'định'.

    Vì sao mạnh hơn cổng 'ngoài từ điển' của fix_vocab: từ lạ có thể là tên
    riêng, từ nước ngoài, hay lối viết cổ — nên fix_vocab buộc phải đòi ứng viên
    DUY NHẤT, bỏ lỡ phần lớn ca. Còn chuỗi phạm luật cấu âm thì KHÔNG THỂ là chữ
    Việt, nên nới được ràng buộc đó và để mô hình ngôn ngữ chọn giữa nhiều ứng
    viên. Đo trên 3000 dòng test:
        nhãn    :  118/33712 từ phạm luật (0.35%)   <- báo động giả
        dự đoán : 1788/33183 từ phạm luật (5.39%)   <- lỗi bắt được
    Chênh 15 lần, và 118 ca trong nhãn gần như toàn từ nước ngoài (kishi,
    bhagavati, même, piastre) chứ không phải chữ Việt.

    Bắt đúng hai lớp lỗi mà chuỗi cũ bỏ sót: lặp ('làlà', 'năăm', 'nguười') và
    MẤT PHỤ ÂM CUỐI ('địh' -> 'định', 'chíh' -> 'chính') — khớp với khai thác lỗi
    trước đó: thiếu 'g' 198 lần, 'n' 165, 't' 128.

    Áp SAU chuỗi 6 phép (acc 0.3153, CER 9.87%):
        acc 0.3237, CER 9.79%, 175 dòng tốt / 61 xấu.
    Hạ margin từ 4.5 xuống -2.0 gần như KHÔNG đổi kết quả -> chính cổng cấu âm
    làm việc chứ không phải ngưỡng LM, nên số đo không phải may rủi khi dò tham số.
    """
    if not text or lm is None or not getattr(lm, 'trained', False):
        return text
    vocab = lm.vocab
    cur = text
    cur_score = lm.lm_score(cur)
    n = max(len(cur), 1)
    for _ in range(max_edits):
        best, best_gain, best_score = None, margin, cur_score
        for m in _WORD_RE.finditer(cur):
            w = m.group()
            if not _impossible(w.lower(), vocab):
                continue
            for c in _pho_cands(w.lower(), vocab):
                cand = cur[:m.start()] + _keep_case(w, c) + cur[m.end():]
                sc = lm.lm_score(cand)
                gain = (sc - cur_score) * n
                if gain > best_gain:
                    best, best_gain, best_score = cand, gain, sc
        if best is None:
            break
        cur, cur_score = best, best_score
    return cur


def fix_punct_space(text):
    """Tách dấu câu dính liền chữ kế tiếp: 'Nxb.Văn hóa' -> 'Nxb. Văn hóa'.

    Khác với chuẩn hóa khoảng trắng tổng quát (đã đo và LOẠI, -0.40đ vì nhãn giữ
    nguyên lối in gốc), luật này hẹp đến mức không đụng chỗ nhãn còn phân vân —
    ba ràng buộc, mỗi cái chặn một lớp hỏng thật quan sát được:

      (1) sau dấu phải là CHỮ CÁI, không phải chữ số  -> giữ '1,5' và '1.000'
      (2) trước dấu phải là chữ/số, không phải khoảng trắng
          -> 'trên .và' là dấu rác do CTC nhả thừa, không phải hết câu
      (3) sau dấu phải có ÍT NHẤT HAI chữ cái
          -> chặn mẫu rác 'căm tức.c.' và 'iễiên.g.' mà CTC hay nhả ở cuối dòng

    Ăn nhiều nhất ở chú thích thư mục — 'Nxb.Văn', 'tr.26', 'Sđd,tr' — vốn dày
    đặc trong sách nghiên cứu, đúng loại tài liệu của bộ này.

    Đo trên 3000 dòng test, áp SAU chuỗi (acc 0.3117 -> 0.3153, CER 9.91% ->
    9.87%), 72 dòng tốt lên / 15 dòng xấu đi. Đã so bốn biến thể; bản này CER tốt
    nhất và tỉ lệ an toàn gần 5:1, trong khi bản lỏng nhất chỉ 1.8:1 dù acc nhỉnh
    hơn 0.0004 — chọn theo CER và số dòng chứ không theo riêng acc.
    """
    return _STUCK_PUNCT.sub(r'\1\2 \3', text) if text else text


def fix_text(text, lm):
    """Chuỗi hậu xử lý production, đo trên 3000 dòng crop test của rec v11:

        đầu ra tham lam                       acc 0.2337  CER micro 10.66%
        bản trước (có gộp chữ số)                 0.3113             9.97%
        BẢN NÀY                                   0.3117             9.91%

    So bản trước: 240 dòng đổi, 148 tốt lên / 60 xấu đi. Ba thay đổi:

    1. BỎ HẲN xử lý chữ số. Ngưỡng cũ vẫn phá số thật và phá KHÔNG ĐỀU TAY:
       'năm 1993'->'năm 193', 'số hiệu 5500'->'500', '19990000222'->'1902',
       'dân số 1000000 người'->'dân số 1000 người'. Với tài liệu lịch sử thì SỐ
       CHÍNH LÀ NỘI DUNG, sai số liệu nặng hơn sót một lỗi lặp. Giá: -0.53đ acc.
    2. `đ` quy về `d` trong _base_char, để cặp d↔đ không lọt khỏi bộ khử lặp
       ('Đạt dđược' trước đây không bắt được, trong khi 'đđầu' thì bắt được).
    3. THÊM fix_vocab: sửa âm tiết ngoài từ điển về từ cách đúng 1 phép sửa,
       CHỈ KHI có duy nhất một ứng viên. Giá trị: +0.57đ acc và CER tốt hơn.

    Không làm sai chữ số nào — vì không còn đụng tới chữ số.
    """
    if not text or lm is None or not getattr(lm, 'trained', False):
        return text
    out = fix_repeat(text, lm)
    # collapse_digit_runs ĐÃ BỎ khỏi chuỗi: nó phá số thật
    # ('100000'->'1000', '19990000222'->'1902'). Giữ hàm lại để tham chiếu
    # và cho ai muốn bật thủ công, nhưng KHÔNG chạy mặc định.
    out = fix_tone(out, lm)
    out = fix_split(out, lm)
    out = fix_punct(out, lm)
    out = fix_vocab(out, lm)
    out = fix_phono(out, lm)
    return fix_punct_space(out)