File size: 71,675 Bytes
6e802a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# gen_fish_cot_samples.py: The generator: text -> think block (self-written, ground truth, or none) -> Fish S2
# codes, with the plan window, plan injection, the silence guard and the loop breaker.

import argparse
import json
import os as _os_rtf
import time as _time_rtf
CODEC_FPS = float(_os_rtf.environ.get("MVC_CODEC_FPS", "21.53"))
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, NamedTuple, Optional

import torch

REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO))
sys.path.insert(0, str(REPO / "scripts"))

FISH_CB = [4096] + [1024] * 9

PLAN_STATUS_OK = "ok"
PLAN_STATUS_ABSENT = "absent"
PLAN_STATUS_EMPTY = "empty"
PLAN_STATUS_UNTERMINATED = "unterminated"
PLAN_STATUS_MALFORMED = "malformed"

PLAN_BIN_ORDER = ("pitch", "duration", "energy")


class PlanIdMaps(NamedTuple):
    start: int
    end: int
    word: int
    pitch: dict
    duration: dict
    energy: dict


def plan_id_maps(reg) -> PlanIdMaps:
    if not getattr(reg, "plan_enabled", False):
        raise SystemExit(
            "the tokenizer in this process has no P²-CoT plan block — export "
            "MVC_ENABLE_PLAN_TOKENS=1 before running (it must also match the "
            "checkpoint: the gate shifts every speech-token id).")
    return PlanIdMaps(
        start=reg.plan_start_id, end=reg.plan_end_id, word=reg.plan_word_sep_id,
        pitch={t: b for b, t in enumerate(reg.pitch_ids)},
        duration={t: b for b, t in enumerate(reg.duration_ids)},
        energy={t: b for b, t in enumerate(reg.energy_ids)},
    )


@dataclass
class ParsedPlan:
    words: List[dict] = field(default_factory=list)
    status: str = PLAN_STATUS_ABSENT
    reason: str = ""

    @property
    def ok(self) -> bool:
        return self.status == PLAN_STATUS_OK

    @property
    def n_words(self) -> int:
        return len(self.words)


def plan_region(ids, reg) -> List[int]:
    ids = [int(t) for t in ids]
    if reg.think_start_id not in ids:
        return []
    lo = len(ids) - 1 - ids[::-1].index(reg.think_start_id)
    body = ids[lo + 1:]
    if reg.think_end_id in body:
        body = body[:body.index(reg.think_end_id)]
    return body


def parse_plan_tokens(ids, maps: PlanIdMaps) -> ParsedPlan:
    ids = [int(t) for t in ids]
    if maps.start not in ids:
        return ParsedPlan([], PLAN_STATUS_ABSENT, "no <PLAN> token in the block")
    lo = ids.index(maps.start)
    body = ids[lo + 1:]
    if maps.start in body:
        return ParsedPlan([], PLAN_STATUS_MALFORMED,
                          "more than one <PLAN> block in the think region")
    terminated = maps.end in body
    if terminated:
        body = body[:body.index(maps.end)]

    def _name(tid: int) -> str:
        for fam, table in (("pitch", maps.pitch), ("duration", maps.duration),
                           ("energy", maps.energy)):
            if tid in table:
                return f"{fam} bin {table[tid]}"
        if tid == maps.word:
            return "[PW]"
        if tid == maps.end:
            return "</PLAN>"
        return "non-plan token"

    words: List[dict] = []
    i, n = 0, len(body)
    while i < n:
        if body[i] != maps.word:
            return ParsedPlan(
                words, PLAN_STATUS_MALFORMED,
                f"word {len(words)}: expected [PW], got id {body[i]} "
                f"({_name(body[i])}) at plan token {i}")
        i += 1
        pitch = None
        if i < n and body[i] in maps.pitch:
            pitch = maps.pitch[body[i]]
            i += 1
        if i >= n or body[i] not in maps.duration:
            got = "end of block" if i >= n else f"id {body[i]} ({_name(body[i])})"
            return ParsedPlan(words, PLAN_STATUS_MALFORMED,
                              f"word {len(words)}: expected [D:*], got {got}")
        dur = maps.duration[body[i]]
        i += 1
        if i >= n or body[i] not in maps.energy:
            got = "end of block" if i >= n else f"id {body[i]} ({_name(body[i])})"
            return ParsedPlan(words, PLAN_STATUS_MALFORMED,
                              f"word {len(words)}: expected [E:*], got {got}")
        words.append({"pitch_bin": pitch, "dur_bin": dur,
                      "energy_bin": maps.energy[body[i]]})
        i += 1

    if not terminated:
        return ParsedPlan(
            words, PLAN_STATUS_UNTERMINATED,
            f"<PLAN> never closed ({len(words)} word(s) parsed before the block "
            f"ended) — ΣD is truncated, so the window it implies is too short")
    if not words:
        return ParsedPlan([], PLAN_STATUS_EMPTY, "<PLAN></PLAN> carries no words")
    return ParsedPlan(words, PLAN_STATUS_OK, "")


def plan_dur_frames(words) -> List[float]:
    import plan_extract as pe
    q = [{"pitch_bin": (pe.PITCH_UNVOICED_BIN if w["pitch_bin"] is None
                        else int(w["pitch_bin"])),
          "dur_bin": int(w["dur_bin"]), "energy_bin": int(w["energy_bin"])}
         for w in words]
    return [float(x["dur_hat_frames"]) for x in pe.dequantise_plan(q)]


def plan_sum_frames(words) -> float:
    return float(sum(plan_dur_frames(words)))


def plan_token_string(words) -> str:
    import plan_extract as pe
    q = [{"pitch_bin": (pe.PITCH_UNVOICED_BIN if w["pitch_bin"] is None
                        else int(w["pitch_bin"])),
          "dur_bin": int(w["dur_bin"]), "energy_bin": int(w["energy_bin"])}
         for w in words]
    return pe.plan_to_tokens(q, wrap=True)


class DecodeWindow(NamedTuple):
    min_fr: int
    max_fr: int
    source: str
    sum_frames: Optional[float]
    fallback: bool
    t_hat: Optional[float] = None


def resolve_decode_window(parsed: Optional[ParsedPlan], base_min: int,
                          base_max: int, base_source: str, *,
                          dur_lo: float, dur_hi: float, use_plan: bool,
                          floor: int = 20,
                          speech_ratio: float = 1.0) -> DecodeWindow:
    if not use_plan:
        return DecodeWindow(base_min, base_max, base_source, None, False, None)
    if parsed is None or not parsed.ok:
        return DecodeWindow(base_min, base_max, base_source, None, True, None)
    if not (speech_ratio > 0.0):
        raise ValueError(f"speech_ratio must be > 0, got {speech_ratio!r}")
    sigma_d = plan_sum_frames(parsed.words)
    t_hat = sigma_d / speech_ratio
    min_fr = max(floor, int(dur_lo * t_hat))
    max_fr = max(min_fr + 5, int(dur_hi * t_hat))
    return DecodeWindow(min_fr, max_fr, "plan", sigma_d, False, t_hat)


def resolve_plan_injection(on: bool, off: bool) -> bool:
    if off and on:
        print("[plan] --plan_injection_off: the injection channel is OFF for "
              "this cell (prefix-only consumption, master plan R3).", flush=True)
    return bool(on) and not bool(off)


def _row_window(row: dict, field: str, val_path: str, j: int) -> tuple:
    w = row.get(field)
    if w is None:
        raise SystemExit(
            f"{val_path} row {j} has no {field!r} field, but --plan_window_field "
            f"{field} was requested. Every row of a flip/D-PLAN cell must carry "
            f"the SAME window its control was decoded under (scripts/"
            f"plan_flip_probe.py build writes `plan_window`); falling back "
            f"row-by-row would mix two decode regimes in one cell.")
    if not isinstance(w, (list, tuple)) or len(w) != 2:
        raise SystemExit(f"{val_path} row {j}: {field!r} must be "
                         f"[min_frames, max_frames], got {w!r}")
    try:
        lo, hi = int(w[0]), int(w[1])
    except (TypeError, ValueError):
        raise SystemExit(f"{val_path} row {j}: {field!r}={w!r} is not a pair of "
                         f"integers") from None
    if lo < 1 or hi <= lo:
        raise SystemExit(f"{val_path} row {j}: {field!r}=[{lo}, {hi}] is not a "
                         f"usable window (need 1 <= min < max)")
    return lo, hi


