File size: 102,873 Bytes
7d3eb8f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
"""Canvas with a pretrained Thai body: WangchanBERTa (105M, bidirectional, 78 GB of Thai) as the
encoder over the 8 x 10 syllable grid, with the phoneme channels, grid coordinates and บาท ROLE
embeddings (frame / complete / develop / close) added at the input. Same joint decoding as
scripts/canvas_mlm.py: one rime family per rhyme group, tone masks at line ends, iterative refinement,
per-บาท topic steering. Trained as a masked LM on the human corpus (PyTorch, MPS).

Why: the small canvas had the craft (human-likeness 0.79) but no language knowledge (sense 1.6/5
by the judge). This keeps the joint decision and adds the knowledge.

Usage: canvas_wangchan.py train --epochs 3 --out runs/CANVAS_WC
       canvas_wangchan.py decode --model runs/CANVAS_WC --prompts data/prompts/test.jsonl --out O.jsonl [--topic-boost 3]
"""
import collections, os, re, sys, json, math, time, random, argparse, collections, hashlib
sys.path.insert(0, "."); sys.path.insert(0, "scripts")
import torch, torch.nn as nn, torch.nn.functional as F
from klon8 import prosody as P
import syl_align
from canvas_mlm import (C, TL, PAD, MASK, UNK, SEP, EVEN, CHECKED, STOP_SYL,
                        phon, syls_of, Vocab, clauses_of)

L = 4                                              # one บท
GROUPS = [[(1, -1), (2, 2)], [(2, -1), (3, -1), (4, 2)]]
TONE_AT = {2: CHECKED, 3: EVEN, 4: EVEN}
N_BOT = 1

WC = "airesearch/wangchanberta-base-att-spm-uncased"
DEV = torch.device(os.environ.get("KLON8_DEVICE") or ("mps" if torch.backends.mps.is_available() else "cpu"))   # KLON8_DEVICE=cpu tests beside a GPU job
ROLE_OF_LINE = [1, 2, 3, 4]                        # วรรค roles: สดับ / รับ / รอง / ส่ง (0 = topic)

# PRONOUNCED syllables per วรรค, measured over 12,000 บท with klon8.prosody.g2p_line — the count the
# user confirmed by hand on six lines (2026-09-22). The grid segmenter under-counts: it reads ถวิล as
# one syllable where it is ถ|วิล, and ธนา as one where it is ธ|นา, so an earlier version of this table
# was built from cell counts and was wrong (it put 10-13% of วรรค at 7 syllables; the truth is ~1%,
# and the real alternative to 8 is 9). A grid cell is therefore an approximation of a syllable, and
# the decoder verifies the real count with g2p_line rather than trusting the cell count.
WAK_LEN_DIST = {0: {7: 16, 8: 764, 9: 213, 10: 7},
                1: {7: 6, 8: 782, 9: 207, 10: 6},
                2: {7: 10, 8: 740, 9: 240, 10: 10},
                3: {7: 7, 8: 714, 9: 267, 10: 11}}


def pick_lens(mode, rng):
    """Target PRONOUNCED syllables per วรรค. mode: '8', 'sample', or a pattern like '8,8,9,8'."""
    if mode == "sample":
        out = []
        for i in range(L):
            d = WAK_LEN_DIST[i]; tot = sum(d.values()); r = rng.random() * tot
            for k, v in sorted(d.items()):
                r -= v
                if r <= 0: out.append(k); break
            else: out.append(8)
        return out
    if "," in mode:
        v = [int(x) for x in mode.split(",")]
        assert len(v) == L and all(7 <= x <= 10 for x in v), f"--wak-lens {mode} must be {L} values in 7..10"
        return v
    n = int(mode); assert 7 <= n <= 10, f"--wak-lens {mode} out of range"
    return [n] * L


def syl_counts(poem):
    """What the poem actually says, per วรรค. The authority for length; the grid is not."""
    return [len(P.g2p_line(x)) for x in poem.split("\n")]



def load_poems(path, vocab_min=3, seg="aligned"):
    """Four-line บท on a grid of SYLLABLES.

    seg='aligned' uses scripts/syl_align.py, which cuts the written form where the pronunciation
    says a syllable begins (ถ|วิล, พัน|ธ|นา): 96.7% of human วรรค come out with the pronounced
    count, against 81.5% for the old character-level segmenter. A บท is kept only when every one of
    its four วรรค matches its pronounced count exactly, so a cell in the training data IS a syllable.
    seg='grid' is the old behaviour, kept as the control.
    """
    seg_fn = (lambda t, lim=None: syl_align.syls_aligned(t, lim)) if seg == "aligned" else syls_of
    poems, kept, seen = [], 0, 0
    for l in open(path, encoding="utf-8"):
        if not l.strip(): continue
        r = json.loads(l)
        lines = r["poem"].split("\n")
        if len(lines) != L: continue
        seen += 1
        grid = [seg_fn(ln) for ln in lines]
        if not all(7 <= len(g) <= C for g in grid): continue
        # verify against the JOINED cells, not the source line: the model emits syllables
        # concatenated with no spaces, and rejoining can change how newmm reads the line, so this
        # is the form whose count has to be right.
        if seg == "aligned" and any(len(g) != len(P.g2p_line("".join(g))) for g in grid):
            continue                                    # the cell count must be the syllable count
        kept += 1
        poems.append({"title": seg_fn(r.get("title", "") or "", TL), "grid": grid, "board": r.get("board") or ""})
    print(f"load_poems({path}, seg={seg}): {kept}/{seen} บท kept", flush=True)
    return poems


LAYOUT_BUFS = ("line_id", "pos_id", "seg_id", "role_id")


def load_body(model, path, old_itos=None, V=None, allow_missing_expert=False):
    """Warm start from an 8-วรรค checkpoint. The layout buffers are a function of the grid and must
    never be imported (WC14 stores role_id for a 12-cell title, which silently overwrote WCPLAN v1's
    บาท roles). self.line is Embedding(L+1) and no longer matches, so it is skipped by shape too."""
    sd = torch.load(path, map_location="cpu")
    for k in LAYOUT_BUFS: sd.pop(k, None)
    own = model.state_dict()
    if old_itos and V is not None and len(old_itos) != len(V.itos):
        # vocabulary changed (re-segmentation): copy the rows for syllables present in both
        old = {t: i for i, t in enumerate(old_itos)}
        for key in ("tok.weight", "out.weight"):
            if key not in sd or key not in own: continue
            w, fresh = sd[key], own[key].clone()
            n = 0
            for j, t in enumerate(V.itos):
                i = old.get(t)
                if i is not None and i < w.shape[0]:
                    fresh[j] = w[i]; n += 1
            sd[key] = fresh
            print(f"  {key}: {n}/{len(V.itos)} rows carried over from the old vocabulary", flush=True)
    shape_skip = [k for k, v in sd.items() if k in own and own[k].shape != v.shape]
    for k in shape_skip: sd.pop(k)
    r = model.load_state_dict(sd, strict=False)
    assert not r.unexpected_keys, f"unexpected keys in {path}: {r.unexpected_keys}"
    kept = [k for k in r.missing_keys if k not in LAYOUT_BUFS and k not in shape_skip
            and not (allow_missing_expert and (k.startswith("expert") or k.startswith("diction") or k.startswith("word_head") or k.startswith("det_head")))]   # only a warm start may lack the expert / diction channel / word head
    assert not kept, f"weights missing from the warm start: {kept}"
    print(f"warm start from {path}: {len(sd)} tensors loaded, "
          f"layout buffers rebuilt, re-initialised for the new grid: {shape_skip}", flush=True)
    return model


ZONE_TAIL = 2          # line-final cells in the zone (review 2026-09-27: 4 covered 56% of the poem)
EXPERT_AFTER = 7       # the expert block runs after body layer index 7 (8th of 12), so later layers see it

# Diction conditioning (user idea 2026-09-27: "About my poetic words injection, why don't you change the
# structure of the network"). Each training poem gets a DICTION LEVEL = how many of its 12 rhyme-zone cells
# (rhyme_zone's definition) hold a strong poetic-diction syllable (runs/LOC/poetic_syl.json, log-odds >= 2.5).
# Buckets by that count, from the train distribution (0-1: 39%, 2: 26%, 3: 18%, 4+: 17% of 6,962 บท):
# level 1 = 0-1, 2 = 2, 3 = 3, 4 = 4+; level 0 = "none" (condition dropped: 10% of training rows, and the
# default at decode). A learned embedding per level is added to EVERY cell's input through a per-dimension
# gate that starts at ZERO, so step 0 is exactly the base model. Every cell, not just the rhyme zone: the
# words leading into a poetic rhyme word have to collocate with it, and a zone-only signal would recreate
# the post-hoc bonus's problem (a poetic rhyme word bolted onto a line that was not written for it).
DICTION_LEVELS = 4
DICTION_LEX_MIN = 2.5
_DICTION_STRONG = None


def diction_count(grid, lex_path="runs/LOC/poetic_syl.json"):
    """How many of a poem's rhyme-zone cells hold a strong poetic syllable."""
    global _DICTION_STRONG
    if _DICTION_STRONG is None:
        _DICTION_STRONG = {s for s, lo in json.load(open(lex_path, encoding="utf-8")).items() if lo >= DICTION_LEX_MIN}
    k = 0
    for i, ln in enumerate(grid):
        n = len(ln); cells = set(range(max(0, n - ZONE_TAIL), n))
        if i in (1, 3): cells |= {j for j in (2, 4) if j < n}
        k += sum(ln[j] in _DICTION_STRONG for j in cells)
    return k


def diction_levels(poems, min_board=200):
    """1..4 per poem, WITHIN its forum board (reviews 2026-09-28: raw counts track register - lament and romance
    boards are poetic, jokes and technical titles are not - so a raw level would teach "level 4 = sad poem"
    and pull topic). Level = quartile of the poem's percentile of its count (ties split by a fixed hash) among poems of the same
    board; boards with < min_board poems are pooled. Order-independent, so the trainer and any later check
    that pass the same poem set get the same levels."""
    import collections
    ks = [diction_count(p["grid"]) for p in poems]
    size = collections.Counter(p.get("board", "") for p in poems)
    grp = [p.get("board", "") if size[p.get("board", "")] >= min_board else "_pooled" for p in poems]
    dist = collections.defaultdict(collections.Counter)
    for g, k in zip(grp, ks): dist[g][k] += 1
    out = []
    for p_, g, k in zip(poems, grp, ks):
        d = dist[g]; n = sum(d.values())
        # ties (counts are small integers) are split by a fixed hash of the poem, so every board gets ~25%
        # per level; a mid-rank left board means 2.22-2.76 because boards differ in how many poems have 0
        u = int(hashlib.sha256("".join("".join(l) for l in p_["grid"]).encode()).hexdigest()[:8], 16) / 16 ** 8
        pct = (sum(c for v, c in d.items() if v < k) + u * d[k]) / n
        out.append(1 + min(int(pct * DICTION_LEVELS), DICTION_LEVELS - 1))
    return out


def rhyme_zone(tok):
    """[B, T] 1.0 on the rhyme zone: the last ZONE_TAIL filled cells of each วรรค (the line-final word and
    the syllable before it, where bad spans end) and the receiving rhyme cells (3rd/5th of วรรค 2 and 4).
    User idea (2026-09-27): give more network to where the poems fail (asymmetry)."""
    B, T = tok.shape
    z = torch.zeros(B, T, device=tok.device)
    for i in range(L):
        cells = tok[:, TL + i * C: TL + (i + 1) * C]
        n = (cells != PAD).sum(1)                                   # line length per sample
        col = torch.arange(C, device=tok.device)[None, :]
        z[:, TL + i * C: TL + (i + 1) * C] = ((col >= (n[:, None] - ZONE_TAIL)) & (col < n[:, None])).float()
        if i in (1, 3):
            for j in (2, 4): z[:, TL + i * C + j] = torch.maximum(z[:, TL + i * C + j], (n > j).float())
    return z


# Whole-word rhyme slot (component C1, director 2026-09-27). 49.8% of the judge's bad spans reach the line
# end and are built from dictionary pieces whose phrase breaks (คงเกยใจ, มานระทม): the rhyme syllable is
# appended as a monosyllable after context it does not fit (last newmm token multi-syllable in 27% of bad
# line-end lines vs 46% of human lines). A head reads the hidden states of each line's last WORD_MAX_K cells
# and predicts the line's ENDING WORD (1-3 syllables) from a lexicon of words that end human TRAIN lines plus
# poetic/archaic entries (scripts/build_rhyme_lex.py). Trained as an auxiliary loss; at decode (--word-rhyme)
# the line end is drawn as a WORD inside the chosen rhyme family and all its syllables are committed jointly.
WORD_MAX_K = 3


def ending_word_idx(grid_line, lexmap):
    """lexicon index of a line's ending word (its last newmm token when that is exactly the last 1-3 cells), else -1"""
    from pythainlp.tokenize import word_tokenize
    ws = [w for w in word_tokenize("".join(grid_line), engine="newmm") if w.strip()]
    if not ws: return -1
    for k in range(1, WORD_MAX_K + 1):
        if k <= len(grid_line) and "".join(grid_line[-k:]) == ws[-1]:
            return lexmap.get(tuple(grid_line[-k:]), -1)
    return -1


