File size: 67,102 Bytes
74679d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de74b71
74679d4
 
 
 
 
 
de74b71
74679d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de74b71
 
74679d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de74b71
74679d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
de74b71
74679d4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Graph-captured greedy decoding: AR baseline and self-speculative decoding.

All compute runs in captured CUDA/HIP graphs with static buffers:
  ar             token -> all layers -> LM head argmax                       (1 row)
  seg[a, b, m]   m hidden rows (buffer) -> layers [a, b) at positions pos[:m] (m rows)
  head[k]        one hidden row -> head k -> (confidence, token id)
  fill[a, b]     CALM K/V copy for layers [a, b) from one hidden row          (regime B)
  verify[c, n]   n hidden rows at layer c -> layers [c, L) -> argmax           (n rows)
Positions are read from a device buffer, so graphs replay anywhere. Attention
uses the row-invariant Triton kernel and GEMMs use rocBLAS, so AR and every
batched path compute each row bitwise identically (greedy parity).

Regime A (default, kv_fill="lazy"): before a draft row climbs to a deeper head,
the rows of the block that are shallower are caught up exactly in one batched
segment pass; head scores depend only on the exact prefix and verification runs
only [c_max, L). Regime B (kv_fill="copy"): K/V of skipped layers are copied from
the exit hidden state; verification runs [c_min, L).
Prefill (shared by all methods) runs eagerly.
"""

from __future__ import annotations

import math
import time

import numpy as np

import torch
import torch.nn.functional as F

from .decode import GenStats, StaticPolicy
from .heads import ExitHead
try:
    from .kernels import row_attention
except ImportError:  # no Triton (CPU tests)
    row_attention = None
from .layered import LayeredDecoder, _rotate_half


def _lam(controller) -> float:
    """Scalar lambda of an online controller (NoSlowdown wraps it in .aci)."""
    c = getattr(controller, "aci", controller)
    return float(np.min(getattr(c, "lam", float("nan"))))


class DraftModel:
    """Classical speculative decoding: a small model of the same tokenizer drafts for the target
    (P1 baseline). Own FastDecoder (own KV, AR graph). Per cycle: catch up the committed tokens it
    has not processed, then draft `gamma` tokens greedily, or stop early when its max probability
    falls below `conf` (adaptive). The drafts are verified exactly by the target like copies."""

    def __init__(self, fast: "FastDecoder", gamma: int = 4, conf: float | None = None):
        self.fast, self.gamma, self.conf = fast, gamma, conf

    def start(self, prompt: list[int]) -> None:
        self.fast._check_capacity(prompt, 0)
        self.fast._prefill(prompt)
        self.d_len = len(prompt)  # positions whose draft KV is valid

    def _step(self, tok: int, pos: int):
        f = self.fast
        f._set_pos([pos])
        f.ids.fill_(tok)
        a, c = f._g_ar_conf().tolist()
        return int(a), float(c)

    def _catchup(self, seq: list[int], start: int, end: int) -> None:
        """Committed tokens [start, end) the draft has not processed (e.g. after prompt-lookup
        cycles): processed in blocks of max_rows through the draft's layer-0 verify graphs (KV
        only; a draft-side error could only lower acceptance, never exactness)."""
        f = self.fast
        W = f.max_rows
        ids = torch.tensor(seq[start:end], device=f.dec.device)
        for s0 in range(0, end - start, W):
            m = min(W, end - start - s0)
            f._set_pos(list(range(start + s0, start + s0 + m)))
            f.buf[:m].copy_(f.dec.embed(ids[s0:s0 + m]))
            f._g_verify(0, m)

    def propose(self, seq: list[int], budget: int) -> list[int]:
        pos0 = len(seq) - 1
        if pos0 - self.d_len > 1:
            self._catchup(seq, self.d_len, pos0)
        else:
            for p in range(self.d_len, pos0):  # committed tokens the draft has not processed yet
                self._step(seq[p], p)
        x, drafts, self.n_inputs = seq[pos0], [], 0
        for j in range(min(self.gamma, budget)):
            t, c = self._step(x, pos0 + j)
            self.n_inputs += 1
            if self.conf is not None and j > 0 and c < self.conf:
                break
            drafts.append(t)
            x = t
        self.pos0 = pos0
        return drafts

    def commit(self, accepted: int) -> None:
        # inputs at pos0 + i (i >= 1) were d_i: valid iff i <= accepted
        self.d_len = self.pos0 + 1 + min(accepted, self.n_inputs - 1)


class FastDecoder:
    def __init__(self, dec: LayeredDecoder, heads: list[ExitHead], head_layers: list[int],
                 max_rows: int = 16, use_graphs: bool | None = None, shared_pool: bool = False,
                 prefill: str = "engine", prefill_chunk: int = 2048):
        assert prefill in ("engine", "full"), prefill
        self.prefill_mode, self.prefill_chunk = prefill, prefill_chunk
        self.dec = dec
        self.heads = heads
        self.head_layers = head_layers
        self.max_rows = max_rows
        dev, d = dec.device, dec.spec.hidden_size
        # Row metadata in ONE device buffer [pos | slot | cl | anc], filled by one copy from a
        # host staging buffer (pinned on GPU): audit T5, piste 1 (3-4 pageable copies before).
        W = max_rows
        self._meta_d = torch.zeros(3 * W + W * W, dtype=torch.int64, device=dev)
        self._meta_h = torch.zeros(3 * W + W * W, dtype=torch.int64)
        if dev.type == "cuda":
            self._meta_h = self._meta_h.pin_memory()
        self.pos = self._meta_d[0:W]
        # Tree drafts: slot = KV write index of each row; keys t < cl are read at index t,
        # keys t >= cl at anc[row, t - cl] (the row's path). Chains: slot = pos, cl = pos + 1.
        self.slot = self._meta_d[W:2 * W]
        self.cl = self._meta_d[2 * W:3 * W]
        self.anc = self._meta_d[3 * W:].view(W, W)
        self.cl.fill_(1)
        self.idx = torch.zeros(max_rows, dtype=torch.int64, device=dev)
        self.ids = torch.zeros(1, dtype=torch.long, device=dev)
        self.buf = torch.zeros(max_rows, d, device=dev, dtype=dec.dtype)    # segment rows
        self.hrows = torch.zeros(max_rows, d, device=dev, dtype=dec.dtype)  # block rows
        self.hb = torch.zeros(1, d, device=dev, dtype=dec.dtype)            # head input
        # sampling (T > 0): Gumbel noise filled eagerly before each head replay (no RNG inside
        # graphs), per-head renormalised V_k distribution of the last sample, saved per draft row
        vmax = max([h.n_vocab for h in heads] + [1])
        self.gnoise = torch.zeros(vmax, device=dev, dtype=torch.float32)
        self.qout = torch.zeros(vmax, device=dev, dtype=torch.float32)
        self.qsave = torch.zeros(max_rows, vmax, device=dev, dtype=torch.float32)
        self.temperature = 1.0
        self._sampling, self._gen, self._rng = False, None, None
        self.graphs: dict = {}
        self._trace = None  # debug: per-layer tensors (eager mode only)
        # ROCm 7.0 (CLR graph "packet capture"): a graph replay is not reliably ordered
        # with later work on the same stream; this faulted on MI210 (jobs 893662-893901).
        # Synchronising after every replay removes it (job 893903) and is the fastest
        # fix measured (96 tok/s AR vs 72 with DEBUG_CLR_GRAPH_PACKET_CAPTURE=0).
        self.sync_each = bool(torch.version.hip)
        self.use_graphs = dev.type == "cuda" if use_graphs is None else use_graphs
        # One memory pool for all graphs: safe because graphs never run concurrently and
        # every graph output is consumed (or copied into a persistent buffer) right away.
        self.pool = torch.cuda.graph_pool_handle() if (shared_pool and self.use_graphs) else None
        self.capture_seconds = 0.0
        self.stats = {"seg_replays": 0, "seg_rows": 0, "noncontig": 0}

    def _sync(self) -> None:
        if self.dec.device.type == "cuda":
            torch.cuda.synchronize()

    # ------------------------------------------------------------------ compute
    def _rope_tables(self, n):
        pos = self.pos[:n]
        return pos, self.dec.cos.index_select(0, pos)[:, None], self.dec.sin.index_select(0, pos)[:, None]

    def _pad(self, x: torch.Tensor) -> torch.Tensor:
        n = x.shape[0]
        return x if n == self.max_rows else F.pad(x, (0, 0) * (x.dim() - 1) + (0, self.max_rows - n))

    def _lin(self, mod, x: torch.Tensor) -> torch.Tensor:
        """GEMM on a fixed-width (max_rows) input; returns all max_rows rows."""
        return F.linear(self._pad(x), mod.weight, mod.bias)

    def _mlp(self, mlp, x: torch.Tensor) -> torch.Tensor:
        return self._lin(mlp.down_proj, F.silu(self._lin(mlp.gate_proj, x)) * self._lin(mlp.up_proj, x))

    def _final_logits(self, h: torch.Tensor) -> torch.Tensor:
        n = h.shape[0]
        return self._lin(self.dec.lm_head, self.dec.norm(self._pad(h))).float()[:n]

    def _layers(self, h: torch.Tensor, first: int, last: int) -> torch.Tensor:
        """Layers [first, last) on n real rows, at FIXED WIDTH.

        Every elementwise op, reduction (RMSNorm, q/k norm) and GEMM runs on exactly
        `max_rows` rows (real rows first, zero padding after), so each kernel sees the
        same shape whatever n is and a row is computed bitwise identically in AR (n=1),
        catch-up (m rows) and verification (n rows). Library kernels pick different
        code paths by shape: hipBLASLt/rocBLAS GEMMs (jobs 893616, 893848) and the
        MLP-block elementwise/reduction kernels (job 893872) were not row-invariant.
        Only the K/V cache writes (exact copies) and attention (row-invariant Triton
        kernel) touch the n real rows alone. Pad rows use position pos[n:] (kept valid).
        """
        dec = self.dec
        n = h.shape[0]
        W = self.max_rows
        hd = dec.spec.head_dim
        pos, cos, sin = self._rope_tables(W)
        h = self._pad(h)
        for li in range(first, last):
            layer = dec.layers[li]
            attn = layer.self_attn
            x = layer.input_layernorm(h)
            q = self._lin(attn.q_proj, x).view(W, -1, hd)
            k = self._lin(attn.k_proj, x).view(W, -1, hd)
            v = self._lin(attn.v_proj, x).view(W, -1, hd)
            if dec.spec.has_qk_norm:
                q, k = attn.q_norm(q), attn.k_norm(k)
            q = q * cos + _rotate_half(q) * sin
            k = k * cos + _rotate_half(k) * sin
            dec.k_cache[li].index_copy_(1, self.slot[:n], k[:n].transpose(0, 1))
            dec.v_cache[li].index_copy_(1, self.slot[:n], v[:n].transpose(0, 1))
            o = self._attention(q[:n].contiguous(), li, n)
            h = h + self._lin(attn.o_proj, o.reshape(n, -1))
            if self._trace is not None:
                self._trace.append(("attn_out", li, o.clone(), h[:n].clone()))
            h = h + self._mlp(layer.mlp, layer.post_attention_layernorm(h))
            if self._trace is not None:
                self._trace.append(("layer_out", li, h[:n].clone()))
        return h[:n]

    def _attention(self, q: torch.Tensor, li: int, n: int) -> torch.Tensor:
        dec = self.dec
        if row_attention is not None and q.is_cuda:
            return row_attention(q, dec.k_cache[li], dec.v_cache[li], self.pos, dec.scaling,
                                 cl=self.cl, anc=self.anc)
        outs = []  # reference path (CPU tests): one query per row over its exact path
        cls, ancs = self.cl[:n].tolist(), self.anc[:n].tolist()
        for i, p in enumerate(self.pos[:n].tolist()):
            idx = torch.tensor(list(range(min(cls[i], p + 1))) + ancs[i][: max(p + 1 - cls[i], 0)])
            outs.append(F.scaled_dot_product_attention(
                q[i:i + 1].transpose(0, 1)[None], dec.k_cache[li, :, idx][None],
                dec.v_cache[li, :, idx][None], scale=dec.scaling, enable_gqa=True)[0].transpose(0, 1))
        return torch.cat(outs)

    def _fill(self, h: torch.Tensor, first: int, last: int) -> None:
        dec = self.dec
        hd = dec.spec.head_dim
        W = self.max_rows
        pos, cos, sin = self._rope_tables(W)
        x0 = self._pad(h)
        for li in range(first, last):
            layer = dec.layers[li]
            attn = layer.self_attn
            x = layer.input_layernorm(x0)
            k = self._lin(attn.k_proj, x).view(W, -1, hd)
            if dec.spec.has_qk_norm:
                k = attn.k_norm(k)
            k = k * cos + _rotate_half(k) * sin
            v = self._lin(attn.v_proj, x).view(W, -1, hd)
            dec.k_cache[li].index_copy_(1, pos[:1], k[:1].transpose(0, 1))
            dec.v_cache[li].index_copy_(1, pos[:1], v[:1].transpose(0, 1))

    def _head_out(self, k: int) -> torch.Tensor:
        head = self.heads[k]
        logits = head(self.hb)[0]
        probs = F.softmax(logits, -1)
        conf, arg = probs.max(-1)
        if head.reject:
            body = logits[:-1].argmax(-1)
            conf = torch.where(arg == logits.shape[-1] - 1, torch.zeros_like(conf), conf)
        else:
            body = arg
        return torch.stack([conf.float(), head.to_token_ids(body).float()])

    def _head_out4(self, k: int) -> torch.Tensor:
        """Head k on hb: [conf, id_1..id_4, p_1..p_4] over V_k ∪ ⊥ (⊥ reported as id -1).
        Iterated argmax instead of torch.topk (ROCm 7.0 graph bug, see _g_verify_topk)."""
        head = self.heads[k]
        logits = head(self.hb)[0].float()
        probs = F.softmax(logits, -1)
        conf, arg = probs.max(-1)
        rej = logits.shape[-1] - 1
        if head.reject:
            conf = torch.where(arg == rej, torch.zeros_like(conf), conf)
        ids, ps, pr = [], [], probs
        for _ in range(4):
            i = pr.argmax(-1)
            ps.append(pr.gather(0, i[None])[0])
            tok = head.to_token_ids(i.clamp_max(head.n_vocab - 1)[None])[0].float()
            ids.append(torch.where(i == rej, torch.full_like(tok, -1.0), tok) if head.reject else tok)
            pr = pr.scatter(0, i[None], float("-inf"))
        return torch.stack([conf] + ids + ps)

    def _g_head4(self, k):
        return self._run(("head4", k), lambda: self._head_out4(k))

    # ------------------------------------------------------------------ graphs
    def _run(self, key, fn):
        if not self.use_graphs:
            return fn()
        if key not in self.graphs:
            t0 = time.perf_counter()
            s = torch.cuda.Stream()
            s.wait_stream(torch.cuda.current_stream())
            with torch.cuda.stream(s):
                for _ in range(2):
                    fn()
            torch.cuda.current_stream().wait_stream(s)
            g = torch.cuda.CUDAGraph()
            with torch.cuda.graph(g, pool=self.pool):
                out = fn()
            self.graphs[key] = (g, out)
            self._sync()
            self.capture_seconds += time.perf_counter() - t0
        g, out = self.graphs[key]
        if self.sync_each:
            # ROCm 7.0: a replay is not reliably ordered with the eager work queued just
            # before it either (e.g. the buf <- hrows copy): prompt-lookup cycles verified
            # from layer 0 read a stale buf (job 899287, parity 0.67; exact without graphs or
            # with packet capture off, 899290-899291). A bare sync costs ~4 us (job 899254).
            torch.cuda.synchronize()
        g.replay()
        if self.sync_each:
            torch.cuda.synchronize()
        return out

    def _g_ar(self):
        L = self.dec.spec.num_layers
        return self._run(("ar",), lambda: self._final_logits(
            self._layers(self.dec.embed(self.ids), 0, L)).argmax(-1))

    def _head_sample_out(self, k: int) -> torch.Tensor:
        """Head k on hb at temperature self.temperature: [conf (exit score, 0 if ⊥ wins),
        sampled token id from q' = softmax(V_k logits / T) via Gumbel-max on gnoise, q'(sample)];
        q' is left in qout[:n_vocab]."""
        head = self.heads[k]
        lg = head(self.hb)[0].float()
        probs = F.softmax(lg, -1)
        conf, arg = probs.max(-1)
        if head.reject:
            conf = torch.where(arg == lg.shape[-1] - 1, torch.zeros_like(conf), conf)
        nv = head.n_vocab
        qb = F.softmax(lg[:nv] / self.temperature, -1)
        x = (qb.clamp_min(1e-30).log() + self.gnoise[:nv]).argmax(-1)
        self.qout[:nv].copy_(qb)
        tok = head.to_token_ids(x[None])[0].float()
        return torch.stack([conf, tok, qb.gather(0, x[None])[0]])

    def _g_head_sample(self, k):
        return self._run(("head_sample", k), lambda: self._head_sample_out(k))

    def _g_ar_logits(self):
        L = self.dec.spec.num_layers
        return self._run(("ar_logits",), lambda: self._final_logits(
            self._layers(self.dec.embed(self.ids), 0, L)).float()[0])

    def _g_verify_logits(self, c, n):
        L = self.dec.spec.num_layers
        return self._run(("verify_logits", c, n), lambda: self._final_logits(
            self._layers(self.buf[:n], c, L)).float())

    def _g_ar_conf(self):
        """AR step returning [argmax, max prob] (draft model with adaptive stop)."""
        L = self.dec.spec.num_layers

        def fn():
            pr = F.softmax(self._final_logits(self._layers(self.dec.embed(self.ids), 0, L)).float(), -1)[0]
            conf, arg = pr.max(-1)
            return torch.stack([arg.float(), conf])
        return self._run(("ar_conf",), fn)

    def _g_seg(self, a, b, m):
        def fn():
            self.buf[:m].copy_(self._layers(self.buf[:m], a, b))
            return self.buf
        self.stats["seg_replays"] += 1
        self.stats["seg_rows"] += m
        return self._run(("seg", a, b, m), fn)

    def _g_head(self, k):
        return self._run(("head", k), lambda: self._head_out(k))

    def _g_fill(self, a, b):
        def fn():
            self._fill(self.hb, a, b)
            return self.hb
        return self._run(("fill", a, b), fn)

    TOPK = 8

    def _g_verify_topk(self, c, n):
        """Verification returning, per row, the argmax then the top-TOPK token ids (lossy rules)."""
        L = self.dec.spec.num_layers

        def fn():
            # Iterated argmax instead of torch.topk: topk on a 151k vocabulary crashed inside a
            # HIP graph (illegal address, ROCm 7.0); argmax is the kernel the lossless path uses.
            lg = self._final_logits(self._layers(self.buf[:n], c, L)).float()
            cols = []
            for _ in range(self.TOPK):
                i = lg.argmax(-1, keepdim=True)
                cols.append(i)
                lg = lg.scatter(-1, i, float("-inf"))
            return torch.cat([cols[0]] + cols, -1)
        return self._run(("verify_topk", c, n), fn)

    def _g_verify(self, c, n):
        L = self.dec.spec.num_layers
        return self._run(("verify", c, n), lambda: self._final_logits(
            self._layers(self.buf[:n], c, L)).argmax(-1))

    # ------------------------------------------------------------------ helpers
    def _set_pos(self, plist: list[int], slots: list[int] | None = None) -> None:
        # Pad rows (fixed width) reuse the last real position: always a valid index.
        # slots: KV indices of the rows (default = positions; differ only for star siblings).
        W = self.max_rows
        full = list(plist) + [plist[-1]] * (W - len(plist))
        sl = full if slots is None else list(slots) + [slots[-1]] * (W - len(slots))
        h = self._meta_h
        h[0:W] = torch.tensor(full)
        h[W:2 * W] = torch.tensor(sl)
        # a row whose slot differs from its position reads its own key from the slot
        h[2 * W:3 * W] = torch.tensor([p + 1 if s_ == p else p for p, s_ in zip(full, sl)])
        if slots is None:  # chains never read anc (cl = pos + 1): skip its transfer
            self._meta_d[:3 * W].copy_(h[:3 * W])
        else:
            a = h[3 * W:].view(W, W)
            a.zero_()
            a[:, 0] = h[W:2 * W]
            self._meta_d.copy_(h)

    def _set_rows(self, pos: list[int], slots: list[int], cls: list[int], ancs: list[list[int]]) -> None:
        """Tree rows: positions, KV slots, contiguous prefix lengths and path slots."""
        W = self.max_rows
        assert len(set(slots)) == len(slots), "two rows would write the same KV slot"
        pad = W - len(pos)
        h = self._meta_h
        h[0:W] = torch.tensor(pos + [pos[-1]] * pad)
        h[W:2 * W] = torch.tensor(slots + [slots[-1]] * pad)
        h[2 * W:3 * W] = torch.tensor(cls + [cls[-1]] * pad)
        a = h[3 * W:].view(W, W)
        a.zero_()
        for i, row in enumerate(ancs + [ancs[-1]] * pad):
            if row:
                a[i, :len(row)] = torch.tensor(row)
        self._meta_d.copy_(h)

    def _gather(self, rows: list[int]) -> None:
        m = len(rows)
        if rows == list(range(rows[0], rows[0] + m)):
            self.buf[:m].copy_(self.hrows[rows[0]:rows[0] + m])
        else:
            self.stats["noncontig"] = self.stats.get("noncontig", 0) + 1
            self.idx[:m].copy_(torch.tensor(rows, dtype=torch.int64))
            torch.index_select(self.hrows, 0, self.idx[:m], out=self.buf[:m])

    def _scatter(self, rows: list[int]) -> None:
        m = len(rows)
        if rows == list(range(rows[0], rows[0] + m)):
            self.hrows[rows[0]:rows[0] + m].copy_(self.buf[:m])
        else:
            self.hrows.index_copy_(0, self.idx[:m], self.buf[:m])

    def _embed_row(self, j: int, tok: int) -> None:
        self.ids.fill_(tok)
        self.hrows[j:j + 1].copy_(self.dec.embed(self.ids))

    def _prefill(self, prompt: list[int]) -> int:
        """Prompt processed by the engine itself, in causal chunks of max_rows rows
        through the fixed-width graphs (verify graphs from layer 0). No third-party
        attention kernel runs between graph replays, and the prompt KV is computed
        with exactly the same operators as generated tokens. prefill="full": wide GEMMs
        and standard causal attention instead (_prefill_full)."""
        self._prefix = None  # a greedy prefill overwrites the cached sampling prefix
        if self.prefill_mode == "full":
            return self._prefill_full(prompt)
        W = self.max_rows
        ids = torch.tensor(prompt, device=self.dec.device)
        last = None
        for s0 in range(0, len(prompt), W):
            m = min(W, len(prompt) - s0)
            self._set_pos(list(range(s0, s0 + m)))
            self.buf[:m].copy_(self.dec.embed(ids[s0:s0 + m]))
            last = self._g_verify(0, m)
        return int(last[m - 1])

    @torch.no_grad()
    def _prefill_full(self, prompt: list[int]) -> int:
        """Standard prefill: eager wide GEMMs over the whole prompt (chunks of prefill_chunk
        rows) and causal scaled-dot-product attention, writing the prompt KV in place.

        Decoding exactness is unchanged: AR and speculative decoding both start from this
        same KV and first token, and every decode-time row still goes through the
        fixed-width graphs. Only the prompt KV differs (bf16 rounding of other kernels)
        from the in-engine prefill, so outputs may differ from an engine-prefill run."""
        dec = self.dec
        hd = dec.spec.head_dim
        ids = torch.tensor(prompt, device=dec.device)
        n = len(prompt)
        h = None
        for s0 in range(0, n, self.prefill_chunk):
            m = min(self.prefill_chunk, n - s0)
            pos = torch.arange(s0, s0 + m, device=dec.device)
            cos, sin = dec.cos.index_select(0, pos)[:, None], dec.sin.index_select(0, pos)[:, None]
            h = dec.embed(ids[s0:s0 + m])
            if s0 == 0:
                mask, causal = None, True
            else:  # query i (position s0 + i) sees keys t <= s0 + i
                mask = (torch.arange(s0 + m, device=dec.device)[None] <= pos[:, None])[None, None]
                causal = False
            for li in range(dec.spec.num_layers):
                layer = dec.layers[li]
                attn = layer.self_attn
                x = layer.input_layernorm(h)
                q = attn.q_proj(x).view(m, -1, hd)
                k = attn.k_proj(x).view(m, -1, hd)
                v = attn.v_proj(x).view(m, -1, hd)
                if dec.spec.has_qk_norm:
                    q, k = attn.q_norm(q), attn.k_norm(k)
                q = q * cos + _rotate_half(q) * sin
                k = k * cos + _rotate_half(k) * sin
                dec.k_cache[li, :, s0:s0 + m].copy_(k.transpose(0, 1))
                dec.v_cache[li, :, s0:s0 + m].copy_(v.transpose(0, 1))
                o = F.scaled_dot_product_attention(
                    q.transpose(0, 1)[None], dec.k_cache[li, :, :s0 + m][None], dec.v_cache[li, :, :s0 + m][None],
                    attn_mask=mask, is_causal=causal, scale=dec.scaling, enable_gqa=True)[0]
                h = h + attn.o_proj(o.transpose(0, 1).reshape(m, -1))
                h = h + layer.mlp(layer.post_attention_layernorm(h))
        tok = int(dec.lm_head(dec.norm(h[-1:])).float().argmax(-1)[0])
        self._sync()  # eager kernels must finish before the next graph replay (ROCm ordering)
        return tok

    @staticmethod
    def _boundaries(policy, head_layers):
        # extra_bounds: segment boundaries without a head. Prompt lookup alone uses the h0
        # layer: copies go through seg(0, L_h0) + verify(L_h0) like every other policy.
        # Decode-time verification straight from layer 0 gave a wrong first replay after
        # the prefill on MI210 (ROCm 7.0, jobs 899287/899321: graph token 0, eager correct).
        return sorted({0} | {head_layers[k] for k in policy.head_order}
                      | set(getattr(policy, "extra_bounds", ())))

    def warm_blas(self, max_prefill: int = 0) -> None:
        """Kept for compatibility: with the in-engine prefill, every GEMM has the fixed
        width max_rows, so no larger BLAS call happens after capture."""
        self._warm = self.dec.max_len

    def precapture(self, policies: list, kv_fill: str = "lazy") -> None:
        """Capture every graph the given policies can use, at scratch positions."""
        self._precapture_body(policies, kv_fill)

    def _precapture_body(self, policies: list, kv_fill: str = "lazy") -> None:
        if not getattr(self, "_warm", 0):
            self.warm_blas()
        scratch = self.dec.max_len - self.max_rows - 2
        self._set_pos(list(range(scratch, scratch + self.max_rows)))
        self.ids.fill_(0)
        self._g_ar()
        for m in range(1, self.max_rows + 1):  # in-engine prefill chunks
            self._g_verify(0, m)
        for pol in policies:
            b = self._boundaries(pol, self.head_layers)
            c_min, c_max = (b[1] if len(b) > 1 else 0), b[-1]  # head-less (prompt lookup): [0]
            n_rows = self.max_rows if getattr(pol, "tree", None) else min(pol.max_draft + 1, self.max_rows)
            for k in pol.head_order:
                self._g_head(k)
            for u, v in zip(b, b[1:]):
                for m in range(1, (n_rows if kv_fill == "lazy" else 1) + 1):
                    self._g_seg(u, v, m)
            if kv_fill == "copy":
                for k in pol.head_order:
                    if self.head_layers[k] < c_max:
                        self._g_fill(self.head_layers[k], c_max)
            for n in range(1, n_rows + 1):
                self._g_verify(c_max if kv_fill == "lazy" else c_min, n)
                if getattr(pol, "lossy", False):
                    self._g_verify_topk(c_max if kv_fill == "lazy" else c_min, n)
        for pol in policies:  # tree drafts (star, or trie with star siblings): the top-4 head graph
            st_cfg = getattr(pol, "star", None) or (getattr(pol, "tree", None) or {}).get("star")
            if st_cfg is not None:
                self.hb.zero_()
                self._g_head4(st_cfg.get("head", 0))
        self.stats = {"seg_replays": 0, "seg_rows": 0, "noncontig": 0}
        self._sync()

    # ------------------------------------------------------------------ decoding
    @torch.no_grad()
    def _check_capacity(self, prompt, max_new) -> None:
        # drafts write KV up to max_rows slots beyond the last committed position
        if len(prompt) + max_new + self.max_rows > self.dec.max_len:
            raise ValueError(f"prompt {len(prompt)} + max_new {max_new} + {self.max_rows} draft rows "
                             f"exceeds the KV capacity {self.dec.max_len}")

    def _sample(self, probs: torch.Tensor) -> int:
        return int(torch.multinomial(probs, 1, generator=self._gen))

    def _first_token_sampled(self, prompt: list[int]) -> int:
        """Prefill prompt[:-1] (KV only), then the last prompt token through the AR graph: the
        first new token is SAMPLED from its logits (same arithmetic for AR and speculation)."""
        # prompt[:-1] KV is never rewritten by generation (writes start at len(prompt) - 1): reuse it
        # across samples of the same prompt (distribution tests draw thousands)
        if len(prompt) > 1 and not (getattr(self, "reuse_prefix", False)
                                    and getattr(self, "_prefix", None) == tuple(prompt[:-1])):
            self._prefill(prompt[:-1])
            self._prefix = tuple(prompt[:-1])
        self._set_pos([len(prompt) - 1])
        self.ids.fill_(prompt[-1])
        return self._sample(F.softmax(self._g_ar_logits() / self.temperature, -1))

    def _sample_verify(self, c_verify: int, drafts: list[int], st) -> list[int]:
        """Exact speculative sampling on a chain (Leviathan et al.): draft i is accepted with
        probability min(1, p_i(x_i) / q_i(x_i)); at the first rejection the token is resampled
        from norm(max(p_i - q_i, 0)); if all are accepted the bonus is sampled from p_n. q_i is
        the head's renormalised V_k distribution at T (saved at drafting) or a point mass (copy)."""
        n = len(drafts) + 1
        P = F.softmax(self._g_verify_logits(c_verify, n)[:n] / self.temperature, -1)
        st.cycles += 1
        st.drafted += len(drafts)
        st.empty_drafts += int(not drafts)
        committed = []
        if drafts:
            pv = P[torch.arange(len(drafts), device=P.device), torch.tensor(drafts, device=P.device)].tolist()
        for i, x in enumerate(drafts):
            k, qx = self._draft_q[i]
            if self._rng.random() < min(1.0, pv[i] / max(qx, 1e-30)):
                committed.append(x)
                continue
            res = P[i].clone()
            if k < 0:
                res[x] = 0.0
            else:
                h = self.heads[k]
                q = torch.zeros_like(res)
                q[h.vocab_ids] = self.qsave[i, :h.n_vocab]
                res = (res - q).clamp_min(0.0)
            tot = float(res.sum())
            committed.append(self._sample(res / tot if tot > 0 else P[i]))
            st.rejected_cycles += 1
            break
        else:
            committed.append(self._sample(P[len(drafts)]))
        st.accepted += min(len(committed) - 1, len(drafts))
        return committed

    @staticmethod
    def _emit(st: GenStats, on_commit, toks: list[int], srcs: list[str]) -> None:
        st.sources.extend(srcs)
        if on_commit is not None:
            on_commit(list(toks), list(srcs))

    def _commit(self, out, committed, srcs, index, stop, eos_ids, max_new, st, on_commit) -> bool:
        """Append a cycle's committed tokens to out (truncated at stop / EOS / max_new), feed the
        prompt-lookup index, record their sources. Returns True if the stop rule fired."""
        n0, stopped = len(out), False
        for t in committed:
            out.append(t)
            if index is not None:
                index.extend([t])
            if stop is not None and stop(t):
                stopped = True
                break
            if t in eos_ids or len(out) >= max_new:
                break
        self._emit(st, on_commit, out[n0:], srcs[:len(out) - n0])
        return stopped

    def generate_ar(self, prompt: list[int], max_new: int, eos_ids=frozenset(), stop=None,
                    temperature: float = 0.0, gen: torch.Generator | None = None,
                    on_commit=None) -> tuple[list[int], GenStats]:
        """stop: optional callable fed every new token, True = stop after it (same rule as
        generate_spec, so both stop at the same token). on_commit(tokens, sources): optional
        callable fed the tokens appended to the output (and their sources) after the prefill
        and after every step."""
        self._check_capacity(prompt, max_new)
        st = GenStats()
        self._sync()
        t0 = time.perf_counter()
        if temperature > 0:
            self.temperature, self._gen = temperature, gen
            out = [self._first_token_sampled(prompt)]
            self._sync()
            st.prefill_seconds = time.perf_counter() - t0
            self._emit(st, on_commit, out[:1], ["model"])
            while len(out) < max_new and out[-1] not in eos_ids and not (stop is not None and stop(out[-1])):
                self._set_pos([len(prompt) + len(out) - 1])
                self.ids.fill_(out[-1])
                out.append(self._sample(F.softmax(self._g_ar_logits() / temperature, -1)))
                self._emit(st, on_commit, out[-1:], ["model"])
                st.cycles += 1
            self._sync()
            st.seconds = time.perf_counter() - t0
            st.new_tokens = len(out)
            return out, st
        out = [self._prefill(prompt)]
        self._sync()
        st.prefill_seconds = time.perf_counter() - t0
        self._emit(st, on_commit, out[:1], ["model"])
        stopped = stop is not None and stop(out[0])
        steps, t_prev = [], time.perf_counter()
        while not stopped and len(out) < max_new and out[-1] not in eos_ids:
            self._set_pos([len(prompt) + len(out) - 1])
            self.ids.fill_(out[-1])
            out.append(int(self._g_ar()))       # .item() synchronises: per-step wall time
            self._emit(st, on_commit, out[-1:], ["model"])
            if stop is not None and stop(out[-1]):
                break
            t_now = time.perf_counter()
            steps.append(t_now - t_prev)
            t_prev = t_now
            st.cycles += 1
        if steps:  # robust unit for cost controllers (outliers: syncs, GC)
            st.ar_step_median = float(np.median(steps))
        self._sync()
        st.seconds = time.perf_counter() - t0
        st.new_tokens = len(out)
        return out, st

    def _draft_lazy(self, policy, first_tok, pos0, budget, eos_ids, st, proposal=None, pld_mode=None):
        """Regime A. Returns drafts; on exit all block rows sit in hrows at c_max.

        proposal / pld_mode (prompt lookup): "first" drafts the copied tokens without
        evaluating any head; "fallback" uses them only if no head exits at the first
        position (an empty cycle). Copied drafts enter at depth 0 and are caught up
        lazily with the other rows (head index -1)."""
        hl = self.head_layers
        b = self._boundaries(policy, hl)
        c_max = b[-1]
        nxt = {u: v for u, v in zip(b, b[1:])}
        depth: list[int] = []

        def advance(target: int) -> None:
            while True:
                lag = [j for j, d in enumerate(depth) if d < target]
                if not lag:
                    return
                u = min(depth[j] for j in lag)
                batch = [j for j in lag if depth[j] == u]
                self._set_pos([pos0 + j for j in batch])
                self._gather(batch)
                self._g_seg(u, nxt[u], len(batch))
                self._scatter(batch)
                for j in batch:
                    depth[j] = nxt[u]

        drafts: list[int] = []
        self._draft_heads, self._stop_info, self._draft_q = [], None, []
        tok = first_tok

        def copy_in() -> None:
            for t in proposal[: budget - len(drafts)]:
                drafts.append(t)
                self._draft_heads.append(-1)
                if self._sampling:
                    self._draft_q.append((-1, 1.0))  # copy: deterministic draft, q = delta
                st.exits["pld"] = st.exits.get("pld", 0) + 1
                self._embed_row(len(depth), t)
                depth.append(0)
                if t in eos_ids:
                    break

        while True:
            j = len(depth)
            self._embed_row(j, tok)
            depth.append(0)
            if len(drafts) >= budget or (drafts and drafts[-1] in eos_ids):
                break
            if j == 0 and proposal and pld_mode == "first":
                copy_in()
                break
            exited, row = None, {}
            for k in policy.head_order:
                advance(hl[k])
                self.hb.copy_(self.hrows[j:j + 1])
                if self._sampling:
                    self.gnoise.uniform_(1e-10, 1.0, generator=self._gen).log_().neg_().log_().neg_()
                    conf, tok_f, qx = self._g_head_sample(k).tolist()
                else:
                    conf, tok_f = self._g_head(k).tolist()
                row[k] = (conf, int(tok_f))
                st.head_evals += 1
                if policy.decide(k, conf, len(drafts)):
                    exited = k
                    if self._sampling:  # keep q'_k of this draft row for the residual
                        self.qsave[len(drafts), :self.heads[k].n_vocab].copy_(self.qout[:self.heads[k].n_vocab])
                        self._draft_q.append((k, qx))
                    break
            if exited is None:
                self._stop_info = row  # every head was evaluated at the stop position
                if j == 0 and proposal and pld_mode == "fallback":
                    self._stop_info = None
                    copy_in()
                break
            tok = int(tok_f)
            st.exits[exited] = st.exits.get(exited, 0) + 1
            drafts.append(tok)
            self._draft_heads.append(exited)
        advance(c_max)
        return drafts, c_max

    def _draft_copy(self, policy, first_tok, pos0, budget, eos_ids, st):
        """Regime B (CALM copy). Returns drafts; rows at c_min are left in hrows."""
        hl = self.head_layers
        b = self._boundaries(policy, hl)
        c_min, c_max = b[1], b[-1]
        nxt = {u: v for u, v in zip(b, b[1:])}
        drafts: list[int] = []
        self._draft_heads = []  # head of each draft (provenance)
        tok = first_tok
        while True:
            j = len(drafts)
            self._set_pos([pos0 + j])
            self._embed_row(j, tok)
            self.buf[:1].copy_(self.hrows[j:j + 1])
            self._g_seg(0, c_min, 1)
            self.hrows[j:j + 1].copy_(self.buf[:1])  # hidden at c_min (verified later)
            if len(drafts) >= budget or (drafts and drafts[-1] in eos_ids):
                break
            depth, exited = c_min, None
            for k in policy.head_order:
                while depth < hl[k]:
                    self._g_seg(depth, nxt[depth], 1)
                    depth = nxt[depth]
                self.hb.copy_(self.buf[:1])
                conf, tok_f = self._g_head(k).tolist()
                st.head_evals += 1
                if policy.decide(k, conf, len(drafts)):
                    exited = k
                    break
            if exited is None:
                break
            if depth < c_max:
                self._g_fill(depth, c_max)
            tok = int(tok_f)
            st.exits[exited] = st.exits.get(exited, 0) + 1
            drafts.append(tok)
            self._draft_heads.append(exited)
        return drafts, c_min

    def _star_cycle(self, star: dict, first_tok: int, pos0: int, budget: int, st) -> list[int]:
        """Depth-1 tree draft (star): root = last committed token; the shallow head (index
        star["head"]) proposes up to C children from its top-4 at the root; children are
        computed on their own KV slots pos0+1+i at position pos0+1, verified together with
        the root; the accepted child's KV is compacted to slot pos0+1. Exact (lossless).
        Admission: q_r = p_r (head probability of rank r, ⊥ excluded) >= tau[r], and the
        survival sum of the admitted q_r >= gamma. Returns the committed tokens."""
        k = star.get("head", 0)
        c = self.head_layers[k]
        self._embed_row(0, first_tok)
        self._set_pos([pos0])
        self._gather([0])
        self._g_seg(0, c, 1)
        self._scatter([0])
        self.hb.copy_(self.hrows[0:1])
        r = self._g_head4(k).tolist()
        st.head_evals += 1
        ids, ps = [int(x) for x in r[1:5]], r[5:9]
        tau = star.get("tau", [0.05, 0.02, 0.02, 0.02])
        kids = [t for t, p, th in zip(ids, ps, tau) if t >= 0 and p >= th][: min(star.get("C", 4), budget)]
        qs = [p for t, p, th in zip(ids, ps, tau) if t >= 0 and p >= th][: len(kids)]
        if kids and sum(qs) < star.get("gamma", 0.3):
            kids = []
        m = len(kids)
        if m:
            for i, t in enumerate(kids):
                self._embed_row(1 + i, t)
            self._set_pos([pos0 + 1] * m, [pos0 + 1 + i for i in range(m)])
            self._gather(list(range(1, 1 + m)))
            self._g_seg(0, c, m)
            self._scatter(list(range(1, 1 + m)))
        n = 1 + m
        self._set_pos([pos0] + [pos0 + 1] * m, [pos0] + [pos0 + 1 + i for i in range(m)])
        self.buf[:n].copy_(self.hrows[:n])
        target = self._g_verify(c, n).tolist()
        st.cycles += 1
        st.drafted += m
        st.empty_drafts += int(m == 0)
        committed = [target[0]]
        if m:
            st.exits["star"] = st.exits.get("star", 0) + m
            j = next((i for i, t in enumerate(kids) if t == target[0]), None)
            if j is None:
                st.rejected_cycles += 1
            else:
                st.accepted += 1
                committed.append(target[1 + j])
                if j > 0:  # compaction: the accepted child's KV (all layers) to slot pos0+1
                    self.stats["star_compactions"] = self.stats.get("star_compactions", 0) + 1
                    dk, dv = self.dec.k_cache, self.dec.v_cache
                    dk[:, :, pos0 + 1].copy_(dk[:, :, pos0 + 1 + j])
                    dv[:, :, pos0 + 1].copy_(dv[:, :, pos0 + 1 + j])
        return committed

    def _tree_cycle(self, policy, cfg: dict, root_tok: int, pos0: int, eos_ids, st, proposal,
                    lam: float = float("-inf"), match_n: int = 0, ext=None):
        """General tree draft (trie), one verification. Rows = nodes in creation order
        (row 0 = root at pos0); node i sits at position pos0 + depth, writes its KV at slot
        pos0 + i and attends to [0, pos0] plus the slots of its own path.
          1. prompt-lookup copy (proposal, <= cfg["m"]) inserted under the root first, so it
             rides in the root's first segment;
          2. head chain (lazy, policy.decide, <= cfg["head_budget"] drafts) that reuses a
             copy row when its token matches; a head is never evaluated on a row already
             computed beyond its layer;
          3. optional star siblings at the root from the top-4 of cfg["star"]["head"].
        Verification walks the trie from the root; the accepted path's KV is compacted to
        positions pos0+1.. Exact (lossless).

        Safety gate (chantier 3b §7.6, alpha guarantee on trees): every node v carries a
        predicted acceptance q_v (head: a_hat_k; copy: cfg["gate"]["q_copy"][n][d] for match
        length n and copy depth d; star: isotone map of the rank probability). After the
        rows are computed, the children of u are KEPT (drafted, verified) only if
        p(u) = min(1, sum_v q_v) >= lam; lam > 1 keeps none (pure probe). Returns
        (committed tokens, info) with info = path origins, failed child origin, stop (p, a)
        and the number of drafted nodes, for online.cycle_from_tree."""
        gate = cfg.get("gate") or {}
        qc = gate.get("q_copy")
        qs = gate.get("q_star")
        hl = self.head_layers
        W = self.max_rows
        b = self._boundaries(policy, hl)
        c_max = b[-1]
        nxt = dict(zip(b, b[1:]))
        tok, par, dep, ld, kids, origin, q = [], [], [], [], [], [], []
        shown = []  # provenance reported to the user (st.sources); origin feeds the statistics

        def add(t, parent, how, qv=0.0):
            if len(tok) >= W:
                return None
            i = len(tok)
            tok.append(t); par.append(parent); dep.append(0 if parent is None else dep[parent] + 1)
            ld.append(0); kids.append({}); origin.append(how); shown.append(how); q.append(float(qv))
            if parent is not None:
                kids[parent][t] = i
            self._embed_row(i, t)
            return i

        def path(i):
            out = []
            while par[i] is not None:
                out.append(pos0 + i)
                i = par[i]
            return out[::-1]

        def set_rows(rows):
            self._set_rows([pos0 + dep[i] for i in rows], [pos0 + i for i in rows],
                           [pos0 + 1] * len(rows), [path(i) for i in rows])

        def advance(target):
            while True:
                lag = [i for i in range(len(tok)) if ld[i] < target]
                if not lag:
                    return
                u = min(ld[i] for i in lag)
                batch = [i for i in lag if ld[i] == u]
                set_rows(batch)
                self._gather(batch)
                self._g_seg(u, nxt[u], len(batch))
                self._scatter(batch)
                for i in batch:
                    ld[i] = nxt[u]

        root = add(root_tok, None, "root")
        c = root
        for t in (ext or []):  # external draft model chain (P1: SD + PLD in one verification)
            n = kids[c].get(t)
            if n is None:
                n = add(t, c, "draft")
                if n is None:
                    break
                st.exits["draft"] = st.exits.get("draft", 0) + 1
            c = n
            if t in eos_ids:
                break
        c = root
        for d, t in enumerate((proposal or [])[: cfg.get("m", 15)], start=1):
            n = kids[c].get(t)
            if n is None:
                qv = 0.0
                if qc is not None:
                    row = qc.get(str(match_n)) or qc.get(str(max(int(k) for k in qc)))
                    qv = row[min(d, len(row)) - 1]
                n = add(t, c, "copy", qv)
                if n is None:
                    break
                st.pld_drafted += 1
            c = n
            if t in eos_ids:
                break
        star = cfg.get("star")
        if hasattr(policy, "start_block"):
            policy.start_block()
        c, drafted = root, 0
        while drafted < cfg.get("head_budget", 8) and len(tok) < W and (c == root or tok[c] not in eos_ids):
            exited = None
            for k in policy.head_order:
                if hl[k] < ld[c]:
                    continue  # row already beyond this head's layer (shared copy row)
                advance(hl[k])
                self.hb.copy_(self.hrows[c:c + 1])
                if star is not None and c == root and k == star.get("head", 0):
                    r = self._g_head4(k).tolist()
                    conf, t_k = r[0], int(r[1])
                    tau = star.get("tau", [0.05, 0.02, 0.02, 0.02])
                    adm = [(int(x), p, rk) for rk, (x, p, th) in enumerate(zip(r[1:5], r[5:9], tau))
                           if x >= 0 and p >= th]
                    if adm and sum(p for _, p, _ in adm) >= star.get("gamma", 0.3):
                        for x, p, rk in adm[: star.get("C", 4)]:
                            qv = float(np.interp(p, *qs[rk])) if qs is not None else 0.0
                            if x in kids[root]:
                                q[kids[root][x]] = max(q[kids[root][x]], qv)
                            elif add(x, root, "star", qv) is not None:
                                st.exits["star"] = st.exits.get("star", 0) + 1
                else:
                    conf, t_k = self._g_head(k).tolist()
                    t_k = int(t_k)
                st.head_evals += 1
                if conf > 0 and policy.decide(k, conf, drafted):  # conf == 0: ⊥ won
                    exited = k
                    break
            if exited is None:
                break
            if gate.get("q_head") is not None:  # head map refitted for the gate (pool A)
                q_head = float(np.interp(conf, *gate["q_head"]))
            else:
                q_head = float(policy._ahat(exited, conf)) if hasattr(policy, "_ahat") else float(conf)
            n = kids[c].get(t_k)
            if n is None:
                n = add(t_k, c, "head", q_head)
                if n is None:
                    break
                st.exits[exited] = st.exits.get(exited, 0) + 1
            else:
                q[n] = max(q[n], q_head)
                if origin[n] == "star":  # the star's rank-0 candidate is the chain's own token
                    shown[n] = "head"
            c = n
            drafted += 1
            if t_k in eos_ids:
                break
        advance(c_max)
        # safety gate: keep the children of u only if p(u) = min(1, sum_v q_v) >= lam. Children
        # are distinct tokens and at most one equals the greedy target: disjoint events, so the
        # sum is the calibrated probability that one is accepted (chantier 3b §7.6)
        gate_p = [min(1.0, sum(q[v] for v in kids[u].values())) for u in range(len(tok))]
        if gate.get("g") is not None:  # final isotone recalibration g(P) -> HIT (pool A)
            gate_p = [float(np.interp(x, *gate["g"])) if kids[u] else 0.0 for u, x in enumerate(gate_p)]
        keep, frontier = [root], [root]
        while frontier:
            u = frontier.pop(0)
            if kids[u] and gate_p[u] >= lam:
                ch = sorted(kids[u].values())
                keep += ch
                frontier += ch
        keep.sort()
        kept = set(keep)
        n_rows = len(keep)
        set_rows(keep)
        if keep == list(range(n_rows)):
            self.buf[:n_rows].copy_(self.hrows[:n_rows])
        else:
            self.buf[:n_rows].copy_(self.hrows.index_select(0, torch.tensor(keep, device=self.hrows.device)))
        tgt_rows = self._g_verify(c_max, n_rows).tolist()
        target = {u: tgt_rows[j] for j, u in enumerate(keep)}
        st.cycles += 1
        st.drafted += n_rows - 1
        st.empty_drafts += int(n_rows == 1)
        acc, node = [], root
        while True:
            nxt_node = kids[node].get(target[node])
            if nxt_node is None or nxt_node not in kept:
                break
            acc.append(nxt_node)
            node = nxt_node
        st.accepted += len(acc)
        st.pld_accepted += sum(1 for i in acc if origin[i] == "copy")
        kept_kids = [v for v in kids[node].values() if v in kept]
        st.rejected_cycles += int(bool(kept_kids))  # some draft below the last accepted node failed
        info = {"path": [origin[i] for i in acc], "sources": [shown[i] for i in acc], "n_nodes": n_rows - 1,
                "failed": origin[max(kept_kids, key=lambda v: q[v])] if kept_kids else None,
                "stop": None if kept_kids else (gate_p[node], float(target[node] in kids[node]))}
        src = [pos0 + i for i in acc]
        dst = [pos0 + 1 + d for d in range(len(acc))]
        if src != dst:  # compaction of the accepted path (all layers, K and V)
            self.stats["tree_compactions"] = self.stats.get("tree_compactions", 0) + 1
            si = torch.tensor(src, device=self.dec.k_cache.device)
            di = torch.tensor(dst, device=self.dec.k_cache.device)
            for cache in (self.dec.k_cache, self.dec.v_cache):
                cache.index_copy_(2, di, cache.index_select(2, si))
        return [tok[i] for i in acc] + [target[node]], info

    @torch.no_grad()
    def generate_spec(self, policy, prompt: list[int], max_new: int, eos_ids=frozenset(),
                      kv_fill: str = "lazy", controller=None, predictor=None,
                      ar_step_seconds: float | None = None, accept_rule=None, pld=None,
                      stop=None, star=None, drafter=None, temperature: float = 0.0,
                      gen: torch.Generator | None = None, on_commit=None) -> tuple[list[int], GenStats]:
        """controller/predictor: online calibration (chantier 3b). Before each cycle the
        controller's lambda is turned into confidence thresholds (exit at the first head
        with a_hat_k >= lambda); lambda > 1 means a plain AR step (or, if the controller
        probes, a cycle that evaluates every head without drafting). After verification
        the cycle feedback (labels on the verified prefix, stop-position head outputs) is
        sent back. Requires kv_fill="lazy".

        accept_rule: None (exact, lossless) or a LOSSY relaxation of greedy verification:
          ("topk", k)       accept a draft that is among the target's top-k tokens (r3)
          ("equiv", cls)    accept a draft in the same formatting class as the target
                            argmax, cls = list mapping token id -> class id (r5)
        An accepted draft is committed as is; rejections and bonus use the target argmax.

        pld: prompt lookup drafts, dict(mode="first"|"fallback", n_max=4, n_min=1, m=15); with
        a policy without heads, "fallback" is plain PLD. stop, on_commit: as in generate_ar.
        st.sources[i] is where output token i came from: "copy" (prompt lookup), "head" (head
        chain), "star" (top-k sibling of h0), "draft" (draft model) or "model" (prefill token,
        correction or bonus token of each cycle)."""
        from .calibration import online as onl
        from .pld import NgramIndex
        self._check_capacity(prompt, max_new)
        assert policy.max_draft + 1 <= self.max_rows
        assert controller is None or kv_fill == "lazy"
        st = GenStats()
        K = len(self.heads)
        gamma_only = bool(getattr(controller, "gamma_only", False))
        xi = float(getattr(controller, "xi", 0.0))

        def account(cost, emitted, drafting):
            g = cost - (1 + xi) * emitted
            st.cost_units += cost
            st.excess_end += g
            st.excess_max = max(st.excess_max, st.excess_end)
            if drafting:
                st.h_max = max(st.h_max, g)
        if controller is not None:
            st.lam_start = _lam(controller)
            st.gamma_start = float(getattr(controller, "gamma", float("nan")))
        draft = self._draft_lazy if kv_fill == "lazy" else self._draft_copy
        seg_rows0 = self.stats["seg_rows"]  # engine counters are cumulative: report this request only
        self._sync()
        t0 = time.perf_counter()
        self._sampling = temperature > 0
        if self._sampling:  # exact speculative sampling on chains (heads + copies)
            assert controller is None and accept_rule is None and star is None and drafter is None
            assert getattr(policy, "tree", None) is None and kv_fill == "lazy"
            import random
            self.temperature, self._gen = temperature, gen
            self._rng = random.Random(gen.initial_seed() if gen is not None else 0)
            out = [self._first_token_sampled(prompt)]
        else:
            out = [self._prefill(prompt)]
        self._sync()
        st.prefill_seconds = time.perf_counter() - t0
        self._emit(st, on_commit, out[:1], ["model"])
        assert pld is None or (controller is None and kv_fill == "lazy")
        index = None
        if pld is not None:
            index = NgramIndex(pld.get("n_max", 4), pld.get("n_min", 1))
            index.extend(prompt)
            index.extend(out)
        stopped = stop is not None and stop(out[0])
        assert star is None or (controller is None and pld is None and accept_rule is None and kv_fill == "lazy")
        if drafter is not None:  # optional prompt lookup FIRST (copy if a match exists, else the draft model)
            assert controller is None and kv_fill == "lazy" and (pld is None or pld.get("mode") == "first")
            drafter.start(prompt)
        tree = getattr(policy, "tree", None)
        if tree is not None:
            # controllers on a tree: a gamma-only cost safety (NoSlowdown, Prop. 4), or an ACI
            # on the safety gate lambda (virtual head, chantier 3b §7.6), possibly wrapped in it
            assert accept_rule is None and kv_fill == "lazy"
            if tree.get("copy") and index is None:
                index = NgramIndex(tree["copy"].get("n_max", 4), tree["copy"].get("n_min", 1))
                index.extend(prompt)
                index.extend(out)
        while tree is not None and not stopped and len(out) < max_new and out[-1] not in eos_ids:
            pos0 = len(prompt) + len(out) - 1
            lam, probe = float("-inf"), False
            if controller is not None:  # gamma floor / gate lambda; plain AR step when asked
                lamv, gamma, ar = controller.policy()
                if not gamma_only:
                    lam = float(np.atleast_1d(lamv)[0])
                if ar and controller.probe:  # counterfactual probe: compute the tree, draft nothing
                    probe, lam, ar = True, float("inf"), False
                if ar:
                    self._set_pos([pos0])
                    self.ids.fill_(out[-1])
                    out.append(int(self._g_ar()))
                    self._emit(st, on_commit, out[-1:], ["model"])
                    st.cycles += 1
                    st.ar_steps += 1
                    controller.update(onl.Cycle([], [], 0, 0, 1, 1.0, ar=True))
                    account(1.0, 1, False)
                    if index is not None:
                        index.extend([out[-1]])
                    if stop is not None and stop(out[-1]):
                        stopped = True
                    continue
                base = getattr(policy, "_gamma_base", None)
                if base is None:
                    base = policy._gamma_base = policy.cum_gamma
                policy.cum_gamma = ({k: max(v, float(gamma)) for k, v in base.items()} if isinstance(base, dict)
                                    else max(float(base), float(gamma)))
                t_cycle = time.perf_counter()
            prop = index.propose(tree["copy"].get("m", 15)) if tree.get("copy") else None
            ext = drafter.propose(prompt + out, min(max_new - len(out), self.max_rows - 1)) if drafter is not None else None
            committed, info = self._tree_cycle(policy, tree, out[-1], pos0, eos_ids, st, prop, lam=lam,
                                               match_n=getattr(index, "last_n", 0) if index is not None else 0,
                                               ext=ext)
            if drafter is not None:  # draft KV valid up to the common prefix with the accepted path
                k = 0
                while k < min(len(ext), len(committed) - 1) and ext[k] == committed[k]:
                    k += 1
                drafter.commit(k)
            if controller is not None:
                cost = ((time.perf_counter() - t_cycle) / ar_step_seconds) if ar_step_seconds else 1.0
                if gamma_only:
                    cyc = onl.Cycle([], [], len(committed) - 1, 0, len(committed), cost)
                else:
                    cyc = onl.cycle_from_tree(info["path"], info["failed"], emitted=len(committed), cost=cost,
                                              stop=info["stop"], n_nodes=info["n_nodes"])
                    cyc.ar = probe
                controller.update(cyc)
                st.lab_n += len(cyc.labels)
                st.lab_s += float(sum(cyc.labels))
                account(cost, len(committed), True)
            stopped = self._commit(out, committed, info["sources"] + ["model"], index, stop, eos_ids,
                                   max_new, st, on_commit) or stopped
        while star is not None and not stopped and len(out) < max_new and out[-1] not in eos_ids:
            pos0 = len(prompt) + len(out) - 1
            committed = self._star_cycle(star, out[-1], pos0, max_new - len(out), st)
            stopped = self._commit(out, committed, ["star"] * (len(committed) - 1) + ["model"], None, stop,
                                   eos_ids, max_new, st, on_commit) or stopped
        while not stopped and len(out) < max_new and out[-1] not in eos_ids:
            pos0 = len(prompt) + len(out) - 1
            budget = min(policy.max_draft, max_new - len(out))
            t_cycle = time.perf_counter()
            probe = False
            if controller is not None:
                taus, gamma, ar = controller.policy()
                if ar and not controller.probe:
                    self._set_pos([pos0])
                    self.ids.fill_(out[-1])
                    out.append(int(self._g_ar()))
                    self._emit(st, on_commit, out[-1:], ["model"])
                    st.cycles += 1
                    st.ar_steps += 1
                    controller.update(onl.Cycle([], [], 0, 0, 1, 1.0, ar=True))
                    account(1.0, 1, False)
                    if stop is not None and stop(out[-1]):  # same rule as the tree branch
                        stopped = True
                    continue
                probe = bool(ar)
                if not gamma_only:  # gamma_only: the policy keeps its heads and a_hat thresholds
                    tau_h = predictor.thresholds(taus)          # exit iff H = -conf <= tau
                    policy.thresholds = {k: (float("inf") if probe else float(-tau_h[k])) for k in range(K)}
                    policy.head_order = list(range(K))
                if hasattr(policy, "cum_gamma"):
                    base = getattr(policy, "_gamma_base", None)
                    if base is None:
                        base = policy._gamma_base = policy.cum_gamma
                    if isinstance(base, dict):  # per-head gamma_k: gamma_k <- max(gamma_k^pol, gamma_safety)
                        policy.cum_gamma = {k: max(v, float(gamma)) for k, v in base.items()}
                    else:
                        policy.cum_gamma = float(gamma)
            if hasattr(policy, "start_block"):
                policy.start_block()
            used_drafter = False
            if drafter is not None:
                prop = index.propose(pld.get("m", 15)) if index is not None else []
                if not prop:
                    prop, used_drafter = drafter.propose(prompt + out, budget), True
                drafts, c_verify = draft(policy, out[-1], pos0, budget, eos_ids, st, proposal=prop, pld_mode="fallback")
                if used_drafter:  # draft-model tokens went through the copy path: count them apart
                    nd = sum(1 for k in self._draft_heads if k < 0)
                    st.exits["pld"] = st.exits.get("pld", 0) - nd
                    st.exits["draft"] = st.exits.get("draft", 0) + nd
            elif index is not None:
                drafts, c_verify = draft(policy, out[-1], pos0, budget, eos_ids, st,
                                         proposal=index.propose(pld.get("m", 15)), pld_mode=pld.get("mode", "fallback"))
            else:
                drafts, c_verify = draft(policy, out[-1], pos0, budget, eos_ids, st)
            n = len(drafts) + 1
            self._set_pos(list(range(pos0, pos0 + n)))
            self.buf[:n].copy_(self.hrows[:n])
            if self._sampling:
                committed = self._sample_verify(c_verify, drafts, st)
            else:
                if accept_rule is None:
                    target = self._g_verify(c_verify, n).tolist()
                    tk = None
                else:
                    rows = self._g_verify_topk(c_verify, n).tolist()
                    target, tk = [r[0] for r in rows], [r[1:] for r in rows]
                st.cycles += 1
                st.drafted += len(drafts)
                st.empty_drafts += int(not drafts)
                committed = []
                for i, d in enumerate(drafts):
                    ok = target[i] == d
                    if not ok and accept_rule is not None:
                        if accept_rule[0] == "topk":
                            ok = d in tk[i][: accept_rule[1]]
                        elif accept_rule[0] == "equiv":
                            ok = accept_rule[1][d] == accept_rule[1][target[i]]
                    if not ok:
                        break
                    committed.append(d)
                st.accepted += len(committed)
                if index is not None and not (drafter is not None and used_drafter):
                    st.pld_drafted += sum(1 for k in self._draft_heads if k < 0)
                    st.pld_accepted += sum(1 for k in self._draft_heads[:len(committed)] if k < 0)
                st.rejected_cycles += int(len(committed) < len(drafts))
                if hasattr(policy, "update"):
                    policy.update(len(drafts), len(committed))
                if drafter is not None and used_drafter:
                    drafter.commit(len(committed))
                committed.append(target[len(committed)])  # correction or bonus token
            if controller is not None:
                n_acc = st_acc = len(committed) - 1
                per_token = [(k, i < n_acc) for i, k in enumerate(self._draft_heads)]
                stop_pos = None  # (a_hat, labels) at the stop position (not the `stop` rule)
                if (predictor is not None and n_acc == len(drafts) and self._stop_info is not None
                        and len(self._stop_info) == K):
                    confs = np.array([self._stop_info[k][0] for k in range(K)])
                    toks = [self._stop_info[k][1] for k in range(K)]
                    ahat = predictor.predict(-confs[None, :])[0]
                    stop_pos = (ahat, np.array([float(t == target[len(drafts)]) for t in toks]))
                cost = ((time.perf_counter() - t_cycle) / ar_step_seconds) if ar_step_seconds else 1.0
                cyc = onl.cycle_from_hook(per_token, emitted=len(committed), cost=cost, stop=stop_pos)
                cyc.ar = probe
                controller.update(cyc)
                st.lab_n += len(cyc.labels)
                st.lab_s += float(sum(cyc.labels))
                account(cost, len(committed), True)
            srcs = [("draft" if used_drafter else "copy") if k < 0 else "head"
                    for k in self._draft_heads[:len(committed) - 1]] + ["model"]
            stopped = self._commit(out, committed, srcs, index, stop, eos_ids, max_new, st, on_commit) or stopped
        self._sync()
        st.seconds = time.perf_counter() - t0
        st.new_tokens = len(out)
        st.layer_rows = self.stats["seg_rows"] - seg_rows0
        self._sampling = False
        if controller is not None:
            st.lam_end = _lam(controller)
            st.n_jumps = int(getattr(getattr(controller, "aci", controller), "n_jumps", -1))
            st.gamma_end = float(getattr(controller, "gamma", float("nan")))
        return out, st