def _norm_plan_word(w, i: int, where: str) -> dict:
    from config import DURATION_BINS, ENERGY_BINS, PITCH_BINS
    if isinstance(w, (list, tuple)):
        if len(w) != 3:
            raise SystemExit(f"{where} word {i}: a compact word must be "
                             f"[{', '.join(PLAN_BIN_ORDER)}], got {list(w)}")
        w = dict(zip(("pitch_bin", "dur_bin", "energy_bin"), w))
    if not isinstance(w, dict):
        raise SystemExit(f"{where} word {i}: expected an object, got "
                         f"{type(w).__name__}")

    def _req(key, hi):
        if w.get(key) is None:
            raise SystemExit(f"{where} word {i}: missing {key!r} "
                             f"(keys={sorted(w)[:8]})")
        v = int(w[key])
        if not 0 <= v < hi:
            raise SystemExit(f"{where} word {i}: {key}={v} out of range [0,{hi})")
        return v

    pb = w.get("pitch_bin")
    if pb is None or int(pb) == PITCH_BINS:
        pitch = None
    else:
        pitch = int(pb)
        if not 0 <= pitch < PITCH_BINS:
            raise SystemExit(f"{where} word {i}: pitch_bin={pitch} out of range "
                             f"[0,{PITCH_BINS}] ({PITCH_BINS} = unvoiced)")
    return {"pitch_bin": pitch, "dur_bin": _req("dur_bin", DURATION_BINS),
            "energy_bin": _req("energy_bin", ENERGY_BINS)}


def plan_words_from_obj(obj, *, where: str) -> List[dict]:
    if isinstance(obj, str):
        import plan_extract as pe
        try:
            raw = pe.tokens_to_plan(obj)
        except Exception as e:
            raise SystemExit(f"{where}: unparseable plan string ({e})")
    elif isinstance(obj, dict):
        if obj.get("ok") is False:
            raise SystemExit(
                f"{where}: this plan was REJECTED by the extractor "
                f"(flags={obj.get('flags')}, reason={obj.get('reason')!r}). "
                f"Fix or drop the row; do not condition on a plan the audio "
                f"does not follow.")
        raw = obj.get("words") if obj.get("words") is not None else obj.get("plan")
        if raw is None:
            raise SystemExit(f"{where}: object has neither 'words' nor 'plan' "
                             f"(keys={sorted(obj)[:8]})")
        return plan_words_from_obj(raw, where=where)
    else:
        raw = obj
    if not isinstance(raw, (list, tuple)) or not raw:
        raise SystemExit(f"{where}: empty or non-list plan "
                         f"({type(raw).__name__})")
    return [_norm_plan_word(w, i, where) for i, w in enumerate(raw)]


def load_forced_plans(path: str) -> dict:
    rows, ordered = {}, []
    with open(path, encoding="utf-8") as f:
        for ln, line in enumerate(f, 1):
            line = line.strip()
            if not line:
                continue
            try:
                d = json.loads(line)
            except json.JSONDecodeError as e:
                raise SystemExit(f"{path}:{ln} is not valid JSON: {e}")
            if not isinstance(d, dict):
                raise SystemExit(f"{path}:{ln} is not a JSON object")
            if "row" in d and d["row"] is not None:
                key = int(d["row"])
                if key in rows:
                    raise SystemExit(f"{path}:{ln} repeats row {key} — the "
                                     f"forced plan for a row must be unique")
                rows[key] = d
            ordered.append(d)
    if not ordered:
        raise SystemExit(f"{path} has no plan rows")
    if rows and len(rows) != len(ordered):
        raise SystemExit(f"{path} mixes rows with and without a 'row' key "
                         f"({len(rows)} of {len(ordered)}) — pick one")
    return {"by_row": rows, "ordered": ordered}


def forced_plan_for(forced: dict, row_index: int, order_index: int, path: str):
    if forced["by_row"]:
        if row_index not in forced["by_row"]:
            raise SystemExit(
                f"--force_plan {path} has no entry for val row {row_index}. "
                f"Every generated row must carry the plan it was supposed to "
                f"be given, or the arm is a mixture of two conditions.")
        return forced["by_row"][row_index]
    if order_index >= len(forced["ordered"]):
        raise SystemExit(
            f"--force_plan {path} holds {len(forced['ordered'])} plans but the "
            f"selection reached index {order_index}. Add a 'row' key to each "
            f"line to bind plans to val row indices instead of file order.")
    return forced["ordered"][order_index]


def plan_prefix_ids(reg, words) -> List[int]:
    ids = [reg.plan_start_id]
    for w in words:
        ids.append(reg.plan_word_sep_id)
        if w["pitch_bin"] is not None:
            ids.append(reg.pitch_bin_to_id(w["pitch_bin"]))
        ids.append(reg.duration_bin_to_id(w["dur_bin"]))
        ids.append(reg.energy_bin_to_id(w["energy_bin"]))
    ids.append(reg.plan_end_id)
    return ids


def plan_bins_tensor(words, device=None):
    from model import PLAN_BIN_ABSENT, PLAN_BIN_CHANNELS
    if tuple(PLAN_BIN_CHANNELS) != PLAN_BIN_ORDER:
        raise RuntimeError(
            f"model.PLAN_BIN_CHANNELS is {PLAN_BIN_CHANNELS} but this script "
            f"builds columns as {PLAN_BIN_ORDER} — a transposed plan tensor "
            f"trains/decodes silently and looks like 'the plan does nothing'.")
    rows = [[(PLAN_BIN_ABSENT if w["pitch_bin"] is None else int(w["pitch_bin"])),
             int(w["dur_bin"]), int(w["energy_bin"])] for w in words]
    return torch.tensor([rows], dtype=torch.long, device=device)


def _speaker_inject(model, speaker_id, device):
    speaker_emb = inject = None
    if speaker_id is not None and getattr(model, "use_speaker_conditioning", False):
        sid = torch.tensor([speaker_id], dtype=torch.long, device=device)
        speaker_emb = model.speaker_encoder(sid)
        if getattr(model, "use_speaker_input", False):
            inject = model.speaker_input_proj(speaker_emb)
    return speaker_emb, inject


@torch.no_grad()
def think_budget(max_think, prompt):
    if max_think and max_think > 0:
        return int(max_think)
    return max(120, 6 * len((prompt or "").split()) + 64)


def gen_reasoning(model, prefix, reg, max_think, temp, top_k, speaker_id, device):
    import test_depth_checkpoint as tdc
    NL = tdc.NUM_LEVELS
    backbone = model.backbone.backbone
    emb = backbone.embedding
    speaker_emb, inject = _speaker_inject(model, speaker_id, device)
    ids = prefix.clone().to(device)
    codes = torch.full((1, ids.shape[1], NL), -1, dtype=torch.long, device=device)
    think_end = reg.think_end_id
    gen_toks = []
    for _ in range(max_think):
        if inject is not None:
            emb.set_speaker(inject)
        emb.set_codes(codes)
        h = backbone(ids)
        if speaker_emb is not None:
            h = model.adaln(h, speaker_emb)
        logits = model.backbone.lm_head(h[:, -1, :])[0].float()
        for _bad in (reg.audio_start_id, reg.audio_end_id, reg.eos_id,
                     reg.bos_id, reg.pad_id, reg.user_prompt_id,
                     reg.think_start_id):
            logits[_bad] = float("-inf")
        logits[reg.speech_token_id_min:reg.speech_token_id_max + 1] = float("-inf")
        if temp and temp > 0:
            probs = torch.softmax(logits / temp, dim=-1)
            if top_k and top_k > 0:
                v, ix = torch.topk(probs, min(top_k, probs.numel()))
                probs = torch.zeros_like(probs).scatter_(0, ix, v)
                probs = probs / probs.sum()
            tok = int(torch.multinomial(probs, 1).item())
        else:
            tok = int(logits.argmax().item())
        gen_toks.append(tok)
        col = torch.full((1, 1, NL), -1, dtype=torch.long, device=device)
        ids = torch.cat([ids, torch.tensor([[tok]], device=device)], dim=1)
        codes = torch.cat([codes, col], dim=1)
        if tok == think_end:
            break
    if not gen_toks or gen_toks[-1] != think_end:
        print(f"    [WARN] reasoning hit max_think={max_think} without </THINK> "
              f"-> degenerate prefix", flush=True)
        ids = torch.cat([ids, torch.tensor([[think_end]], device=device)], dim=1)
    reasoning_str = model.tokenizer.decode([t for t in gen_toks if t != think_end])
    return ids, reasoning_str