class Canvas(nn.Module):
    def __init__(self, V, wc_tok, wc_model, expert="none", diction=False, word_lex=None, detector=False):
        super().__init__()
        self.expert_mode = expert
        self.diction = bool(diction)
        self.diction_level = None            # decode-time level (int); None = channel off
        if self.diction:
            _rng = torch.get_rng_state()
            self.diction_emb = nn.Embedding(DICTION_LEVELS + 1, wc_model.config.hidden_size)
            # std 0.1: an embedding row then has norm ~2.8, the size of the input it joins (pre-LN cell norm 2.76),
            # so a grown gate of ~0.15 gives a ~15% signal; at std 0.02 it stayed at 0.3% after 100 steps (scale check)
            nn.init.normal_(self.diction_emb.weight, std=0.1, generator=torch.Generator().manual_seed(11))
            self.diction_gate = nn.Parameter(torch.zeros(wc_model.config.hidden_size))
            torch.set_rng_state(_rng)
        self.word_lex = word_lex             # list of syllable lists, or None (no word head)
        if word_lex:
            d_ = wc_model.config.hidden_size
            # own seed, global RNG restored: the head's init must not shift the body's dropout stream, so CTL and
            # the head arm train on the same masks, order AND dropout (review rule from the diction channel)
            _rng = torch.get_rng_state(); torch.manual_seed(13)
            self.word_head = nn.Linear(WORD_MAX_K * d_, len(word_lex))
            torch.set_rng_state(_rng)
            ids = torch.full((len(word_lex), WORD_MAX_K), PAD, dtype=torch.long)
            for w, sy in enumerate(word_lex):
                for q, x in enumerate(sy): ids[w, q] = V.enc(x)
            assert not (ids == UNK).any(), "a lexicon syllable is outside the vocabulary"
            self.register_buffer("word_ids", ids, persistent=False)
            self.register_buffer("word_len", torch.tensor([len(sy) for sy in word_lex]), persistent=False)
        self.detector = bool(detector)
        if self.detector:
            # [B] per-cell binary 'replaced' logit on the body's last hidden state; own seed, global RNG restored,
            # so the body's dropout stream is the control's
            _rng = torch.get_rng_state(); torch.manual_seed(17)
            self.det_head = nn.Linear(wc_model.config.hidden_size, 1)
            torch.set_rng_state(_rng)
        if expert != "none":
            # one extra transformer block inserted after body layer EXPERT_AFTER (mid-stack), initialised
            # from the next layer; gated per dimension from ZERO, so at step 0 the model is exactly the base
            import copy
            self.expert = copy.deepcopy(wc_model.encoder.layer[EXPERT_AFTER + 1])
            self.expert_gate = nn.Parameter(torch.zeros(wc_model.config.hidden_size))
        n, d = len(V.itos), wc_model.config.hidden_size
        self.body = wc_model.encoder                          # 12 pretrained bidirectional layers
        self.ln_in = wc_model.embeddings.LayerNorm
        base = wc_model.embeddings.word_embeddings.weight.detach()
        init = torch.zeros(n, d)
        mask_id = wc_tok.mask_token_id
        for i, s in enumerate(V.itos):
            if i == MASK:
                init[i] = base[mask_id]; continue
            if i in (PAD, UNK, SEP):
                init[i] = base[wc_tok.pad_token_id if i == PAD else wc_tok.unk_token_id]; continue
            ids = [t for t in wc_tok(s, add_special_tokens=False)["input_ids"] if wc_tok.convert_ids_to_tokens(t) != "▁"]
            init[i] = base[ids].mean(0) if ids else base[wc_tok.unk_token_id]
        self.tok = nn.Embedding(n, d); self.tok.weight.data.copy_(init)
        self.out = nn.Linear(d, n); self.out.weight = self.tok.weight        # tied
        self.rime = nn.Embedding(V.n_rime, d); self.tone = nn.Embedding(6, d)
        self.line = nn.Embedding(L + 1, d); self.pos = nn.Embedding(max(TL, C), d)
        self.seg = nn.Embedding(2, d); self.role = nn.Embedding(5, d)
        for e in (self.rime, self.tone, self.line, self.pos, self.seg, self.role):
            nn.init.normal_(e.weight, std=0.02)
        self.register_buffer("rime_of", torch.tensor(V.rime)); self.register_buffer("tone_of", torch.tensor(V.tone))
        T = TL + L * C
        self.register_buffer("line_id", persistent=False, tensor=torch.tensor([0] * TL + [1 + i for i in range(L) for _ in range(C)]))
        self.register_buffer("pos_id", persistent=False, tensor=torch.tensor(list(range(TL)) + [j for _ in range(L) for j in range(C)]))
        self.register_buffer("seg_id", persistent=False, tensor=torch.tensor([0] * TL + [1] * (L * C)))
        self.register_buffer("role_id", persistent=False, tensor=torch.tensor([0] * TL + [ROLE_OF_LINE[i] for i in range(L) for _ in range(C)]))

    def forward(self, tok, level=None, return_hidden=False):
        known = tok != MASK
        r = torch.where(known, self.rime_of[tok], torch.zeros_like(tok)); t = torch.where(known, self.tone_of[tok], torch.full_like(tok, 5))
        x = (self.tok(tok) + self.rime(r) + self.tone(t) + self.line(self.line_id)[None] + self.pos(self.pos_id)[None]
             + self.seg(self.seg_id)[None] + self.role(self.role_id)[None])
        if self.diction:
            # no level given = level 0, the "no level" embedding the trainer uses for its dropped rows
            lv = level if level is not None else (self.diction_level if self.diction_level is not None else 0)
            if not torch.is_tensor(lv): lv = torch.full((tok.shape[0],), int(lv), dtype=torch.long, device=tok.device)
            self._x_norm = x.detach().norm(dim=-1).mean()       # for the injection-scale log
            x = x + self.diction_gate * self.diction_emb(lv)[:, None, :]    # gate starts at 0: base model
        x = self.ln_in(x)
        if self.expert_mode == "none":
            h = self.body(x).last_hidden_state
        else:
            # the expert sits MID-STACK so the layers after it attend to what it wrote - at the last layer
            # ASYM and SYM would compute the same function on the zone cells (purpose review 2026-09-27)
            h = x
            for li, layer in enumerate(self.body.layer):
                h = layer(h); h = h[0] if isinstance(h, tuple) else h
                if li == EXPERT_AFTER:
                    e = self.expert(h)
                    e = e[0] if isinstance(e, tuple) else e     # transformers >= 5 returns a tensor
                    assert e.shape == h.shape, f"expert output {tuple(e.shape)} != hidden {tuple(h.shape)}"
                    zone = rhyme_zone(tok)[..., None] if self.expert_mode == "asym" else torch.ones_like(h[..., :1])
                    h = h + zone * self.expert_gate * (e - h)   # ASYM: rhyme zone only; SYM: every cell
        return (self.out(h), h) if return_hidden else self.out(h)

    def word_logits(self, h, tok):
        """[B, L, n_words]: the ending-word head on each line's last WORD_MAX_K cells (by line length)"""
        B = tok.shape[0]; feats = []
        for i in range(L):
            n = (tok[:, TL + i * C: TL + (i + 1) * C] != PAD).sum(1)                     # [B]
            pos = TL + i * C + (n[:, None] - WORD_MAX_K + torch.arange(WORD_MAX_K, device=tok.device)[None, :]).clamp(min=0)
            feats.append(torch.gather(h, 1, pos[..., None].expand(-1, -1, h.shape[-1])).reshape(B, -1))
        return self.word_head(torch.stack(feats, 1))

    def detect_logits(self, tok):
        """[B, T] per-cell 'replaced' logit (experiment B). The input is a COMPLETE poem: no MASK cell and no
        corruption mask ever enters - the detector reads only the tokens, in training and at decode alike."""
        assert not (tok == MASK).any(), "the detector reads complete poems (no MASK cells)"
        _, h = self.forward(tok, return_hidden=True)
        return self.det_head(h)[..., 0]


def build_tok(poems, V):
    T = TL + L * C
    out = torch.full((len(poems), T), PAD, dtype=torch.long)
    for n, p in enumerate(poems):
        for j, s in enumerate(p["title"]): out[n, j] = V.enc(s)
        for i, line in enumerate(p["grid"]):
            for j, s in enumerate(line): out[n, TL + i * C + j] = V.enc(s)
    return out


def mask_batch(y, seg_id, gen):
    B, T = y.shape
    ratio = torch.rand(B, 1, generator=gen) * 0.75 + 0.15
    u = torch.rand(B, T, generator=gen)
    m = (seg_id[None, :] == 1) & (y != PAD) & (u < ratio)
    return torch.where(m, torch.full_like(y, MASK), y), m


def word_loss(model, hid, x, m, wt, acc=False):
    """Auxiliary ending-word CE on lines whose gold ending word is in the lexicon AND whose ending cells are
    all masked in x (otherwise the head could read the answer). Returns (mean CE, n lines[, n top-1 correct])."""
    wlog = model.word_logits(hid, x)                              # [B, L, W]
    B = x.shape[0]; sel = []
    for i in range(L):
        n = (x[:, TL + i * C: TL + (i + 1) * C] != PAD).sum(1)
        g = wt[:, i]; k = model.word_len[g.clamp(min=0)]
        ok = g >= 0
        for q in range(WORD_MAX_K):   # every cell of the gold word must be masked
            col = TL + i * C + (n - 1 - q).clamp(min=0)
            ok = ok & ((q >= k) | m.gather(1, col[:, None])[:, 0])
        sel.append(ok)
    sel = torch.stack(sel, 1)                                     # [B, L]
    nsel = int(sel.sum())
    if not nsel: return (torch.zeros((), device=x.device), 0) + ((0,) if acc else ())
    ce = F.cross_entropy(wlog[sel], wt[sel])
    if acc: return ce.detach(), nsel, int((wlog[sel].argmax(-1) == wt[sel]).sum())
    return ce, nsel


def stutter_ul(logits, x, y, m, seg_id, pos_id):
    """Unlikelihood on predicting a masked cell as its neighbour's syllable (C5, 6.3% of bad spans are
    stutters) - skipped where the gold itself repeats its neighbour (ค่อยค่อย is legitimate Thai)."""
    p = F.softmax(logits.float(), -1)
    tot, cnt = logits.new_zeros((), dtype=torch.float32), 0
    for sh, edge in ((1, 0), (-1, C - 1)):
        nb = torch.roll(x, sh, dims=1)            # the neighbour as the model SEES it: masked neighbours are skipped
        ok = m & (seg_id[None, :] == 1) & (pos_id[None, :] != edge) & (nb >= 4) & (nb != y)
        if ok.any():
            pn = p[ok].gather(1, nb[ok][:, None])[:, 0]
            tot = tot + (-torch.log((1 - pn).clamp(min=1e-6))).sum(); cnt += int(ok.sum())
    return tot / max(cnt, 1)


# [A'] unlikelihood on flagged word pairs of the model's own poems
def load_ul(path, V):
    """Rows from scripts/ul_prep.py flags: {"title": [syl], "grid": [[syl] x 4], "pairs": [[line, a, b, [sides]]]}
    where a, b are the junction cells of a word pair unseen in the train table and sides lists the cells that
    may be masked (never a rhyme cell). Returns (UY [N, T] token ids, PAIRS: per poem a list of
    (pos_a, pos_b, [eligible positions])). Rows with a syllable outside the vocabulary are dropped."""
    ys, prs, n_drop = [], [], 0
    for l in open(path, encoding="utf-8"):
        r = json.loads(l)
        y = build_tok([{"title": r["title"], "grid": r["grid"]}], V)[0]
        if (y[TL:] == UNK).any(): n_drop += 1; continue
        ys.append(y)
        prs.append([(TL + i * C + a_, TL + i * C + b_, [TL + i * C + q for q in sd]) for i, a_, b_, sd in r["pairs"]])
    print(f"unlikelihood data {path}: {len(ys)} poems, {sum(map(len, prs))} flagged pairs, "
          f"{n_drop} dropped (out-of-vocabulary syllable)", flush=True)
    return torch.stack(ys), prs


def ul_mask(prs, T, gen):
    """For each flagged pair mask ONE eligible side, its partner kept VISIBLE, so the loss targets the pairing
    (this syllable next to that one), not the syllable on its own. Pairs are visited in a random order; a pair
    is skipped when its chosen side or its partner is already masked, or its chosen side is another pair's partner."""
    m = torch.zeros(len(prs), T, dtype=torch.bool)
    for n, pl in enumerate(prs):
        keep = set()                   # partners of masked sides: must stay visible
        for k in torch.randperm(len(pl), generator=gen).tolist():
            a_, b_, sd = pl[k]
            s_ = sd[int(torch.randint(0, len(sd), (1,), generator=gen))]
            partner = b_ if s_ == a_ else a_
            if m[n, s_] or m[n, partner] or s_ in keep: continue
            m[n, s_] = True; keep.add(partner)
    return m