def predict_frames(dm, row):
    t = row.get("prompt") or ""
    p = (row.get("pace") or "normal").strip().lower()
    x = [1.0, float(len(t.split())), float(len(t)),
         1.0 if p == "slow" else 0.0, 1.0 if p == "fast" else 0.0]
    c = dm["coef"]
    if len(c) != len(x):
        raise ValueError(f"duration model has {len(c)} coefs, features give "
                         f"{len(x)} — regenerate it with fit_duration_model.py")
    return max(20.0, sum(a * b for a, b in zip(c, x)))


def cot_text(row, cot_mode):
    if cot_mode in ("none", "plan"):
        return ""
    tags = (f"EMO={row.get('emotion_label') or 'neutral'} "
            f"PACE={row.get('pace') or 'normal'} "
            f"PITCH={row.get('pitch') or 'normal'}")
    if cot_mode in ("tags", "tags+plan"):
        return tags
    prose = (row.get("reasoning") or "").strip()
    if cot_mode == "full":
        return f"{prose} {tags}" if prose else tags
    return prose


def f0_row(row, args):
    if not (args.prompt_tags or args.force_emotion or args.force_pace
            or args.force_pitch):
        return row, None
    r = dict(row)
    if args.force_emotion:
        r["emotion_label"] = args.force_emotion
    if args.force_pace:
        r["pace"] = args.force_pace
    if args.force_pitch:
        r["pitch"] = args.force_pitch
    tag = cot_text(r, "tags")
    if args.prompt_tags:
        r["prompt"] = f"{row['prompt']} {tag}"
    return r, tag


def build_prefix(mode, model, reg, row, max_think, think_temp, think_top_k,
                 speaker_id, device, cot_mode="prose", plan_ids=None):
    tok = model.tokenizer
    prompt_ids = tok.encode(row["prompt"], add_special_tokens=False)
    base = [reg.bos_id, reg.user_prompt_id] + prompt_ids
    if plan_ids and mode != "oracle":
        raise ValueError(f"--force_plan / an oracle plan cannot be given to "
                         f"--mode {mode}: the plan would have nowhere to go "
                         f"(nocot has no <THINK> block) or would compete with "
                         f"the model's own program (self).")
    if mode == "nocot":
        ids = torch.tensor([base + [reg.audio_start_id]], dtype=torch.long)
        return ids, None
    if mode == "oracle":
        rtext = cot_text(row, cot_mode)
        r_ids = tok.encode(rtext, add_special_tokens=False)
        ids = torch.tensor([base + [reg.think_start_id] + r_ids
                            + list(plan_ids or [])
                            + [reg.think_end_id, reg.audio_start_id]],
                           dtype=torch.long)
        return ids, rtext
    if mode == "self":
        think_prefix = torch.tensor([base + [reg.think_start_id]], dtype=torch.long)
        ids, rstr = gen_reasoning(model, think_prefix, reg, max_think,
                                  think_temp, think_top_k, speaker_id, device)
        ids = torch.cat(
            [ids, torch.tensor([[reg.audio_start_id]], device=ids.device)], dim=1)
        return ids, rstr
    raise ValueError(mode)


def _teacher_forced_cache_check(model, reg, args):
    import test_depth_checkpoint as tdc
    backbone = model.backbone.backbone
    emb = backbone.embedding
    NL = tdc.NUM_LEVELS
    dev = args.device
    rows = [json.loads(l) for l in open(args.tf_check, encoding="utf-8") if l.strip()]
    summary = []
    for r in rows:
        prefix, _ = build_prefix("nocot", model, reg, r, think_budget(args.max_think, r.get("prompt")), args.think_temp,
                                 args.think_top_k, r.get("speaker_id"), dev)
        prefix = prefix.to(dev)
        levels = [r["speech_tokens"]] + r["residual_codes"]
        T = len(levels[0])
        frames = [[int(levels[k][t]) for k in range(NL)] for t in range(T)]
        spk = r.get("speaker_id"); speaker_emb = None; inject = None
        if spk is not None and getattr(model, "use_speaker_conditioning", False):
            sid = torch.tensor([int(spk)], dtype=torch.long, device=dev)
            speaker_emb = model.speaker_encoder(sid)
            if getattr(model, "use_speaker_input", False):
                inject = model.speaker_input_proj(speaker_emb)
        placeholder = reg.audio_start_id
        P = prefix.shape[1]

        def ids_codes(t):
            ids = torch.cat([prefix, torch.full((1, t), placeholder, dtype=torch.long, device=dev)], dim=1)
            codes = torch.full((1, P + t, NL), -1, dtype=torch.long, device=dev)
            for i in range(t):
                codes[0, P + i] = torch.tensor(frames[i], device=dev)
            return ids, codes

        def head(h):
            if speaker_emb is not None:
                h = model.adaln(h, speaker_emb)
            last = h[:, -1, :]
            return last.float().clone(), model.backbone.lm_head(last).float()[0].clone()

        last_re, logit_re, last_ca, logit_ca = [], [], [], []
        with torch.no_grad():
            for t in range(T):
                ids, codes = ids_codes(t)
                if inject is not None:
                    emb.set_speaker(inject)
                emb.set_codes(codes)
                a, b = head(backbone(ids)); last_re.append(a); logit_re.append(b)
            ip = tdc._fresh_inference_cache(P + T + 8); n_fed = 0
            for t in range(T):
                ids, codes = ids_codes(t)
                lo = n_fed
                if inject is not None:
                    emb.set_speaker(inject)
                emb.set_codes(codes[:, lo:])
                ip.seqlen_offset = n_fed
                h = backbone(ids[:, lo:], inference_params=ip)
                n_fed = ids.shape[1]
                a, b = head(h); last_ca.append(a); logit_ca.append(b)
            fr_re = [model.depth_module.generate_frame(a) for a in last_re]
            fr_ca = [model.depth_module.generate_frame(a) for a in last_ca]
        rel = [float((a - b).norm() / (a.norm() + 1e-9)) for a, b in zip(last_re, last_ca)]
        lm_mism = []
        for t, (la, lb) in enumerate(zip(logit_re, logit_ca)):
            if int(la.argmax()) != int(lb.argmax()):
                top2 = torch.topk(la, 2).values
                lm_mism.append({"t": t, "top2_gap": float(top2[0] - top2[1]),
                                "max_abs_logit_diff": float((la - lb).abs().max())})
        fr_mism = [t for t in range(T) if list(fr_re[t]) != list(fr_ca[t])]
        rec = {"row": r.get("row"), "frames": T, "max_rel_hidden": max(rel),
               "mean_rel_hidden": sum(rel) / len(rel), "lmhead_argmax_mismatch": len(lm_mism),
               "lmhead_mismatches": lm_mism[:10], "depth_frame_mismatch": len(fr_mism),
               "depth_frame_mismatch_first": fr_mism[:10]}
        summary.append(rec)
        print(f"[tf_check] row {rec['row']}: frames={T} max_rel_hidden={rec['max_rel_hidden']:.2e} "
              f"mean_rel={rec['mean_rel_hidden']:.2e} lmhead_argmax_mismatch={len(lm_mism)}/{T} "
              f"depth_frame_mismatch={len(fr_mism)}/{T}"
              + (f" first_depth_mismatch_t={fr_mism[0]}" if fr_mism else ""), flush=True)
    with open(args.out, "w", encoding="utf-8") as f:
        json.dump(summary, f, indent=1)
    print(f"[tf_check] wrote {args.out}", flush=True)

ARM_E_TEMPS = (0.2, [0.2, 0.2, 0.3, 0.9, 1.3, 1.6, 1.9, 2.2, 2.5])
H_TEMPS = (0.7, [0.80, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7])