def flagged_ul(model, uy, uf):
    """Welleck et al. 2020 token unlikelihood: -log(1 - p(generated syllable)) at each masked flagged cell;
    everything else (title, the pair partner, the rest of the poem) is visible context. Unflagged cells get no
    loss, so the model is never trained to imitate its own output."""
    x = torch.where(uf, torch.full_like(uy, MASK), uy)
    p = F.softmax(model(x)[uf].float(), -1).gather(1, uy[uf][:, None])[:, 0]
    return (-torch.log((1 - p).clamp(min=1e-6))).mean(), p.detach().mean()


# [B] replaced-cell detection on the shared body
def load_det_bank(path, V):
    """The in-context corruption bank from scripts/det_prep.py: rows {title, grid, lab, line, split}. Replacements
    were written by the generator itself with the rest of the poem visible (ELECTRA-style), on human AND model-written
    host poems. Returns train and valid tensors (x token ids, lab 0/1 per cell, line corrupted or -1)."""
    out = {"train": ([], [], []), "valid": ([], [], [])}; n_drop = 0
    for l in open(path, encoding="utf-8"):
        r = json.loads(l)
        x = build_tok([{"title": r["title"], "grid": r["grid"]}], V)[0]
        if (x[TL:] == UNK).any(): n_drop += 1; continue
        lab = torch.zeros_like(x, dtype=torch.float)
        for i, li in enumerate(r["lab"]):
            for j, v in enumerate(li): lab[TL + i * C + j] = float(v)
        X, LB, LN = out[r["split"]]; X.append(x); LB.append(lab); LN.append(r["line"])
    res = {k: (torch.stack(X), torch.stack(LB), torch.tensor(LN)) for k, (X, LB, LN) in out.items()}
    print(f"detection bank {path}: train {len(res['train'][0])}, valid {len(res['valid'][0])} poems, "
          f"{n_drop} dropped (out of vocabulary); replaced share (train) "
          f"{float(res['train'][1].sum() / (res['train'][0][:, TL:] != PAD).sum()):.3f}", flush=True)
    return res


def det_valid_mask(x):
    return (x != PAD) & (torch.arange(x.shape[1], device=x.device)[None, :] >= TL)


def det_line_hit(lg, x, valid, line):
    """share of corrupted rows whose highest mean-probability line is the corrupted one (the locator rule)"""
    pr = torch.sigmoid(lg.float()); hit = n = 0
    for b in range(x.shape[0]):
        if line[b] < 0: continue
        s = [float(pr[b, TL + i * C:TL + (i + 1) * C][valid[b, TL + i * C:TL + (i + 1) * C]].mean()) for i in range(L)]
        hit += int(max(range(L), key=lambda i: s[i]) == int(line[b])); n += 1
    return hit, n


# [ULKL] KL anchor to a frozen copy of the starting model
def kl_all(cur, ref, x, excl=None):
    """KL(p_ref || p_cur) averaged over ALL non-PAD cells of input x (title and poem, masked or not): the anchor
    holds every prediction near the frozen starting model, not only the masked MLM targets. excl: cells left out
    (on the unlikelihood batch, the flagged cells themselves - the anchor must not pull back exactly what the
    unlikelihood term pushes down; review 2026-09-28)."""
    v = x != PAD
    if excl is not None: v = v & ~excl
    lr, lc = F.log_softmax(ref[v].float(), -1), F.log_softmax(cur[v].float(), -1)
    return (lr.exp() * (lr - lc)).sum(-1).mean()


def tripwire_sample(HY, HP):
    """fixed masks on held-out poems: one flagged side per pair (partner visible) and, separately, the same number
    of unflagged, non-rhyme cells"""
    g = torch.Generator().manual_seed(31)
    fm = ul_mask(HP, HY.shape[1], g)
    um = torch.zeros_like(fm)
    for n in range(len(HY)):
        flagged = {q for a_, b_, _ in HP[n] for q in (a_, b_)}
        rc = set()
        for i in range(L):
            ln = int((HY[n, TL + i * C: TL + (i + 1) * C] != PAD).sum())
            rc |= {TL + i * C + j for j in ({ln - 1} | ({2, 4} if i in (1, 3) else set()))}
        cand = [t for t in range(TL, HY.shape[1]) if HY[n, t] >= 4 and t not in flagged and t not in rc]
        for q in torch.randperm(len(cand), generator=g)[:int(fm[n].sum())].tolist(): um[n, cand[q]] = True
    return HY, fm, um


@torch.no_grad()
def tripwire_p(model, HY, fm, um):
    was = model.training; model.eval(); out = []
    for msk in (fm, um):
        x = torch.where(msk, torch.full_like(HY, MASK), HY).to(DEV)
        p = F.softmax(model(x)[msk.to(DEV)].float(), -1).gather(1, HY.to(DEV)[msk.to(DEV)][:, None])[:, 0]
        out.append(float(p.mean()))
    if was: model.train()
    return out


def flagged_ul_full(model, uy, uf):
    """flagged_ul with the full logits returned (for the KL term on the same forward); same loss definition."""
    x = torch.where(uf, torch.full_like(uy, MASK), uy)
    lg = model(x)
    p = F.softmax(lg[uf].float(), -1).gather(1, uy[uf][:, None])[:, 0]
    return (-torch.log((1 - p).clamp(min=1e-6))).mean(), p.detach().mean(), x, lg