def resolve_decode_temps(args):
    explicit = args.temp_semantic is not None and args.acoustic_temps is not None
    h_family = (getattr(args, "rep_penalty_levels", None) is not None or (getattr(args, "ras_tau", 0.0) or 0.0) > 0
                or getattr(args, "ras_tau_levels", None) is not None)
    if h_family and not explicit and not getattr(args, "allow_default_temps", False):
        raise SystemExit(
            "decode temperatures not given: --rep_penalty_levels / --ras_tau belong to decode H, whose temperatures are "
            "--temp_semantic 0.7 --acoustic_temps 0.80 0.7 0.7 0.7 0.7 0.7 0.7 0.7 0.7. Pass them explicitly (or "
            "--allow_default_temps to reproduce a pre-2026-09-28 cold cell).")
    if args.temp_semantic is None:
        args.temp_semantic = ARM_E_TEMPS[0]
    if args.acoustic_temps is None:
        args.acoustic_temps = list(ARM_E_TEMPS[1])
    args.temps_explicit = explicit
    return args


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--mode", required=True, choices=["gt", "nocot", "oracle", "self"])
    ap.add_argument("--cot_mode", default="prose",
                    choices=["prose", "tags", "none", "plan", "tags+plan", "full"],
                    help="MUST match the arm. Only affects --mode oracle, which "
                         "otherwise injects the prose into every arm. The last "
                         "three are the P²-CoT tiers (C1/C2/C3): they put a "
                         "<PLAN> program in the block, which needs a plan for "
                         "the row (sidecar `--plan_field`, or --force_plan).")
    ap.add_argument("--checkpoint", help="required for nocot/oracle/self")
    ap.add_argument("--val", default="data/s2_qwen_cot_fish_val.jsonl")
    ap.add_argument("--out", required=True)
    ap.add_argument("--rows", type=int, nargs="*", default=None,
                    help="explicit 0-based val row indices; default = --limit head")
    ap.add_argument("--limit", type=int, default=10)
    ap.add_argument("--max_words", type=int, default=0,
                    help="skip prompts longer than this. Decode cost is ~T^2 "
                         "(full-sequence forward per frame) and min_frames is "
                         "6*words, so long prompts are quadratically expensive; "
                         "0 = no filter.")
    ap.add_argument("--model_name", default="state-spaces/mamba2-1.3b")
    ap.add_argument("--rep_penalty", type=float, default=1.0,
                    help="windowed repetition penalty per codebook (1.0 = off). "
                         "Every reference AR-TTS uses one: fish-speech 1.1-1.2 "
                         "over 16 frames, CosyVoice-2 RAS over 10, IndexTTS 10.0. "
                         "The fan IS token repetition; this is the mechanism "
                         "that suppresses it WITHOUT cooling the temperature.")
    ap.add_argument("--rep_penalty_levels", type=float, nargs=10, default=None,
                    help="PER-LEVEL penalties (10 values, cb0..cb9), overriding "
                         "--rep_penalty. The residual fan concentrates in cb1 "
                         "(flip2 1.58 vs GT 0.85) while cb4-9 already sit near "
                         "GT, so a global rise would tax levels that are fine.")
    ap.add_argument("--ras_tau", type=float, default=0.0,
                    help="Repetition Aware Sampling threshold (VALL-E 2 Alg.1; "
                         "0 = off). After nucleus sampling, if the drawn token "
                         "already occupies >= tau of this level's last "
                         "--ras_win frames, REDRAW from the full untruncated "
                         "distribution. CosyVoice-2 ships tau=0.1 over win 10.")
    ap.add_argument("--ras_tau_levels", type=float, nargs=10, default=None,
                    help="per-level RAS thresholds (cb0..cb9), overriding "
                         "--ras_tau — our fan is localized to cb1.")
    ap.add_argument("--ras_win", type=int, default=10)
    ap.add_argument("--rep_win", type=int, default=16,
                    help="repetition-penalty window in frames (fish uses 16).")
    ap.add_argument("--loop_break_flipk", type=float, default=0.0,
                    help="cb0 LOOP-BREAKER trigger: trailing-window flipK%% "
                         "(period-2..8 cycle rate) at/above which the kick "
                         "engages. 0 = off (default; every frozen battery DEC "
                         "is unchanged). 5.0 is the ear-validated threshold "
                         "(2026-08-23 map: warble clips 5-29, clean/GT <2). "
                         "The loop is the tremolo+early-cutoff mechanism: "
                         "rep_penalty 1.2 and RAS cannot break it at "
                         "temp_semantic 0.2 (peaked distribution).")
    ap.add_argument("--loop_break_win", type=int, default=32,
                    help="trailing cb0 window (frames) for the loop metric.")
    ap.add_argument("--loop_break_temp", type=float, default=0.6,
                    help="cb0 temp while inside a detected loop (kick).")
    ap.add_argument("--loop_break_rep", type=float, default=2.0,
                    help="cb0 rep_penalty while inside a detected loop.")
    ap.add_argument("--loop_break_stop_after", type=int, default=0,
                    help="force a clean stop after this many CONSECUTIVE "
                         "kicked frames past min_frames (0 = never): a loop "
                         "that survives the kick ends at a frame boundary "
                         "instead of vamping to the window cap.")
    ap.add_argument("--silence_guard_codes", default="",
                    help="SILENCE GUARD (ledger S200): path to a JSON list of "
                         "cb0 PAUSE codes (data/silence_codes_fish_s2.json). "
                         "Empty = off (default; every frozen DEC unchanged). The "
                         "'cuts off / fails to speak' clips are the semantic "
                         "stream collapsing into these codes for 30-240 frames "
                         "where real speech never exceeds 9.")
    ap.add_argument("--silence_guard_budget", type=int, default=10,
                    help="mid-utterance: after this many CONSECUTIVE pause-set "
                         "frames, mask the set on the next frame so the head "
                         "must re-engage with the injected word. 10 > GT max 9.")
    ap.add_argument("--silence_guard_stop_after", type=int, default=10,
                    help="at the plan's LAST word: a pause run this long is the "
                         "end of the utterance -> clean stop (0 = never).")
    ap.add_argument("--silence_guard_stop_noplan", action="store_true",
                    help="also apply the stop rule when there is NO plan "
                         "schedule (base / noplan cells), VAD-style.")
    ap.add_argument("--silence_guard_retries", type=int, default=0,
                    help="v2 REWIND (S201): on a spent mid-utterance budget, "
                         "discard the whole pause run and re-sample it from the "
                         "last speech frame with the pause set masked for "
                         "`budget` frames and cb0 at --silence_guard_retry_temp; "
                         "up to this many times per row. 0 = v1 (single-frame "
                         "mask), bit-identical to before.")
    ap.add_argument("--silence_guard_retry_temp", type=float, default=0.6,
                    help="cb0 temperature while re-sampling a rewound segment.")
    ap.add_argument("--onset_frames", type=int, default=0,
                    help="damp the ACOUSTIC temperatures for this many frames at "
                         "audio start. flip2 peaks in the first 20%% of every "
                         "generated utterance (5.4-8.2%% vs GT 1.26%%) because "
                         "there is no acoustic history yet. 0 = off.")
    ap.add_argument("--onset_temp_scale", type=float, default=1.0,
                    help="multiplier applied to acoustic temps during the onset "
                         "window (0.0 = argmax, which also tests whether the "
                         "onset fan is a SAMPLING artifact or the model itself).")
    ap.add_argument("--hybrid_attention_top_k", type=int, default=0,
                    help="rebuild the top-K attention graft before loading. MUST "
                         "match the checkpoint: a hybrid ckpt loaded into a pure "
                         "model has shape-mismatched attention projections, and "
                         "those layers would fall back to PRETRAINED Mamba weights "
                         "while the rest are trained (now a hard error).")
    ap.add_argument("--backbone", choices=["mamba", "transformer"], default="mamba",
                    help="must match the checkpoint. 'transformer' also requires "
                         "--model_name EleutherAI/pythia-1.4b. Mismatching these "
                         "now raises in load_checkpoint rather than degrading to "
                         "base text weights, but set them correctly regardless.")
    ap.add_argument("--num_speakers", type=int, default=8192)
    ap.add_argument("--speaker_dim", type=int, default=256)
    ap.add_argument("--depth_dim", type=int, default=1024)
    ap.add_argument("--depth_layers", type=int, default=2)
    ap.add_argument("--depth_arch", choices=["gru", "mamba2", "transformer"], default="gru",
                    help="MUST match the checkpoint's depth head.")
    ap.add_argument("--depth_heads", type=int, default=8)
    ap.add_argument("--depth_feedback", choices=["all", "semantic"],
                    default="semantic",
                    help="MUST match training. fish-recipe arms use 'all' (MCF).")
    ap.add_argument("--decode_feedback", choices=["all", "semantic", "argmax"],
                    default=None,
                    help="what the TEMPORAL STREAM is fed at decode; defaults "
                         "to --depth_feedback (i.e. match training). 'argmax' "
                         "is decode-time STATE DECOUPLING: emit the sampled "
                         "frame but feed the MODE back, so the recurrence sees "
                         "near-manifold inputs instead of codes drawn at temps "
                         "up to 2.5 while training was teacher-forced on "
                         "ground truth (the S11 mechanism, inference side). "
                         "Separate from --depth_feedback because that one also "
                         "configures the model's teacher-forced masking and "
                         "must keep matching the checkpoint.")
    ap.add_argument("--depth_cond", choices=["add", "prefix"], default="add",
                    help="MUST match training. fish-recipe arms use 'prefix'.")
    ap.add_argument("--device", default="cuda")
    ap.add_argument("--dtype", choices=["fp32", "bf16"], default="fp32")
    ap.add_argument("--candidates", type=int, default=1,
                    help="best-of-N: emit N sampled candidates per row (rank "
                         "them by WER downstream). Decode is stochastic, and "
                         "each candidate reseeds so draws are independent.")
    ap.add_argument("--seed", type=int, default=1234)
    ap.add_argument("--force_speaker_id", type=int, default=None,
                    help="override the row's speaker_id. Needed to test the "
                         "STAGE-1 base fairly: ids 4200-4204 are Stage-2-only "
                         "(Stage-1 trained 0-3955), so scoring the base on them "
                         "would confound 'untrained embedding' with 'collapse'.")
    ap.add_argument("--max_frames", type=int, default=400)
    ap.add_argument("--tf_check", default="",
                    help="DIAGNOSTIC: a generated JSONL whose rows are teacher-forced through both the "
                         "recompute and the cached backbone paths; per-frame hidden-state and logit "
                         "agreement is written to --out and the run exits (pbs/cached_identity.pbs phase 2).")
    ap.add_argument("--cached", action="store_true",
                    help="Cached-state decode (2026-09-11): carry Mamba2 conv/ssm states, the hybrid "
                         "KV cache and the transformer past_key_values across frames and feed one "
                         "position per step, instead of re-running the whole prefix every frame. "
                         "Numerics-equivalent to the default path, NOT bit-identical (S212: hidden <=3e-3 rel, "
                         "LM-head argmax 360/360; token-identical on mamba2-130m in tests); a battery adopts it "
                         "for ALL compared arms or none (prereg Amendment 7, pbs/p2cot_eval.pbs CACHED=1). "
                         "Off by default: every banked result used the recompute path.")
    ap.add_argument("--force_frames", type=int, default=0,
                    help="EFFICIENCY SWEEP ONLY (external_benchmarks.md §3.4): pin the decode to exactly N "
                         "frames (min_frames = max_frames = N), overriding every derived window, so "
                         "rtf_lm / peak_mem_gb are measured at a chosen generated length. Use with "
                         "--mode nocot; a plan schedule would be desynchronised from a forced length.")
    ap.add_argument("--min_frames_per_word", type=float, default=6.0)
    ap.add_argument("--duration_model", default=None,
                    help="JSON from scripts/fit_duration_model.py. When set, "
                         "min/max frames come from the predicted duration "
                         "instead of the flat 6-frames-per-word floor and the "
                         "fixed --max_frames cap.")
    ap.add_argument("--dur_lo", type=float, default=0.6,
                    help="min_frames = dur_lo * predicted. Below 1.0 so the "
                         "stop head can still end a row the predictor "
                         "overestimated.")
    ap.add_argument("--dur_hi", type=float, default=1.5,
                    help="hard cap = dur_hi * predicted. This is what removes "
                         "the runaway; 1.5 leaves room for the ~30%% of rows "
                         "the predictor misses by more than 20%%.")
    ap.add_argument("--temp_semantic", type=float, default=None)
    ap.add_argument("--acoustic_temps", type=float, nargs="*", default=None)
    ap.add_argument("--allow_default_temps", action="store_true",
                    help="decode on the arm-E ladder even though H-family controls (--rep_penalty_levels / --ras_tau) "
                         "are set, i.e. reproduce a cell decoded before 2026-09-28. Never for new cells.")
    ap.add_argument("--top_k", type=int, default=64)
    ap.add_argument("--top_p", type=float, default=0.0,
                    help="nucleus sampling (0 = off). Reference AR-TTS systems "
                         "all use it: fish-speech 0.7-0.8, CosyVoice-2 0.8, "
                         "IndexTTS 0.8. generate_frame already implements it; "
                         "only the CLI flag was missing.")
    ap.add_argument("--stop_threshold", type=float, default=0.95)
    ap.add_argument("--dump_stop_probs", action="store_true",
                    help="record p(stop) for EVERY frame into the output row as "
                         "`stop_probs` (+ `stop_argmax`). Combined with a "
                         "threshold that never fires (--stop_threshold 1.1) "
                         "this makes one run yield the exact clip length for "
                         "every (threshold, dur_lo) pair, because the stop "
                         "check is a pure break that consumes no randomness. "
                         "Consumed by scripts/stop_threshold_sweep.py.")
    ap.add_argument("--antifan_bias", type=float, default=0.0)
    ap.add_argument("--antifan_levels", type=int, nargs="*", default=None)
    ap.add_argument("--scale_cap", type=float, default=0.0,
                    help="UNBOUNDED decode with the frame cap scaled to the "
                         "predicted duration (e.g. 3.0 = 3x). A flat 400 cap "
                         "means ~5x on a 14-word line and ~1.5x on a 35-word "
                         "one, so 'at-cap' is not comparable across prompt "
                         "lengths (S141). Needs --duration_model_path.")
    ap.add_argument("--duration_model_path", default=None,
                    help="predictor for --scale_cap ONLY; does not bound")
    ap.add_argument("--prompt_tags", action="store_true",
                    help="append the EMO/PACE/PITCH tag TEXT to the prompt "
                         "region. For probing a base model that has never seen "
                         "a <THINK> block; the record keeps the original prompt "
                         "so WER still catches the model speaking the tag.")
    ap.add_argument("--force_emotion", default=None,
                    help="override emotion_label on every row (tag-flip probe)")
    ap.add_argument("--force_pace", default=None,
                    help="override pace on every row (tag-flip probe)")
    ap.add_argument("--force_pitch", default=None,
                    help="override pitch on every row (tag-flip probe)")
    ap.add_argument("--think_temp", type=float, default=0.7)
    ap.add_argument("--think_top_k", type=int, default=50)
    ap.add_argument("--max_think", type=int, default=120,
                    help="<THINK> token budget per row; 0 = AUTO (6 x words + 64, floor 120). The fixed "
                         "default caps a self-authored plan at ~28 words - use 0 on any set with longer texts.")
    ap.add_argument("--plan_window", action="store_true",
                    help="derive the decode window from the PLAN the model "
                         "emitted (or was given): min/max frames = "
                         "--plan_dur_lo/--plan_dur_hi times ΣD̂ of its "
                         "dequantised word durations, replacing the "
                         "text-regression --duration_model for that row. S155: "
                         "the stop threshold is inert and the duration FLOOR is "
                         "the lever, so this is the mechanism that ties clip "
                         "length to the model's stated intent. A row whose plan "
                         "is missing or malformed keeps the old window and says "
                         "so (plan_status / plan_window_fallback).")
    ap.add_argument("--plan_injection", action="store_true",
                    help="feed the plan into the AUDIO block through the "
                         "duration-scheduled input-injection channel (§3.2), "
                         "scheduled from the model's OWN emitted durations. "
                         "Requires a checkpoint trained with --plan_injection: "
                         "a zero-init injector is refused rather than run as a "
                         "silent no-op.")
    ap.add_argument("--plan_injection_off", action="store_true",
                    help="cancel --plan_injection for THIS cell, keeping every "
                         "other plan flag identical. The prefix-only condition "
                         "(master plan R3): if injection is the only path to "
                         "the program, 'the plan was consumed' is trivially "
                         "true, so the honest headline number is measured with "
                         "the channel off. Exists as a negation rather than by "
                         "dropping the flag so a batch script can share one "
                         "argument block across the ON and OFF cells "
                         "(pbs/p2cot_eval.pbs) — the two cells then differ by "
                         "exactly this one token.")
    ap.add_argument("--plan_dur_lo", type=float, default=0.8,
                    help="min_frames = plan_dur_lo * T-hat (D-PLAN profile). "
                         "0.8 per Amendment 4: [0.9,1.5] contained the true "
                         "length on only 82.6%% of eval rows even rho-corrected "
                         "(bar 95%%); [0.8,1.5] measures 98.2%%.")
    ap.add_argument("--plan_dur_hi", type=float, default=1.5,
                    help="hard cap = plan_dur_hi * T-hat, where T-hat = ΣD̂ / ρ "
                         "(D-PLAN profile). NB the cap scales T-hat, NOT ΣD̂ "
                         "directly — see plan_decode_window(), which computes "
                         "max_fr from t_hat. The help text used to say ΣD̂, which "
                         "understates the cap by 1/ρ (~1.5x) and cost a truncation "
                         "diagnosis a wrong turn on 2026-09-01.")
    ap.add_argument("--force_plan", default=None,
                    help="JSONL of plans to inject INSTEAD of the model's own — "
                         "the oracle-plan condition and the plan-flip probe. "
                         "One object per line, `row` binding it to a val row "
                         "index (else file order over the selection), carrying "
                         "`plan` (a token string, a word list, or an extractor "
                         "sidecar). Requires --mode oracle; get the model's own "
                         "plans from a --mode self run's plan_words first.")
    ap.add_argument("--plan_field", default="plan",
                    help="row field holding the plan sidecar for a --mode "
                         "oracle plan arm (matches the dataset's plan_field).")
    ap.add_argument("--plan_window_field", default=None,
                    help="row field holding an EXPLICIT [min_frames, max_frames] "
                         "window, which then bounds decode instead of "
                         "--duration_model or --plan_window. This is how the "
                         "G-P4 flip cells keep one window across conditions: "
                         "duration bins bound the decode window (§3.4), so "
                         "letting the window follow a FLIPPED plan would "
                         "manufacture the length effect the probe measures. "
                         "scripts/plan_flip_probe.py build writes the control "
                         "plan's window into every cell row as `plan_window`. A "
                         "row missing the field is a hard error, not a silent "
                         "fall back to a different window — that would put two "
                         "decode regimes inside one cell.")
    ap.add_argument("--plan_schedule", choices=["stretch", "pack"],
                    default="stretch",
                    help="how plan words map onto frames during injection. "
                         "'stretch' is training's default and needs a target "
                         "length (ΣD̂ / --plan_speech_ratio); 'pack' needs only "
                         "the plan but drifts early at every pause.")
    ap.add_argument("--plan_speech_ratio", type=float, default=1.0,
                    help="ρ for the 'stretch' target: T̂ = ΣD̂ / ρ. ΣD counts "
                         "SPEECH frames only (word spans exclude pauses — 0.64 "
                         "of total on the one clip measured so far), so the "
                         "default 1.0 schedules the program over its own ΣD̂ "
                         "frames and holds the last word after that. Set it "
                         "from the corpus's measured time_coverage to spread "
                         "the program the way training's 'stretch' did; the "
                         "default invents no constant.")
    ap.add_argument("--plan_d_plan", type=int, default=256,
                    help="MUST match the checkpoint's plan-injector width.")
    ap.add_argument("--plan_on_malformed", choices=["fallback", "skip", "error"],
                    default="fallback",
                    help="what to do with a row whose plan is absent/truncated/"
                         "malformed: keep it on the OLD window (recorded, "
                         "warned, counted), drop the candidate, or abort.")
    args = ap.parse_args()
    resolve_decode_temps(args)

    args.plan_injection = resolve_plan_injection(args.plan_injection,
                                                 args.plan_injection_off)

    plan_mode = bool(args.plan_window or args.plan_injection or args.force_plan
                     or args.plan_window_field
                     or args.cot_mode in ("plan", "tags+plan", "full"))

    os.environ.setdefault("MVC_NUM_SPEECH_TOKENS", "2048")
    os.environ.setdefault("MVC_NUM_CODEC_LEVELS", "10")
    if plan_mode:
        os.environ.setdefault("MVC_ENABLE_PLAN_TOKENS", "1")
        if args.mode in ("gt", "nocot"):
            raise SystemExit(
                f"--mode {args.mode} has no <THINK> block, so there is no plan "
                f"to read, force or schedule from. Plan decode applies to "
                f"--mode self (the model's own program) and --mode oracle (a "
                f"given one).")
        if args.force_plan and args.mode != "oracle":
            raise SystemExit(
                "--force_plan requires --mode oracle. The flip/oracle probe is "
                "two passes: --mode self to get the model's own plans "
                "(plan_words in the output), then --mode oracle --force_plan "
                "with the modified file.")
        if args.mode == "oracle" and args.cot_mode not in ("plan", "tags+plan",
                                                           "full"):
            raise SystemExit(
                f"--mode oracle with plan decode needs --cot_mode plan/"
                f"tags+plan/full (got {args.cot_mode!r}): the window and the "
                f"injection must come from a plan the model was actually "
                f"conditioned on, not one it never saw.")
        if args.plan_dur_lo > args.plan_dur_hi:
            raise SystemExit(f"--plan_dur_lo {args.plan_dur_lo} > --plan_dur_hi "
                             f"{args.plan_dur_hi}: the window would be empty")
        if args.plan_window and args.plan_window_field:
            raise SystemExit(
                "--plan_window and --plan_window_field are mutually exclusive: "
                "the first derives the window from the plan IN CONTEXT (which "
                "moves when the plan is flipped), the second pins it to the "
                "value carried on the row. Pick one per cell.")
        if args.plan_speech_ratio <= 0.0:
            raise SystemExit("--plan_speech_ratio must be > 0")
        if (args.plan_injection and args.plan_schedule == "stretch"
                and args.plan_speech_ratio == 1.0):
            print("[plan] WARNING: --plan_schedule stretch with "
                  "--plan_speech_ratio 1.0 schedules the program over its own "
                  "SumD_hat frames and holds the last word afterwards. Training "
                  "stretched it over the FULL utterance (SumD/T ~ 0.64 on the "
                  "one clip measured so far), so this is a train/inference "
                  "schedule mismatch unless your corpus really has no pauses. "
                  "Use the `rho = SumD/T median` line from the training log.",
                  flush=True)
        if args.mode == "self":
            print(f"[plan] --max_think {args.max_think} caps the program at "
                  f"~{max(0, (args.max_think - 8)) // 4} words (3-4 tokens "
                  f"each, before any prose/tags). Rows that outgrow it are "
                  f"reported as `unterminated`, not silently shortened.",
                  flush=True)
        if args.plan_window and not args.dump_stop_probs:
            print("[plan] NOTE: --dump_stop_probs is off, so this run cannot "
                  "feed the S154 stop-calibration readout (the pre-registered "
                  "prediction is that plan windows remove the certain-early "
                  "population). The window itself is recorded either way.",
                  flush=True)

    rows_all = [json.loads(l) for l in open(args.val, encoding="utf-8") if l.strip()]
    if args.rows is not None:
        idxs = args.rows
    else:
        cand = range(len(rows_all))
        if args.max_words:
            cand = [i for i in cand
                    if len(rows_all[i]["prompt"].split()) <= args.max_words]
        idxs = list(cand)[:args.limit]
        print(f"[gen] {len(idxs)} prompts selected"
              + (f" (<= {args.max_words} words)" if args.max_words else ""),
              flush=True)
    rows = [rows_all[i] for i in idxs]

    out_path = Path(args.out)
    out_path.parent.mkdir(parents=True, exist_ok=True)

    if args.mode == "gt":
        n = 0
        with out_path.open("w", encoding="utf-8") as f:
            for j, r in zip(idxs, rows):
                f.write(json.dumps({
                    "prompt": r["prompt"], "row": j, "mode": "gt",
                    "emotion": r.get("emotion_label"), "speaker_id": r.get("speaker_id"),
                    "speech_tokens": r["speech_tokens"],
                    "residual_codes": r["residual_codes"],
                    "n_frames": len(r["speech_tokens"]),
                }) + "\n")
                n += 1
        print(f"GEN_FISH_COT_SAMPLES_DONE mode=gt wrote {n} -> {out_path}")
        return

    assert args.checkpoint, "nocot/oracle/self require --checkpoint"
    import config as _cfg
    from model import MambaCoTModel
    from train import load_checkpoint
    import test_depth_checkpoint as tdc
    assert tdc.NUM_LEVELS == 10, (
        f"MVC_NUM_CODEC_LEVELS not seen as 10 (tdc.NUM_LEVELS={tdc.NUM_LEVELS}, "
        f"env={os.environ.get('MVC_NUM_CODEC_LEVELS')!r})")
    assert _cfg.NUM_SPEECH_TOKENS == 2048, (
        f"MVC_NUM_SPEECH_TOKENS not seen as 2048 "
        f"(config.NUM_SPEECH_TOKENS={_cfg.NUM_SPEECH_TOKENS}, "
        f"env={os.environ.get('MVC_NUM_SPEECH_TOKENS')!r})")

    from train import checkpoint_speaker_rows
    _ckpt_spk = checkpoint_speaker_rows(args.checkpoint)
    if _ckpt_spk is not None and _ckpt_spk != args.num_speakers:
        print(f"[speaker] checkpoint carries a {_ckpt_spk}-row speaker table; "
              f"--num_speakers {args.num_speakers} overridden to match it "
              f"(a mismatch would re-initialise every voice).", flush=True)
        args.num_speakers = _ckpt_spk

    _dtype = torch.float32 if args.dtype == "fp32" else torch.bfloat16
    model = MambaCoTModel(model_name=args.model_name, device=args.device,
                          dtype=_dtype, mtp_num_heads=0,
                          backbone_kind=args.backbone)
    if args.hybrid_attention_top_k:
        model.enable_hybrid_attention(args.hybrid_attention_top_k)
    model.enable_speaker_conditioning(speaker_dim=args.speaker_dim,
                                      num_speakers=args.num_speakers,
                                      input_injection=True)
    model.enable_depth_module(num_levels=10, codebook_size=2048,
                              d_depth=args.depth_dim, depth_layers=args.depth_layers,
                              depth_feedback=args.depth_feedback, cross_frame=0,
                              codebook_sizes=FISH_CB,
                              depth_arch=args.depth_arch,
                              depth_cond=args.depth_cond,
                              depth_heads=args.depth_heads)
    _ckpt_has_injector = False
    try:
        from train import checkpoint_tensor_shapes
        _ckpt_has_injector = any(k.startswith("plan_injector.")
                                 for k in checkpoint_tensor_shapes(args.checkpoint))
    except Exception as e:
        print(f"[plan] could not peek at {args.checkpoint} ({type(e).__name__}); "
              f"falling back to the flag", flush=True)
    if args.plan_injection or _ckpt_has_injector:
        model.enable_plan_injection(d_plan=args.plan_d_plan,
                                    scaffold_dropout=0.0)
        if _ckpt_has_injector and not args.plan_injection:
            print("[plan] checkpoint carries a plan injector; building it so the "
                  "weights load EXACTLY, but this cell decodes with the plan "
                  "channel unused (Amendment 4's matched floor).", flush=True)
    load_checkpoint(args.checkpoint, model, device=args.device)
    model.eval()
    reg = model.token_registry
    if args.tf_check:
        _teacher_forced_cache_check(model, reg, args)
        return
    lts = [args.temp_semantic] + list(args.acoustic_temps)

    maps = None
    if plan_mode:
        maps = plan_id_maps(reg)
    if args.plan_injection:
        w = model.plan_injector.proj.weight
        if float(w.detach().abs().sum().item()) == 0.0:
            raise SystemExit(
                f"--plan_injection: {args.checkpoint} carried no trained "
                f"plan_injector (proj.weight is still exactly zero-init), so "
                f"injection would be a numeric no-op. Either the checkpoint "
                f"predates the plan channel, or --plan_d_plan "
                f"{args.plan_d_plan} does not match its width and the weights "
                f"were dropped on load.")
        print(f"[plan] injection ON (d_plan={args.plan_d_plan}, bins="
              f"{model.plan_bin_sizes}, schedule={args.plan_schedule})",
              flush=True)

    if args.force_pace and args.duration_model:
        print(f"[f0] bounded + --force_pace {args.force_pace}: window is "
              f"computed from the ORIGINAL row; readout_f0_probe.py verifies "
              f"it is identical across cells from the recorded dur_window",
              flush=True)

    dur_model_for_cap = None
    if args.scale_cap and args.duration_model:
        raise SystemExit(
            "--scale_cap and --duration_model are mutually exclusive: one "
            "imposes a WINDOW on the length, the other only a ceiling, and "
            "the bounded path silently wins. Pick one.")
    if args.scale_cap:
        if not args.duration_model_path:
            raise SystemExit(
                "--scale_cap needs --duration_model_path (the predictor is "
                "used for the ceiling only; pass --duration_model instead if "
                "you want a bounded window)")
        dur_model_for_cap = json.loads(
            Path(args.duration_model_path).read_text(encoding="utf-8"))
        print(f"CAP SCALED to {args.scale_cap:g}x the predicted duration "
              f"(floor {args.max_frames}); decode is otherwise UNBOUNDED",
              flush=True)

    dur_model = None
    if args.duration_model:
        dur_model = json.loads(Path(args.duration_model).read_text(
            encoding="utf-8"))
        print(f"DURATION-BOUNDED decode: {args.duration_model} "
              f"feats={dur_model.get('features')} "
              f"window [{args.dur_lo:g}x, {args.dur_hi:g}x] of prediction",
              flush=True)

    forced = load_forced_plans(args.force_plan) if args.force_plan else None
    if args.plan_window:
        print(f"PLAN-BOUNDED decode (§3.4): window [{args.plan_dur_lo:g}x, "
              f"{args.plan_dur_hi:g}x] of the plan's own ΣD̂"
              + (f", overriding {args.duration_model}" if dur_model else "")
              + f"; malformed -> {args.plan_on_malformed}", flush=True)
    plan_counts = {}

    n = 0
    with out_path.open("w", encoding="utf-8") as f, torch.no_grad():
        for order_index, (j, r_orig) in enumerate(zip(idxs, rows)):
            r, f0_tag = f0_row(r_orig, args)
            spk = (args.force_speaker_id if args.force_speaker_id is not None
                   else r.get("speaker_id", 1))
            min_fr = max(20, int(len(r_orig["prompt"].split())
                                 * args.min_frames_per_word))
            max_fr = args.max_frames
            if dur_model is not None:
                t_hat = predict_frames(dur_model, r_orig)
                min_fr = max(20, int(args.dur_lo * t_hat))
                max_fr = max(min_fr + 5, int(args.dur_hi * t_hat))
            elif args.scale_cap:
                t_hat = predict_frames(dur_model_for_cap, r_orig)
                max_fr = max(args.max_frames, int(args.scale_cap * t_hat))
            window_field_value = None
            if args.plan_window_field:
                window_field_value = _row_window(r_orig, args.plan_window_field,
                                                 args.val, j)
                min_fr, max_fr = window_field_value
            base_min_fr, base_max_fr = min_fr, max_fr
            base_source = (
                f"row_field:{args.plan_window_field}" if window_field_value
                else "duration_model" if dur_model is not None
                else "scale_cap" if args.scale_cap else "words")

            given_words, plan_ids, plan_source = None, None, "self"
            if plan_mode and args.mode == "oracle":
                if forced is not None:
                    obj = forced_plan_for(forced, j, order_index, args.force_plan)
                    given_words = plan_words_from_obj(
                        obj, where=f"--force_plan entry for row {j}")
                    plan_source = "forced"
                else:
                    if r_orig.get(args.plan_field) is None:
                        raise SystemExit(
                            f"{args.val} row {j} has no {args.plan_field!r} "
                            f"field, so there is no oracle plan to condition "
                            f"on. Point --plan_field at the sidecar, or pass "
                            f"--force_plan.")
                    given_words = plan_words_from_obj(
                        r_orig[args.plan_field],
                        where=f"{args.val} row {j} field {args.plan_field!r}")
                    plan_source = "row"
                plan_ids = plan_prefix_ids(reg, given_words)
            for c in range(args.candidates):
                torch.manual_seed(args.seed + 1000 * j + c)
                prefix, rstr = build_prefix(args.mode, model, reg, r, think_budget(args.max_think, r.get("prompt")),
                                            args.think_temp, args.think_top_k, spk,
                                            args.device, cot_mode=args.cot_mode,
                                            plan_ids=plan_ids)
                min_fr, max_fr = base_min_fr, base_max_fr
                plan_extra, plan_bins, plan_sched = {}, None, None
                if plan_mode:
                    parsed = parse_plan_tokens(
                        plan_region(prefix[0].tolist(), reg), maps)
                    if given_words is not None:
                        got = [(w["pitch_bin"], w["dur_bin"], w["energy_bin"])
                               for w in parsed.words]
                        want = [(w["pitch_bin"], w["dur_bin"], w["energy_bin"])
                                for w in given_words]
                        if not parsed.ok or got != want:
                            raise SystemExit(
                                f"row {j}: the plan written into the prefix does "
                                f"not read back ({parsed.status}: "
                                f"{parsed.reason or 'bins differ'}). The "
                                f"registry's bin<->id mapping and the parser "
                                f"disagree — every conditioned arm would be "
                                f"conditioned on something other than the plan "
                                f"it was handed.")
                    win = resolve_decode_window(
                        parsed, base_min_fr, base_max_fr, base_source,
                        dur_lo=args.plan_dur_lo, dur_hi=args.plan_dur_hi,
                        use_plan=args.plan_window,
                        speech_ratio=args.plan_speech_ratio)
                    min_fr, max_fr = win.min_fr, win.max_fr
                    plan_counts[parsed.status] = plan_counts.get(
                        parsed.status, 0) + 1
                    if not parsed.ok:
                        print(f"[row {j} cand {c}] PLAN {parsed.status}: "
                              f"{parsed.reason}", flush=True)
                        if args.plan_on_malformed == "error":
                            raise SystemExit(
                                f"row {j} cand {c}: plan {parsed.status} and "
                                f"--plan_on_malformed error")
                        if args.plan_on_malformed == "skip":
                            continue
                    target_frames = None
                    if args.plan_injection and parsed.ok:
                        durs = plan_dur_frames(parsed.words)
                        if args.plan_schedule == "stretch":
                            target_frames = max(1, int(round(
                                sum(durs) / args.plan_speech_ratio)))
                        plan_sched = tdc.build_decode_plan_schedule(
                            durs, prefix.shape[1], max_fr,
                            mode=args.plan_schedule, target_frames=target_frames,
                            device=args.device)
                        plan_bins = plan_bins_tensor(parsed.words,
                                                     device=args.device)
                    plan_extra = {
                        "plan_source": plan_source,
                        "plan_status": parsed.status,
                        "plan_ok": parsed.ok,
                        "plan_reason": parsed.reason,
                        "plan_n_words": parsed.n_words,
                        "plan_used": (plan_token_string(parsed.words)
                                      if parsed.ok else None),
                        "plan_words": [[w["pitch_bin"], w["dur_bin"],
                                        w["energy_bin"]] for w in parsed.words],
                        "plan_t_hat": (round(win.t_hat, 2)
                                       if win.t_hat is not None else None),
                        "plan_sum_frames": (round(plan_sum_frames(parsed.words), 2)
                                            if parsed.ok else None),
                        "plan_window_fallback": win.fallback,
                        "plan_injected": plan_bins is not None,
                        "plan_schedule_mode": (args.plan_schedule
                                               if plan_bins is not None else None),
                        "plan_speech_ratio": (args.plan_speech_ratio
                                              if plan_bins is not None else None),
                        "plan_target_frames": target_frames,
                        "dur_window_source": win.source,
                    }
                sp_out = [] if args.dump_stop_probs else None
                lb = (tdc.LoopBreaker(threshold=args.loop_break_flipk,
                                      win=args.loop_break_win,
                                      temp=args.loop_break_temp,
                                      rep=args.loop_break_rep,
                                      stop_after=args.loop_break_stop_after)
                      if args.loop_break_flipk > 0 else None)
                if args.force_frames:
                    min_fr = max_fr = int(args.force_frames)
                sg = None
                if args.silence_guard_codes:
                    with open(args.silence_guard_codes, encoding="utf-8") as _sf:
                        _sc = json.load(_sf)
                    sg = tdc.SilenceGuard(
                        codes=(_sc["codes"] if isinstance(_sc, dict) else _sc),
                        budget=args.silence_guard_budget,
                        stop_after=args.silence_guard_stop_after,
                        stop_without_plan=args.silence_guard_stop_noplan,
                        retries=args.silence_guard_retries,
                        retry_temp=args.silence_guard_retry_temp)
                _cuda = str(args.device).startswith("cuda")
                if _cuda:
                    torch.cuda.synchronize(); torch.cuda.reset_peak_memory_stats()
                _t0 = _time_rtf.perf_counter()
                codes = tdc.depth_generate(
                    model, prefix, reg, max_frames=max_fr, level_temps=lts,
                    stop_prob_out=sp_out,
                    top_k=args.top_k, top_p=args.top_p, allow_stop=True, min_frames=min_fr,
                    stop_threshold=args.stop_threshold, speaker_id=spk,
                    feedback=(args.decode_feedback or args.depth_feedback),
                    cross_frame_decode=False,
                    device=args.device, antifan_bias=args.antifan_bias,
                    antifan_levels=args.antifan_levels,
                    onset_frames=args.onset_frames,
                    onset_temp_scale=args.onset_temp_scale,
                    rep_penalty=(args.rep_penalty_levels or args.rep_penalty),
                    rep_win=args.rep_win,
                    ras_tau=(args.ras_tau_levels or args.ras_tau),
                    ras_win=args.ras_win,
                    plan_bins=plan_bins, plan_schedule=plan_sched,
                    loop_breaker=lb, silence_guard=sg, cached=args.cached)
                if _cuda:
                    torch.cuda.synchronize()
                _gen_s = _time_rtf.perf_counter() - _t0
                _peak_gb = (torch.cuda.max_memory_allocated() / 2 ** 30) if _cuda else None
                if codes is None or len(codes[0]) < 3:
                    print(f"[row {j} cand {c}] SKIP empty gen: {r['prompt'][:40]!r}",
                          flush=True)
                    continue
                stop_extra = {}
                if sp_out is not None:
                    stop_extra = {
                        "stop_probs": [round(p, 6) for p, _ in sp_out],
                        "stop_argmax": [int(a) for _, a in sp_out],
                    }
                f.write(json.dumps({
                    "prompt": r_orig["prompt"], "row": j, "cand": c,
                    "mode": args.mode,
                    "prompt_used": r["prompt"], "tag": f0_tag,
                    "dur_window": [min_fr, max_fr],
                    "emotion": r.get("emotion_label"), "speaker_id": spk,
                    "reasoning_used": rstr,
                    "speech_tokens": codes[0], "residual_codes": codes[1:],
                    "n_frames": len(codes[0]),
                    "audio_seconds": round(len(codes[0]) / CODEC_FPS, 3),
                    "gen_seconds": round(_gen_s, 3),
                    "rtf_lm": round(_gen_s / (len(codes[0]) / CODEC_FPS), 4),
                    "cached": bool(args.cached),
                    "seed": int(args.seed),
                    "level_temps": [round(float(t), 3) for t in lts],
                    "temps_explicit": bool(getattr(args, "temps_explicit", False)),
                    **({"peak_mem_gb": round(_peak_gb, 3)} if _peak_gb is not None else {}),
                    "cb0_flipk": round(tdc.flipk_rate(codes[0]), 2),
                    **({"loop_break": lb.stats()} if lb is not None else {}),
                    **({"silence_guard": sg.stats()} if sg is not None else {}),
                    **plan_extra,
                    **stop_extra,
                }) + "\n")
                n += 1
                f.flush()
                extra = f" think={rstr[:40]!r}" if rstr else ""
                if plan_extra:
                    extra += (f" plan={plan_extra['plan_status']}/"
                              f"{plan_extra['plan_n_words']}w "
                              f"win={min_fr}-{max_fr}")
                print(f"[row {j} c{c}] {len(codes[0])} fr  "
                      f"emo={r.get('emotion_label')}  {r['prompt'][:34]!r}{extra}",
                      flush=True)
    if plan_mode:
        print("PLAN_STATUS_SUMMARY " + (" ".join(
            f"{k}={v}" for k, v in sorted(plan_counts.items())) or "none"),
            flush=True)
    print(f"GEN_FISH_COT_SAMPLES_DONE mode={args.mode} wrote {n} -> {out_path}")

if __name__ == "__main__":
    main()