def train(a):
    a._old_itos = json.load(open(a.init_vocab, encoding="utf-8"))["itos"] if getattr(a, "init_vocab", None) else None
    from transformers import AutoTokenizer, AutoModel
    random.seed(0); torch.manual_seed(0)
    poems = load_poems(a.data, seg=getattr(a, "seg", "aligned")); random.shuffle(poems)
    if getattr(a, "vocab", None):
        # rebuild the SAME Vocab object from a frozen itos so every derived table
        # (rime, tone, fam, n_rime) is recomputed by the constructor, not left stale
        frozen = json.load(open(a.vocab, encoding="utf-8"))["itos"]
        V = Vocab(frozen[4:] * 3)          # min_freq is 3; itos[:4] are the specials
        assert V.itos == frozen, f"frozen vocab not reproduced: {len(V.itos)} vs {len(frozen)}"
        print(f"frozen vocab from {a.vocab}: {len(V.itos)} entries, {V.n_rime-1} rimes", flush=True)
    else:
        V = Vocab([s for p in poems for line in p["grid"] for s in line] + [s for p in poems for s in p["title"]])
    os.makedirs(a.out, exist_ok=True)
    # record HOW the grid was segmented, so decode can use the same segmenter for the topic
    word_w = getattr(a, "word_loss_w", 0.0) or 0.0
    det_w = getattr(a, "det_weight", 0.0) or 0.0
    word_lex = None
    if word_w > 0:
        # the lexicon travels with the model: decode reads it from the model directory
        lex = json.load(open(a.word_lex, encoding="utf-8"))
        word_lex = lex["words"]
        json.dump(lex, open(os.path.join(a.out, "rhyme_words.json"), "w", encoding="utf-8"), ensure_ascii=False)
    json.dump({"itos": V.itos, "seg": getattr(a, "seg", "aligned"), "expert": getattr(a, "expert", "none"),
               **({"diction": True} if getattr(a, "diction", False) else {}),
               **({"word_rhyme": "rhyme_words.json"} if word_lex else {}),
               **({"detector": True} if det_w else {})},
              open(os.path.join(a.out, "vocab.json"), "w", encoding="utf-8"), ensure_ascii=False)
    wc_tok = AutoTokenizer.from_pretrained(WC); wc = AutoModel.from_pretrained(WC)
    model = Canvas(V, wc_tok, wc, expert=getattr(a, "expert", "none"), diction=getattr(a, "diction", False), word_lex=word_lex, detector=det_w > 0)
    if getattr(a, "init", None):
        sd_keys = torch.load(a.init, map_location="cpu").keys()
        load_body(model, a.init, old_itos=getattr(a, "_old_itos", None), V=V, allow_missing_expert=True)
        if getattr(model, "expert_mode", "none") != "none" and not any(k.startswith("expert") for k in sd_keys):
            # start the new block from the FINE-TUNED last layer, not the raw pretrained one
            model.expert.load_state_dict(model.body.layer[EXPERT_AFTER + 1].state_dict())
            print(f"  expert block initialised from the warm-started body.layer[{EXPERT_AFTER + 1}]; gate = 0", flush=True)
    model = model.to(DEV)
    kl_b = getattr(a, "kl_beta", 0.0) or 0.0
    if kl_b:
        assert getattr(a, "init", None), "--kl-beta anchors to the --init model"
        assert not (model.diction or word_lex or det_w), "the KL anchor is tested only on the plain body"
        import copy
        ref_model = copy.deepcopy(model).eval()      # frozen: no grad, never stepped, dropout off
        for q in ref_model.parameters(): q.requires_grad_(False)
        ref_sum = lambda: sum(float(q.detach().cpu().double().sum()) + float((q.detach().cpu().double() ** 2).sum()) for q in ref_model.parameters())   # MPS has no float64: sum on CPU
        ref_chk = ref_sum()
        print(f"KL anchor: beta {kl_b} to a frozen copy of {a.init} on all non-PAD cells", flush=True)
    trip = None
    if getattr(a, "ul_heldout", None):
        # in-training tripwire (review 2026-09-28): mean p(flagged side) and p(unflagged non-rhyme cells) on a fixed
        # sample of title-held-out generated poems, every 200 steps; the arm aborts if either falls below 0.5 x its
        # value at step 0 (A' drove both to ~0). Own RNG, no dropout: CTL's streams are untouched.
        HY, HP = load_ul(a.ul_heldout, V)
        trip = tripwire_sample(HY[:64], HP[:64])
        trip0 = tripwire_p(model, *trip)
        print(f"tripwire start: p(flagged) {trip0[0]:.4f} p(unflagged) {trip0[1]:.4f}", flush=True)
    nv = min(getattr(a, "valid", 500) or 500, max(1, len(poems) // 5))
    valid, tr = poems[:nv], poems[nv:]
    assert len(tr) >= a.batch, f"only {len(tr)} training poems after a {nv}-poem valid split"
    Y, VY = build_tok(tr, V), build_tok(valid, V)
    DL = VDL = None
    if model.diction:
        LV = torch.tensor(diction_levels(poems)); VDL, DL = LV[:nv], LV[nv:]
        dgen = torch.Generator().manual_seed(7)     # own RNG: the level dropout must not shift CTL's masks/order
        print(f"diction levels (train, within board): " + ", ".join(f"{k}: {int((DL == k).sum())}" for k in range(1, DICTION_LEVELS + 1)), flush=True)
    WT = VWT = None
    if word_lex:
        lexmap = {tuple(w): k for k, w in enumerate(word_lex)}
        WT = torch.tensor([[ending_word_idx(g, lexmap) for g in p["grid"]] for p in tr])
        VWT = torch.tensor([[ending_word_idx(g, lexmap) for g in p["grid"]] for p in valid])
        print(f"word-rhyme head: {len(word_lex)} words, loss weight {word_w}; line endings in the lexicon: "
              f"train {float((WT >= 0).float().mean()):.3f}, valid {float((VWT >= 0).float().mean()):.3f}", flush=True)
    assert not (word_lex and model.diction), "the word head and the diction channel are separate experiments (not combined yet)"
    stut_w = getattr(a, "stutter_ul", 0.0) or 0.0
    seg = model.seg_id.cpu()
    new = {n for n, _ in model.named_parameters() if n.startswith("expert") or n.startswith("diction") or n.startswith("word_head") or n.startswith("det_head")}
    gates = {"expert_gate", "diction_gate"}
    groups = [{"params": [p for n, p in model.named_parameters() if n not in new], "lr": a.lr, "weight_decay": 0.01}]
    if new:   # new parameters learn faster than the converged body; the gate has no weight decay
        groups.append({"params": [p for n, p in model.named_parameters() if n in new and n not in gates],
                       "lr": getattr(a, "expert_lr", a.lr), "weight_decay": 0.01})
        groups.append({"params": [p for n, p in model.named_parameters() if n in gates],
                       "lr": getattr(a, "gate_lr", a.lr), "weight_decay": 0.0})
    opt = torch.optim.AdamW(groups)
    steps = a.epochs * (len(tr) // a.batch)
    sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=[g["lr"] for g in groups], total_steps=steps, pct_start=0.06)
    gen = torch.Generator().manual_seed(0)
    ul_w = getattr(a, "ul_weight", 0.0) or 0.0
    if ul_w:
        # own RNG and a dropout-free extra forward: the MLM batches, masks, order and dropout draws are exactly
        # CTL's; weight 0 never reaches this branch (byte-identical to the old trainer)
        UY, UP = load_ul(a.ul_data, V)
        ugen = torch.Generator().manual_seed(11)
    if det_w:
        # [B] own RNG and a dropout-free extra forward, as A': the MLM batches, masks, order and dropout draws
        # are exactly CTL's; weight 0 never reaches this branch
        DB = load_det_bank(a.det_bank, V)
        DX, DLAB, _ = DB["train"]; VDX, VDLAB, VDLINE = DB["valid"]; VDVAL = det_valid_mask(VDX)
        detgen = torch.Generator().manual_seed(23)
    print(f"poems {len(poems)} (train {len(tr)}), vocab {len(V.itos)}, rime {V.n_rime-1}, params {sum(p.numel() for p in model.parameters())/1e6:.0f}M, device {DEV}", flush=True)
    t0 = time.time(); it = 0
    best_vl, bad_epochs = float("inf"), 0
    for ep in range(a.epochs):
        model.train(); perm = torch.randperm(len(tr), generator=gen)
        for i in range(0, len(tr) - a.batch + 1, a.batch):
            y = Y[perm[i:i + a.batch]]
            x, m = mask_batch(y, seg, gen)
            x, y, m = x.to(DEV), y.to(DEV), m.to(DEV)
            if DL is not None:
                # condition dropout: 10% of rows train with level 0 ("none"), so an unconditioned decode is
                # in distribution (drawn from gen only when the channel exists: the old path is unchanged)
                lv = DL[perm[i:i + a.batch]].clone()
                lv[torch.rand(lv.shape[0], generator=dgen) < 0.1] = 0
                logits = model(x, level=lv.to(DEV))
            elif WT is not None:
                logits, hid = model(x, return_hidden=True)
            else:
                logits = model(x)
            zw = getattr(a, "zone_weight", 1.0)
            if zw != 1.0:   # the same weighting in every arm, so arms differ only in architecture
                w = 1.0 + (zw - 1.0) * rhyme_zone(y)
                loss = (F.cross_entropy(logits[m], y[m], reduction="none") * w[m]).sum() / w[m].sum()
            else:
                loss = F.cross_entropy(logits[m], y[m])
            if WT is not None:
                _mlm = float(loss)
                wl, _wn = word_loss(model, hid, x, m, WT[perm[i:i + a.batch]].to(DEV))
                if _wn: loss = loss + word_w * wl
                if (it + 1) % 50 == 0: print(f"   it {it + 1} MLM {_mlm:.3f} ending-word CE {float(wl):.3f} (x{word_w})", flush=True)
            if stut_w:
                loss = loss + stut_w * stutter_ul(logits, x, y, m, model.seg_id, model.pos_id)
            if kl_b:
                with torch.no_grad(): ref_lg = ref_model(x)
                klm = kl_all(logits, ref_lg, x)
                loss = loss + kl_b * klm
            if ul_w:
                ub = torch.randint(0, len(UY), (getattr(a, "ul_batch", 0) or a.batch,), generator=ugen).tolist()
                uf = ul_mask([UP[k] for k in ub], UY.shape[1], ugen)
                model.eval()                  # no dropout: the global RNG stream (dropout) stays CTL's
                if kl_b:
                    ul, up, xu, lgu = flagged_ul_full(model, UY[ub].to(DEV), uf.to(DEV))
                    with torch.no_grad(): ref_u = ref_model(xu)
                    klu = kl_all(lgu, ref_u, xu, excl=uf.to(DEV))
                    loss = loss + kl_b * klu
                    if (it + 1) % 50 == 0: print(f"   it {it + 1} KL mlm {float(klm):.4f} ul-batch {float(klu):.4f} (x{kl_b})", flush=True)
                else:
                    ul, up = flagged_ul(model, UY[ub].to(DEV), uf.to(DEV))
                model.train()
                if (it + 1) % 50 == 0: print(f"   it {it + 1} unlikelihood {float(ul):.3f} (x{ul_w}), mean p(flagged) {float(up):.4f}", flush=True)
                loss = loss + ul_w * ul
            if det_w:
                db = torch.randint(0, len(DX), (getattr(a, "det_batch", 0) or a.batch,), generator=detgen)
                xd, ld = DX[db], DLAB[db]; vd = det_valid_mask(xd)
                model.eval()                  # no dropout: the global RNG stream (dropout) stays CTL's
                dlg = model.detect_logits(xd.to(DEV))
                dl = F.binary_cross_entropy_with_logits(dlg[vd.to(DEV)].float(), ld.to(DEV)[vd.to(DEV)])
                model.train()
                if (it + 1) % 50 == 0: print(f"   it {it + 1} detection BCE {float(dl.detach()):.3f} (x{det_w}), replaced share {float(ld[vd].mean()):.3f}", flush=True)
                loss = loss + det_w * dl
            opt.zero_grad(); loss.backward(); torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0); opt.step(); sched.step(); it += 1
            if trip is not None and it % 200 == 0:
                tp = tripwire_p(model, *trip)
                print(f"   it {it} tripwire p(flagged) {tp[0]:.4f} p(unflagged) {tp[1]:.4f} (start {trip0[0]:.4f} / {trip0[1]:.4f})", flush=True)
                if tp[0] < 0.5 * trip0[0] or tp[1] < 0.5 * trip0[1]:
                    print(f"== TRIPWIRE: held-out p fell below half its start value - arm aborted", flush=True)
                    raise SystemExit(5)
            if it % 200 == 0 and str(DEV) == "mps": torch.mps.empty_cache()
            if it % 50 == 0:
                print(f"ep {ep} it {it}/{steps} loss {loss.item():.3f} {time.time()-t0:.0f}s mps {(torch.mps.driver_allocated_memory() if str(DEV) == "mps" else 0)/1e9:.1f}GB", flush=True)
        model.eval()
        with torch.no_grad():  # chunked: one forward over all 500 valid poems held ~3 GB of logits in the MPS cache
            x, m = mask_batch(VY, seg, torch.Generator().manual_seed(1))
            tot, cnt, zt, zc = 0.0, 0, 0.0, 0
            wt_, wn_, wc_ = 0.0, 0, 0
            for i in range(0, len(VY), 50):
                xi, yi, mi = x[i:i + 50].to(DEV), VY[i:i + 50].to(DEV), m[i:i + 50].to(DEV)
                lvi = VDL[i:i + 50].to(DEV) if VDL is not None else None
                if VWT is not None:
                    lg, hid = model(xi, return_hidden=True)
                    wl, wn, wk = word_loss(model, hid, xi, mi, VWT[i:i + 50].to(DEV), acc=True)
                    wt_ += float(wl) * wn; wn_ += wn; wc_ += wk
                else:
                    lg = model(xi, level=lvi)
                ce = F.cross_entropy(lg[mi], yi[mi], reduction="none"); zm = rhyme_zone(yi)[mi] > 0
                tot += ce.sum().item(); cnt += int(mi.sum()); zt += ce[zm].sum().item(); zc += int(zm.sum())
            vl = tot / max(cnt, 1)
            print(f"   valid loss on the rhyme zone {zt / max(zc, 1):.3f}, elsewhere {(tot - zt) / max(cnt - zc, 1):.3f}", flush=True)
            if det_w:
                dh = dn = 0; dbce = 0.0
                for i in range(0, len(VDX), 50):
                    lg_ = model.detect_logits(VDX[i:i + 50].to(DEV)).cpu()
                    vv = VDVAL[i:i + 50]
                    dbce += float(F.binary_cross_entropy_with_logits(lg_[vv].float(), VDLAB[i:i + 50][vv], reduction="sum"))
                    h_, n_ = det_line_hit(lg_, VDX[i:i + 50], vv, VDLINE[i:i + 50]); dh += h_; dn += n_
                print(f"   valid detection BCE {dbce / max(int(VDVAL.sum()), 1):.4f}; corrupted line found {dh}/{dn} = {dh / max(dn, 1):.3f}", flush=True)
            if VWT is not None:
                print(f"   valid ending-word CE {wt_ / max(wn_, 1):.3f}, top-1 {wc_ / max(wn_, 1):.3f} on {wn_} masked line endings", flush=True)
        if str(DEV) == "mps": torch.mps.empty_cache()
        if getattr(model, "expert_mode", "none") != "none":
            g = model.expert_gate.detach().abs()
            print(f"   gate |g| mean {g.mean().item():.4f} max {g.max().item():.4f}", flush=True)
        if model.diction:
            g = model.diction_gate.detach().abs()
            inj = (model.diction_gate * model.diction_emb.weight).detach().norm(dim=-1)
            print(f"   diction gate |g| mean {g.mean().item():.4f} max {g.max().item():.4f}; injected norm by level "
                  f"{[round(v, 3) for v in inj.tolist()]} vs input norm {float(model._x_norm):.3f} "
                  f"(ratio max {float(inj.max() / model._x_norm):.3f})", flush=True)
        if kl_b:
            assert ref_sum() == ref_chk, "the KL reference model changed during training"
            print(f"   KL reference unchanged (checksum {ref_chk:.6e})", flush=True)
        print(f"== epoch {ep} valid masked-loss {vl:.3f}" + ("  (best)" if vl < best_vl else ""), flush=True)
        if vl < best_vl - a.min_delta:
            best_vl, bad_epochs = vl, 0
            torch.save(model.state_dict(), os.path.join(a.out, "model.pt"))
        else:
            bad_epochs += 1
            if bad_epochs >= a.patience:
                print(f"== early stop at epoch {ep}: no improvement for {a.patience} epochs (best {best_vl:.3f})", flush=True); break


def load_model(path):
    from transformers import AutoTokenizer, AutoModel
    V = Vocab.__new__(Vocab)
    meta = json.load(open(os.path.join(path, "vocab.json"), encoding="utf-8"))
    V.itos = meta["itos"]
    V.seg = meta.get("seg", "grid")          # checkpoints written before this field used the old grid
    V.stoi = {t: i for i, t in enumerate(V.itos)}
    rimes = {}; V.rime, V.tone, V.fam = [0] * len(V.itos), [5] * len(V.itos), {}
    for i, t in enumerate(V.itos):
        if i < 4: continue
        r, tn = phon(t)
        if r is None: continue
        rimes.setdefault(r, len(rimes) + 1); V.rime[i], V.tone[i] = rimes[r], tn
        V.fam.setdefault(rimes[r], []).append(i)
    V.n_rime = len(rimes) + 1
    wc_tok = AutoTokenizer.from_pretrained(WC); wc = AutoModel.from_pretrained(WC)
    word_lex = json.load(open(os.path.join(path, meta["word_rhyme"]), encoding="utf-8"))["words"] if meta.get("word_rhyme") else None
    model = Canvas(V, wc_tok, wc, expert=meta.get("expert", "none"), diction=meta.get("diction", False), word_lex=word_lex,
                   detector=meta.get("detector", False))
    load_body(model, os.path.join(path, "model.pt"))
    return model.to(DEV).eval(), V


class CleanPrior:
    """Decode-time cleanness constraints from corpus syllable statistics (data/syl_ngrams.json).
    rhyme_lex: rhyme cells may only take syllables the corpus uses at that position >= N times.
    bigram_w:  open cells get w * log P(cell | committed left neighbour) + w * log P(committed right neighbour | cell),
               add-alpha smoothed adjacent-syllable bigrams within a วรรค."""
    def __init__(self, V, path="data/syl_ngrams.json", rhyme_lex=0, bigram_w=0.0, alpha=0.1):
        d = json.load(open(path, encoding="utf-8")); n = len(V.itos); self.n = n; self.w = bigram_w; self.alpha = alpha
        self.uni = torch.zeros(n)
        for s, c in d["uni"].items():
            i = V.enc(s)
            if i != UNK: self.uni[i] = c
        self.rows = collections.defaultdict(dict); self.cols = collections.defaultdict(dict)
        for k, c in d["bi"].items():
            a, b = k.split("\t"); ia, ib = V.enc(a), V.enc(b)
            if ia == UNK or ib == UNK: continue
            self.rows[ia][ib] = c; self.cols[ib][ia] = c
        self.final_mask = self.x_mask = None
        if rhyme_lex:
            self.final_mask = torch.full((n,), -1e9); self.x_mask = torch.full((n,), -1e9)
            for s, c in d["final"].items():
                if c >= rhyme_lex and V.enc(s) != UNK: self.final_mask[V.enc(s)] = 0.0
            for s, c in d["x"].items():
                if c >= rhyme_lex and V.enc(s) != UNK: self.x_mask[V.enc(s)] = 0.0
        self.denom = self.uni + alpha * n
        self._cache_l, self._cache_r = {}, {}
    def left(self, a):  # vector over b: log P(b | a)
        if a not in self._cache_l:
            v = torch.full((self.n,), math.log(self.alpha / float(self.denom[a])))
            for b, c in self.rows[a].items(): v[b] = math.log((c + self.alpha) / float(self.denom[a]))
            self._cache_l[a] = v
        return self._cache_l[a]
    def right(self, b):  # vector over x: log P(b | x)
        if b not in self._cache_r:
            v = torch.log(self.alpha / self.denom)
            for x, c in self.cols[b].items(): v[x] = math.log((c + self.alpha) / float(self.denom[x]))
            self._cache_r[b] = v
        return self._cache_r[b]



JUNK_CHARS = ".*๐"          # crawl artefacts that became vocabulary: '.กลอน', '..บท', '*คง.', '.....๐'


def junk_ids(V):
    """Vocabulary entries carrying corpus punctuation padding (725 of 8561); 2.71% of corpus วรรค and
    8 of 110 generated poems contain one, and one bad วรรค disqualifies a poem."""
    return [i for i, t in enumerate(V.itos) if i >= 4 and any(c in t for c in JUNK_CHARS)]


@torch.no_grad()
def rarity_gain(sy, freq, p_ref=1e-3, lo=1.0, hi=3.0):
    """How much harder a RARE topic syllable has to be pushed to compete.

    The boost is added to logits, so a flat constant is worth very different things to syllables
    whose priors span three orders of magnitude: one at 1e-5 needs about log(1e-3/1e-5) = 4.6 nats
    to reach the same odds as one at 1e-3, and a flat 1.5 does not get it there. Measured on the
    220 judged benchmark poems: the topic gate passes 4% when the topic's rarest content syllable
    is below 1e-5, 44% at 1e-5-1e-4, 70% at 1e-4-1e-3 and 83% above, while CLEANNESS is flat across
    the same split (54/41/45/55). Rarity breaks the topic gate specifically.
    """
    if freq is None: return 1.0
    p = freq["count"].get(sy, 0) / freq["total"]
    if p <= 0: return hi
    return min(hi, max(lo, math.log(p_ref / p)))


def decode_one(model, V, title_syls, T=12, temp=0.8, lens=None, fam_temp=0.5, topic=None, topic_boost=0.0, inner_boost=0.0, clean=None, fam_distinct=False, fam_size_alpha=0.0, fam_prior=None, fam_prior_w=0.0, ban_junk=False, seg=syls_of, rhyme_first=True, commit_n=0, rhyme_pos="3", fam_soft=0.0, fam_delay=0, topic_zero="global", topic_freq=None, prefill=None, orig=None, rhyme_last=False, trace=None, seam_repair=False, anaphora_penalty=0.0, poetic=None, word_rhyme=False, word_mlm_w=1.0, commit_log=None):
    """prefill: {line index: [token ids]} - those วรรค are committed before step 0 and never change
    (the refill mode). orig: the original poem's token lists for all four lines, used only to read
    which receiving position (3 or 5) its kept rhyme links used."""
    n = len(V.itos); lens = lens or [8] * L
    if rhyme_last: rhyme_first = False     # the two orders are opposites
    tok = torch.full((TL + L * C,), PAD, dtype=torch.long)
    for j, s in enumerate(title_syls[:TL]): tok[j] = V.enc(s)
    idx = {(i, j): TL + i * C + j for i in range(L) for j in range(C)}
    cells = [(i, j) for i in range(L) for j in range(lens[i])]
    for c in cells: tok[idx[c]] = MASK
    committed = set()
    for i, ids in (prefill or {}).items():
        assert len(ids) == lens[i], f"prefilled line {i} has {len(ids)} tokens for {lens[i]} cells"
        for j, t_ in enumerate(ids): tok[idx[(i, j)]] = t_; committed.add(idx[(i, j)])
    base_bad = torch.zeros(n); base_bad[:4] = -1e9
    if ban_junk:
        ids = junk_ids(V)
        assert ids, "--ban-junk but no vocabulary entry matches JUNK_CHARS"
        base_bad[torch.tensor(ids)] = -1e9
    tone_mask = {ln: torch.tensor([0.0 if (V.tone[k] in allowed and k >= 4) else -1e9 for k in range(n)]) for ln, allowed in TONE_AT.items()}
    fam_vec = {}
    for f, ks in V.fam.items():
        v = torch.full((n,), -1e9); v[torch.tensor(ks)] = 0.0; fam_vec[f] = v
    # The receiving cell of a rhyme link may sit at syllable 3 OR 5 (klon8.prosody.STRICT_POS);
    # human บท use 5 in 10.3% of สดับ->รับ and 12.4% of รับ->ส่ง links. GROUPS names syllable 3, and
    # the decoder only ever tried that one. With rhyme_pos="auto" the position is chosen per group
    # by which cell the model puts more family mass on, doubling the candidate mass at the most
    # constrained cell in the poem for nothing.
    def resolve(g, alt):
        out = []
        for (l, c) in g:
            if c == -1: out.append((l - 1, lens[l - 1] - 1))
            else: out.append((l - 1, 4 if (alt and 4 < lens[l - 1]) else c))
        return out
    groups = [resolve(g, False) for g in GROUPS]
    groups_alt = [resolve(g, True) for g in GROUPS]
    group_family, group_pos = {}, {}
    free_pos = set()                 # prefilled groups whose receiving cell sits in a refilled line
    if prefill:
        # A group touching a kept line inherits its family NOW, before step 0: the kept rhyme word is
        # fixed, so the refilled line must meet it. Left to the normal path, fam_delay would leave the
        # rhyme cells unmasked and rhyme_first could commit one outside the family at t=0.
        for gi, g in enumerate(GROUPS):
            recv_line = g[-1][0] - 1
            if recv_line in prefill:
                # the receiver is kept: use the position (3 or 5) where the ORIGINAL poem rhymed
                # (resolved on the ORIGINAL line lengths: a refilled line may be retried at another length)
                def shares(alt):
                    rs = {V.rime[orig[l - 1][-1] if c == -1 else orig[l - 1][4 if (alt and 4 < len(orig[l - 1])) else c]]
                          for (l, c) in g}
                    return len(rs) == 1 and 0 not in rs
                group_pos[gi] = 0 if shares(False) or not shares(True) else 1
            elif rhyme_pos == "auto":
                free_pos.add(gi)
            kept = [c for c in (groups_alt if group_pos.get(gi) else groups)[gi] if c[0] in prefill]
            if kept and V.rime[int(tok[idx[kept[0]]])]:
                group_family[gi] = V.rime[int(tok[idx[kept[0]]])]
    topic_vec = None
    if topic and topic_boost:
        rows_ = []
        # one clause per วรรค. The 8-วรรค grid appended each clause twice (one per บาท); keeping
        # that here dropped clauses 3 and 4 entirely and let clauses 1-2 steer two วรรค each.
        for clause in (list(topic) + [topic[-1]] * L)[:L]:   # short prompts: the last clause repeats
            v = torch.zeros(n)
            for sy in seg(clause):
                if sy not in STOP_SYL and V.enc(sy) != UNK and len(sy) > 1:
                    v[V.enc(sy)] = topic_boost * rarity_gain(sy, topic_freq)
            rows_ += [v]
        while len(rows_) < L: rows_.append(torch.zeros(n))
        topic_vec = torch.stack(rows_[:L])
    big_fams = [(f, torch.tensor(ks)) for f, ks in V.fam.items() if len(ks) >= 20]
    # สัมผัสใน steering: an open cell whose template partner (P.VOWEL_SLOTS, 1-indexed within the line)
    # is committed gets a bonus on every syllable sharing that partner's rime, except the identical syllable.
    fam_ind = {f: (v == 0).float() for f, v in fam_vec.items()}
    inner_pairs = {}
    if inner_boost:
        for i in range(L):
            for (p, q), w in P.VOWEL_SLOTS.items():
                if q <= lens[i]:
                    inner_pairs.setdefault(idx[(i, p - 1)], []).append((idx[(i, q - 1)], w))
                    inner_pairs.setdefault(idx[(i, q - 1)], []).append((idx[(i, p - 1)], w))
    cell_of = {v: c for c, v in idx.items()}
    repaired = set()
    # C1 word-rhyme: each open line's last WORD_MAX_K cells (except a receiving 3rd/5th cell) are HELD until the
    # line's ending is drawn as a whole word (all its syllables committed jointly), so no partial ending commits
    # first and the head sees the fully-masked ending it was trained on (blind review 2026-09-28). The word is
    # drawn only once the rest of its line exists (at most 2 of its other cells open) - never forced early
    # (purpose review: that recreated the old rhyme-first problem). A line with no admissible word is released to the normal path.
    assert not (word_rhyme and rhyme_last), "--word-rhyme and --rhyme-last are different orders"
    word_pending = {i for i in range(L) if idx[(i, lens[i] - 1)] not in committed} if word_rhyme else set()
    line_group = {}
    for gi, g in enumerate(GROUPS):
        for (l_, c_) in g:
            if c_ == -1: line_group[l_ - 1] = gi
    recv_cols = {i: {c[1] for g_ in (resolve(g, False) for g in GROUPS) for c in g_ if c[0] == i and c[1] != lens[i] - 1} |
                    {c[1] for g_ in (resolve(g, True) for g in GROUPS) for c in g_ if c[0] == i and c[1] != lens[i] - 1}
                 for i in range(L)}
    word_hold = {i: {idx[(i, j)] for j in range(max(0, lens[i] - WORD_MAX_K), lens[i]) if j not in recv_cols[i]}
                 for i in word_pending}
    for i in list(word_pending):
        if any(k in committed for k in word_hold[i]): word_pending.discard(i)   # refill: an ending already kept

    def rhyme_hold():
        """Rhyme cells still held: the resolved position of a group whose position is known, BOTH
        candidate positions (3 and 5) of a receiver whose position is not yet chosen."""
        h = set()
        for gi in range(len(groups)):
            cg = (groups_alt if group_pos.get(gi) else groups)[gi]
            if gi not in group_pos and rhyme_pos == "auto": cg = list(set(cg) | set(groups_alt[gi]))
            h |= {idx[c] for c in cg}
        h.add(idx[(L - 1, lens[L - 1] - 1)])   # the ส่ง line-final cell: no rhyme link, but a tone class,
        return h                               # and line-final spans were counted for all four วรรค

    def eligible(c):
        held = rhyme_hold()
        return all(idx[(c[0], j)] in committed or idx[(c[0], j)] in held for j in range(lens[c[0]]))

    for t in range(T):
        if word_pending:
            _out, _hid = model(tok[None].to(DEV), return_hidden=True)
            wlp = F.log_softmax(model.word_logits(_hid, tok[None].to(DEV))[0].float().cpu(), -1)   # [L, W]
            logits = _out[0].float().cpu() + base_bad[None, :]
            # canvas term on ONE scale for every cell of a candidate word: the model's own log-probs before the
            # family / tone masks (masked log-probs renormalise the final cell inside the family and made a
            # one-syllable word look better than a multi-syllable one - purpose review 2026-09-28)
            lp_raw = F.log_softmax(logits, dim=-1)
        else:
            logits = model(tok[None].to(DEV))[0].float().cpu() + base_bad[None, :]
        for ln, tm in tone_mask.items():
            logits[idx[(ln - 1, lens[ln - 1] - 1)]] += tm
        if topic_vec is not None:
            tv = topic_vec.clone()
            if committed:
                if topic_zero == "row":
                    # zero a topic syllable ONLY in the วรรค where it was committed. The original
                    # zeroed it in every row, so each topic syllable steered until its first
                    # commitment ANYWHERE - and a short title yields 2-3 usable syllables after the
                    # stopword/UNK filter, so the topic channel died within ~3 of ~33 commits,
                    # typically inside วรรค 1. Measured signature on 195 judged candidates: topic
                    # present in 67% of วรรค1 and 17-19% of วรรค2-4 (human 45% / 5-15%).
                    for k in committed:
                        if int(tok[k]) >= 4: tv[(k - TL) // C, int(tok[k])] = 0.0
                else:
                    tv[:, torch.tensor(sorted({int(tok[k]) for k in committed if tok[k] >= 4}))] = 0.0
            for i in range(L):
                for j in range(lens[i]): logits[idx[(i, j)]] += tv[i]
        if inner_boost:
            for k, partners in inner_pairs.items():
                if k in committed: continue
                for pk, w in partners:
                    if pk not in committed: continue
                    pt = int(tok[pk]); f = V.rime[pt]
                    if not f or f not in fam_ind: continue
                    b = fam_ind[f] * (inner_boost * w); b[pt] = 0.0
                    logits[k] += b
        if clean is not None:
            for (i, j) in cells:
                k = idx[(i, j)]
                if k in committed: continue
                if clean.final_mask is not None:
                    if j == lens[i] - 1: logits[k] += clean.final_mask
                    elif i % 2 == 1 and j in (2, 4): logits[k] += clean.x_mask
                if clean.w:
                    if j > 0 and idx[(i, j - 1)] in committed and int(tok[idx[(i, j - 1)]]) >= 4:
                        logits[k] += clean.w * clean.left(int(tok[idx[(i, j - 1)]]))
                    if j + 1 < lens[i] and idx[(i, j + 1)] in committed and int(tok[idx[(i, j + 1)]]) >= 4:
                        logits[k] += clean.w * clean.right(int(tok[idx[(i, j + 1)]]))
        lp = F.log_softmax(logits, dim=-1)
        for gi in list(free_pos):
            # inherited family, refilled receiver: choose 3 or 5 by the model's family mass, as the
            # normal path does when it picks the family
            ks = torch.tensor(V.fam[group_family[gi]]) if gi in group_family else None
            if ks is not None:
                def mass(cg): return sum(torch.logsumexp(lp[idx[c]][ks], 0).item() for c in cg if idx[c] not in committed)
                group_pos[gi] = 1 if mass(groups_alt[gi]) > mass(groups[gi]) else 0
            free_pos.discard(gi)
        for gi, cells_g in enumerate(groups):
            if gi in group_family: continue
            # The family used to be fixed at t=0, off a blank canvas, where the model holds only
            # ~2.9% of its mass on the family the human poet used and ranks the poet's own rhyme word
            # third. With the rest of the บท visible it ranks that word FIRST. fam_delay holds the
            # choice back until some content has committed, so the rhyme is chosen against a line
            # that exists. Until then the rhyme cells are simply left open.
            if rhyme_last:
                # choose a group's family only once one of its cells may commit, i.e. once the rest of
                # THAT cell's line exists (see rhyme_hold below). The group's other lines may still be
                # open (group 1 spans วรรค 2-4); they then have to meet a family chosen from one line.
                if not any(eligible(c) for c in set(cells_g) | set(groups_alt[gi])): continue
            elif t < fam_delay: continue
            done = [idx[c] for c in cells_g if idx[c] in committed]
            if done:
                f = V.rime[int(tok[done[0]])]
                if f: group_family[gi] = f
                continue
            used_f = collections.Counter(group_family.values())
            scores, fams = [], []
            for f, ks in big_fams:
                # per-cell mean of the family log-mass; size correction (alpha=1 -> mean log-prob per member);
                # human-frequency prior; reuse across the poem's 4 groups either penalised or forbidden
                sc = sum(torch.logsumexp(lp[idx[c]][ks], 0).item() - fam_size_alpha * math.log(len(ks)) for c in cells_g) / len(cells_g)
                if fam_prior is not None and fam_prior_w: sc += fam_prior_w * fam_prior.get(f, math.log(1e-4))
                if used_f.get(f, 0): sc += -1e9 if fam_distinct else -1.0
                scores.append(sc); fams.append(f)
            if fams:
                z = torch.tensor(scores) / max(fam_temp, 1e-6)
                group_family[gi] = fams[int(torch.multinomial(F.softmax(z, 0), 1))] if fam_temp > 0 else fams[int(torch.argmax(z))]
                if rhyme_pos == "auto" and gi not in group_pos:
                    ks = dict(big_fams).get(group_family[gi])
                    if ks is not None:
                        def mass(cg): return sum(torch.logsumexp(lp[idx[c]][ks], 0).item() for c in cg) / len(cg)
                        group_pos[gi] = 1 if mass(groups_alt[gi]) > mass(groups[gi]) else 0
        for gi in range(len(groups)):
            cells_g = (groups_alt if group_pos.get(gi) else groups)[gi]
            f = group_family.get(gi)
            if f in fam_vec:
                # A HARD family mask (-1e9) forces the rhyme cell into whatever the chosen family
                # allows, however badly it reads - and the family itself was picked at t=0 off a
                # blank canvas. --fam-soft replaces the wall with a finite penalty, so a much better
                # word can win and the checker (plus retries) decides whether the poem still scans.
                pen = fam_vec[f] if not fam_soft else (fam_vec[f] / 1e9) * fam_soft
                for cell in cells_g:
                    if idx[cell] not in committed: logits[idx[cell]] += pen
        for gi in range(len(groups)):
            cells_g = (groups_alt if group_pos.get(gi) else groups)[gi]
            for cell in cells_g:
                if idx[cell] in committed: continue
                for other in cells_g:
                    if other != cell and idx[other] in committed: logits[idx[cell], int(tok[idx[other]])] = -1e9
        if committed:
            rep = torch.tensor(sorted({int(tok[k]) for k in committed if tok[k] >= 4}))
            logits[:, rep] -= 2.0
        pbon = None
        # In refill (prefill given) every rhyme family is inherited from the kept lines before step 0, so
        # fam_delay's reason does not apply - and rhyme-first commits a refilled line-1/3 rhyme cell AT step 0,
        # which never got the bonus (reviews 2026-09-27: 60/150 targeted refills). Live from step 0 there.
        if poetic is not None and (t >= fam_delay or prefill):
            # poetic-diction steering (plan B, user idea 2026-09-27): the judge forgives imagery and poetic
            # diction, and 80% of dirty poems fail at the rhyme positions. Uncommitted rhyme-zone cells get a
            # bonus toward whole-word syllables far more common in poems than in prose (runs/LOC/poetic_syl.json).
            # Reviews 2026-09-27: (1) not before fam_delay - until then rhyme-first commits the line-1 rhyme
            # cell with NO family mask on a blank canvas, and the bonus would pick group 0's family; (2) a
            # lexicon syllable already committed anywhere loses its bonus (as --topic-zero row does), so +W
            # cannot outweigh the -2 repeat penalty; (3) the 3rd/5th receiving cell is boosted only once
            # its group's position is resolved, and only the chosen one; (4) the bonus is kept out of the
            # commit-order confidence below, so it changes WHICH word, not WHEN the cell commits.
            pv = poetic.clone()
            used = sorted({int(tok[k]) for k in committed if tok[k] >= 4})
            if used: pv[torch.tensor(used)] = 0.0
            recv = {idx[c] for gi in group_family for c in (groups_alt if group_pos.get(gi) else groups)[gi]}
            pbon = torch.zeros_like(logits)
            for i in range(L):
                for j in range(lens[i]):
                    k = idx[(i, j)]
                    if k in committed: continue
                    if j >= lens[i] - ZONE_TAIL or k in recv: pbon[k] = pv
            logits = logits + pbon
        if anaphora_penalty:
            # template breaker (2026-09-27): 32% of our poems open 3+ lines with the same two syllables
            # against 1% of human poems, and half of those are judged unclean. A syllable that two OTHER
            # lines already have at the same opening position (1st or 2nd) is penalised here.
            for i in range(L):
                for j in (0, 1):
                    k = idx[(i, j)]
                    if k in committed or j >= lens[i]: continue
                    seen = collections.Counter(int(tok[idx[(l, j)]]) for l in range(L)
                                               if l != i and j < lens[l] and idx[(l, j)] in committed)
                    for t_, c_ in seen.items():
                        if c_ >= 2 and t_ >= 4: logits[k, t_] -= anaphora_penalty
        word_step = False
        if word_pending and (t >= fam_delay or prefill):
            lpc = F.log_softmax(logits, dim=-1)                                  # masked: admissibility only
            wid, wlen = model.word_ids.cpu(), model.word_len.cpu()
            held_all = set().union(*(word_hold[i] for i in word_pending))
            normal_open = [idx[c] for c in cells if idx[c] not in committed and idx[c] not in held_all]
            cmask = torch.zeros(tok.shape[0], dtype=torch.bool)
            if committed: cmask[torch.tensor(sorted(committed))] = True
            used = torch.zeros(len(V.itos), dtype=torch.bool)
            if committed: used[torch.tensor(sorted({int(tok[k]) for k in committed if tok[k] >= 4}), dtype=torch.long)] = True
            cand = []
            for i in sorted(word_pending):
                gi = line_group.get(i)
                if gi is not None and gi not in group_family: continue            # family not chosen yet
                n_i = lens[i]
                rest = [idx[(i, j)] for j in range(n_i) if idx[(i, j)] not in word_hold[i]]
                n_open_rest = sum(k not in committed for k in rest)
                sc = wlp[i].clone()
                last = wid.gather(1, (wlen - 1)[:, None])[:, 0]
                # the line-final syllable must pass the family / tone / same-syllable-in-group masks
                ok = (wlen < n_i) & (lpc[idx[(i, n_i - 1)], last] > -1e8)
                lp_sum = torch.zeros(len(wlen)); n_open = torch.zeros(len(wlen), dtype=torch.long)
                n_rep = torch.zeros(len(wlen))
                for q in range(WORD_MAX_K):
                    in_word = q < wlen
                    j = (n_i - wlen + q).clamp(min=0, max=n_i - 1)               # cell of the word's q-th syllable
                    syl = wid[:, q]
                    cellk = TL + i * C + j                                        # == idx[(i, j)]
                    comm = cmask[cellk]
                    # a committed cell must already hold that syllable; an open one is scored under the canvas
                    ok = ok & (~in_word | ~comm | (tok[cellk] == syl))
                    lpq = lp_raw[cellk, syl.clamp(min=0)]
                    lp_sum = lp_sum + torch.where(in_word & ~comm, lpq, torch.zeros_like(lpq))
                    n_open = n_open + (in_word & ~comm).long()
                    n_rep = n_rep + (in_word & ~comm & used[syl.clamp(min=0)]).float()
                    for rj in recv_cols[i]:                                       # never cover a receiving cell
                        ok = ok & ~(in_word & (j == rj))
                # per-cell MEAN of the raw canvas log-probs (a sum charges each extra syllable); the head's
                # log-prob carries the word's own probability
                sc = sc + word_mlm_w * lp_sum / n_open.clamp(min=1)
                # the same -2 per already-used syllable every other cell pays (the raw scale above omits it;
                # smoke without it: cross-line repeats 2.07 vs CTL 1.10)
                sc = sc - 2.0 * n_rep
                sc[~ok] = -1e9
                if float(sc.max()) < -1e8:
                    word_pending.discard(i); continue                           # released to the normal path
                pr = F.softmax(sc / temp, 0)
                # context rule only: a lexicon softmax and a syllable softmax are not comparable, and a "more confident
                # than the best ordinary commit" clause drew endings EARLIER than the control (smoke: 0.24 vs 0.31 of the
                # schedule), the opposite of the purpose
                ready = n_open_rest <= 2 or t >= T - 1 - len(word_pending) or not normal_open
                if ready: cand.append((float(pr.max()), i, pr))
            # one word per step while ordinary cells remain; every ready line at once when only held cells are
            # left (otherwise a held ending would have to fall back to cell-by-cell commits)
            for _, i, pr in sorted(cand, reverse=True)[: (len(cand) if not normal_open or t >= T - 1 else 1)]:
                w = int(torch.multinomial(pr, 1)); k_ = int(wlen[w])
                if trace is not None: trace.append(("word", i, w, k_))
                for q in range(k_):
                    kk = idx[(i, lens[i] - k_ + q)]
                    if kk not in committed:
                        tok[kk] = int(wid[w, q]); committed.add(kk)
                        if trace is not None: trace.append(kk)
                        if commit_log is not None: commit_log[kk] = t
                # cells of the hold set the word did not cover (a shorter word) go back to the normal path
                word_pending.discard(i); word_step = True
        probs = F.softmax(logits / temp, dim=-1)
        conf = (probs if pbon is None else F.softmax((logits - pbon) / temp, dim=-1)).max(dim=-1).values
        open_cells = [idx[c] for c in cells if idx[c] not in committed]
        if word_pending:
            _wh = set().union(*(word_hold[i] for i in word_pending))
            open_cells = [k for k in open_cells if k not in _wh] or open_cells
        if rhyme_last:
            # Rhyme cells commit LAST in their line: 56% of judged bad spans run up to the line-final
            # rhyme word (23% by chance), i.e. the line bends to reach a syllable chosen before it
            # existed. A rhyme cell may commit only once every other cell of its line has.
            if t < T - 1:   # the last step commits whatever is left; a held cell must never be dropped
                open_cells = [k for k in open_cells if k not in rhyme_hold() or eligible(cell_of[k])] or open_cells
        elif t < fam_delay:                       # hold the rhyme cells open until the family is chosen
            held = {idx[c] for gi in range(len(groups)) for c in (groups_alt if group_pos.get(gi) else groups)[gi]}
            open_cells = [k for k in open_cells if k not in held] or open_cells
        n_pre = sum(len(v) for v in (prefill or {}).values())
        n_commit = max(1, math.ceil((len(cells) - n_pre) * (t + 1) / T) - (len(committed) - n_pre))
        order = sorted(open_cells, key=lambda k: -float(conf[k]))
        if rhyme_first and t == 0:
            # Asking for the rhyme word off a blank canvas: probed on 30 human บท, the model puts
            # 2.9% (median) of its mass on the family the poet used and ranks the poet's own rhyme
            # word 3rd. With the rest of the บท known the same model ranks it 1st. rhyme_first=False
            # lets confidence order the rhyme cells like any other.
            rh = [idx[c] for gi in range(len(groups)) for c in (groups_alt if group_pos.get(gi) else groups)[gi] if idx[c] not in committed]
            if word_pending: rh = [k for k in rh if k in open_cells]      # held line ends wait for their word
            order = list(dict.fromkeys(rh + order))
        if commit_n:
            n_commit = min(n_commit, commit_n)          # 1 removes the conditional-independence error
        held_now = rhyme_hold() if (rhyme_last and seam_repair) else set()
        if word_step: order = []          # the word was this step's commit (its neighbours are chosen against it next step)
        for k in order[:n_commit]:
            tok[k] = int(torch.multinomial(probs[k], 1)); committed.add(k)
            if commit_log is not None: commit_log[k] = t
            if trace is not None: trace.append(k)      # commit order, for tests
            if k in held_now:
                # seam repair (review 2026-09-26): with rhyme-last the rhyme cell's neighbours were fixed
                # while it was still MASK, so they never saw the family - the model's own likelihood of
                # the syllable before a line-final rhyme fell from -1.67 to -3.17. Once the rhyme word
                # exists, re-open its immediate neighbours (once each) so they are re-chosen against it.
                i, j = cell_of[k]
                for jj in (j - 1, j + 1):
                    if 0 <= jj < lens[i]:
                        nb = idx[(i, jj)]
                        if nb in committed and nb not in held_now and nb not in repaired and \
                                not (prefill and i in prefill):
                            committed.discard(nb); tok[nb] = MASK; repaired.add(nb)
        for k in open_cells:
            if k not in committed: tok[k] = MASK
    lines = ["".join(V.itos[int(tok[idx[(i, j)]])] for j in range(lens[i]) if int(tok[idx[(i, j)]]) >= 4) for i in range(L)]
    return "\n".join(lines)


def rows_preview(path):
    return [(lambda r: r.get("text") or r.get("prompt"))(json.loads(l)) for l in open(path, encoding="utf-8") if l.strip()]


def refill(a, model, V, seg, clean, fam_prior, topic_freq, eff_boost):
    """Re-draw ONE วรรค of an already-selected poem, keeping the other three byte for byte.

    Why: on the 220 judged benchmark poems 20% fail on cleanness alone with exactly one bad วรรค.
    Selection throws such a poem away and hopes another candidate is wholly clean; this keeps the three
    good วรรค and offers the selector K alternatives for each line. The rhyme family of every link
    touching a kept line is inherited; the refilled line still chooses its own rhyme word inside it.

    --prompts is a best-pick file (id, prompt, poem). Writes OUT/r{line}_{k}.jsonl, one row per id, so
    the files drop straight into select_by_typhoon.py next to the original. A poem whose lines do not
    round-trip through the segmenter and vocabulary is copied unchanged, and counted.
    """
    assert a.commit_n >= 1, "refill schedules one commit per step; pass --commit-n 1"
    rows = [json.loads(l) for l in open(a.prompts, encoding="utf-8") if l.strip()]
    if a.limit: rows = rows[:a.limit]
    todo = [int(x) - 1 for x in a.refill_lines.split(",")]
    line_map = None
    if a.refill_line_map:
        # one line per poem, chosen by a locator (e.g. the canvas score's weakest วรรค), 1-based
        line_map = {k: int(v) - 1 for k, v in json.load(open(a.refill_line_map, encoding="utf-8")).items()}
        todo = [0, 1, 2, 3]
    tf = topic_freq if a.refill_boost_mode == "rarity" else None
    os.makedirs(a.out, exist_ok=True)
    outs = {(i, k): open(os.path.join(a.out, f"r{i + 1}_{k}.jsonl"), "w", encoding="utf-8")
            for i in todo for k in range(a.refill_k)}
    n_skip = n_pass = n_tot = 0
    for ri, r in enumerate(rows, 1):
        text = r.get("prompt") or r.get("text")
        orig_lines = r["poem"].split("\n")
        orig = [[V.enc(sy) for sy in seg(ln, C)] for ln in orig_lines]
        ok = len(orig_lines) == L and all(UNK not in o and "".join(V.itos[t] for t in o) == ln
                                          for o, ln in zip(orig, orig_lines))
        title = seg(re.split(r"\s+|[,;.!?]", text)[0], TL)
        for i in todo:
            if line_map is not None and line_map.get(r["id"]) != i:
                continue
            for k in range(a.refill_k):
                if not ok:
                    outs[(i, k)].write(json.dumps({**r, "refill_line": None, "sample": k}, ensure_ascii=False) + "\n")
                    continue
                _rs = (a.seed * 100003 + int(hashlib.sha256(f"{r['id']}|{i}|{k}".encode()).hexdigest()[:8], 16))
                torch.manual_seed(_rs % (2**31)); rng_len = random.Random(_rs)
                want = pick_lens(a.wak_lens, rng_len)[i]
                cells_i, best = want, None
                prefill = {l: orig[l] for l in range(L) if l != i}
                for attempt in range(a.retries):
                    lens = [len(o) for o in orig]; lens[i] = cells_i
                    steps = max(a.steps, cells_i // a.commit_n + 1)
                    poem = decode_one(model, V, title, T=steps, temp=a.temp, lens=lens, fam_temp=a.fam_temp,
                                      topic=clauses_of(text) if a.topic_boost else None, topic_boost=eff_boost(text), inner_boost=a.inner_boost, clean=clean, fam_distinct=a.fam_distinct, fam_size_alpha=a.fam_size_alpha, fam_prior=fam_prior, fam_prior_w=a.fam_prior_w, ban_junk=a.ban_junk, seg=seg, rhyme_first=not a.no_rhyme_first, commit_n=a.commit_n, rhyme_pos=a.rhyme_pos, fam_soft=a.fam_soft, fam_delay=a.fam_delay, rhyme_last=a.rhyme_last, seam_repair=a.seam_repair, anaphora_penalty=a.anaphora_penalty, poetic=a.poetic_vec, word_rhyme=a.word_rhyme, word_mlm_w=a.word_mlm_w, topic_zero=a.topic_zero, topic_freq=tf,
                                      prefill=prefill, orig=orig)
                    new = poem.split("\n")
                    assert all(new[l] == orig_lines[l] for l in prefill), f"{r['id']}: a kept วรรค changed"
                    s_ = P.score(poem, n_bot_expected=N_BOT)
                    got = syl_counts(poem)
                    key = (got[i] == want, s_["all_pass"], s_["ext_rhyme"] + s_["tone"] + 0.5 * s_["internal"])
                    if best is None or key > best[0]: best = (key, poem, s_, got)
                    if got[i] == want and s_["all_pass"]: break
                    cells_i = max(5, min(C, cells_i - (got[i] - want)))
                _, poem, s_, got = best
                n_tot += 1; n_pass += bool(s_["all_pass"])
                outs[(i, k)].write(json.dumps({"id": r["id"], "prompt": text, "sample": k, "poem": poem,
                    "refill_line": i + 1, "orig_poem": r["poem"], "syl": got, "topic_boost": eff_boost(text), "poetic_boost": a.poetic_boost,
                    "refill_boost_mode": a.refill_boost_mode, "all_pass": s_["all_pass"], "structure": s_["structure"],
                    "ext_rhyme": s_["ext_rhyme"], "tone": s_["tone"], "internal": s_["internal"]}, ensure_ascii=False) + "\n")
        n_skip += not ok
        print(f"{ri}/{len(rows)} {'refilled' if ok else 'COPIED (no round-trip)'}", file=sys.stderr, flush=True)
    for f in outs.values(): f.close()
    print(f"refilled {n_tot} variants, form {100 * n_pass / max(n_tot, 1):.1f}%; "
          f"{n_skip}/{len(rows)} poems copied unchanged (segmenter round-trip failed)", file=sys.stderr, flush=True)
    P.save_cache()


def decode(a):
    model, V = load_model(a.model)
    if getattr(model, "diction", False) and getattr(a, "diction_level", None) is None:
        a.diction_level = 0                      # as trained: level 0 = "no level"
    if getattr(a, "diction_level", None) is not None:
        assert model.diction, f"--diction-level needs a model trained with --diction ({a.model} was not)"
        assert 0 <= a.diction_level <= DICTION_LEVELS, f"--diction-level must be 0..{DICTION_LEVELS}"
        model.diction_level = a.diction_level
        print(f"  diction level {a.diction_level} (0 = none, 1..{DICTION_LEVELS} = within-board quartile of poetic rhyme-zone cells)", file=sys.stderr, flush=True)
    if a.word_rhyme:
        assert getattr(model, "word_lex", None), f"--word-rhyme needs a model trained with --word-loss-w > 0 ({a.model} was not)"
        print(f"  word-rhyme: line endings drawn as words from {len(model.word_lex)} (canvas weight {a.word_mlm_w})", file=sys.stderr, flush=True)
    seg = (lambda t, lim=None: syl_align.syls_aligned(t, lim)) if getattr(V, "seg", "grid") == "aligned" else syls_of

    def eff_boost(text):
        """A short prompt gets --short-topic-boost, not --topic-boost (sweep 2026-09-20: 3.0 repeats
        the topic words, 1.5 keeps form). That is deliberate, but it means a job script saying
        '--topic-boost 3.0' applies 1.5 to 98% of our topics, and runs have been reported under the
        wrong number. The effective value is printed and recorded per row."""
        return a.short_topic_boost if len(seg(text)) <= 8 else a.topic_boost
    if getattr(a, "rhyme_last", False) and (a.fam_delay or a.no_rhyme_first is False):
        print("  NOTE: --rhyme-last overrides --fam-delay and rhyme-first ordering", file=sys.stderr, flush=True)
    print(f"decoding {a.model}: topic segmented with '{getattr(V, 'seg', 'grid')}' (as trained)", file=sys.stderr, flush=True)
    _n_short = sum(1 for r in rows_preview(a.prompts) if len(seg(r)) <= 8)
    _n_all = len(rows_preview(a.prompts))
    print(f"  topic boost: {a.short_topic_boost} on {_n_short}/{_n_all} prompts (<=8 syllables), "
          f"{a.topic_boost} on the other {_n_all - _n_short}", file=sys.stderr, flush=True)
    clean = CleanPrior(V, a.ngrams, a.rhyme_lex, a.bigram_w) if (a.rhyme_lex or a.bigram_w) else None
    fam_prior = None
    if a.fam_prior_w:
        d = json.load(open(a.ngrams, encoding="utf-8"))["fam_final"]; tot = sum(d.values())
        rime_key = {}
        for i, s in enumerate(V.itos):
            if i < 4: continue
            r, _ = phon(s)
            if r and V.rime[i]: rime_key[V.rime[i]] = "|".join(r)
        fam_prior = {fid: math.log(d.get(k, 0.5) / tot) for fid, k in rime_key.items()}   # log human line-final family frequency
    a.poetic_vec = None
    if a.poetic_boost:
        lex = json.load(open(a.poetic_lex, encoding="utf-8"))
        a.poetic_vec = torch.zeros(len(V.itos))
        for sy, lo in lex.items():
            if V.enc(sy) != UNK: a.poetic_vec[V.enc(sy)] = a.poetic_boost * min(max((lo - 1.5) / 3.0, 0.0), 1.0)
        print(f"  poetic boost {a.poetic_boost}: {int((a.poetic_vec > 0).sum())} syllables from {a.poetic_lex}, "
              f"mean bonus {float(a.poetic_vec[a.poetic_vec > 0].mean()):.2f}", file=sys.stderr, flush=True)
    topic_freq = None
    if a.boost_mode == "rarity":
        topic_freq = json.load(open(a.syl_freq, encoding="utf-8"))
        assert topic_freq.get("total", 0) > 0 and topic_freq.get("count"), f"{a.syl_freq} is not a frequency table"
        print(f"  boost mode rarity: {topic_freq['total']:,} corpus syllable tokens, "
              f"{len(topic_freq['count']):,} types", file=sys.stderr, flush=True)
    if a.refill_k:
        return refill(a, model, V, seg, clean, fam_prior, topic_freq, eff_boost)
    rows = [json.loads(l) for l in open(a.prompts, encoding="utf-8") if l.strip()]
    if a.limit: rows = rows[:a.limit]
    os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True)
    torch.manual_seed(a.seed)
    rng_len = random.Random(a.seed)
    n_hit = [0, 0]
    with open(a.out, "w", encoding="utf-8") as f:
        for i, r in enumerate(rows, 1):
            # Seed PER ROW. Seeding once before the loop makes a row's randomness depend on how much
            # every earlier row consumed, so two arms decoded with "the same seed" drift apart from
            # row 1 onward even where their inputs are identical - 83 of 211 supposedly-differing
            # pairs in runs/RARITY64 differ only by that drift. The invariant this restores: a
            # one-row prompt file must produce the same poem as that row inside the full file.
            # Seed per ROW IDENTITY, not per position. Seeding once before the loop makes a row's
            # randomness depend on how much every earlier row consumed, so two arms decoded with
            # "the same seed" drift apart from the first treated row onward - 83 of 211 supposedly
            # differing pairs in runs/RARITY64 differ only by that drift. Seeding by position would
            # fix the arms but still make a row's poem depend on where it sits in the file; keying on
            # the id gives the invariant that actually matters: one row decodes the same alone as it
            # does inside the full set.
            _rs = a.seed * 100003 + int(hashlib.sha256(str(r["id"]).encode()).hexdigest()[:8], 16)
            torch.manual_seed(_rs % (2**31))
            rng_len = random.Random(_rs)
            title = seg(re.split(r"\s+|[,;.!?]", r["text"])[0], TL)
            # A grid cell is an approximation of a syllable: the segmenter that fills the cells reads
            # ถวิล as one where it is ถ|วิล. So the target is a PRONOUNCED count, the poem is counted
            # with g2p_line after decoding, and the cell budget is corrected by the error and retried.
            want = pick_lens(a.wak_lens, rng_len)
            steps = a.steps if not a.commit_n else max(a.steps, sum(want) // a.commit_n + 1)
            cells, best = list(want), None
            for attempt in range(a.retries):
                if a.rhyme_last and a.commit_n:
                    # retries can grow the cells past sum(want); with rhyme-last the cells left over would
                    # be exactly the held rhyme cells, so the budget follows the ACTUAL cell count. Gated
                    # on the flag so ordinary decoding stays byte-identical to what was measured.
                    steps = max(a.steps, sum(cells) // a.commit_n + 1 + (16 if a.seam_repair else 0))
                _cl = {} if a.log_commit_steps else None
                poem = decode_one(model, V, title, T=steps, temp=a.temp, lens=cells, fam_temp=a.fam_temp,
                                  topic=clauses_of(r["text"]) if a.topic_boost else None, topic_boost=eff_boost(r["text"]), inner_boost=a.inner_boost, clean=clean, fam_distinct=a.fam_distinct, fam_size_alpha=a.fam_size_alpha, fam_prior=fam_prior, fam_prior_w=a.fam_prior_w, ban_junk=a.ban_junk, seg=seg, rhyme_first=not a.no_rhyme_first, commit_n=a.commit_n, rhyme_pos=a.rhyme_pos, fam_soft=a.fam_soft, fam_delay=a.fam_delay, rhyme_last=a.rhyme_last, seam_repair=a.seam_repair, anaphora_penalty=a.anaphora_penalty, poetic=a.poetic_vec, word_rhyme=a.word_rhyme, word_mlm_w=a.word_mlm_w, commit_log=_cl, topic_zero=a.topic_zero, topic_freq=topic_freq)
                s = P.score(poem, n_bot_expected=N_BOT)
                got = syl_counts(poem)
                hit = sum(1 for g, w in zip(got, want) if g == w)
                key = (hit, s["all_pass"], s["ext_rhyme"] + s["tone"] + 0.5 * s["internal"])
                if best is None or key > best[0]:
                    best = (key, poem, s, list(cells), got)
                    if _cl is not None:
                        # refinement step at which each line's final cell and first tail cell (last 3) committed
                        best_steps = {"end_step": [_cl.get(TL + i * C + cells[i] - 1) for i in range(L)],
                                      "tail_first_step": [min((_cl[k] for k in range(TL + i * C + max(0, cells[i] - WORD_MAX_K), TL + i * C + cells[i]) if k in _cl), default=None) for i in range(L)],
                                      "n_steps": steps}
                if hit == L and s["all_pass"]: break
                cells = [max(5, min(C, c - (g - w))) for c, g, w in zip(cells, got, want)]
            _, poem, s, cells, got = best
            eb = eff_boost(r["text"])
            n_hit[0] += sum(1 for g, w in zip(got, want) if g == w); n_hit[1] += L
            f.write(json.dumps({"id": r["id"], "prompt": r["text"], "sample": 0, "poem": poem,
                                "syl_target": want, "syl": got, "cells": cells, "topic_boost": eb, "poetic_boost": a.poetic_boost, "poetic_lex": a.poetic_lex if a.poetic_boost else None, **({"word_rhyme": True, "word_mlm_w": a.word_mlm_w} if a.word_rhyme else {}), **(best_steps if a.log_commit_steps else {}), "topic_zero": a.topic_zero, "boost_mode": a.boost_mode, "all_pass": s["all_pass"],
                                "structure": s["structure"], "ext_rhyme": s["ext_rhyme"], "tone": s["tone"], "internal": s["internal"]},
                               ensure_ascii=False) + "\n"); f.flush()
            print(f"{i}/{len(rows)} all_pass={s['all_pass']} syl={got} want={want}", file=sys.stderr, flush=True)
    print(f"วรรค hitting their pronounced syllable target: {n_hit[0]}/{n_hit[1]} ({100*n_hit[0]/max(n_hit[1],1):.1f}%)", file=sys.stderr, flush=True)
    P.save_cache()


if __name__ == "__main__":
    ap = argparse.ArgumentParser(); sub = ap.add_subparsers(dest="cmd")
    t = sub.add_parser("train"); t.add_argument("--data", default="data/modern_klon8.jsonl"); t.add_argument("--out", default="runs/CANVAS_WC")
    t.add_argument("--epochs", type=int, default=3); t.add_argument("--batch", type=int, default=32); t.add_argument("--lr", type=float, default=5e-5)
    t.add_argument("--expert", choices=["none", "asym", "sym"], default="none", help="extra transformer block inserted mid-stack (after body layer EXPERT_AFTER): asym = gated to the rhyme zone only (user's asymmetry idea), sym = every cell (capacity-matched control)")
    t.add_argument("--expert-lr", type=float, default=1e-4); t.add_argument("--gate-lr", type=float, default=3e-3)
    t.add_argument("--diction", action="store_true", help="add the gated diction-level input channel (user idea 2026-09-27; levels from runs/LOC/poetic_syl.json); the expert/gate learning rates apply to it")
    t.add_argument("--zone-weight", type=float, default=1.0, help="loss weight on rhyme-zone cells (1 = unweighted)")
    t.add_argument("--word-loss-w", type=float, default=0.0, help="C1: weight of the auxiliary ending-word loss; > 0 adds the word-rhyme head (0 = off, the old trainer)")
    t.add_argument("--word-lex", default="runs/LOC/rhyme_words.json", help="C1 lexicon built by scripts/build_rhyme_lex.py (copied into --out)")
    t.add_argument("--det-bank", default=None, help="B: in-context corruption bank from scripts/det_prep.py (title, grid, lab, line, split)")
    t.add_argument("--det-weight", type=float, default=0.0, help="B: weight of the replaced-cell detection loss (0 = off, no head: the old trainer)")
    t.add_argument("--det-batch", type=int, default=0, help="B: corrupted poems per step for the detection term (0 = --batch)")
    t.add_argument("--ul-data", default=None, help="A': flagged generated poems from scripts/ul_prep.py flags (title, grid, pairs)")
    t.add_argument("--ul-weight", type=float, default=0.0, help="A': weight of the unlikelihood term on flagged word pairs of the model's own poems (0 = off, the old trainer)")
    t.add_argument("--ul-heldout", default=None, help="ULKL: title-held-out flags for the in-training tripwire (off when absent)")
    t.add_argument("--kl-beta", type=float, default=0.0, help="ULKL: weight of KL(frozen init || model) on all non-PAD cells of the MLM and unlikelihood batches (0 = off)")
    t.add_argument("--ul-batch", type=int, default=0, help="A': generated poems per step for the unlikelihood term (0 = --batch)")
    t.add_argument("--stutter-ul", type=float, default=0.0, help="C5: weight of the unlikelihood loss on predicting a neighbour's syllable (0 = off)")
    t.add_argument("--patience", type=int, default=3); t.add_argument("--min-delta", type=float, default=0.01)
    t.add_argument("--init", default=None, help="warm-start state dict, e.g. runs/CANVAS_WC8/model.pt")
    t.add_argument("--seg", choices=["aligned", "grid"], default="aligned", help="how a วรรค becomes cells: aligned = one cell per PRONOUNCED syllable (scripts/syl_align.py), grid = the old character-level segmenter")
    t.add_argument("--init-vocab", default=None, help="the warm-start checkpoint's vocab.json; rows for syllables present in both vocabularies are carried over when --seg changes the segmentation")
    t.add_argument("--vocab", default=None, help="freeze the vocabulary from this vocab.json instead of rebuilding it from --data; REQUIRED with --init when --data is not the original corpus")
    t.add_argument("--valid", type=int, default=500, help="validation poems held out from the front (capped at 20%% of the data)")
    d = sub.add_parser("decode"); d.add_argument("--model", default="runs/CANVAS_WC"); d.add_argument("--prompts", required=True)
    d.add_argument("--diction-level", type=int, default=None, help="diction level for a model trained with --diction: 1..4 = within-board quartile of poetic rhyme-zone cells, 0 = no level (the default for such a model)")
    d.add_argument("--out", required=True); d.add_argument("--limit", type=int, default=0); d.add_argument("--steps", type=int, default=12)
    d.add_argument("--temp", type=float, default=0.8); d.add_argument("--seed", type=int, default=0); d.add_argument("--fam-temp", type=float, default=0.5)
    d.add_argument("--retries", type=int, default=4); d.add_argument("--topic-boost", type=float, default=0.0)
    d.add_argument("--short-topic-boost", type=float, default=1.5, help="topic bonus used instead of --topic-boost when the prompt is a short topic (<= 8 syllables); 3.0 repeats the topic words, 1.5 keeps form (sweep 2026-09-20)")
    d.add_argument("--fam-distinct", action="store_true", help="each of the 4 rhyme groups in a poem takes a different rime family (humans: 3.87/4)")
    d.add_argument("--fam-size-alpha", type=float, default=0.0, help="subtract alpha*log(family size) from the family score (1 = mean log-prob per member)")
    d.add_argument("--fam-prior-w", type=float, default=0.0, help="add w*log(human line-final family frequency) to the family score")
    d.add_argument("--rhyme-lex", type=int, default=0, help="rhyme cells limited to syllables seen >= N times at that position in the corpus (0 = off)")
    d.add_argument("--bigram-w", type=float, default=0.0, help="weight of the corpus syllable-bigram log-probability toward committed neighbours (0 = off)")
    d.add_argument("--wak-lens", default="8", help="target PRONOUNCED syllables per วรรค, verified with g2p_line after decoding and corrected by retry: 8, sample (drawn from the human distribution: 47%% of human บท are all-8, the usual alternative is 9), or a pattern like 8,8,9,8")
    d.add_argument("--boost-mode", choices=["flat", "rarity"], default="flat", help="flat: every topic syllable gets the same bonus, whatever its corpus prior (the original). rarity: the bonus is multiplied by clip(log(1e-3/p), 1, 3), so a syllable the corpus almost never uses is pushed hard enough to compete. Measured 2026-09-24: the topic gate is 4%% when the rarest content syllable sits below 1e-5 and 83%% above 1e-3, while cleanness is flat across that split")
    d.add_argument("--syl-freq", default="data/syl_freq.json", help="corpus syllable frequencies for --boost-mode rarity, segmented as trained")
    d.add_argument("--topic-zero", choices=["global", "row"], default="row", help="when a topic syllable is committed, stop boosting it everywhere (global, the original) or only in the วรรค that used it (row, the default since 2026-09-24). Global kills the topic channel after ~3 of ~33 commits: judged at N=8 on 50 paired topics it costs 26%% against row's 50%%, with the topic gate at 40%% against 74%% (McNemar p = 0.008)")
    d.add_argument("--fam-delay", type=int, default=0, help="postpone the rhyme-family choice until this refinement step, so content commits first and the rhyme is chosen against a line that exists (0 = the old behaviour, chosen at step 0 off a blank canvas)")
    d.add_argument("--fam-soft", type=float, default=0.0, help="replace the hard rhyme-family mask with a finite penalty of this size (e.g. 6). 0 = the old wall. Lets a much better word outrank the family; the checker and --retries decide whether the poem still scans")
    d.add_argument("--rhyme-pos", choices=["3", "auto"], default="3", help="which syllable receives a rhyme link: 3 (the only one the decoder ever tried) or auto (3 or 5, whichever the model prefers; humans use 5 in 10-12%% of links and the checker accepts both)")
    d.add_argument("--no-rhyme-first", action="store_true", help="do NOT commit the rhyme cells before everything else; let confidence order them. The model ranks the human's own rhyme word 1st when the บท is known and 3rd off a blank canvas")
    d.add_argument("--commit-n", type=int, default=0, help="cells committed per refinement step (0 = the old schedule, ~3). 1 removes the conditional-independence error of sampling several cells from one forward pass")
    d.add_argument("--ban-junk", action="store_true", help="forbid the 725 vocabulary entries containing . * or ๐ (crawl padding that became vocabulary)")
    d.add_argument("--ngrams", default="data/syl_ngrams.json")
    d.add_argument("--rhyme-last", action="store_true", help="commit each rhyme cell only after every other cell of its line, and choose a group's family only then (overrides rhyme-first and fam-delay). 2026-09-26: 56%% of judged bad spans run up to the line-final rhyme word against 23%% by chance")
    d.add_argument("--poetic-boost", type=float, default=0.0, help="bonus at uncommitted rhyme-zone cells toward poetic-diction syllables, scaled by clip((log-odds - 1.5)/3, 0, 1); 0 = off (plan B)")
    d.add_argument("--poetic-lex", default="runs/LOC/poetic_syl.json", help="syllable -> poem-vs-prose log-odds, built by scripts/poetic_syllables.py")
    d.add_argument("--anaphora-penalty", type=float, default=0.0, help="penalty on a line-opening syllable (position 1 or 2) that two other lines already open with; 0 = off. 32%% of our poems vs 1%% of human poems open 3+ lines alike")
    d.add_argument("--word-rhyme", action="store_true", help="C1: draw each line ending as a whole WORD from the model's ending-word head (inside the chosen rhyme family) and commit its syllables jointly; needs a model trained with --word-loss-w > 0")
    d.add_argument("--log-commit-steps", action="store_true", help="record per row the refinement step at which each line's final cell / first of its last 3 cells committed")
    d.add_argument("--word-mlm-w", type=float, default=1.0, help="C1: weight of the canvas's own syllable log-probs added to the head's word log-prob (0 = head only)")
    d.add_argument("--seam-repair", action="store_true", help="with --rhyme-last: when a rhyme cell commits, re-open its immediate neighbours once so they are re-chosen against the rhyme word")
    d.add_argument("--refill-k", type=int, default=0, help="REFILL mode: --prompts is a best-pick file (id, prompt, poem); re-draw each วรรค in --refill-lines K times with the other three kept, writing --out/r{line}_{k}.jsonl (--out is a directory)")
    d.add_argument("--refill-lines", default="1,2,3,4")
    d.add_argument("--refill-line-map", default=None, help="JSON {poem id: line 1-4}: refill only that line of each poem (rows without an entry are skipped); files r{line}_{k} then hold only the poems located on that line")
    d.add_argument("--refill-boost-mode", choices=["flat", "rarity"], default="flat", help="topic boost on the refilled line: flat (default) because the rarity-boosted syllable is in 80%% of the bad วรรค it produced")
    d.add_argument("--inner-boost", type=float, default=0.0, help="สัมผัสใน steering bonus toward a committed template partner's rime")
    a = ap.parse_args()
    train(a) if a.cmd == "train" else decode(a)