File size: 159,706 Bytes
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
 
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0547f76
 
 
11330a4
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0547f76
 
 
 
 
 
 
 
 
 
 
11330a4
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
 
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
 
 
 
 
 
 
 
 
 
 
 
 
 
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
 
 
 
 
 
 
0547f76
 
 
 
 
 
11330a4
 
 
 
 
 
 
 
 
 
 
 
 
 
0547f76
 
 
 
 
 
 
 
 
 
11330a4
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
 
 
 
 
 
 
 
 
 
 
 
 
 
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
 
 
 
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
0547f76
 
 
 
 
11330a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
 
 
 
 
 
 
 
 
 
 
 
 
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
0547f76
11330a4
 
 
 
 
0547f76
 
 
 
 
 
 
 
 
 
11330a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0547f76
 
 
 
11330a4
 
 
 
 
 
 
0547f76
 
 
 
 
 
 
 
 
 
 
 
11330a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0547f76
11330a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0547f76
 
11330a4
 
 
 
 
 
 
 
 
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11330a4
 
0547f76
 
 
11330a4
0547f76
 
11330a4
 
 
 
 
0547f76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
#!/usr/bin/env node
// follower_sim.mjs — offline simulator for the frame-based score follower
// (spec: docs/frame-follower-spec.md §8). Node 22 ESM, no dependencies.
//
// WHY: the follower's numbers cannot be tuned on the live rig (one run per
// minute, no ground truth better than typed keys). The sim synthesises a
// deliberately mismatched piano from a trace's score, adds the accompaniment
// bleed on the true timeline exactly as the app reports it, runs the SAME
// FrameAnalyzer and ScoreFollower the browser runs, and scores commits against
// the true onsets. Every scenario in §8.4 has a pass/fail target; exit code 1
// on any miss.
//
//   node scripts/follower_sim.mjs --all
//   node scripts/follower_sim.mjs --scenario bleed --seeds 3 --wav /tmp/bleed.wav --out /tmp/bleed.json
//   node scripts/follower_sim.mjs --scenario bleed --timeline          # per-event: onset, commit, hold reasons
//   node scripts/follower_sim.mjs --scenario bleed --synth legacy      # the pre-2026-09-15 tone, for audit
//   node scripts/follower_sim.mjs --scenario phone-bleed              # the mobile case: speaker and mic inches apart
//   SIM_DEBUG_MIX=1 node scripts/follower_sim.mjs --scenario phone-bleed  # print the mix's measured SER and nonlinear residual
//
// THE TONE THIS SYNTHESISES was measured off the rig on 2026-09-15, not
// guessed: see the RIG block below and scenario table's re-baseline note. Until
// then it was 8 partials with h^-1.3 amplitudes — fundamental-dominant, nothing
// above 784 Hz at a G2 — which is to say it was a picture of the notes the
// engine's shipped 6-partial template already fits, so every attempt to model
// the real rig's upper tail looked like a regression here. `--synth legacy`
// restores that tone exactly (same random draws, same order) so every
// before/after number in the scenario table can be re-checked in one command.
//
// Debug environment (frame-level, for one scenario at a time):
//   SIM_DEBUG=t0,t1     print one line per frame between t0 and t1 seconds of audio
//   SIM_DEBUG_H=1       add the candidate/committed hypothesis internals (caps, gains, onset score, pitch margin)
//   SIM_DEBUG_ELL=1     add the candidate's and committed state's likelihoods and NNLS gains
//   SIM_DEBUG_X=1       dump the whitened observation and the best hypothesis' templates
//   SIM_DEBUG_RES=1     dump the committed fit, its residual and the observation's top rows
//   SIM_DEBUG_BEAM=1    list every live hypothesis (index, weight, evidence, birth, parent) per frame
//   SIM_VERBOSE=1       print false/missed lists and hold histograms for passing scenarios too
//
// KNOWN LIMIT, found while building `low-note-weak` and not fixed here. Through
// a laptop speaker a G2 arrives as its partials, and its strongest one is
// 196 Hz — which IS G3. So a plain G3 fits a fundamental-starved G2 template
// almost exactly, while a G2 does NOT fit a G3 template (294 Hz is left over).
// The confusion is one-way, and it is sharp: with a G2 written immediately
// before a G3, the G3's attack spawns a fresh "the G2 starts NOW" hypothesis
// that out-weighs the real one, the committed state's onset clock jumps a beat
// late, and the future guard then refuses the G3 for a whole IOI — measured
// recall 0.05 with the engine tracking the right rhythm five events behind.
// Twinkle never writes that interval (all five of its G2s are followed by B2 or
// C3), so no scenario here contains it; a piece that did would need the engine
// to price the missing 294 Hz, not just tolerate a missing fundamental.

import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, '..');
const STATIC = path.join(ROOT, 'static');

// Import the very modules the browser loads (query strings are part of the
// specifier in follower.js; Node resolves them as distinct file URLs).
const salience = await import(pathToFileURL(path.join(STATIC, 'salience.js')).href);
const followerMod = await import(pathToFileURL(path.join(STATIC, 'follower.js')).href);
const { FrameAnalyzer } = salience;
const { ScoreFollower, FOLLOWER_DEFAULTS, FOLLOWER_VERSION, normaliseEvents } = followerMod;

// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------

function parseArgs(argv) {
  const a = { opt: {}, seeds: 1, seed: 1, scenario: null, all: false, sr: 48000, json: false, quiet: false, dumpSal: false, trackerDebounce: false, reverb: false };
  for (let i = 0; i < argv.length; i++) {
    const k = argv[i];
    const next = () => argv[++i];
    switch (k) {
      case '--trace': a.trace = next(); break;
      case '--score': a.score = next(); break;
      case '--scenario': a.scenario = next(); break;
      case '--all': a.all = true; break;
      case '--seeds': a.seeds = Number(next()); break;
      case '--seed': a.seed = Number(next()); break;
      case '--bpm': a.bpm = Number(next()); break;
      case '--tempo-scale': a.tempoScale = Number(next()); break;
      case '--bleed': a.bleed = Number(next()); break;
      case '--snr': a.snr = Number(next()); break;
      case '--hpf': a.hpf = Number(next()); break;
      case '--jitter': a.jitter = Number(next()); break;
      case '--rubato': a.rubato = Number(next()); break;
      case '--bleed-lag': a.bleedLag = Number(next()); break;
      case '--bleed-clip': a.bleedClip = Number(next()); break;   // phone-speaker soft clip drive (0 = linear)
      case '--sr': a.sr = Number(next()); break;
      case '--synth': a.synth = next(); break;   // rig (measured, default) | legacy (pre-2026-09-15 8-partial h^-1.3)
      case '--opt': { const s = next(); const eq = s.indexOf('='); a.opt[s.slice(0, eq)] = parseOptValue(s.slice(eq + 1)); break; }
      case '--wav': a.wav = next(); break;
      case '--out': a.out = next(); break;
      case '--replay': a.replay = next(); break;
      case '--json': a.json = true; break;
      case '--dump-sal': a.dumpSal = true; break;
      case '--quiet': a.quiet = true; break;
      case '--reverb': a.reverb = true; break;
      case '--tracker-debounce': a.trackerDebounce = true; break;
      case '--timeline': a.timeline = true; break;
      case '--help': case '-h': usage(); process.exit(0);
      default: console.error('unknown argument ' + k); usage(); process.exit(2);
    }
  }
  if (a.scenario === 'all') { a.all = true; a.scenario = null; }
  return a;
}

function parseOptValue(s) {
  if (s === 'true') return true;
  if (s === 'false') return false;
  if (s.startsWith('[') || s.startsWith('{')) return JSON.parse(s);
  const n = Number(s);
  return Number.isFinite(n) ? n : s;
}

function usage() {
  console.log(`node scripts/follower_sim.mjs [--trace PATH] [--score twinkle|repeated] [--scenario NAME|all|--all] [--seeds N] [--seed N]
      [--bpm X] [--tempo-scale X] [--bleed G] [--snr DB] [--hpf HZ] [--jitter MS] [--rubato PCT] [--bleed-lag MS] [--bleed-clip G]
      [--sr 48000|44100] [--synth rig|legacy] [--opt key=value ...] [--wav OUT.wav] [--out TRACE.json] [--replay SESSION.json]
      [--json] [--dump-sal] [--quiet] [--reverb] [--tracker-debounce]`);
}

// ---------------------------------------------------------------------------
// Deterministic randomness
// ---------------------------------------------------------------------------

function makeRng(seed) {
  let s = (seed >>> 0) || 1;
  const rnd = () => {
    s = (s + 0x6D2B79F5) >>> 0;
    let t = s;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
  return {
    uniform: (lo = 0, hi = 1) => lo + (hi - lo) * rnd(),
    normal: (mu = 0, sd = 1) => {
      let u = 0; while (u === 0) u = rnd();
      return mu + sd * Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * rnd());
    },
    next: rnd,
  };
}

// ---------------------------------------------------------------------------
// Scores
// ---------------------------------------------------------------------------

// Twinkle as recorded in .accompy_traces (solo G2–A3 with a chord at the ends,
// accompaniment one octave up — the harmonic-overlap bleed the rig actually has).
const TWINKLE_RH = [[[48],0],[[55],1],[[52],2],[[55],3],[[53],4],[[57],5],[[48],6],[[55],7],[[43],8],[[47],9],[[48],10],[[52],11],[[43],12],[[47],13],[[48,52],14],[[52],16],[[55],17],[[50],18],[[55],19],[[48],20],[[55],21],[[47],22],[[55],23],[[52],24],[[55],25],[[50],26],[[55],27],[[48],28],[[55],29],[[47],30],[[43],31],[[48],32],[[55],33],[[52],34],[[55],35],[[53],36],[[57],37],[[48],38],[[55],39],[[43],40],[[47],41],[[48],42],[[52],43],[[43],44],[[47],45],[[48,52],46]];
const TWINKLE_LH = [[[60],0],[[60],1],[[67],2],[[67],3],[[69],4],[[69],5],[[67],6],[[65],8],[[65],9],[[64],10],[[64],11],[[62],12],[[62],13],[[60],14],[[67],16],[[67],17],[[65],18],[[65],19],[[64],20],[[64],21],[[62],22],[[67],24],[[67],25],[[65],26],[[65],27],[[64],28],[[64],29],[[62],30],[[60],32],[[60],33],[[67],34],[[67],35],[[69],36],[[69],37],[[67],38],[[65],40],[[65],41],[[64],42],[[64],43],[[62],44],[[62],45],[[60],46]];

// Repeats and unison-with-bleed that Twinkle lacks (§8.1).
const REPEATED_RH = [48, 48, 48, 48, 55, 55, 48, 48, 52, 52, 48, 48].map((p, i) => [[p], i, 1]);
const REPEATED_LH = [0, 2, 4, 6, 8, 10].map((b) => [[REPEATED_RH[b][0][0] + 12], b, 2]);

// The index-8/index-12 configuration of .accompy_traces/20260915-081347,
// stretched out. G2 (MIDI 43, 98.00 Hz) is the lowest note of the solo part,
// and every one of its audible partials belongs to something else in the piece:
// 2f0 = 196.0 Hz is G3 (MIDI 55, ITSELF a solo pitch), 3f0 = 294.0 Hz is D4
// (MIDI 62, +2 cents — the same FFT bin at 48 kHz/2048) and 4f0 = 392.0 Hz is
// G4 (MIDI 67, exact); D4 and G4 are the two commonest accompaniment pitches
// here. B2 (123.47 Hz) is the same trap one step up: 3f0 = 370.4 Hz is F#4.
// The accompaniment strikes G4 and D4 under the low notes, including two
// D4+G4 chords, so the bleed component and the solo template compete for the
// same rows — the configuration in which the real session died. Bars 1–2 are
// Twinkle's own opening, which is what the real engine got right (12 clean
// advances in 16.7 s) before it met index 8; from there on half the events are
// G2 or B2.
//
// One ordering is deliberately absent, and it is not a convenience: NO G2 is
// followed by a G3. With the fundamental gone a G2 IS a G3 plus a 294 Hz
// partial, so a G3 fits a starved-G2 template while a G2 does NOT fit a G3
// template (the 294 Hz is unexplained) — the confusion is one-way. Twinkle
// never writes that interval either (its five G2s are followed by B2 or C3
// every time), so a scenario that used it would be testing a figure the
// repertoire does not contain. See the note in the header of this file.
const LOWNOTE_RH = [
  48, 55, 52, 55, 53, 57, 48, 55,   // Twinkle bar 1: the beam locks on
  43, 47, 48, 52, 43, 47, 48, 52,   // the real trace's index 8–15
  43, 47, 43, 48, 52, 43, 47, 43,   // G2-dense, D4/G4 underneath
  48, 43, 47, 43,
].map((p, i) => [[p], i, 1]);
const LOWNOTE_LH = [[60], [67], [69], [67], [62], [67], [62], [67], [62, 67], [62], [67], [62], [67], [62, 67]].map((p, i) => [p, i * 2, 2]);

// The note density that broke the engine, taken from the score that broke it:
// .accompy_traces/20260916-113517_cello-concerto-no1-in-c-major-hobviib1-joseph-ha
// (Haydn cello concerto no. 1, 143 solo notes over 120 beats at 60 bpm, recall
// 7 %). `DENSE_PITCHES` and `DENSE_GAPS24` ARE that trace's meta.rh, first 200
// solo events, read out verbatim — not a fast pattern invented here. Two
// properties are taken from the real score together, because it is the pair
// that makes the passage hard and either one alone is easy:
//
//  1. THE SPACING. Gaps in this prefix: p10 0.125 b, p25 0.125 b, MEDIAN
//     0.250 b, p75 0.500 b; 66 of 199 (33 %) are under 0.25 beat. The whole
//     excerpt reads p10 0.125 / median 0.250 with 128 of 350 under 0.25 beat
//     (37 %), so the prefix carries the same distribution. At bps 1 that is
//     250 ms median and 125 ms in the fastest decile, against an engine whose
//     own end-to-end commit latency on the two reference Twinkles is 175-182 ms
//     median and 203-212 ms p90. A third of these notes are therefore OVER
//     before the evidence for them has been accumulated — by construction, not
//     by bad luck. (Both Twinkles' shortest IOI is 985 ms: nothing in the
//     existing suite is within 4x of this.)
//  2. THE PITCH AMBIGUITY. 16 distinct pitches over 200 events, MIDI 60-84,
//     with 79/74/76/72 carrying most of them. On the full excerpt one pitch
//     leaves a median of 37 candidate positions and it takes a 5-gram before
//     the median position is unique. A dense score written out of a scale
//     would be localisable from one frame and would test the commit rule
//     instead of the search, which is the thing under test here.
//
// Gaps longer than 1.5 beats are CLAMPED to 1.5. The real prefix contains a
// 16-beat and a 103-beat tutti; a long rest is the one thing that hands the
// engine a free re-lock, and this scenario is the runaway player. Clamping
// drops 121 beats of silence, so the piece runs 83 beats instead of 204.
const DENSE_PITCHES = [
  72, 74, 72, 71, 72, 74, 76, 77, 77, 76, 74, 76, 77, 76, 71, 72, 67, 64, 60, 72,
  71, 67, 74, 72, 72, 74, 72, 71, 72, 74, 76, 77, 77, 76, 74, 69, 81, 79, 77, 76,
  74, 72, 71, 83, 83, 81, 81, 81, 81, 79, 79, 79, 76, 78, 78, 79, 79, 79, 81, 83,
  84, 81, 83, 84, 83, 84, 83, 84, 83, 79, 79, 74, 76, 72, 71, 69, 67, 69, 69, 71,
  69, 71, 69, 71, 69, 71, 67, 79, 79, 84, 84, 79, 82, 82, 81, 80, 81, 79, 77, 76,
  74, 72, 74, 74, 76, 74, 76, 74, 76, 74, 76, 72, 79, 81, 79, 79, 79, 84, 84, 84,
  84, 84, 84, 84, 83, 81, 79, 77, 77, 76, 74, 72, 74, 76, 74, 76, 74, 76, 74, 76,
  72, 72, 72, 79, 81, 79, 78, 79, 81, 83, 84, 84, 83, 81, 83, 84, 83, 78, 79, 79,
  77, 76, 76, 78, 79, 78, 81, 81, 79, 79, 77, 76, 76, 78, 79, 78, 79, 81, 79, 79,
  81, 83, 79, 74, 71, 72, 74, 71, 67, 76, 78, 79, 76, 72, 72, 74, 76, 72, 69, 69,
];
// Twenty-fourths of a beat, so the score's own quantisation survives: 3 = 0.125 b
// (x56), 6 = 0.25 b (x55), 12 = 0.5 b (x33), 24 = 1 b (x17), plus the 4s and 9s
// of the triplet and dotted figures.
const DENSE_GAPS24 = [
  18, 3, 3, 9, 3, 9, 3, 12, 24, 12, 18, 3, 3, 12, 12, 12, 12, 24, 12, 12,
  24, 12, 12, 24, 18, 3, 3, 9, 3, 9, 3, 12, 24, 12, 6, 6, 12, 6, 6, 6,
  6, 24, 36, 12, 36, 12, 36, 12, 36, 12, 12, 12, 30, 6, 6, 6, 30, 6, 6, 6,
  30, 6, 6, 6, 24, 24, 24, 24, 6, 6, 6, 6, 6, 6, 6, 6, 18, 6, 3, 3,
  3, 3, 3, 3, 3, 3, 36, 36, 36, 6, 24, 6, 6, 6, 6, 6, 6, 6, 6, 6,
  6, 18, 6, 3, 3, 3, 3, 3, 3, 3, 3, 12, 24, 24, 12, 24, 30, 6, 6, 6,
  30, 6, 6, 6, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
  12, 12, 36, 18, 3, 3, 9, 3, 9, 3, 12, 24, 12, 18, 3, 3, 12, 12, 12, 24,
  12, 12, 16, 4, 4, 4, 4, 4, 12, 24, 12, 12, 16, 4, 4, 4, 4, 4, 6, 3,
  3, 6, 6, 6, 3, 3, 6, 6, 6, 3, 3, 6, 6, 6, 3, 3, 6, 6, 6,
];
const DENSE_TRANSPOSE = -12;   // see REGISTER below
const DENSE_RH = DENSE_PITCHES.map((p, i) => {
  let b = 0; for (let k = 0; k < i; k++) b += DENSE_GAPS24[k] / 24;
  return [[p + DENSE_TRANSPOSE], +b.toFixed(4)];
});
// REGISTER. The solo is transposed DOWN AN OCTAVE and the accompaniment line
// sits above it, which is the one place this scenario departs from the
// recording, so it is stated plainly rather than buried.
//
// The real excerpt writes the solo at MIDI 60-84 over an orchestral reduction
// whose commonest pitches are 72 (x538), 62 (x507), 67 (x477) and 60 (x472) —
// i.e. the accompaniment is mostly BELOW the solo. In the 33..96 bank that
// configuration inverts the partial counts: a MIDI 84 solo note shows TWO
// partials below the bank top (1046 and 2093 Hz, bankTopHz 2154) while a MIDI
// 60 accompaniment note shows eight and a MIDI 55 one shows ten, so the bleed
// component's comb covers every row the solo template owns and a great many
// more. Measured with the accompaniment there: the bleed explained a median
// 0.73 of the frame against the expected solo note's 0.11, `ev` never rose
// above 0 on the committed event, and the scenario read recall 0.10 with the
// accompaniment ON against 0.54 with it OFF. The wide search went with it:
// top-1 error median +4 events, only 6 % within +-1, 25 of 31 scans pointing
// AHEAD of the pianist — which the 1.5-nat margin bar correctly refused every
// single time (31 scans, 31 `ambiguous`), so nothing unsafe happened, but the
// scenario was measuring an inverted-register bleed and not the note density.
//
// That register is ALSO the one part of this bench that has never been
// measured. The RIG profile was taken on Twinkle, where the accompaniment is
// an octave ABOVE the solo, and both other built-in scores keep it there. So
// the choice is between reproducing an unvalidated corner of the synth and
// testing the thing the scenario is named for. It tests the density: the solo
// moves to MIDI 48-72 (Twinkle's own register, where a note has 8-21 visible
// partials) and the accompaniment to a line in 72-79 above it, which is the
// relationship BASE's `bleed` 1.0 was calibrated against. The RHYTHM — the
// only thing under test — is untouched.
//
// What that costs in fidelity is worth naming: with the accompaniment below,
// this scenario reproduces the real session almost exactly (recall 0.10 here
// against 0.07 on the rig). Above, it reads 0.45. The difference is the
// register, not the density, and it is the measurement that says the Haydn's
// remaining 90 % is a BLEED problem in the solo's own register — the same
// conclusion the mic-distance work reached from the other side. Set
// DENSE_TRANSPOSE to 0 and DENSE_LH_LINE back below the solo to re-run it.
const DENSE_LH_LINE = [72, 79, 76, 74, 72, 77, 79, 76, 74, 72, 76, 79, 77, 74, 72, 76];
const DENSE_LH = (() => {
  const out = [];
  for (let b = 0; b <= Math.floor(DENSE_RH[DENSE_RH.length - 1][1]); b++) out.push([[DENSE_LH_LINE[b % DENSE_LH_LINE.length]], b, 1]);
  return out;
})();

function loadScore(args) {
  if (args.score === 'repeated') return { name: 'repeated', rh: REPEATED_RH, lh: REPEATED_LH, bps: 1 };
  if (args.score === 'lownote') return { name: 'lownote', rh: LOWNOTE_RH, lh: LOWNOTE_LH, bps: 1 };
  if (args.score === 'dense') return { name: 'dense', rh: DENSE_RH, lh: DENSE_LH, bps: 1 };
  const tracePath = args.trace || path.join(ROOT, '.accompy_traces', 'latest.json');
  try {
    const tr = JSON.parse(fs.readFileSync(tracePath, 'utf8'));
    const m = tr.meta || {};
    if (Array.isArray(m.rh) && m.rh.length) return { name: m.score || path.basename(tracePath), rh: m.rh, lh: Array.isArray(m.lh) ? m.lh : [], bps: m.initialBps || 1 };
  } catch (e) { /* fall through to the built-in copy */ }
  return { name: 'twinkle (built-in)', rh: TWINKLE_RH, lh: TWINKLE_LH, bps: 1 };
}

// ---------------------------------------------------------------------------
// Synthesis (§8.2) — the measured rig, with `--synth legacy` as the fallback
// ---------------------------------------------------------------------------

const midiToFreq = (m) => 440 * 2 ** ((m - 69) / 12);

// ---------------------------------------------------------------------------
// THE MEASURED RIG (§8.2). One block: edit this and nothing else when the rig
// is re-measured.
// ---------------------------------------------------------------------------
// SOURCE: .accompy_traces/20260915-083040_twinkle-twinkle-little-star.json.
// Every verbose follow.frame's quantised `sal` was decoded with the engine's
// own dequantiseSal and re-whitened with the follower's own noise-floor
// parameters as recorded in that trace's follower.init (nfMargin 2, nfDown 0.3,
// nfUp 1.002, floorInitFrames 20, nfSmoothBins 4, compress 0.5); each typed
// input.key gave a (post − pre) mean-POWER spectrum — sustain +160..+340 ms,
// attack +40..+140 ms, pre-roll −350..−100 ms — which was deconvolved against
// the analyser's own toneResponse() columns by NNLS (one column per partial,
// plus free columns for any accompaniment pitch ringing in the span and for the
// sub-fundamental bins). 43 presses with no other key within ±400 ms: MIDI 43
// ×16 (12 with no accompaniment ringing at all), 55 ×9, 48 ×5, 52 ×5, 47 ×3,
// 50 ×2, 41/53/57 ×1. NNLS R² 0.983–0.9997. Profile script and the full JSON
// (per-pitch a(h) with p25/p75, the noise floor, the overlap tables):
//   scratchpad/partial_profile5.mjs → scratchpad/rig_partial_profile.json
//
// Everything below is in the RAW (post − pre) power domain, never the whitened
// one. Whitening divides by the min-statistics floor, which on this session is
// +1.88 dB at row 43 and −13.22 dB at row 89 — a ~15 dB tilt that belongs to
// the mic and the analyser, not to the note. Partials fitted to whitened
// numbers would apply that tilt twice; the sim's own pink noise reproduces it
// (measured floor −3.94 dB/octave in this constant-Q bank against pink's −3.0,
// i.e. right to within ~1 dB — the floor is NOT what the bench got wrong).
//
// WHAT THE OLD SYNTH GOT WRONG — three independent things, and it is worth
// naming them because five attempts at modelling the real rig's upper tail were
// reverted for "regressing" scenarios that could not contain the phenomenon:
//
//  1. TRUNCATION. `for (let h = 1; h <= 8; h++)` stopped every pitch at 8
//     partials, so a synthetic G2 had NOTHING above 784 Hz. The real G2 is a
//     near-flat-topped comb running to the top of the 33..96 bank, sitting at
//     −7 to −13 dB (re its strongest partial) all the way to h = 20 (1960 Hz,
//     row 95). h^-1.3 puts h8 at −11.7 dB and h9+ at −∞; the rig is 20–40 dB
//     louder than that over half the bank. The deep raw NULLS in between
//     (MIDI 43 h8 at −34.2 dB with h7 at −10.4 and h10 at −7.0) prove this is
//     LINE structure, not a broadband hiss a noise floor could stand in for.
//  2. NO PLAYBACK RESPONSE. The exponent was never the problem: alternating
//     least squares on L(pitch,h) = C_pitch − 20·alpha·log10(h) + R(f) over all
//     nine measured pitches gives alpha = 1.14 (rmse 5.12 dB) against 8.20 dB
//     for the same fit with no R, and the per-pitch exponents then agree with
//     each other (0.92–1.30). One source law plus ONE rig curve explains every
//     pitch; there is no pitch-specific tone. R rises ~22 dB from 110 Hz to
//     1.8 kHz — that rise is what creates the tail, and the old synth had no
//     counterpart to it at all.
//  3. THE WRONG LOW END. speakerHpf 180 / speakerOrder 4 takes 21.2 dB off a
//     98 Hz fundamental. Measured, the rig takes 12–14 dB off 98–139 Hz
//     relative to its 440–880 Hz plateau — roughly 1st-order with a corner near
//     200–250 Hz, and a TILT rather than a corner. The laptop mic shares a
//     chassis with the laptop speaker, so the fundamental arrives
//     structure-borne: 1.8 dB (row readout) / 5.2 dB (deconvolved) below the
//     strongest partial in a G2's sustain. What buries it in the ENGINE's
//     domain is the noise floor's tilt, not the speaker.
//
// Cross-check of the whole chain against numbers already recorded in
// FOLLOWER_DEFAULTS for the real G2 at 15.657 s — expected whitened peak row 83,
// row94 0.70, row95 0.66, row74 0.70, row81 0.53, rows40–47 0.09; the profile
// pipeline reproduces 83 / 0.682 / 0.608 / 0.613 / 0.510 / 0.094.
const RIG = {
  // H(m) = floor(bankTopHz / f0): every partial the 33..96 filterbank can see.
  // 2154 Hz = midiToFreq(96)·2^(1/24), the top row's upper edge. 24 partials at
  // MIDI 41, 21 at G2 (43), 16 at C3, 10 at G3, 8 at C4, 4 at C5.
  bankTopHz: 2154,
  // Source law a_src(h) = h^-alpha. Measured 1.14 over nine pitches; 1.10 is
  // the value the per-pitch medians centre on once R is removed (41:0.92,
  // 43:1.08, 47:1.06, 48:1.10, 50:1.22, 52:1.10, 53:1.58, 55:1.12, 57:1.30).
  alpha: 1.10,
  // Playback response R(f), 1/3-octave bands, dB, with the press count each
  // band was averaged over. +8.5 dB resonance at 349 Hz, −4.6 dB dip at 698 Hz,
  // +7..+9 dB above 1.1 kHz. Interpolated in log f, clamped to the end values.
  // CAVEAT, stated rather than buried: the f0 span of the measurement is only
  // 1.3 octaves, so the alpha/R split is under-determined — treat R as a
  // measured curve of this laptop, not as physics.
  responseDb: [
    [110, -13.56], [139, -12.17], [175, -6.23], [220, -1.63], [277, 3.94],
    [349, 8.52], [440, 0.11], [554, -0.34], [698, -4.56], [880, -2.48],
    [1109, 3.78], [1397, 7.06], [1760, 9.30], [2217, 8.26],
  ],
  // Every partial except the fundamental repeats press to press: the measured
  // p75−p25 per partial has a median of 1.9 dB at MIDI 43 (16 presses) and
  // 0.6–1.3 dB at 48/52/55, so each note draws an independent ±2 dB ripple on
  // h ≥ 2 (uniform(−2,2) has an inter-quartile range of 2 dB).
  partialRippleDb: 2,
  // ...the fundamental alone is stochastic, and only at the bottom of the
  // range. Measured p75−p25 of a(1): MIDI 43 (98 Hz, 16 presses) 17.0 dB,
  // 52 (165 Hz, 5) 25.4 dB, 48 (131 Hz, 5) 7.6 dB, 47 (123 Hz, 3) 6.3 dB,
  // against 0.0 dB at MIDI 55 (196 Hz) over NINE presses, where a(1) is the
  // strongest partial every single time. So below `fundamentalSpreadBelowHz`
  // the fundamental is drawn with MIDI 43's own quartiles (median −5.2 dB re
  // the strongest partial, p25 −18.8, p75 −1.8 → σ = 20.2 dB below the median
  // and 5.0 dB above it) and above it the law's own a(1) stands. "The note
  // that loses its fundamental" is a per-PRESS event, not a property of the
  // pitch, and no scenario produced one before this.
  // These are MIDI 43's own quartiles, which is what the profile recommends;
  // pooling all 31 presses below 180 Hz (43, 47, 48, 50, 52) weighted by press
  // count gives median −8.9 / p25 −20.9 / p75 −6.1 instead, so the choice moves
  // the fundamental by under 4 dB and is not load-bearing. Below p25 the normal
  // tail is an extrapolation — `min` bounds it at −45 dB, and MIDI 52's
  // measured p25 of −39.6 dB says collapses that deep do happen.
  fundamentalDb: { median: -5.2, sdLow: 20.2, sdHigh: 5.0, max: 2, min: -45 },
  fundamentalSpreadBelowHz: 180,
  // The profile above is a SUSTAIN profile, measured +160..+340 ms after the
  // input.key EVENT — and the rig's speaker→mic loop is ~110 ms (the app's own
  // measured median on this session, see low-note-lag-over below), so in NOTE
  // time that window is +50..+230 ms and its centre is 140 ms. Partial
  // amplitudes are referred to t = profileSec, so a rendered note matches the
  // measured a(h) in the window a(h) was measured in rather than at t = 0.
  // This also reproduces, for free, the measured "partials above h = 15 are
  // 5–15 dB louder in the attack than in the sustain" (MIDI 43 h20: rawAttDb
  // −6.45 against rawSusDb −17.22).
  profileSec: 0.14,
  // The player types on the LAPTOP keyboard, a few centimetres from the mic:
  // 28 % of the power a G2 press adds in the attack window sits BELOW the
  // fundamental, falling to 7 % by the sustain. The old hammer burst was
  // band-passed 1–5 kHz and had no low thump at all.
  thumpShareAttack: 0.28,
  thumpHz: 100,
  thumpTauSec: 0.035,
  thumpWindowSec: 0.14,
  // Inharmonicity B in fh = h·f0·√(1 + B·h²). The old 4e-4 was a piano STRING
  // and it was harmless while h stopped at 8 (+1.3 % at h8); at h = 20 it is
  // +6.5 %, more than a semitone, so every partial above ~h12 landed in the
  // wrong filterbank row. The rig's solo voice is the app's own digital synth
  // through a speaker: measured, MIDI 43's partials sit on their harmonic rows
  // out to h = 21 (h10 → row 83, h14 → row 89, h20 → row 95). 2e-5 keeps a
  // touch of stretch (+7 cents at h20, +10 at h24) without moving rows.
  inharmonicity: 2e-5,
};

// The pre-2026-09-15 synth, kept so every number in this file stays auditable:
// `--synth legacy` (or `synth: 'legacy'` in a scenario mut) restores it exactly
// — 8 partials, h^-1.3, no playback response, no fundamental spread, no thump.
// It is fundamental-dominant by construction (h8 at −11.7 dB, nothing above),
// which is to say it is a picture of the notes the engine's shipped 6-partial
// template already fits well. Do not baseline against it.
const LEGACY_SYNTH = { partials: 8, alpha: 1.3, inharmonicity: 4e-4 };

// R(f) in dB, log-f interpolation between the measured 1/3-octave bands.
function rigResponseDb(f) {
  const B = RIG.responseDb;
  if (!(f > B[0][0])) return B[0][1];
  if (f >= B[B.length - 1][0]) return B[B.length - 1][1];
  for (let i = 1; i < B.length; i++) {
    if (f <= B[i][0]) {
      const k = Math.log(f / B[i - 1][0]) / Math.log(B[i][0] / B[i - 1][0]);
      return B[i - 1][1] + k * (B[i][1] - B[i - 1][1]);
    }
  }
  return B[B.length - 1][1];
}

// Two-sided normal: the fundamental's press-to-press spread is strongly skewed
// (p25 −18.8, median −5.2, p75 −1.8), so one σ per side, both from the quartiles.
function splitNormalDb(rng, { median, sdLow, sdHigh, max, min }) {
  const z = rng.normal(0, 1);
  return Math.min(max, Math.max(min, median + z * (z < 0 ? sdLow : sdHigh)));
}

// Piano-like additive tone, deliberately NOT the engine's template. Under the
// default `rig` model (see RIG above) it is the laptop speaker + laptop mic
// measured on 20260915-083040: H(m) = floor(2154/f0) partials, a(h) =
// h^-1.10 · R(h·f0) referred to the sustain window, ±3 dB of per-press ripple
// on h ≥ 2, a fundamental drawn with its own 17 dB spread, inharmonicity,
// per-partial decays with random spread, a hammer burst, a keyboard thump
// (solo only — the accompaniment is not typed) and a release tail. Amplitudes
// are normalised so `gain` still means "the strongest partial's amplitude",
// exactly as it did when the strongest partial was always h = 1.
// `legacy: true` restores the old 8-partial h^-1.3 tone. Renders into `mix` at
// `t0` seconds.
function renderNote(mix, sr, rng, { midi, t0, noteOffSec, gain, detuneCents = 0, legacy = false, thumpShare = 0 }) {
  const f0 = midiToFreq(midi) * 2 ** (detuneCents / 1200);
  const start = Math.max(0, Math.round(t0 * sr));
  const relSec = 0.15;
  const endSec = noteOffSec + relSec + 0.05;
  const nSamp = Math.min(mix.length - start, Math.ceil(endSec * sr));
  if (nSamp <= 0) return;
  const hMax = legacy ? LEGACY_SYNTH.partials : Math.max(1, Math.floor(RIG.bankTopHz / f0));
  const alpha = legacy ? LEGACY_SYNTH.alpha : RIG.alpha;
  const B = legacy ? LEGACY_SYNTH.inharmonicity : RIG.inharmonicity;
  const parts = [];
  for (let h = 1; h <= hMax; h++) {
    const fh = h * f0 * Math.sqrt(1 + B * h * h);
    if (fh >= sr / 2) break;
    const tau = 0.9 * (200 / fh) ** 0.35 * rng.uniform(0.8, 1.2);
    // `sus` is the decay already suffered by t = profileSec: dividing it out
    // makes the SUSTAIN spectrum equal the measured a(h) instead of the t = 0
    // one, which also reproduces the measured "h ≥ 15 is 5–15 dB louder in the
    // attack than in the sustain" for free.
    const sus = legacy ? 1 : Math.exp(-RIG.profileSec / tau);
    let amp = h ** -alpha;
    if (!legacy) amp *= 10 ** ((rigResponseDb(fh) + (h === 1 ? 0 : rng.uniform(-RIG.partialRippleDb, RIG.partialRippleDb))) / 20) / sus;
    parts.push({ amp, sus, w: 2 * Math.PI * fh / sr, tau, ph: rng.uniform(0, 2 * Math.PI) });
  }
  if (!parts.length) return;
  if (!legacy) {
    // The fundamental is drawn against the strongest of the OTHER partials,
    // which is where it was measured from; for a G2 that strongest partial is
    // h3 (294 Hz, on the rig's 349 Hz resonance), never h1.
    let peakSus = 0;
    for (let i = 1; i < parts.length; i++) peakSus = Math.max(peakSus, parts[i].amp * parts[i].sus);
    if (peakSus > 0 && f0 < RIG.fundamentalSpreadBelowHz) parts[0].amp = peakSus * 10 ** (splitNormalDb(rng, RIG.fundamentalDb) / 20) / parts[0].sus;
    // `gain` keeps meaning "amplitude of the strongest partial in the sustain".
    let peak = 0;
    for (const p of parts) peak = Math.max(peak, p.amp * p.sus);
    if (peak > 0) for (const p of parts) p.amp /= peak;
  }
  const attackN = Math.max(1, Math.round(0.004 * sr));
  const offN = Math.round(noteOffSec * sr);
  const relN = Math.round(relSec * sr);
  for (let i = 0; i < nSamp; i++) {
    const t = i / sr;
    let env = i < attackN ? i / attackN : 1;
    if (i >= offN) { const k = (i - offN) / relN; if (k >= 1) break; env *= 1 - k; }
    let v = 0;
    for (const p of parts) v += p.amp * Math.exp(-t / p.tau) * Math.sin(p.w * i + p.ph);
    mix[start + i] += gain * env * v;
  }
  // Hammer: 15 ms of band-passed (1–5 kHz) noise at −20 dB relative to the tone.
  const hN = Math.round(0.015 * sr);
  const bp = biquadBandpass(sr, 1000, 5000);
  for (let i = 0; i < hN && start + i < mix.length; i++) {
    const n = bp.process(rng.normal(0, 1)) * 0.3;
    mix[start + i] += gain * 0.1 * n * (1 - i / hN);
  }
  // Keyboard thump — the one part of a press that is not a note. Measured: 28 %
  // of the power a G2 press adds over the attack window sits below f0. Scaled
  // against this note's own tonal power over the same window, so the share is
  // what was measured rather than an absolute level.
  if (thumpShare > 0 && thumpShare < 1) {
    const tN = Math.min(mix.length - start, Math.round(RIG.thumpWindowSec * sr));
    if (tN > 0) {
      let msTone = 0;
      for (const p of parts) {   // mean square of Σ amp·e^{−t/τ}·sin(...) over the window
        const r = Math.exp(-2 / (p.tau * sr));
        msTone += 0.5 * p.amp * p.amp * (r === 1 ? tN : (1 - r ** tN) / (1 - r)) / tN;
      }
      const lp1 = biquadLowpass(sr, RIG.thumpHz), lp2 = biquadLowpass(sr, RIG.thumpHz);
      const buf = new Float64Array(tN);
      let msBuf = 0;
      for (let i = 0; i < tN; i++) { buf[i] = lp2.process(lp1.process(rng.normal(0, 1))) * Math.exp(-(i / sr) / RIG.thumpTauSec); msBuf += buf[i] * buf[i]; }
      msBuf /= tN;
      if (msBuf > 0 && msTone > 0) {
        const g = gain * Math.sqrt(thumpShare / (1 - thumpShare) * msTone / msBuf);
        for (let i = 0; i < tN; i++) mix[start + i] += g * buf[i];
      }
    }
  }
}

// Second-order sections. Butterworth high-pass (Q = 1/√2) for the speaker/mic
// model; a wide band-pass for the hammer burst.
function biquad(b0, b1, b2, a1, a2) {
  let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
  return { process(x) { const y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2; x2 = x1; x1 = x; y2 = y1; y1 = y; return y; } };
}
function biquadHighpass(sr, fc, Q = Math.SQRT1_2) {
  const w = 2 * Math.PI * fc / sr, c = Math.cos(w), s = Math.sin(w), al = s / (2 * Q);
  const a0 = 1 + al;
  return biquad((1 + c) / 2 / a0, -(1 + c) / a0, (1 + c) / 2 / a0, -2 * c / a0, (1 - al) / a0);
}
// A laptop speaker, not a microphone: almost nothing below ~150–200 Hz reaches
// the room. Two cascaded Butterworth sections roll off as (f/fc)^4, so at
// fc = 180 Hz a G2 loses 21.2 dB of its 98 Hz fundamental and 0.3 dB of its
// 196 Hz second partial — the physics behind the index-12 stall in
// 20260915-081347, where the lowest solo note reached the mic as its harmonics
// and nothing else. `hpf` (the mic's own high-pass) stays separate: this one
// sits in the PLAYBACK path, so it shapes the solo and the accompaniment
// together and leaves the room noise alone.
function speakerChain(sr, fc, order) {
  const secs = [];
  for (let i = 0; i < Math.max(1, Math.round(order / 2)); i++) secs.push(biquadHighpass(sr, fc));
  return { process(x) { for (const s of secs) x = s.process(x); return x; } };
}
// Two of these in series make the laptop-chassis keyboard thump: a soft
// low-pass at 100 Hz, i.e. below every solo fundamental in the repertoire.
function biquadLowpass(sr, fc, Q = Math.SQRT1_2) {
  const w = 2 * Math.PI * fc / sr, c = Math.cos(w), s = Math.sin(w), al = s / (2 * Q);
  const a0 = 1 + al;
  return biquad((1 - c) / 2 / a0, (1 - c) / a0, (1 - c) / 2 / a0, -2 * c / a0, (1 - al) / a0);
}
function biquadBandpass(sr, fLo, fHi) {
  const fc = Math.sqrt(fLo * fHi), Q = fc / (fHi - fLo);
  const w = 2 * Math.PI * fc / sr, c = Math.cos(w), s = Math.sin(w), al = s / (2 * Q);
  const a0 = 1 + al;
  return biquad(al / a0, 0, -al / a0, -2 * c / a0, (1 - al) / a0);
}

// Pink noise, Voss–McCartney with 16 rows.
function pinkNoise(n, rng) {
  const rows = 16, vals = new Float64Array(rows);
  let sum = 0;
  for (let r = 0; r < rows; r++) { vals[r] = rng.normal(); sum += vals[r]; }
  const out = new Float32Array(n);
  for (let i = 0; i < n; i++) {
    // Row k updates every 2^k samples (trailing-zero count of the counter).
    let k = 0, c = i + 1;
    while ((c & 1) === 0 && k < rows - 1) { c >>= 1; k++; }
    sum -= vals[k]; vals[k] = rng.normal(); sum += vals[k];
    out[i] = (sum + rng.normal()) / Math.sqrt(rows + 1);
  }
  return out;
}

function rmsOf(buf) { let s = 0; for (let i = 0; i < buf.length; i++) s += buf[i] * buf[i]; return Math.sqrt(s / buf.length); }

// Build the performance: true onset times, which events are played/wrong/
// skipped, the accompaniment fire list, and the audio mix.
function synthesise(score, cfg, rng) {
  const sr = cfg.sr;
  const bps = cfg.bps * cfg.tempoScale;
  const rubato = cfg.rubato / 100;
  // Rubato is a ±rubato TEMPO modulation over a 16-beat cycle, integrated so
  // time stays monotonic: dT/dbeat = (1 − rubato·sin(2π·beat/16)) / bps.
  const A = rubato * 16 / (2 * Math.PI);
  const timeOfBeat = (beat) => (beat - A * (1 - Math.cos(2 * Math.PI * beat / 16))) / bps;
  const tempoAtBeat = (beat) => bps / (1 - rubato * Math.sin(2 * Math.PI * beat / 16));
  const solo = normaliseEvents(score.rh);
  const acc = normaliseEvents(score.lh);
  // Which tone model renders every note below (see RIG / LEGACY_SYNTH above).
  const legacy = cfg.synth === 'legacy';
  const thumpShare = legacy ? 0 : RIG.thumpShareAttack;
  const preRoll = 1.0;   // the mic is open before Play: the floors must learn on noise alone
  // Session started at a later event (startAt): the prefix is never played
  // and the accompaniment starts from that beat, as in the app. The engine is
  // seeked there startLead × IOI before the first note (see runEngine).
  const startAt = Math.min(Math.max(0, cfg.startAt || 0), solo.length - 1);
  const startBeat = startAt > 0 ? solo[startAt].beat : -Infinity;
  // A run of notes that are in the piece nowhere: cfg.wrongInsert = [index, n]
  // puts n intruders in front of event `index` and pushes the rest of the piece
  // — the solo AND the ideal accompanist with it — n beats later. The pitches
  // miss every pitch in the score and every pitch a partial of one would land
  // on, so the WRONG absorber is the only thing that can explain those frames
  // and takes the mass legitimately: the state the real session entered at
  // 17.5 s and never left.
  //
  // They are INSERTED, not substituted, so that when the pianist recovers he is
  // playing the very event the engine is already waiting for. Substituting
  // score events would also demand a four-event skip on the way back, which is
  // what `skipped-note` measures; this scenario is about whether the beam can
  // come back at all once the absorber owns the posterior.
  const inPiece = new Set();
  for (const e of solo) for (const p of e.pitches) for (const d of [0, 12, 19, 24, 28]) inPiece.add(p + d);
  for (const e of acc) for (const p of e.pitches) for (const d of [0, 12, 19]) inPiece.add(p + d);
  // The nearest pitch to `p` that the piece does not contain — and that the
  // engine can actually TELL is not in the piece. Below pitchMinMidi (54) an
  // off-score pitch is not off-score to the follower: at 48 kHz/2048 rows 40–44
  // are all bin 4, so an A2 played where a G2 is written is the same
  // observation and an intruder there would be asking the engine to hear a
  // difference that is not in the signal (it cost 18 early advances when this
  // scenario first put its intruders at 42/45/46).
  const offScore = (p) => {
    const q = Math.min(84, Math.max(54, Math.round(p)));
    for (let d = 0; d <= 24; d++) for (const s of d ? [1, -1] : [1]) { const m = q + d * s; if (m >= 54 && m <= 84 && !inPiece.has(m)) return m; }
    return q;
  };
  const ins = cfg.wrongInsert || null;
  const insBeat = ins ? solo[Math.min(ins[0], solo.length - 1)].beat : Infinity;
  const insDelay = ins ? ins[1] / bps : 0;
  const shift = (beat) => (beat >= insBeat ? insDelay : 0);
  const events = solo.map((e, k) => {
    const ioi = k + 1 < solo.length ? timeOfBeat(solo[k + 1].beat) - timeOfBeat(e.beat) : (e.durBeats ?? 1) / bps;
    const T = preRoll + timeOfBeat(e.beat) + shift(e.beat) + rng.normal(0, cfg.jitter / 1000);
    let kind = 'played', pitches = e.pitches.slice();
    if (cfg.wrongEvery && (k + 1) % cfg.wrongEvery === 0 && k > 0) {
      kind = 'wrong';
      // A fumble must not land on a pitch the score writes NEXT DOOR. Twinkle
      // writes B2 at index 9 and C3 at index 10, so the old `p + 1` played
      // index 10's own note one beat early and then scored the engine's
      // (correct) recognition of it as an early advance. Under the
      // fundamental-dominant synth that never bit — a B2 and a C3 arrived as
      // nearly the same observation. Under the measured 21-partial comb the
      // engine can tell them apart, and the un-guarded mutation cost 1 early
      // advance and 12 false ones (recall 1.00 → 0.22) the moment the synth
      // became real. `wrongInsert`'s offScore() has carried exactly this guard
      // since it was written, for exactly this reason; this is the same rule.
      const near = new Set();
      for (let d = -1; d <= 2; d++) { const e2 = solo[k + d]; if (e2) for (const p of e2.pitches) near.add(p); }
      const order = rng.next() < 0.5 ? [1, 2, -1, -2, 3, -3] : [2, 1, -2, -1, 3, -3];
      pitches = pitches.map((p) => { for (const d of order) if (!near.has(p + d)) return p + d; return p + order[0]; });
    }
    if (cfg.skipEvery && (k + 1) % cfg.skipEvery === 0 && k > 0) { kind = 'skipped'; }
    if (cfg.noPlay || k < startAt) kind = 'silent';
    const restrike = cfg.doubleStrike && kind === 'played' && rng.next() < 0.25;
    const durSec = Math.min(0.95 * ioi, (e.durBeats ?? ioi * bps) / bps);
    return { k, beat: e.beat, T, ioi, kind, pitches, expected: e.pitches, durSec, restrike, tempo: tempoAtBeat(e.beat) };
  });
  const lastEnd = Math.max(...events.map((e) => e.T + e.durSec)) + 1.5;
  const n = Math.ceil(lastEnd * sr);
  const soloBuf = new Float32Array(n);
  for (const e of events) {
    if (e.kind === 'skipped' || e.kind === 'silent') continue;
    const vel = 0.6 * rng.uniform(0.7, 1.4);
    for (const p of e.pitches) renderNote(soloBuf, sr, rng, { midi: p, t0: e.T, noteOffSec: e.durSec, gain: vel, detuneCents: rng.uniform(-3, 3), legacy, thumpShare });
    if (e.restrike) for (const p of e.pitches) renderNote(soloBuf, sr, rng, { midi: p, t0: e.T + 0.09, noteOffSec: Math.max(0.05, e.durSec - 0.09), gain: vel * 0.8, detuneCents: rng.uniform(-3, 3), legacy, thumpShare });
  }
  // The intruders, in the n beats the rest of the piece was pushed back by.
  const intruders = [];
  if (ins) {
    const base = solo[Math.min(ins[0], solo.length - 1)];
    for (let j = 0; j < ins[1]; j++) {
      const T = preRoll + timeOfBeat(base.beat) + j / bps + rng.normal(0, cfg.jitter / 1000);
      // A fumble in the pianist's own register, not down where the filterbank
      // gives up: these must be audibly, resolvably not the written note.
      const pitches = [offScore(58 + [0, 7, -3, 11, 3, 8][j % 6])];
      const durSec = 0.95 / bps;
      intruders.push({ T, pitches, durSec });
      const vel = 0.6 * rng.uniform(0.7, 1.4);
      for (const p of pitches) renderNote(soloBuf, sr, rng, { midi: p, t0: T, noteOffSec: durSec, gain: vel, detuneCents: rng.uniform(-3, 3), legacy, thumpShare });
    }
  }
  // Accompaniment on the true solo timeline of its beat (an ideal accompanist),
  // audible bleedLag later; the fire is reported at fireMs, before the loop.
  const accBuf = new Float32Array(n);
  const fires = [];
  for (let k = 0; k < acc.length; k++) {
    const a = acc[k];
    if (a.beat < startBeat) continue;
    const nextBeat = k + 1 < acc.length ? acc[k + 1].beat : a.beat + (a.durBeats ?? 1);
    const durSec = (a.durBeats ?? (nextBeat - a.beat)) / bps;
    const fireSec = preRoll + timeOfBeat(a.beat) + shift(a.beat);
    fires.push({ pitches: a.pitches, fireSec, durSec, beat: a.beat });
    // Same speaker, therefore the same R(f) and the same partial-count rule;
    // no keyboard thump, because the accompanist is not typing. Level: measured
    // median −5.0 dB re the solo note it covers (range −2.2 to −27.3 over 22
    // presses), which BASE's bleed 1.0 against the solo's 0.6·uniform(0.7,1.4)
    // already brackets — the bleed path is the one part of this bench that
    // matched the rig, and it is unchanged.
    if (cfg.bleed > 0) for (const p of a.pitches) renderNote(accBuf, sr, rng, { midi: p, t0: fireSec + cfg.bleedLag / 1000, noteOffSec: durSec, gain: 0.6 * cfg.bleed, detuneCents: rng.uniform(-3, 3), legacy });
  }
  // PHONE SPEAKER NONLINEARITY (`bleedClip`, off unless a scenario asks).
  // A memoryless sigmoidal saturation on the accompaniment ONLY — the solo
  // reaches the mic through the air from a real piano and never goes through
  // our loudspeaker. This is the Hammerstein front end the AEC literature uses
  // for hand-held devices (Hofmann et al., EURASIP JASP 2016:113: "increasingly
  // common nonlinear distortions produced by miniaturized amplifiers and
  // loudspeakers in modern portable devices"), and it is exactly what the
  // ICASSP AEC Challenge applies to 80 % of its far-end signals: "clipping the
  // maximum amplitude, using a sigmoidal function ... or applying learned
  // distortion functions" (Cutler et al., arXiv:2009.04972). Peak-preserving so
  // `bleed` stays the level knob and `bleedClip` is purely the drive:
  //     y = A·tanh(g·x/A)/tanh(g),  A = peak|accBuf|,  g = cfg.bleedClip
  // g = 0 is the identity (bit-exact skip, so every existing scenario is
  // untouched). The residual it injects is measured and printed by
  // SIM_DEBUG_MIX=1 as `nl` — see the phone-bleed comment for the number.
  let nlResidDb = null;
  if (cfg.bleedClip > 0) {
    let A = 0; for (let i = 0; i < n; i++) A = Math.max(A, Math.abs(accBuf[i]));
    if (A > 0) {
      const g = cfg.bleedClip;
      // Two passes: shape, then restore the RMS the linear signal had, so
      // `bleed` stays the only level knob and `bleedClip` is pure drive.
      let l2 = 0, y2 = 0;
      for (let i = 0; i < n; i++) { const x = accBuf[i], y = Math.tanh(g * x / A); l2 += x * x; y2 += y * y; }
      const k = y2 > 0 ? Math.sqrt(l2 / y2) : 1;
      let d2 = 0;
      for (let i = 0; i < n; i++) {
        const x = accBuf[i], y = k * Math.tanh(g * x / A);
        d2 += (y - x) * (y - x);
        accBuf[i] = y;
      }
      nlResidDb = l2 > 0 ? 10 * Math.log10(d2 / l2) : null;
    }
  }
  // The playback path. Under the default `rig` synth the measured response
  // R(f) is ALREADY in every partial's amplitude, so this chain is off unless a
  // scenario asks for it by name: with speakerHpf set it is the explicit
  // `speaker-dead-low` variant — a laptop whose low end is deader than the one
  // that was measured — and NOT the model of this rig (see RIG note 3). Under
  // `--synth legacy` it is the only low-end model there is. Solo and
  // accompaniment leave the SAME speaker, so it applies to both (every
  // accompaniment pitch here is ≥ 261 Hz, so in practice it only bites the low
  // solo notes). Filtered before the mix so the --snr noise is still measured
  // against the solo level the mic actually hears; the room noise itself
  // reaches the mic directly and is not filtered.
  if (cfg.speakerHpf > 0) {
    const spS = speakerChain(sr, cfg.speakerHpf, cfg.speakerOrder ?? 4), spA = speakerChain(sr, cfg.speakerHpf, cfg.speakerOrder ?? 4);
    for (let i = 0; i < n; i++) { soloBuf[i] = spS.process(soloBuf[i]); accBuf[i] = spA.process(accBuf[i]); }
  }
  const mix = new Float32Array(n);
  for (let i = 0; i < n; i++) mix[i] = soloBuf[i] + accBuf[i];
  // SIGNAL-TO-ECHO RATIO, measured rather than asserted. The AEC literature's
  // mixing parameter is the SER over DOUBLE-TALK frames only (ICASSP AEC
  // Challenge: "a signal to echo ratio uniformly sampled from -10 dB to 10 dB"),
  // not over the whole file — the accompaniment's duty cycle is far higher than
  // the solo's, so a whole-file ratio would flatter it. Measured here over
  // 512-sample hops in which BOTH sources are audibly present, as
  // 20·log10(median soloRms / median accRms). Reported so every scenario's
  // `bleed` gain can be quoted in the field's own units.
  let serDb = null;
  {
    const H = 512, sHop = [], aHop = [];
    for (let o = 0; o + H <= n; o += H) {
      let ss = 0, aa = 0;
      for (let i = o; i < o + H; i++) { ss += soloBuf[i] * soloBuf[i]; aa += accBuf[i] * accBuf[i]; }
      sHop.push(Math.sqrt(ss / H)); aHop.push(Math.sqrt(aa / H));
    }
    const p90 = (v) => { const q = v.slice().sort((x, y) => x - y); return q.length ? q[Math.min(q.length - 1, Math.floor(0.9 * q.length))] : 0; };
    const sOn = 0.1 * p90(sHop), aOn = 0.1 * p90(aHop);
    const sD = [], aD = [];
    for (let i = 0; i < sHop.length; i++) if (sHop[i] > sOn && aHop[i] > aOn) { sD.push(sHop[i]); aD.push(aHop[i]); }
    const med = (v) => { const q = v.slice().sort((x, y) => x - y); return q.length ? q[Math.floor(q.length / 2)] : 0; };
    if (sD.length > 8 && med(aD) > 0) serDb = 20 * Math.log10(med(sD) / med(aD));
    if (process.env.SIM_DEBUG_MIX) console.log(`  mix: bleed ${cfg.bleed} lag ${cfg.bleedLag}ms clip ${cfg.bleedClip} -> SER ${serDb === null ? '-' : serDb.toFixed(1)} dB over ${sD.length}/${sHop.length} double-talk hops (${(100 * sD.length / sHop.length).toFixed(0)}%), nl residual ${nlResidDb === null ? '-' : nlResidDb.toFixed(1)} dB`);
  }
  // Pink noise at --snr dB below the solo RMS (a silent solo uses the nominal level).
  const soloRms = rmsOf(soloBuf) || 0.05;
  const noise = pinkNoise(n, rng);
  const noiseGain = soloRms / rmsOf(noise) * 10 ** (-cfg.snr / 20);
  for (let i = 0; i < n; i++) mix[i] += noiseGain * noise[i];
  if (cfg.reverb) { const d = Math.round(0.04 * sr), g = 10 ** (-12 / 20); for (let i = n - 1; i >= d; i--) mix[i] += g * mix[i - d]; }
  if (cfg.hpf > 0) { const hp = biquadHighpass(sr, cfg.hpf); for (let i = 0; i < n; i++) mix[i] = hp.process(mix[i]); }
  // Keep the mix inside ±1 like a real capture would.
  let peak = 0; for (let i = 0; i < n; i++) peak = Math.max(peak, Math.abs(mix[i]));
  if (peak > 0.98) { const g = 0.98 / peak; for (let i = 0; i < n; i++) mix[i] *= g; }
  const seekSec = startAt > 0 ? events[startAt].T - (cfg.startLead ?? 0.3) * events[startAt].ioi : null;
  return { mix, events, fires, intruders, sr, bps, preRoll, startAt, seekSec, serDb, nlResidDb };
}

// ---------------------------------------------------------------------------
// Pipeline (§8.3): identical to the worklet
// ---------------------------------------------------------------------------

const SIM_TRANSPORT_MS = 15;   // audio → caller clock (models main-thread delivery)

// The app's role: forward-only, maxJump, ack; optional classic debounce mirror.
class FakeTracker {
  constructor(score, opts = {}) {
    this.score = score; this.position = 0; this.commits = []; this.rejects = 0;
    this.debounce = !!opts.debounce; this.bps = opts.bps || 1;
    this._lastTime = null; this._lastBeat = null; this._lastPitch = null;
  }
  advanceTo(idx, { maxJump, inputPitch }, nowSec) {
    if (idx < this.position) { this.rejects++; return null; }
    if (idx > this.position + maxJump) { this.rejects++; return null; }
    if (idx >= this.score.length) { this.rejects++; return null; }
    const ev = this.score[idx];
    if (this.debounce && this._lastTime !== null) {
      const elapsed = nowSec - this._lastTime;
      const beatDelta = Math.max(0, ev.beat - this._lastBeat);
      const expected = beatDelta > 0 ? beatDelta / this.bps : 0.18;
      const minAdvanceSec = Math.min(0.18, Math.max(0.07, expected * 0.45));
      const minRepeatedSec = Math.min(0.34, Math.max(0.12, expected * 0.65));
      if (elapsed < minAdvanceSec) { this.rejects++; return null; }
      if (this._lastPitch !== null && Math.abs(this._lastPitch - inputPitch) <= 1 && elapsed < minRepeatedSec) { this.rejects++; return null; }
    }
    this._lastTime = nowSec; this._lastBeat = ev.beat; this._lastPitch = inputPitch;
    this.position = idx + 1;
    return ev.beat;
  }
}

function runEngine(perf, score, cfg, hooks = {}) {
  const { mix, events, fires, sr } = perf;
  const win = cfg.opt.win ?? 2048, hop = cfg.opt.hop ?? 512;
  const engineOpts = { ...cfg.opt };
  delete engineOpts.win; delete engineOpts.hop;
  const analyzer = new FrameAnalyzer(sr, { win, hop, minMidi: 33, maxMidi: 96, startAt: 0 });
  const follower = new ScoreFollower({
    solo: score.rh, accompaniment: score.lh, bps: cfg.bps, sampleRate: sr, win, hop,
    filterbank: analyzer.filterbank, position: 0,
    options: { accompFallback: false, ...engineOpts },
  });
  const solo = normaliseEvents(score.rh);
  const tracker = new FakeTracker(solo, { debounce: cfg.trackerDebounce, bps: cfg.bps * cfg.tempoScale });
  const gate = cfg.gate ?? 0.001;
  const outputs = [];   // { tMs, out } for scoring pWrong etc.
  const advances = [];
  const resyncs = [];   // out.resync: the follower's wide-window jumps (follower.js _resyncScan)
  const scans = [];     // out.resyncScan: EVERY armed wide scan, fired or refused, with its reason
  const holds = [];
  const bleeds = [];
  const states = [];
  const frames = [];
  let fireIdx = 0;
  const chunk = new Float32Array(128);
  let seqCheck = -1, lastToIndex = -1, offered = new Set();
  let seeked = perf.seekSec === null;
  let seekAudioMs = null;   // when the seek below actually fired, so buildTrace can record it (see follow.seek there)
  for (let off = 0; off < mix.length; off += 128) {
    const nCh = Math.min(128, mix.length - off);
    for (let i = 0; i < 128; i++) chunk[i] = i < nCh ? mix[off + i] : 0;
    for (const f of analyzer.push(chunk)) {
      const audioMs = f.at / sr * 1000;
      const nowMs = audioMs + SIM_TRANSPORT_MS;
      // Start at a later beat: the app moves the tracker and rebuilds the
      // beam there with seek(position, performance.now()) while the engine
      // already runs on the open mic. Position 0 never exercises this path
      // (the pre-start state is exempt from the future guard).
      if (!seeked && audioMs >= perf.seekSec * 1000) { seeked = true; seekAudioMs = audioMs; follower.seek(perf.startAt, nowMs); tracker.position = perf.startAt; }
      // Report accompaniment fires whose time has come, exactly as the app does (before the loop delay).
      while (fireIdx < fires.length && fires[fireIdx].fireSec * 1000 <= audioMs) {
        const fr = fires[fireIdx++];
        const atMs = fr.fireSec * 1000 + SIM_TRANSPORT_MS;
        follower.accompanimentFired({ pitches: fr.pitches, atMs, durMs: fr.durSec * 1000, beat: fr.beat });
        hooks.onFire?.({ pitches: fr.pitches, beat: fr.beat, atMs, durMs: fr.durSec * 1000, audioMs: fr.fireSec * 1000 });
      }
      const frame = {
        type: 'frame', seq: f.seq, at: f.at, sr, win: f.win, hop: f.hop, rms: f.rms, db: f.db, floorDb: f.floorDb,
        flux: f.flux, onset: f.onset, hfc: f.hfc, sal: f.sal, peak: f.peak, noiseFloor: null, gated: f.rms < gate, attack: false,
      };
      if (cfg.dumpSal) console.log(`${(audioMs / 1000).toFixed(3)}s db=${f.db.toFixed(1)} floor=${f.floorDb.toFixed(1)} onset=${f.onset.toFixed(2)} peak=${f.peak}`);
      const out = follower.observe(frame, nowMs);
      // §3.3 guarantees, asserted.
      if (out.seq !== frame.seq) throw new Error('output.seq mismatch');
      if (seqCheck >= 0 && out.seq !== seqCheck + 1) throw new Error('seq gap in sim');
      seqCheck = out.seq;
      // RESYNCHRONISATION (follower 2026-09-16.3). Its own emission, never
      // counted as a detection: the app moves the tracker with the engine's
      // OWN explicit maxJump (MAX_ACOUSTIC_EVENT_JUMP = 1 would refuse it) and
      // then seeks the follower, which is what _acceptOffer does internally on
      // the ack. Modelled here so `--replay` measures the same behaviour the
      // app will produce.
      if (out.resyncScan) scans.push({ ...out.resyncScan, tMs: out.tMs, audioMs });
      if (out.resync) {
        const r = out.resync;
        const beat = tracker.advanceTo(r.toIndex, { maxJump: r.maxJump, inputPitch: r.pitches[r.pitches.length - 1] }, audioMs / 1000);
        follower.ack(r.id, beat !== null);
        if (beat !== null) { offered.add(r.toIndex); lastToIndex = r.toIndex; }
        resyncs.push({ ...r, accepted: beat !== null, tMs: out.tMs, audioMs: out.tMs - SIM_TRANSPORT_MS });
      }
      if (out.advance) {
        const adv = out.advance;
        if (adv.toIndex < tracker.position) throw new Error(`advance.toIndex ${adv.toIndex} < position ${tracker.position}`);
        if (offered.has(adv.toIndex)) throw new Error(`toIndex ${adv.toIndex} offered twice without a refused ack`);
        const beat = tracker.advanceTo(adv.toIndex, { maxJump: adv.maxJump, inputPitch: adv.pitches[adv.pitches.length - 1] }, audioMs / 1000);
        follower.ack(adv.id, beat !== null);
        if (beat !== null) offered.add(adv.toIndex);
        advances.push({ ...adv, accepted: beat !== null, tMs: out.tMs, audioMs: out.tMs - SIM_TRANSPORT_MS, mode: out.mode, heardMidi: adv.heardMidi });
        lastToIndex = adv.toIndex;
      } else if (out.hold) holds.push({ ...out.hold, tMs: out.tMs, pos: out.position });
      if (out.state) states.push({ ...out.state, tMs: out.tMs, pos: out.position, p: out.best?.p ?? null });
      // The watchdog moved off out.state (it was overwritten by BACKTRACK and
      // by the mode change); keep it visible in the states column.
      if (out.recovered) states.push({ mode: 'RECOVER', reason: out.recovered.reason, tMs: out.tMs, pos: out.position, p: out.best?.p ?? null });
      if (out.bleed) bleeds.push({ ...out.bleed });
      outputs.push({ tMs: out.tMs, audioMs, pWrong: out.pWrong, best: out.best, informative: out.informative });
      frames.push({ frame, out });
      hooks.onFrame?.(frame, out, follower);
    }
  }
  return { follower, tracker, advances, resyncs, scans, holds, bleeds, states, outputs, frames, events, lastToIndex, seekAudioMs };
}

// ---------------------------------------------------------------------------
// Scoring (§8.4)
// ---------------------------------------------------------------------------

const quantile = (arr, q) => {
  if (!arr.length) return null;
  const a = arr.slice().sort((x, y) => x - y);
  const pos = (a.length - 1) * q, lo = Math.floor(pos), hi = Math.ceil(pos);
  return a[lo] + (a[hi] - a[lo]) * (pos - lo);
};

const EARLY_MS = 60;   // a commit this far before the true onset is an advance AHEAD of the pianist

// A wide-window jump may land this many events BEHIND the pianist's true
// event and no more (see resyncBad in scoreRun for the measurement this comes
// from). THE TOLERANCE IS ONE-SIDED, and the asymmetry is the whole point: a
// jump that lands short is a catch-up the next ordinary advance closes, while
// a jump that lands on an event the pianist has NOT BEGUN is the accompaniment
// playing a note nobody played -- the one direction `early` forbids for
// ordinary advances, and the one the comment above resyncBad says was never
// observed. A symmetric assertion passed exactly that: on `dense-drift
// --seed 1` the jump 13 -> 62 at t 34293 ms, with the pianist's last begun
// event 61 and event 62 starting 161 ms later, read `resyncBad 0` while
// directly causing the run's only `early` (event 64 committed 235 ms before
// its onset, 448 ms after the jump).
const RESYNC_LAND_EVENTS = 1;
// Drift the engine is allowed to carry without it counting as an interval of
// being behind: 1 is what perfect tracking reads, 2 is one note of slack for a
// commit that lands after the next onset — which at this bench's 125 ms fastest
// decile is unavoidable and is not the failure being measured.
const DRIFT_OK = 2;

function scoreRun(res, perf) {
  const events = perf.events;
  const played = events.filter((e) => e.kind === 'played');
  const hits = new Map();
  const falses = [];
  const latencies = [], onsetErrs = [], tempoErrs = [];
  for (const adv of res.advances) {
    if (!adv.accepted) continue;
    const e = events[adv.toIndex];
    const tCommit = adv.audioMs / 1000;
    const inWindow = e && e.kind === 'played' && tCommit >= e.T - 0.05 && tCommit <= e.T + 0.4 && !hits.has(adv.toIndex);
    if (inWindow) {
      hits.set(adv.toIndex, adv);
      latencies.push((tCommit - e.T) * 1000);
      onsetErrs.push(adv.onsetAtMs - SIM_TRANSPORT_MS - e.T * 1000);
      tempoErrs.push(adv.bps - e.tempo);
    } else falses.push(adv);
  }
  const missed = played.filter((e) => !hits.has(e.k));
  // Early advances: any accepted commit more than EARLY_MS before the true
  // onset of the event it names, whatever that event's kind. A subset of
  // `false`, counted separately because it is the one failure the follower
  // exists to eliminate (the accompaniment moving ahead of the pianist) and
  // no scenario may tolerate even one of them, including those whose `false`
  // target allows a stray late or mis-indexed commit.
  const early = res.advances.filter((a) => a.accepted && events[a.toIndex] && a.audioMs / 1000 < events[a.toIndex].T - EARLY_MS / 1000);
  // Resync after skips: a hit on one of the next two played events.
  let resyncFails = 0;
  for (const e of events) if (e.kind === 'skipped') {
    const after = events.filter((x) => x.k > e.k && x.kind === 'played').slice(0, 2);
    if (after.length && !after.some((x) => hits.has(x.k))) resyncFails++;
  }
  // EVERY SCENARIO, not only the ones whose target names resync: a wide-window
  // jump may only land where the pianist actually is. The Study phase measured
  // the search on the real Haydn audio at the shipped 1.5-nat bar — 97 % of
  // fires landed exactly, 100 % within one event, 0 % landed AHEAD of the
  // player — and on the reference Twinkle every fire was a distance-1 jump the
  // ordinary rule would have made anyway. +-1 event is therefore the band the
  // mechanism is CLAIMED to hold, so anything outside it is a jump the engine
  // should not have made, whatever it does to recall. This is the resync's
  // counterpart to `early` and it fails every scenario the same way.
  //
  // `trueK` is the last event that had BEGUN when the scan fired — including
  // wrong and skipped ones, because the pianist's hands are past them either
  // way. A scenario in which nothing has been played yet (no-play) leaves it
  // null and any accepted jump at all is a failure.
  // How far the wide search's top-1 was from where the pianist actually was, on
  // EVERY armed scan — fired or refused. This is the calibration number behind
  // `resyncMarginNats`: a suite in which the refused scans were right is a
  // suite whose bar is too high, and one in which the fired scans were wrong is
  // a precision bug. Reported alongside the refusal histogram.
  const scanAtTrue = (t) => { let k = null; for (const e of events) if (e.kind !== 'silent' && e.T <= t) k = e.k; return k; };
  const scanErrs = [], scanErrsFired = [];
  for (const sc of res.scans || []) {
    const k = scanAtTrue(sc.audioMs / 1000);
    if (k === null || sc.toIndex < 0) continue;
    scanErrs.push(sc.toIndex - k);
    if (!sc.refused) scanErrsFired.push(sc.toIndex - k);
  }
  const resyncBad = [];
  for (const r of res.resyncs || []) {
    if (!r.accepted) continue;
    const t = r.audioMs / 1000;
    let trueK = null;
    for (const e of events) if (e.kind !== 'silent' && e.T <= t) trueK = e.k;
    const err = trueK === null ? null : r.toIndex - trueK;
    if (err === null || err > 0 || err < -RESYNC_LAND_EVENTS) resyncBad.push({ to: r.toIndex, at: Math.round(r.audioMs), trueK, err });
  }
  // DRIFT — the metric the Haydn failure was measured in (Study phase): how many
  // events the pianist is ahead of the engine's committed position, sampled at
  // every played onset. An engine tracking perfectly reads 1, because event k's
  // commit lands DURING event k and at k's onset the engine still holds k-1; the
  // real Haydn session read 8 at t=3.6 s, 57 at t=41 s and 98 at t=82 s and
  // never came back. `driftRunSec` is the longest unbroken stretch of time spent
  // above DRIFT_OK, i.e. how long the accompaniment is left playing music from
  // somewhere else — the quantity the user heard as "completely disorganized".
  // Resyncs count towards the committed position exactly as advances do: it is
  // the accompaniment's position that matters, not how it got there.
  const moves = [...res.advances, ...(res.resyncs || [])].filter((a) => a.accepted)
    .map((a) => ({ t: a.audioMs / 1000, to: a.toIndex })).sort((a, b) => a.t - b.t);
  // A session that starts at a later event (startAt) has the tracker SEEKED
  // there rather than advanced there, so the seek is not in `moves` and the
  // prefix it skipped is not drift — start-late read 8 before this line.
  let driftMax = 0, driftRunSec = 0, driftFrom = null, mi = 0, committedIdx = (perf.startAt || 0) - 1;
  for (const e of played) {
    while (mi < moves.length && moves[mi].t <= e.T) committedIdx = Math.max(committedIdx, moves[mi++].to);
    const d = e.k - committedIdx;
    driftMax = Math.max(driftMax, d);
    if (d > DRIFT_OK) { if (driftFrom === null) driftFrom = e.T; driftRunSec = Math.max(driftRunSec, e.T + e.durSec - driftFrom); }
    else driftFrom = null;
  }
  // pWrong while a note the score does not contain is sounding — substituted
  // (wrongEvery) or inserted (wrongInsert) alike.
  const pWrongs = [];
  const wrongWins = [
    ...events.filter((e) => e.kind === 'wrong').map((e) => [e.T * 1000 + 100, e.T * 1000 + 0.9 * e.ioi * 1000]),
    ...(perf.intruders || []).map((e) => [e.T * 1000 + 100, (e.T + 0.9 * e.durSec) * 1000]),
  ];
  for (const [a, b] of wrongWins) for (const o of res.outputs) if (o.informative && o.audioMs >= a && o.audioMs <= b) pWrongs.push(o.pWrong);
  // Longest unbroken run of SOUND in which the WRONG absorber owned the
  // posterior. This is the quantity the engine's wrongDwellMs watchdog counts,
  // so it is the only way to tell from the outside whether a scenario reaches
  // it: in 20260915-081347 the longest legitimate run was 854 ms and the
  // failure was 49 523 ms, and a scenario that never passes wrongDwellMs is not
  // testing recovery however high its median pWrong looks.
  let pWrongRunMs = 0, runMs = 0, runPrev = null;
  for (const o of res.outputs) {
    if (!o.informative) continue;
    if (o.pWrong >= 0.5) { runMs += runPrev === null ? 0 : o.audioMs - runPrev; pWrongRunMs = Math.max(pWrongRunMs, runMs); } else runMs = 0;
    runPrev = o.audioMs;
  }
  // Longest stall: the longest stretch in which the pianist is playing notes the
  // engine is supposed to be following and NO advance is accepted.
  //
  // The old form — the largest gap between consecutive hits — could not see the
  // failure this metric exists for. It only measured intervals that ENDED in a
  // hit, so the real session (20260915-081347: 28 keys and zero advances over
  // the last 45 s, the engine dead and never recovering) would have scored its
  // stall from the last hit it ever made and reported nothing. Here the
  // timeline is split at accepted advances and each segment is measured from
  // the first played onset still waiting to the end of the last one, or to the
  // advance that ended the wait — so a segment that never ends is the whole
  // remaining piece, which is what a dead engine deserves.
  //
  // Wrong and skipped events are not counted as waiting: holding through a note
  // the score does not contain is correct behaviour, not a stall. What that
  // leaves in a wrong-note scenario is exactly the recovery time — the gap from
  // the first CORRECT note after the run to the advance that re-locks on it.
  const advT = res.advances.filter((a) => a.accepted).map((a) => a.audioMs / 1000).sort((a, b) => a - b);
  let stall = 0, segFrom = -Infinity;
  for (const bound of [...advT, Infinity]) {
    const waiting = played.filter((e) => e.T > segFrom && e.T < bound);
    if (waiting.length) {
      const last = waiting[waiting.length - 1];
      stall = Math.max(stall, Math.min(bound, last.T + last.durSec) - waiting[0].T);
    }
    segFrom = bound;
  }
  const holdsByReason = {};
  for (const h of res.holds) holdsByReason[h.reason] = (holdsByReason[h.reason] || 0) + 1;
  return {
    played: played.length, hits: hits.size, recall: played.length ? hits.size / played.length : 1,
    false: falses.length,
    // `lateMs` is how far past the named event's own onset the commit landed.
    // On a dense score most of the `false` list is not a mis-indexed commit at
    // all but the RIGHT event claimed after the 400 ms hit window had closed —
    // the same failure `drift` measures, seen from the other end. Printed so
    // the two can be told apart without re-running.
    falseList: falses.map((a) => ({ to: a.toIndex, at: Math.round(a.audioMs), lateMs: events[a.toIndex] ? Math.round(a.audioMs - events[a.toIndex].T * 1000) : null, kind: events[a.toIndex]?.kind, repeatLike: a.repeatLike, ev: +a.ev.toFixed(2), p: +a.p.toFixed(2) })),
    missed: missed.length, missedList: missed.map((e) => e.k),
    early: early.length, earlyList: early.map((a) => ({ to: a.toIndex, at: Math.round(a.audioMs), aheadMs: Math.round(events[a.toIndex].T * 1000 - a.audioMs), mode: a.mode })),
    latencyMedian: quantile(latencies, 0.5), latencyP90: quantile(latencies, 0.9),
    onsetErrMedian: quantile(onsetErrs, 0.5), onsetErrP90: quantile(onsetErrs, 0.9),
    tempoRmse: tempoErrs.length ? Math.sqrt(tempoErrs.reduce((s, v) => s + v * v, 0) / tempoErrs.length) : null,
    rejects: res.tracker.rejects, stall, pWrongMedian: quantile(pWrongs, 0.5), pWrongRunMs, resyncFails, holdsByReason,
    resyncs: (res.resyncs || []).filter((r) => r.accepted).length, resyncBad: resyncBad.length, resyncBadList: resyncBad, driftMax, driftRunSec,
    scans: (res.scans || []).length, scansByReason: (res.scans || []).reduce((a, x) => (a[x.refused || 'fired'] = (a[x.refused || 'fired'] || 0) + 1, a), {}),
    scanMarginMed: quantile((res.scans || []).map((x) => x.marginNats).filter((v) => v !== null && Number.isFinite(v)), 0.5),
    scanMarginP90: quantile((res.scans || []).map((x) => x.marginNats).filter((v) => v !== null && Number.isFinite(v)), 0.9),
    scanLeadMed: quantile((res.scans || []).map((x) => x.leadNats).filter((v) => v !== null && Number.isFinite(v)), 0.5),
    scanDistMed: quantile((res.scans || []).map((x) => x.distance), 0.5),
    scanErrMed: quantile(scanErrs, 0.5), scanErrAbs1: scanErrs.length ? scanErrs.filter((e) => Math.abs(e) <= 1).length / scanErrs.length : null,
    scanErrAbs2: scanErrs.length ? scanErrs.filter((e) => Math.abs(e) <= 2).length / scanErrs.length : null,
    // "did a jump that ACTUALLY FIRED pick a place the pianist had not
    // reached?" — over the fired scans only, and at e > 0, not e > 1. Over all
    // scans and at e > 1 it printed `ahead 0` for the dense-drift seed 1 run
    // whose accepted jump to event 62 preceded that event's onset by 161 ms,
    // i.e. it read 0 on exactly the run it exists to catch.
    scanErrAhead: scanErrsFired.filter((e) => e > 0).length, scanErrFiredList: scanErrsFired,
    bleedLagMedian: quantile(res.bleeds.map((b) => b.lagMs), 0.5), states: res.states.map((s) => `${s.mode}@${Math.round(s.tMs)}`),
  };
}

// ---------------------------------------------------------------------------
// Scenarios and targets (§8.4)
// ---------------------------------------------------------------------------

// `synth: 'rig'` is the measured laptop-speaker/laptop-mic model (see RIG);
// `'legacy'` is the pre-2026-09-15 8-partial h^-1.3 tone, kept for audit only.
const BASE = { bleed: 1.0, snr: 30, hpf: 0, jitter: 25, rubato: 8, bleedLag: 58, bleedClip: 0, tempoScale: 1.0, score: null, synth: 'rig' };

// 2026-09-15 RE-BASELINE against the measured rig synth (see RIG above).
// Nothing in the engine changed and no target below was touched: the synth
// stopped being a fundamental-dominant 8-partial piano and became the
// 21-partial comb this laptop speaker actually produces, so every number moved.
// `--synth legacy` reproduces the "before" column below exactly, press for
// press (the legacy path draws the same random numbers in the same order), so
// any line here can be re-checked in one command.
//
//                       BEFORE (legacy synth)     RIG SYNTH, engine .13    RIG SYNTH, engine .14
//   clean              0.96 f0 e0 lat 68/90  ok   0.98 f0 e0 lat 75/152  ok   0.98 f0 e0  ok
//   bleed              1.00 f0 e0 stall 0.14 ok   0.87 f0 e0 stall 3.90 FAIL  0.98 f0 e0  ok
//   bleed-loud         0.98 f0 e0            ok   0.00 f21 e0 stall 5.84 FAIL 0.91 f0 e0  ok
//   noisy              0.98 f0 e0            ok   0.89 f0 e0             ok   0.98 f0 e0  ok
//   no-fundamental     1.00 f0 e0            ok   0.93 f0 e0             ok   0.93 f0 e0  ok
//   double-strike      0.98 f0 e0            ok   0.96 f0 e0             ok   0.96 f0 e0  ok
//   wrong-note         1.00 f0 e0            ok   1.00 f0 e0             ok   1.00 f0 e0  ok  (bench bug fixed, see below)
//   skipped-note       1.00 f0 e0            ok   0.95 f1 e0            FAIL  0.92 f2 e0 FAIL
//   repeated-notes     1.00 f0 e0            ok   0.92 f0 e0             ok   0.92 f0 e0  ok
//   no-play              –  f0 e0            ok     –  f0 e0             ok     –  f0 e0  ok
//   fast               0.93 f0 e0            ok   0.91 f1 e0             ok   0.96 f0 e0  ok
//   rubato-heavy       1.00 f0 e0            ok   0.96 f0 e0             ok   0.96 f0 e0  ok
//   bleed-lag-sweep    0.98 f0 e0            ok   0.91 f1 e1            FAIL  0.91 f1 e1 FAIL
//   sr44               0.91 f0 e0            ok   0.89 f0 e0            FAIL  0.91 f0 e0  ok
//   start-late         0.97 f0 e0 stall 1.12 ok   0.87 f0 e0 stall 5.99 FAIL  0.93 f0 e0  ok
//   low-note-weak      0.93 f0 e0            ok   0.86 f1 e0            FAIL  0.89 f0 e0  ok
//   low-note-lag-over  0.89 f0 e0            ok   0.89 f0 e0             ok   0.89 f0 e0  ok
//   wrong-lock-recovery 0.98 f0 e0           ok   0.98 f0 e0             ok   0.98 f0 e0  ok
//   (seed 1 throughout; f = false, e = early. No target was changed in either
//   step. Seed 2 under engine .14: everything ok except double-strike 0.91 f1
//   and start-late 0.95 f1, both of which read f1 under engine .13 as well.)
//
// ENGINE .16 IS BEHAVIOUR-IDENTICAL TO .15 on this bench, line for line — it
// only adds FOLLOWER_DEFAULTS.rigResponse, which ships null. Re-measured after
// that change, so the seed-2 column above has current numbers next to it:
//   bleed-loud    seed 1 0.91 f0 ok    seed 2 0.83 f1 FAIL (recall < 0.85 too)
//   double-strike seed 1 0.96 f0 ok    seed 2 0.93 f1 FAIL
//   skipped-note  seed 1 0.92 f2 FAIL  seed 2 1.00 f0 ok
// All five of those `false`s are LATE commits of notes the player really did
// play — +414, +483, +994 and +2017 ms against the scorer's +400 ms window
// (scoreRun) — and `early` is 0 in every one of them. None of them is the
// accompaniment moving ahead of the pianist; they are the follower catching up
// out of a stall, and two of the four are within 20 % of the bar. The two
// cold-start ones (skipped-note #0, bleed-loud #1 after #0 is lost) are the
// index-0 collapsed-fundamental story already named above, still unsolved.
//
// THE ENGINE STEP (follower 2026-09-15.14) is two changes, both of which this
// bench is what found:
//  * the ACCOMPANIMENT's template now runs to the top of the bank
//    (FOLLOWER_DEFAULTS upperWeight, _extWeights). Its six-partial truncation
//    was the same bug as the solo one and it bites harder, because the
//    accompaniment is an octave above the solo and therefore covers the solo
//    note's EVEN partials: what identifies a C3 under a C4 is C3's odd
//    partials, and from the 7th up neither template could describe them. In
//    bleed-loud seed 1 the bleed explained 0.91-0.97 of every frame with
//    index 0's own share at 0.00 and the piece never locked.
//  * the future guard (§ 'nothing committed ahead of the newest evidence')
//    now measures from the onset the last ACCEPTED commit asserted, not from
//    whichever hypothesis at the committed index is heaviest. The pre-start
//    state spawns a rival for the committed event on the NEXT note's onset;
//    with the rig synth it regularly outweighed the committed state, so the
//    guard read "event j is a whole IOI away" and refused the child that
//    onset belonged to (bleed seed 1: index 1 at p 0.98, ev 6, held
//    'future' with ahead 984 / span 1000 for a second; indices 1-4 lost).
//    The rule is unchanged and `early` is still 0 everywhere it was.
//
// WHAT THE ENGINE STEP DID NOT FIX, stated plainly: the SOLO template is
// still truncated at six partials, so the G2 stall the replay was built to
// measure is still there — the replay reads the same 28 hits it did before
// (at a better latency: p90 339 -> 336 ms, index 0 committed +178 ms instead
// of +371 ms). Every way of extending the solo side was measured and every
// one costs more than it buys; the numbers are in FOLLOWER_DEFAULTS above
// `harmonicWeights`, and 2026-09-15.15 adds three more measured dead ends
// there as off-by-default knobs (soloUpperFrom, xTilt, wrongPartialMax) so the
// seventh attempt is a one-line sweep rather than a rebuild. One of them is
// not quite a dead end and is worth knowing about: `--opt wrongPartialMax=6`
// bars WRONG from the 6th partial of an expected pitch and DOES commit the
// index-12 G2 that no shipped build has committed — it just cannot be scored
// on this recording, for the reason under the `--replay` line below.
// skipped-note and bleed-lag-sweep were the two scenarios
// still red. bleed-lag-sweep is now GREEN (0.91 f1 e1 -> 0.93 f0 e0): its
// `early` turned out NOT to be the collapsed fundamental but one onset
// claiming two events, and two hold-only gates fixed it — see the scenario's
// own comment. skipped-note is still red at f2 (index 43 as well as index 0),
// the bleed tail's cost, declared rather than hidden; index 0 is the
// still-unsolved first-note-in-LISTENING collapsed fundamental. Measured, the
// tail is worth its one false: `--opt upperWeight=0` takes skipped-note to
// 0.95 f1 but bleed-loud seed 1 to 0.00 with 21 false, so removing it from
// the commit path is a net loss and was not taken.
//
// The `--replay` line on the real session: replay bypasses the SYNTH, not the
// ENGINE — it re-runs the recorded frames through the live follower, so hits
// and latency can and did move with the .14 step (see p90 339 -> 336 above).
// What is untouched is what the contract asks for, `hits 28` and `false 0`:
//   keys 56  hits 28  recall 0.50  false 0  latency med 188 p90 336 ms.
//
// READ THE SECOND LINE BEFORE THE FIRST. 2026-09-15.15 makes `--replay` print
// the CEILING of its own metric, and on this session it is 30 of 56 keys (35
// under the ±12 rule the scorer actually uses). "recall 0.50" was never 0.50
// of what is achievable: 24 of the 28 unscored keys are G2 presses made where
// the score asks for C3 or B2, and no follower may advance on those. The
// headroom this recording can demonstrate is two keys. Five solo-template
// rewrites were reverted on differences of five and eleven hits inside that
// band — see replayCeiling for what those differences actually are.
//
// WHAT MOVED, and why. Attributed by rebuilding each scenario with ONE part of
// the new synth switched off:
//
//  * The UPPER TAIL is what costs recall. Capping partials at 784 Hz (the old
//    h8 of a G2) and changing nothing else restores bleed 0.87 → 0.98 and
//    bleed-loud 0.00 → 0.91. That is the shipped 6-partial template failing to
//    explain rows 74–96 and the WRONG absorber taking them — the exact
//    real-session pathology, which no scenario could reproduce before because
//    no synthetic note had anything up there. These are NOT regressions of the
//    engine; they are the bench finally containing the failure it was built to
//    measure, and the engine genuinely cannot do it yet.
//  * The FUNDAMENTAL DRAW costs the first note. bleed-lag-sweep's `early 1`
//    needs BOTH the tail and the draw: with either switched off it goes back to
//    0. It is index 0's C3 arriving with a collapsed fundamental in LISTENING
//    mode, read as index 1's G3 (C3's h3/h6/h9 are G3's h2/h4/h6) and committed
//    585 ms early. Same story for skipped-note (index 0 committed 1.0 s late,
//    scored false) and for bleed-loud on seed 1, where index 0 is lost and the
//    whole piece then runs ~6 s late; seeds 2 and 3 of bleed-loud give 0.98 and
//    0.91. A press that loses its fundamental is measured, not invented
//    (MIDI 43: p25 −18.8 dB re the strongest partial over 16 presses), and no
//    scenario produced one before.
//  * start-late and sr44 lose their margin, not their behaviour: start-late
//    stalls 5.99 s across indices 11–18 (against a 5 s bound) and sr44 misses
//    five instead of four. Both are the same thin-margin story.
//  * wrong-note's collapse was a BENCH BUG, and it is fixed above rather than
//    re-baselined: `wrongEvery` perturbed a pitch by +1/+2 with no guard, so at
//    index 9 it played index 10's own written C3. The fundamental-dominant
//    synth could not tell B2 from C3 and nothing happened; the measured comb
//    can, and the engine's correct recognition scored as 1 early and 12 false
//    advances (recall 1.00 → 0.22). With the guard the scenario reads 1.00 f0
//    e0 under BOTH synths. `wrongInsert` has always had that guard.
//
// NOT re-baselined, and worth saying plainly: the bench is still 25–45 dB
// cleaner than the rig in the only domain the engine sees. Per-row note-to-floor
// on a synthetic G2 is 40–63 dB against the rig's measured 3–18 dB, so the
// whitening's (sal/2·floor − 1)^0.5 barely bites and the simulated G2's
// WHITENED peak is still its own h3 (row 62) where the rig's is row 89. The RAW
// (post − pre) spectrum now matches the rig to 5.9 dB rmse over h ≥ 2 — the
// same order as the profile's own model rmse of 5.12 dB — and peaks on row 62
// exactly as the rig does, which is what makes a 7..21-partial template
// testable here at all. Lowering --snr does not fix the whitened domain
// (measured at 10, −5 and −17 dB: it flattens the whole spectrum toward the
// noise instead of reshaping it). The cause is that this bench has real silence
// between notes and a dry room, so its min-statistics floor falls back to the
// pink noise in every gap, while the rig's floor rides on a continuously
// sounding speaker: the rig's own session-mean salience is only 3.4 dB above
// its floor at row 43 and 10.3 dB at row 89. That is a FOURTH bench error, one
// the phase-1 profile's "keep pink at the current SNR" did not catch, and it is
// the next thing to fix here.
const SCENARIOS = {
  'clean':          { mut: { bleed: 0, snr: 40, rubato: 0 },       tgt: { recall: 0.95, false: 0, latMed: 120, latP90: 250 } },
  // 2026-09-15: 1.00 → 0.87 under the rig synth, FAILING its 0.90 — capping
  // the synth's partials at 784 Hz restored 0.98, so the loss was the engine's
  // 6-partial template against the measured tail. Engine work, and it was
  // done: follower 2026-09-15.14 reads 0.98 f0 e0 (seed 2: 0.20 f14 → 0.98).
  // The target is untouched.
  'bleed':          { mut: {},                                     tgt: { recall: 0.90, false: 0, latMed: 150 } },
  // 2026-09-15: 0.98 → 0.00 with 21 false on seed 1 under the rig synth, where
  // index 0's C3 draws a collapsed fundamental, is never committed, and the
  // piece runs ~6 s late from there. Seeds 2 and 3 read 0.98 and 0.91; capping
  // partials at 784 Hz gave 0.91 on seed 1. Tail + first-note collapse
  // together — and this scenario is what identified the ACCOMPANIMENT's
  // truncated template as the larger half of the tail problem (the bleed
  // explained 0.91-0.97 of every frame while index 0's own share was 0.00).
  // follower 2026-09-15.14 reads 0.91 f0 e0. The target is untouched.
  'bleed-loud':     { mut: { bleed: 2.0 },                         tgt: { recall: 0.85, false: 0 } },
  // -------------------------------------------------------------------------
  // phone-bleed / phone-bleed-lag-wrong — THE MOBILE CASE (added 2026-09-16).
  // BOTH ARE RED TODAY, ON PURPOSE. They are the acceptance test for the
  // mobile bleed work, not a picture of something that already passes.
  //
  // WHY THEY EXIST. Every bleed scenario above models a LAPTOP: the
  // accompaniment leaves a laptop speaker, crosses a room, and arrives at a
  // laptop mic 58–130 ms later at a level BELOW the solo. On a phone or tablet
  // the speaker and the microphone are inches apart in one body, so the
  // accompaniment arrives LOUDER than the soloist and almost immediately, and
  // partly through the case rather than only through the air. The product
  // direction is webapp first, phone second, and nothing here measured that.
  //
  // EVERY NUMBER, AND WHERE IT COMES FROM.
  //
  // bleed 3.0  ->  MEASURED SER −6.4 dB (SIM_DEBUG_MIX=1, over the 94 % of hops
  //   in which both sources sound). The field's mixing parameter is the
  //   signal-to-echo ratio over DOUBLE-TALK frames, and the ICASSP 2021/2022
  //   AEC Challenges sample it "uniformly ... from -10 dB to 10 dB" over
  //   10 000+ real devices (Cutler et al., arXiv:2009.04972, arXiv:2202.13290).
  //   −6.4 dB is inside that range and on its adverse half, which is where the
  //   2022 challenge puts mobile: it reports mobile scoring 0.75 MOS BELOW
  //   desktop in the double-talk degradation category — its largest gap of any
  //   category — "because in mobile devices, the loudspeaker is closer to the
  //   microphone, so the signal-to-echo ratio in these devices is lower on
  //   average". For calibration the existing scenarios measure SER +3.7 dB
  //   (`bleed`) and −2.3 dB (`bleed-loud`); +3.7 dB matches the rig's own
  //   "median −5.0 dB re the solo note it covers" to within 1.3 dB, which is
  //   what validates the metric. phone-bleed is therefore 10.1 dB louder than
  //   `bleed` and 4.1 dB louder than `bleed-loud`.
  //
  // bleedLag 6 ms  ->  the direct + structure-borne path, NOT a room path. A
  //   phone's speaker-to-mic spacing is 1–15 cm = 0.03–0.44 ms of air, and the
  //   case-borne path is faster still; what is left is the platform's own
  //   output buffer, which the traces bound at meta.latency.measuredOutputMs
  //   18.3–33.3 ms. 6 ms is the coupling alone (the app's fire report already
  //   buffer-compensated); the buffer-inclusive case is covered by the 30 ms
  //   row of the ladder below. Do NOT confuse this with the challenge's "RT60
  //   ranges from 200 ms to 1200 ms" — that is the reverb TAIL, a different
  //   quantity from the direct-path delay the scalar `bleedLagMs` models.
  //
  // bleedClip 2  ->  MEASURED distortion residual −19.4 dB re the linear
  //   accompaniment (10.7 % RMS): "mild", and applied to the accompaniment
  //   only, since the solo reaches the mic from a real piano and never passes
  //   through our loudspeaker. Small loudspeakers are strongly nonlinear
  //   (Hofmann et al., EURASIP JASP 2016:113) and the AEC Challenge applies a
  //   nonlinearity to 80 % of its far-end signals, naming "clipping the maximum
  //   amplitude, using a sigmoidal function ... or applying learned distortion
  //   functions"; the sigmoid is the one used here. --bleed-clip 3 and 5 give
  //   −15.1 and −10.9 dB. The shaping is RMS-preserving, so `bleed` and
  //   `bleedClip` are orthogonal (SER moves 1.0 dB from clip 0 to clip 5).
  //
  // WHAT THE SHIPPED ENGINE ACTUALLY DOES, MEASURED (engine 2026-09-16.10).
  //
  // 1. THE NONLINEARITY IS FREE. At a level the engine survives (bleed 1.0,
  //    lag 6, engine seeded 6) clip 0 / 2 / 5 all read 0.98 (45/46) f0 e0 —
  //    identical hits, only p90 latency moves (128/115/107 ms). That is the
  //    predicted result and not a lucky one: a memoryless nonlinearity driven
  //    by a harmonic tone produces mostly that tone's OWN harmonics, and the
  //    bleed template is already a harmonic comb on those rows. What a comb
  //    cannot cover is intermodulation between two simultaneous accompaniment
  //    pitches, which this scenario does contain and which is evidently small.
  //    Nonlinear-AEC work is therefore NOT where the mobile effort should go.
  //
  // 2. THE LEVEL IS THE KILLER, AND IT IS A CLIFF, NOT A SLOPE. Level ladder
  //    at the room lag 58 with the engine seeded truthfully, seed 1:
  //        bleed 2.00  SER −2.3 dB  recall 0.91 (42/46) f0 e0
  //        bleed 2.05  SER −2.5 dB  recall 0.00 ( 0/46) f10  stall 9.80s
  //        bleed 2.25  SER −3.3 dB  recall 0.00 ( 0/46) f18  stall 7.29s
  //        bleed 3.00  SER −5.8 dB  recall 0.00 ( 0/46) f11  stall 12.08s
  //    ONE DECIBEL between 0.91 and 0.00. Seed 2 puts the cliff further out
  //    (bleed 2.25 -> 1.00 f0; bleed 3.00 -> 0.89 f1), so the cliff's position
  //    is the first-note draw and its existence is not. Read the seed-1 0.00
  //    correctly: `early` is 0 in EVERY cell, so the engine never advances
  //    ahead of the pianist. Index 0 is lost, the piece then runs 5.7–13.0 s
  //    late, and the bench scores those late commits as `false`. This is the
  //    cold-start cascade the `bleed`/`wrong-note` comments above already
  //    describe, now reachable 10 dB earlier.
  //
  // 3. THE SHORT DELAY IS GENUINELY HARDER THAN THE ROOM DELAY AT THE SAME
  //    LEVEL. Phone mix, seed 2, engine seeded 58, estimator live:
  //        true lag 110 ms (room)   recall 1.00 (46/46) f0   estimate 131 ms
  //        true lag  30 ms          recall 0.87 (40/46) f0   estimate  46 ms
  //        true lag   6 ms (phone)  recall 0.85 (39/46) f1   estimate  25 ms
  //    Same SER, 0.15 recall and the first false bought purely by collapsing
  //    the loop delay. Two reasons, both structural: the estimate can never
  //    reach 6 ms because follower.js:4880 folds samples in as
  //    clamp(sample, 20, 250) — 20 ms is a FLOOR, and 25 ms is where the EMA
  //    settles from any seed — and at a 6 ms loop the accompaniment's onset and
  //    the soloist's onset land in the SAME frame, so the
  //    bleedOnsetWindowMs [−30, 90] suppression around atMs + 25 ms covers
  //    −5…+115 ms after the fire, which is exactly where the soloist's own
  //    attack lives when both are on the beat.
  //
  // 4. ON A PHONE THE LAG SEED STOPS MATTERING, AND THAT IS NOT GOOD NEWS.
  //    phone-bleed with opt bleedLagMs seeded 6 / 58 / 106 (a 0, +52 and
  //    +100 ms error) reads 0.00 f16 on seed 1 and 0.85 f1 on seed 2 in ALL
  //    THREE cases, latency 93 vs 94 ms — because the bleed is now loud enough
  //    that _calibrateBleed's 2× band test trips on nearly every fire and the
  //    0.3 EMA reaches 25 ms within a few events whatever it started from.
  //    Freezing the estimator outright (--opt bleedMinBand=1e9) confirms it:
  //    0.85 / 0.83 / 0.85 across the same 100 ms swing.
  //
  // 5. THE ESTIMATOR IS BLIND AT LAPTOP LEVEL — the trace pathology, reproduced
  //    on the bench for the first time. bleed 1.0, clip 2, engine seeded 58:
  //        true lag   6 ms -> estimate 25 ms      true lag  58 ms -> estimate 36 ms
  //        true lag  15 ms -> estimate 35 ms      true lag 110 ms -> estimate 37 ms
  //        true lag  30 ms -> estimate 36 ms
  //    A 104 ms range of truths maps to a 12 ms range of estimates. The 2×
  //    band-energy test at follower.js:4878 is a Geigel detector with no
  //    reference correlation, so at this level it trips on the SOLOIST's own
  //    energy, not on the echo's arrival — which is the near-end sample cluster
  //    under 20 ms that holds 7–65 % of samples per session in the real traces,
  //    and the reason the recorded assumptions read 58 vs a measured 112, 132
  //    vs 236, 236 vs 99.
  //
  // 6. AND THE BLINDNESS ONLY COSTS SOMETHING WHEN THE BLEED SITS ON THE
  //    SOLO'S OWN ROWS. Over a 100 ms swing in a FROZEN assumed lag, Twinkle
  //    moves by at most one event (0.93 -> 0.96 at true lag 110; 0.98 flat at
  //    true lag 6) with false 0 everywhere — because Twinkle's accompaniment is
  //    written an octave above its solo (see the score comment above) and the
  //    two barely share rows. On the `lownote` score, where the accompaniment
  //    strikes the very partials the solo's fundamentals are missing, the same
  //    swing is the difference between working and not. See
  //    phone-bleed-lag-wrong.
  //
  // phone-bleed gives the engine the TRUE lag (opt bleedLagMs 6) BY DESIGN: it
  // isolates the shipped bleed MODEL from the shipped bleed ESTIMATOR, so
  // "how far does the model get when the delay is right" can be told apart from
  // "what the delay estimator costs". The estimator's cost is item 6's job.
  //   MEASURED: seed 1 recall 0.00 (0/46) f16 e0 stall 6.64s, missing all 46,
  //             six LOST states, drift 15 events / 44.9 s, estimate 25 ms;
  //             seed 2 recall 0.85 (39/46) f1 e0 stall 3.05s, missing
  //             [0,1,2,3,4,5,12] — the first six events and then a recovery.
  // The target is the GOAL, taken from `bleed-loud`'s 0.85 because that is the
  // nearest scenario in kind and there is no reason a phone should be allowed
  // to be worse than a loud laptop. false 0 is not negotiable on any scenario.
  'phone-bleed':    { mut: { bleed: 3.0, bleedLag: 6, bleedClip: 2, opt: { bleedLagMs: 6 } }, tgt: { recall: 0.85, false: 0 } },
  // phone-bleed-lag-wrong — THE DELAY, ISOLATED, WHERE IT ACTUALLY BITES.
  // The `lownote` score (whose accompaniment strikes the solo's own partial
  // rows) under the phone mix, with the estimator FROZEN at a wrong value.
  //
  // WHY FROZEN. With the estimator live this cannot be tested at all: item 4
  // above measures 0.85 / 0.85 / 0.85 across seeds of 6, 58 and 106 ms, because
  // the estimator overwrites the seed within a few fires. Freezing is done from
  // the outside with `bleedMinBand: 1e9` — follower.js:4878 accepts a sample
  // only when `e >= 2 * a.ref && e >= o.bleedMinBand`, so an unreachable
  // bleedMinBand admits none and `_bleedLagMs` stays at the seed. No engine
  // change, and `bleedLag med` prints `-` to prove no sample was folded in.
  // This is the same fault injection `low-note-lag-over` already does with
  // bleedLagMs 130, taken one step further so the injection actually holds.
  //
  // WHY IT IS A LEGITIMATE FAULT AND NOT A STRAW MAN: item 5 shows the shipped
  // estimator reading 25–37 ms for every true lag from 6 to 110 ms at laptop
  // level, i.e. a frozen-wrong lag is what a blind estimator PRODUCES. The
  // traces agree — assumed 236 ms against a measured median of 99, assumed 132
  // against 236 — and the report prints 78–100 ms misses routinely.
  //
  //   MEASURED, seed 1, phone mix (bleed 3.0 / lag 6 / clip 2) on `lownote`:
  //     reference: low-note-weak as it ships    recall 0.89 (25/28) f0 e0 stall 1.18s
  //     frozen lag   6 ms (correct)             recall 0.61 (17/28) f0 e0 stall 4.71s
  //     frozen lag  58 ms (+52, shipped seed)   recall 0.61 (17/28) f0 e0 stall 4.72s
  //     frozen lag 106 ms (+100)                recall 0.00 ( 0/28) f6 e0 stall 9.16s
  //     estimator LIVE from either seed         recall 0.61 (17/28) f0 e0, estimate 25 ms
  //   So the phone MIX alone costs 0.89 -> 0.61, a +52 ms lag error costs
  //   nothing further, and a +100 ms error costs everything that is left — the
  //   template is switched on 100 ms after the energy it is supposed to explain
  //   has already been attributed to the expected note. `early` stays 0 in every
  //   cell: the six falses at +100 ms are late commits, not advances ahead of
  //   the pianist. The sweep's worst point is the +100 ms one, which is what the
  //   scenario reports.
  // The stall bound is the one from low-note-weak (4 s) for the same reason it
  // is there: a low note the engine cannot identify costs one advance, a low
  // note that takes the wrong absorber with it costs the rest of the piece.
  'phone-bleed-lag-wrong': { sweep: [
                        { score: 'lownote', speakerHpf: 180, speakerOrder: 4, jitter: 30, bleed: 3.0, bleedLag: 6, bleedClip: 2, opt: { bleedLagMs: 58, bleedMinBand: 1e9 } },
                        { score: 'lownote', speakerHpf: 180, speakerOrder: 4, jitter: 30, bleed: 3.0, bleedLag: 6, bleedClip: 2, opt: { bleedLagMs: 106, bleedMinBand: 1e9 } },
                      ],                             tgt: { recall: 0.85, false: 0, stall: 4 } },
  'noisy':          { mut: { snr: 10 },                            tgt: { recall: 0.85, false: 0 } },
  'no-fundamental': { mut: { hpf: 180 },                           tgt: { recall: 0.90, false: 0, latMed: 180 } },
  'double-strike':  { mut: { doubleStrike: true },                 tgt: { recall: 0.90, false: 0 } },
  'wrong-note':     { mut: { wrongEvery: 5 },                      tgt: { recall: 0.90, false: 0, pWrong: 0.5 } },
  // 2026-09-15: 1.00 f0 → 0.95 f1 under the rig synth. The one false is index
  // 0 committed at 2.05 s against a 1.0 s onset — the first note again, not
  // the skips (every deliberately skipped event is still correctly withheld).
  // STILL RED at follower 2026-09-15.14, and one commit worse: 0.92 f2, index
  // 43 joining index 0. Both are LATE commits landing after the next onset
  // (`early` 0); the extra one is the price of the accompaniment tail, and it
  // is declared rather than papered over. Seed 2 reads 1.00 f0 e0. The
  // unsolved case is the first note in LISTENING with a collapsed
  // fundamental; widening the fundamental/overtone split to every pitch the
  // rig loses (< 180 Hz, RIG.fundamentalSpreadBelowHz) was tried and fixes
  // bleed-lag-sweep's `early` but takes bleed-loud back to 0.00, because the
  // same freedom lets index 0's template fit the loud octave bleed.
  // Dropping the accompaniment tail out of the commit path was measured too
  // and is the same trade: `--opt upperWeight=0` gives 0.95 f1 here (index 43
  // clears, index 0 does not) and 0.00 f21 on bleed-loud seed 1. So index 43
  // is the tail's price and it is worth paying; index 0 is the open case, and
  // it is NOT the f0/overtone split — at 48 kHz semitoneBins(48) = 0.332, so
  // a C3 is above f0UnresolvedBins 0.3 and never takes the lowSplit path.
  'skipped-note':   { mut: { skipEvery: 6 },                       tgt: { recall: 0.90, false: 0, resync: true } },
  'repeated-notes': { mut: { score: 'repeated' },                  tgt: { recall: 0.85, false: 1, latP90: 300 } },
  'no-play':        { mut: { noPlay: true },                       tgt: { false: 0 } },
  'fast':           { mut: { tempoScale: 1.8 },                    tgt: { recall: 0.85, false: 1 } },
  'rubato-heavy':   { mut: { jitter: 60, rubato: 20 },             tgt: { recall: 0.85, false: 1 } },
  // 2026-09-15: 0.98 f0 e0 → 0.91 f1 e1. The early is index 0's C3 read as
  // index 1's G3 585 ms ahead in LISTENING (C3's h3/h6/h9 ARE G3's h2/h4/h6);
  // it needs both the tail and the collapsed fundamental — switch either off
  // and early goes back to 0.
  // Was STILL RED at follower 2026-09-15.14 (0.91 f1 e1 at bleedLag 120; the
  // other two lags are clean, and seed 2 is clean at all three) — the one
  // place in the suite where `early` was not 0.
  // NOW GREEN, 0.93 f0 e0, and NOT by a target change: the early was not the
  // collapsed fundamental after all. Index 1 was committed at t 1387 with
  // aheadMs 649 in LISTENING while index 0's OWN child, born on the same
  // 1044 ms onset (the C3's), was still live and audible — one onset claiming
  // two events, which gate 12 (`future`) cannot catch in LISTENING because
  // _spanMs is Infinity for a pre-start anchor. Two spawn/commit gates were
  // added for it, both hold-only: the `same-onset` commit reason (a skip may
  // not share its onset with the event it skips) and an onset-driven child's
  // own 60 ms spawn refractory from birth (an index-0 child born at t 1050
  // fathered an index-1 child 48 ms later off the same attack, against
  // ioiMinMs 150). 0.91 f1 e1 -> 0.93 f0 e0 here; bleed-loud, the counterweight
  // named above, is unchanged at 0.91 f0 e0.
  'bleed-lag-sweep': { sweep: [20, 58, 120].map((lag) => ({ bleedLag: lag })), tgt: { recall: 0.85, false: 0 } },
  // 2026-09-15: 0.91 → 0.89 under the rig synth, five misses instead of four —
  // margin, not behaviour (seed 2 was the sharp one: 0.02 with 14 false).
  // follower 2026-09-15.14 reads 0.91 f0 e0 on seed 1 and 0.98 on seed 2.
  'sr44':           { mut: { sr: 44100 },                          tgt: { recall: 0.90, false: 0, latMed: 150 } },
  // Start at a later beat; the first note follows the seek by 0.2–0.4 × IOI.
  // 2026-09-15: 0.97 → 0.87 under the rig synth, with a 5.99 s stall over
  // indices 11–18 (bound 5 s). follower 2026-09-15.14 reads 0.93 f0 e0 stall
  // 1.15 s on seed 1; seed 2 keeps the f1 it had under .13.
  'start-late':     { sweep: [{ startAt: 1, startLead: 0.2 }, { startAt: 1, startLead: 0.4 }, { startAt: 6, startLead: 0.3 }], tgt: { recall: 0.90, false: 0, latMed: 150 } },
  // The two scenarios below reproduce .accompy_traces/20260915-081347, in which
  // the engine advanced 12 times in 16.7 s and then stalled dead at index 12
  // (G2) while the pianist played 28 more notes over 45 s — precision 100 %,
  // recall 29 %. Neither failure mode had a scenario before.
  //
  // low-note-weak: the lowest solo notes through a laptop speaker (−21 dB on
  // G2's fundamental) with the accompaniment striking the very pitches its
  // partials land on. The stall bound is the point: a G2 the engine cannot
  // identify costs one advance, and that is what `recall` is for — a G2 that
  // takes the WRONG absorber with it costs the rest of the piece.
  //
  // 2026-09-15: under the rig synth this is now explicitly the `speaker-dead-low`
  // variant. R(f) already takes 13.6 dB off 98 Hz, so speakerHpf 180 / order 4
  // adds another 21.2 on top — a laptop deader at the bottom than the one that
  // was measured, which is exactly the case this scenario is for, but it is no
  // longer a picture of THIS rig. Re-baselined 0.93 f0 → 0.86 f1; the one false
  // was index 12 (G2) committed 1.2 s late, and 0/8/12/27 were missed.
  // follower 2026-09-15.14 reads 0.89 f0 e0 (seed 2: 0.96 f0 e0).
  'low-note-weak':  { mut: { score: 'lownote', speakerHpf: 180, speakerOrder: 4, jitter: 30 }, tgt: { recall: 0.85, false: 0, stall: 4 } },
  // low-note-lag-over: the same rig with the engine's ASSUMED speaker→mic loop
  // 72 ms LONGER than the real one (130 ms against BASE's 58). That is the
  // sign the app gets wrong in practice — 20260915-081347 started from
  // measuredOutputMs + 25 = 58 ms against a measured median of 112 ms, and
  // 20260915-081347's own fundamental-starved trim believed 145.7 ms against
  // 101 ms — and it opens a window in which accompaniment energy is already in
  // the mic while _envA still reports zero. Nothing covered it: on
  // low-note-weak even --opt bleedLagMs=250 stays green, because the trim read
  // _bleedLive, which by construction is empty for exactly those frames.
  // Measured at 130 ms over seeds 1-6: worst-seed recall 0.93 reading
  // _bleedLive against 0.89 reading _maskAcc — the four extra misses are G2s
  // the engine now declines to claim on rows a D4/G4 bleed owns, and false
  // stays 0, which is the trade this scenario exists to hold in place.
  'low-note-lag-over': { mut: { score: 'lownote', speakerHpf: 180, speakerOrder: 4, jitter: 30, opt: { bleedLagMs: 130 } }, tgt: { recall: 0.85, false: 0, stall: 4 } },
  // wrong-lock-recovery: eight notes in a row that are in the score nowhere —
  // long enough for the absorber to saturate, since the longest LEGITIMATE run
  // of pWrong ≥ 0.5 in the real session was 854 ms and the failure was
  // 49 523 ms — and then the pianist resumes the written part on the very event
  // the engine was waiting for. Three targets, each pulling a different way:
  // `pWrong` says the absorber must really take the mass (otherwise the
  // scenario is not testing recovery at all), `pWrongRun` says it must then
  // give it back — measured 7.0 s of sound with the wrongDwellMs watchdog
  // disabled against 2.5 s with it, so this is the target that holds that
  // watchdog in place — and `stall` measures the re-lock itself, because the
  // intruders are excluded from it.
  //
  // The intruders are inserted at index 12 rather than at index 8, and that is
  // a statement about what is knowable, not a choice of an easy spot. Twinkle
  // writes G2 at index 8 and again at index 12, four beats apart. Insert eight
  // beats in front of index 8 and the pianist's next G2 is BOTH the note the
  // engine is waiting for and exactly where its tempo clock now points (index
  // 12) — pitch and clock agree on the wrong answer, and no evidence in the
  // audio separates "the pianist played eight unwritten beats" from "the engine
  // missed indices 8–11". Measured: 18 advances up to 8.7 s ahead of the
  // pianist. At index 12 the eight-beat skip lands on index 20 (C3), the pitch
  // gate refuses it, and the question reduces to the one being asked.
  'wrong-lock-recovery': { mut: { wrongInsert: [12, 8] },           tgt: { recall: 0.80, false: 0, pWrong: 0.5, pWrongRun: 4.0, stall: 4 } },
  // dense-drift: the failure of
  // .accompy_traces/20260916-113517_cello-concerto-no1-in-c-major-hobviib1-joseph-ha
  // (143 solo notes over 120 beats, recall 7 %, the accompaniment 40 s adrift)
  // reproduced synthetically. 200 events of that excerpt's OWN score at its own
  // spacing — median 250 ms, fastest decile 125 ms, 66 of 199 gaps under
  // 0.25 beat — with the long tutti rests clamped out, so the pianist never
  // stops and the engine never gets a free re-lock. See DENSE_RH for the
  // provenance of every number and for the one deliberate departure (register).
  //
  // WHAT IT REPRODUCES. The drift is monotone and it is the density that
  // produces it, not the noise: `snr` is BASE's 30 dB and the solo is in
  // Twinkle's own register. Measured on the shipped engine (2026-09-16.9),
  // `--trace /dev/null`, one seed per command:
  //   seed 1  recall 0.39 (78/200)  false 14  early 0  drift 47 / 31.7 s  stall 11.2 s
  //   seed 2  recall 0.55 (109/200) false  9  early 0  drift 25 /  6.2 s  stall  7.9 s
  // against Twinkle's 0.93-0.98 on the same synth. THE SCENARIO IS RED AND IS
  // DECLARED RED (see TARGETS below); these numbers are what it does, not what
  // it has to do. Latency where the engine IS tracking is 76 ms median / 155 ms
  // p90 — under the 125 ms fastest decile at the median, i.e. the engine CAN
  // claim a 125 ms note — and it dies in the 125 ms passages: seed 1 loses
  // events 14-88 outright and again 92-117, seed 2 the runs at #168 on. Nothing
  // else in the suite contains a 125 ms passage; both Twinkles' shortest IOI is
  // 985 ms.
  //
  // WHERE THE `false` COUNT COMES FROM, measured rather than assumed: every one
  // of the 14 (seed 1) and 9 (seed 2) is the RIGHT event committed late — 410 ms
  // to 24.2 s late, see `lateMs` in the false list. Zero are mis-indexed, zero
  // land on an unplayed event, and `early` is 0 on both seeds. On this scenario
  // `false` is `drift` seen from the other end, and the precision that matters
  // is intact.
  //
  // THE RESYNC IS CORRECT HERE AND IT IS NOT ENOUGH. 8 armed wide scans per
  // seed, 3 fired and 5 refused `ambiguous` on both; `resyncBad` is 0 on both,
  // i.e. no jump landed on an event the pianist had not begun. The `ahead 1` in
  // the scans line is the ALIGNMENT's own winner, not the landing: the search
  // picks one event past the pianist (`top-1 err med 1`, `|err|<=1 1.00`) and
  // `resyncLandBack` is what turns that into a landing one event short. Read
  // the two together — `ahead` says what the search chose, `resyncBad` says
  // where the accompaniment went.
  //
  // But a jump needs `resyncContext` 16 onsets plus two LOST episodes to arm,
  // which is ~2-4 s of music, while a 16-note 125 ms run puts the engine 16
  // events behind in 2 s. The mechanism closes a gap it cannot prevent.
  //
  // WHAT WAS MEASURED AND NOT SHIPPED, so it is not re-tried blind:
  //  - Loosening the dense-passage accumulation gate (`denseMinEvNats` 2.5->1.5,
  //    `denseMinEvNatsSoft` 5->3, `denseMinCommitGapMs` 70->40, all of which
  //    apply ONLY where the score's rolling 5-event median IOI is under
  //    250 ms) makes this scenario WORSE, not better: 0.45 -> 0.20, false
  //    16 -> 27, and `early` 0 -> 1. Accumulation speed is not the binding gate
  //    at 125 ms and buying it costs the one thing that may not be traded. Same
  //    conclusion the engine owner reached on the Haydn replay, now with a
  //    bench that can see it.
  //  - `resyncArmLostEpisodes` 2 -> 1 (arm on the FIRST lost episode rather
  //    than the second) is the single constant that moves this scenario:
  //    recall 0.45 -> 0.56, false 16 -> 8, drift 28 -> 23, drift run
  //    10.3 -> 7.7 s, with all three fires still exact and none ahead. Both
  //    reference Twinkle replays stay bit-identical (39/42 and 38/42, false 0,
  //    resync 0) because `resyncArmOnsets` 8 alone excludes them — they never
  //    exceed 3 onsets between commits. It is NOT shipped here for one measured
  //    reason: the Haydn replay's false count goes 3 -> 4 (a third jump fires
  //    at t 83.0 s, 118 -> 131), and `2` is the value whose justification is
  //    written into the engine as "the measured populations do not overlap".
  //    Trading a real-trace precision point for a synthetic recall point on the
  //    strength of a scenario written the same afternoon is the wrong way round.
  //    It belongs to whoever owns the engine, with the rig run to check it.
  //  - Removing the jump prior altogether (`resyncMarginNats` 0,
  //    `resyncPenaltyNats` 0, `resyncMinNewShare` 0, arm on everything, no rate
  //    limit) DOES reach the recall target — 0.60 (120/200), drift 28 -> 15,
  //    false 16 -> 5 — and pays for it exactly where Nakamura's s_j sweep says
  //    it will: `early` 0 -> 1 and one jump landing two events past the pianist
  //    (#120 while he was on #118). That is the whole argument for the shipped
  //    1.5-nat bar in one command, and it doubles as the self-test that the
  //    `resyncBad` assertion below actually binds: it is the only configuration
  //    found that trips it.
  //
  // TARGETS. These are the brief's, and they are a statement of what the tool
  // has to do rather than a record of what it does — this scenario is RED and
  // is expected to stay red until the detection side of the resync can arm
  // without waiting for two LOST episodes. `skipped-note` carries the same
  // kind of declared-red target (see its note). None of them may be lowered.
  //  - recall 0.60. Justified from this bench's own measurements, not chosen to
  //    pass: the engine's commit latency in the stretches where it IS tracking
  //    here is 76 ms median / 155 ms p90, at or below the 125 ms fastest decile
  //    at the median, so there is no structural reason a dense note cannot be
  //    claimed; and seed 2 already holds 109/200 (0.55) as shipped and 113/200
  //    (0.56) with the one arming change described above. 0.60 is the nearest
  //    round number above what a single arming change already reaches.
  //  - drift 4 events and drift run 4 s are the LOAD-BEARING targets, not
  //    recall. The product point is that the accompaniment is in the right
  //    place: an engine that misses half the notes but stays within four
  //    events is doing its job, and one that catches every third note sixty
  //    events late is what the user heard as "completely disorganized". 4
  //    events is ~1 s at this spacing; perfect tracking reads 1 (see DRIFT_OK).
  //  - false 0 and early 0 are the safety pair and are never relaxed. `early`
  //    is 0 on both seeds as shipped and must stay there. It has been 1 once:
  //    before `resyncLandBack`, seed 1 read `early 1` (event 64 committed
  //    235 ms BEFORE its onset, 448 ms after a jump that landed on event 62
  //    while the pianist's last begun event was 61), and `--opt
  //    resyncMaxJump=0` took it back to 0 — i.e. the jump was what enabled it.
  //    That is why the landing is now one event short and why `resyncBad` is
  //    one-sided; if either is ever loosened, re-run this seed first.
  'dense-drift':    { mut: { score: 'dense' },                     tgt: { recall: 0.60, false: 0, drift: 4, driftRun: 4.0 } },
};

// Every scenario, not only the two that name it: the pianist may never be left
// playing for this long with the engine standing still. 5 s is ~3.5 beats at
// the rig's 60 bpm and 1/9 of the real session's stall.
const STALL_MAX_SEC = 5;

function evaluate(m, tgt) {
  const fails = [];
  if (tgt.recall !== undefined && m.recall < tgt.recall) fails.push(`recall ${m.recall.toFixed(2)} < ${tgt.recall}`);
  if (tgt.false !== undefined && m.false > tgt.false) fails.push(`false ${m.false} > ${tgt.false}`);
  // Every scenario: no advance ahead of the pianist, ever (see EARLY_MS).
  if (m.early > 0) fails.push(`early ${m.early} > 0`);
  if (tgt.latMed !== undefined && (m.latencyMedian === null || m.latencyMedian > tgt.latMed)) fails.push(`lat med ${fmt(m.latencyMedian)} > ${tgt.latMed}`);
  if (tgt.latP90 !== undefined && (m.latencyP90 === null || m.latencyP90 > tgt.latP90)) fails.push(`lat p90 ${fmt(m.latencyP90)} > ${tgt.latP90}`);
  if (tgt.pWrong !== undefined && (m.pWrongMedian === null || m.pWrongMedian < tgt.pWrong)) fails.push(`pWrong ${fmt(m.pWrongMedian, 2)} < ${tgt.pWrong}`);
  if (tgt.resync && m.resyncFails > 0) fails.push(`resync failed ${m.resyncFails}×`);
  // Every scenario: no jump the engine should not have made (see resyncBad).
  if (m.resyncBad > 0) fails.push(`resync landed wrong ${m.resyncBad}×`);
  if (tgt.drift !== undefined && m.driftMax > tgt.drift) fails.push(`drift ${m.driftMax} > ${tgt.drift} events`);
  if (tgt.driftRun !== undefined && m.driftRunSec > tgt.driftRun) fails.push(`drift run ${fmt(m.driftRunSec, 1)}s > ${tgt.driftRun}s`);
  const stallMax = tgt.stall ?? STALL_MAX_SEC;
  if (m.stall > stallMax) fails.push(`stall ${fmt(m.stall, 2)}s > ${stallMax}s`);
  if (tgt.pWrongRun !== undefined && m.pWrongRunMs / 1000 > tgt.pWrongRun) fails.push(`pWrong run ${fmt(m.pWrongRunMs / 1000, 1)}s > ${tgt.pWrongRun}s`);
  return fails;
}

const fmt = (v, d = 0) => (v === null || v === undefined || Number.isNaN(v) ? '-' : Number(v).toFixed(d));

function buildCfg(args, mut) {
  const cfg = { ...BASE, ...mut, sr: mut.sr ?? args.sr, opt: { ...(mut.opt || {}), ...args.opt }, trackerDebounce: args.trackerDebounce, reverb: args.reverb, dumpSal: args.dumpSal };
  // Explicit CLI flags override the scenario mutation (they are the user's experiment).
  if (args.bleed !== undefined) cfg.bleed = args.bleed;
  if (args.snr !== undefined) cfg.snr = args.snr;
  if (args.hpf !== undefined) cfg.hpf = args.hpf;
  if (args.jitter !== undefined) cfg.jitter = args.jitter;
  if (args.rubato !== undefined) cfg.rubato = args.rubato;
  if (args.bleedLag !== undefined) cfg.bleedLag = args.bleedLag;
  if (args.bleedClip !== undefined) cfg.bleedClip = args.bleedClip;
  if (args.tempoScale !== undefined) cfg.tempoScale = args.tempoScale;
  if (args.synth !== undefined) cfg.synth = args.synth;
  return cfg;
}

function runOne(args, scenarioName, mut, seed, hooks) {
  const cfg = buildCfg(args, mut);
  const score = loadScore({ ...args, score: cfg.score || args.score });
  cfg.bps = args.bpm ? args.bpm / 60 : score.bps;
  const rng = makeRng(seed * 7919 + 17);
  const perf = synthesise(score, cfg, rng);
  const res = runEngine(perf, score, cfg, hooks);
  const metrics = scoreRun(res, perf);
  return { cfg, score, perf, res, metrics };
}

// Worst run over seeds: the metric that would fail hardest wins.
function worstOf(runs, tgt) {
  let worst = runs[0];
  for (const r of runs) {
    const f = evaluate(r.metrics, tgt), fw = evaluate(worst.metrics, tgt);
    if (f.length > fw.length) worst = r;
    else if (f.length === fw.length && (r.metrics.early > worst.metrics.early || r.metrics.recall < worst.metrics.recall || r.metrics.false > worst.metrics.false || r.metrics.stall > worst.metrics.stall || r.metrics.driftMax > worst.metrics.driftMax)) worst = r;
  }
  return worst;
}

// Per-event timeline: what the engine did around each true onset.
function printTimeline(run) {
  const { perf, res } = run;
  const acc = res.advances.filter((a) => a.accepted);
  for (const e of perf.events) {
    const win = acc.filter((a) => a.toIndex === e.k);
    const t0 = e.T * 1000;
    const holds = {};
    for (const h of res.holds) if (h.tMs - SIM_TRANSPORT_MS >= t0 - 50 && h.tMs - SIM_TRANSPORT_MS <= t0 + 450 && h.index === e.k) holds[h.reason] = (holds[h.reason] || 0) + 1;
    const hit = win.map((a) => `${Math.round(a.audioMs - t0)}ms${a.repeatLike ? ' rep' : ''} p${a.p.toFixed(2)} ev${a.ev.toFixed(1)}`).join('; ');
    const pitches = e.pitches.map(pitchName).join('+');
    console.log(`  #${String(e.k).padStart(2)} ${pitches.padEnd(6)} beat ${String(e.beat).padStart(3)} T ${(e.T).toFixed(2)}s ${e.kind.padEnd(7)} ${hit ? 'commit +' + hit : 'MISS'}  holds ${JSON.stringify(holds)}`);
  }
}

// ---------------------------------------------------------------------------
// Debug outputs (§8.5)
// ---------------------------------------------------------------------------

function writeWav(file, mix, sr) {
  const n = mix.length, buf = Buffer.alloc(44 + n * 2);
  buf.write('RIFF', 0); buf.writeUInt32LE(36 + n * 2, 4); buf.write('WAVE', 8);
  buf.write('fmt ', 12); buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(1, 22);
  buf.writeUInt32LE(sr, 24); buf.writeUInt32LE(sr * 2, 28); buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34);
  buf.write('data', 36); buf.writeUInt32LE(n * 2, 40);
  for (let i = 0; i < n; i++) buf.writeInt16LE(Math.max(-32768, Math.min(32767, Math.round(mix[i] * 32767))), 44 + i * 2);
  fs.writeFileSync(file, buf);
}

const r1 = (v) => (v === null || v === undefined ? null : Math.round(v * 10) / 10);
const r2 = (v) => (v === null || v === undefined ? null : Math.round(v * 100) / 100);
const r3 = (v) => (v === null || v === undefined ? null : Math.round(v * 1000) / 1000);
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const pitchName = (m) => (Number.isFinite(m) ? NOTE_NAMES[((m % 12) + 12) % 12] + (Math.floor(m / 12) - 1) : '?');

// NPTrace-shaped file so scripts/trace_report.py renders sim runs unchanged (§8.5).
function buildTrace(run, args) {
  const { cfg, score, perf, res } = run;
  const t0 = perf.preRoll * 1000;   // trace t = ms from piece start
  const events = [];
  const log = (t, type, data) => events.push({ t: Math.round((t - t0) * 10) / 10, type, ...data });
  const solo = normaliseEvents(score.rh);
  // One key per EVENT with its lead pitch (the rig types one key per event and
  // the follower emits one mic.note per event; a key per chord pitch made the
  // report's ground truth steal the next event's note as a wrong pitch).
  for (const e of perf.events) if (e.kind !== 'skipped' && e.kind !== 'silent') {
    const lead = solo[e.k]?.lead ?? e.pitches[e.pitches.length - 1];
    log(e.T * 1000, 'input.key', { midi: lead, name: pitchName(lead), pitches: e.pitches, src: 'sim', index: e.k, kind: e.kind });
  }
  // The intruders are keys the pianist really pressed: they belong in the
  // ground truth even though no advance should ever name them.
  for (const e of perf.intruders || []) log(e.T * 1000, 'input.key', { midi: e.pitches[e.pitches.length - 1], name: pitchName(e.pitches[e.pitches.length - 1]), pitches: e.pitches, src: 'sim', index: null, kind: 'intruder' });
  const hookFires = run.fires || [];
  for (const f of hookFires) log(f.audioMs, 'accomp.fire', { pitches: f.pitches, beat: f.beat });
  // atMs on the trace clock (same as the app): --replay passes it straight to accompanimentFired.
  for (const f of hookFires) log(f.audioMs, 'follow.accomp', { pitches: f.pitches, beat: f.beat, atMs: Math.round(f.atMs - SIM_TRANSPORT_MS - t0), durMs: Math.round(f.durMs) });
  // The sim's own seek (runEngine), recorded the way app.js:6522 records the
  // app's. Without it --replay finds no follow.seek, falls back to seekPos 0
  // and replays a DIFFERENT session: start-late seed 1 reports recall 0.97 live
  // but 0.38 (keys 40, hits 15) replayed from its own --out trace; with the
  // event it replays back to 0.97 / false 0.
  if (res.seekAudioMs !== null && res.seekAudioMs !== undefined) {
    log(res.seekAudioMs, 'follow.seek', { position: perf.startAt, atMs: Math.round(res.seekAudioMs - t0) });
  }
  let lastBest = null, lastHoldKey = new Map();
  for (const { frame, out } of res.frames) {
    const audioMs = out.tMs - SIM_TRANSPORT_MS;
    const bestChanged = (out.best?.i ?? null) !== lastBest; lastBest = out.best?.i ?? null;
    // Every frame, verbose (quantised `sal` + `pk`), the app's FOLLOWER_TRACE 'frames'
    // format: that is what `--replay` consumes, so a sim trace replays into itself.
    {
      const P = new Map(out.posterior.map((p) => [p.i, p.p]));
      log(audioMs, 'follow.frame', {
        engine: 'follower', k: out.seq, at: frame.at, mode: out.mode, pos: out.position, best: out.best?.i ?? null, p: r3(out.best?.p),
        pcom: r3(P.get(out.committed) || 0), pcur: r3(P.get(out.committed + 1) || 0), pnext: r3(P.get(out.committed + 2) || 0), pw: r3(out.pWrong),
        ev: r2(out.advance?.ev ?? out.hold?.ev ?? null), db: r1(frame.db), on: r2(out.onset.ratio), peak: frame.peak, inf: out.informative,
        sal: Array.from(salience.quantiseSal(frame.sal)), pk: r1(10 * Math.log10(Math.max(...frame.sal) + 1e-20)),
        g: frame.gated ? 1 : 0, a: frame.attack ? 1 : 0, fl: r1(frame.floorDb),
        ur: out.maskRows || 0, gr: r2(out.bleedRef || 0),
      });
    }
    if (out.state) log(audioMs, 'follow.state', { engine: 'follower', mode: out.state.mode, reason: out.state.reason, pos: out.position, p: r3(out.best?.p) });
    if (out.recovered) log(audioMs, 'follow.recover', { engine: 'follower', mode: out.recovered.mode, reason: out.recovered.reason, pos: out.position, pw: r3(out.pWrong) });
    if (out.bleed) log(audioMs, 'follow.bleed', { engine: 'follower', pitches: out.bleed.pitches, atMs: Math.round(out.bleed.atMs), seenMs: Math.round(out.bleed.seenMs), lagMs: Math.round(out.bleed.lagMs), strength: r2(out.bleed.strength) });
    // THE WIDE JUMP, in its own event types, beside the advance and never
    // inside it (see trace_report.py's follow.resync renderer: an advance is
    // "the player played this note", a resync is "the player is not here at
    // all", and precision may only ever be computed from the first). Without
    // these two arms a --out trace showed the tracker teleporting with nothing
    // to explain it: `--scenario dense-drift --seed 1 --out <path>` wrote a
    // type histogram of follow.advance 84 / tracker 84 and ZERO follow.resync
    // for a run that accepted three jumps (13->62, 91->111, 119->144).
    // Field names follow app.js traceFollowerResync / the resyncScan log, so
    // one report renders a sim trace and a session trace the same way.
    if (out.resyncScan) {
      const sc = out.resyncScan;
      log(audioMs, 'follow.resyncScan', {
        engine: 'follower', fromIndex: sc.fromIndex, toIndex: sc.toIndex, distance: sc.distance, refused: sc.refused ?? null,
        contextOnsets: sc.contextOnsets, contextMs: sc.contextMs, windowSize: sc.windowSize, reach: sc.reach,
        marginNats: r2(sc.marginNats), leadNats: r2(sc.leadNats), meanNewShare: r2(sc.meanNewShare),
        mode: sc.mode, lostEpisodes: sc.lostEpisodes, onsetsSinceAccept: sc.onsetsSinceAccept,
      });
    }
    if (out.resync) {
      const rs = out.resync, rec = res.resyncs.find((r) => r.id === rs.id);
      const accepted = !!rec?.accepted;
      log(audioMs, 'follow.resync', {
        engine: 'follower', id: rs.id, accepted, reason: accepted ? null : 'tracker refused',
        // As in app.js: `fromIndex` is the tracker's convention (the next event
        // expected), `engineFrom` the engine's committed one.
        fromIndex: rs.fromIndex + 1, toIndex: rs.toIndex, engineFrom: rs.fromIndex,
        beat: rs.beat, jump: rs.toIndex - (rs.fromIndex + 1),
        onsetsConfirmed: rs.onsetsConfirmed, contextMs: rs.contextMs,
        marginNats: r2(rs.marginNats), leadNats: r2(rs.leadNats), meanNewShare: r2(rs.meanNewShare),
        windowSize: rs.windowSize, reach: rs.reach,
        sinceOnsetEvidenceMs: Math.round(rs.sinceOnsetEvidenceMs), mode: rs.mode,
        nInSession: res.resyncs.filter((r) => r.id <= rs.id && r.accepted).length,
        expected: rs.pitches,
      });
      // The tracker moved, so the tracker event has to say so — `resync: true`
      // is what tells the report this row is not an ordinary advance.
      const rev = solo[rs.toIndex], expEv = solo[rs.fromIndex + 1] || null;
      log(audioMs, 'tracker', {
        src: 'follower', resync: true, input: rev.pitches, inputName: rev.pitches.map(pitchName).join(' '),
        posBefore: rs.fromIndex + 1, posAfter: accepted ? rs.toIndex + 1 : rs.fromIndex + 1,
        expected: expEv ? expEv.pitches : [], expectedName: expEv ? expEv.pitches.map(pitchName).join(' ') : '', expectedBeat: expEv ? expEv.beat : null,
        result: accepted ? 'match' : 'reject', beat: accepted ? rs.beat : null,
        engine: 'follower', ev: null, sinceOnsetMs: null, lagMs: Math.round(rs.lagMs), bps: r3(rs.bps), mode: out.mode,
      });
    }
    if (out.advance) {
      const adv = out.advance, rec = res.advances.find((a) => a.id === adv.id);
      const ev = solo[adv.toIndex], lead = ev.lead;
      log(audioMs, 'mic.note', { midi: lead, name: pitchName(lead), engine: 'follower', reason: 'follower', matched: false, clarity: null, freq: null, calibrating: false,
        heardMidi: adv.heardMidi, heardName: pitchName(adv.heardMidi), p: r3(adv.p), margin: r3(adv.margin), ev: r2(adv.ev),
        sinceOnsetMs: Math.round(adv.sinceOnsetMs), lagMs: Math.round(adv.lagMs), onsetDriven: adv.onsetDriven, repeatLike: adv.repeatLike });
      // Same bag as app.js traceTrackerResult (the report reads `result`, `inputName`, `expectedName`, `posAfter`).
      const accepted = !!rec?.accepted, expEv = solo[adv.fromIndex + 1] || null;
      log(audioMs, 'tracker', {
        src: 'follower', input: ev.pitches, inputName: ev.pitches.map(pitchName).join(' '),
        posBefore: adv.fromIndex + 1, posAfter: accepted ? adv.toIndex + 1 : adv.fromIndex + 1,
        expected: expEv ? expEv.pitches : [], expectedName: expEv ? expEv.pitches.map(pitchName).join(' ') : '', expectedBeat: expEv ? expEv.beat : null,
        result: accepted ? 'match' : 'reject', beat: accepted ? adv.beat : null,
        engine: 'follower', p: r3(adv.p), margin: r3(adv.margin), ev: r2(adv.ev), sinceOnsetMs: Math.round(adv.sinceOnsetMs), lagMs: Math.round(adv.lagMs), bps: r3(adv.bps), mode: out.mode, repeatLike: adv.repeatLike,
      });
      log(audioMs, 'follow.advance', { engine: 'follower', id: adv.id, fromIndex: adv.fromIndex, toIndex: adv.toIndex, beat: adv.beat, accepted: !!rec?.accepted, p: r3(adv.p), margin: r3(adv.margin), ev: r2(adv.ev),
        sinceOnsetMs: Math.round(adv.sinceOnsetMs), onsetAtMs: Math.round(adv.onsetAtMs), lagMs: Math.round(adv.lagMs), bps: r3(adv.bps),
        onsetDriven: adv.onsetDriven, repeatLike: adv.repeatLike, onsetScore: adv.onsetScore, heardMidi: adv.heardMidi,
        explained: { bleed: r2(adv.explained.bleed), prev: r2(adv.explained.prev), new: r2(adv.explained.new), other: r2(adv.explained.other) },
        hyps: adv.hyps.map((h) => ({ i: h.i, tOn: Math.round(h.tOnMs), bps: r3(h.bps), w: r3(h.w), ev: r2(h.ev) })) });
    } else if (out.hold) {
      const key = out.hold.reason + ':' + out.hold.index, last = lastHoldKey.get(key);
      if (last === undefined || audioMs - last >= 250) {
        lastHoldKey.set(key, audioMs);
        const h = out.hold;
        log(audioMs, 'follow.hold', { engine: 'follower', reason: h.reason, index: h.index, pos: out.position, p: r3(h.p), margin: r3(h.margin), ev: r2(h.ev), onsetSeen: h.onsetSeen, dwellMs: Math.round(h.dwellMs) });
        if (h.onsetSeen) {
          const exp = solo[h.index];
          log(audioMs, 'mic.drop', { engine: 'follower', reason: 'held:' + h.reason, expMidi: exp?.lead ?? null, expName: exp ? pitchName(exp.lead) : null, index: h.index, p: r3(h.p), margin: r3(h.margin), ev: r2(h.ev) });
        }
      }
    }
  }
  events.sort((a, b) => a.t - b.t);
  const rhDur = normaliseEvents(score.rh).map((e, i, arr) => e.durBeats ?? (i + 1 < arr.length ? arr[i + 1].beat - e.beat : 1));
  return {
    version: 1, app: 'notepilot', startedAt: new Date().toISOString(),
    meta: { inputMode: 'mic', micEngine: 'follower', followMode: 'leader', score: score.name, initialBps: cfg.bps, rh: score.rh, lh: score.lh, rhDur,
      sim: { scenario: run.scenario, seed: run.seed, cfg: { ...cfg, opt: cfg.opt } },
      // No `tag`: that is the app's ?v= cache-buster for /follower.js, and the
      // sim imports the module directly, so any value here is a guess that goes
      // stale (it was two bumps behind). version is the real identity.
      follower: { version: FOLLOWER_VERSION, sr: cfg.sr, win: cfg.opt.win ?? 2048, hop: cfg.opt.hop ?? 512, minMidi: 33, maxMidi: 96, options: res.follower.options } },
    events,
  };
}

// --replay: feed a verbose session's follow.frame events through a fresh engine (§8.5).
function replay(args) {
  const tr = JSON.parse(fs.readFileSync(args.replay, 'utf8'));
  const meta = tr.meta || {};
  const fmeta = meta.follower || {};
  const sr = fmeta.sr || 48000, win = fmeta.win || 2048, hop = fmeta.hop || 512;
  const frames = tr.events.filter((e) => e.type === 'follow.frame' && Array.isArray(e.sal));
  if (!frames.length) { console.error('replay: no verbose follow.frame events (sal) in ' + args.replay); process.exit(2); }
  const engineOpts = { ...(fmeta.options || {}), ...args.opt }; delete engineOpts.win; delete engineOpts.hop;
  const follower = new ScoreFollower({ solo: meta.rh, accompaniment: meta.lh || [], bps: meta.initialBps || 1, sampleRate: sr, win, hop, position: 0, options: { ...engineOpts, accompFallback: false } });
  const solo = normaliseEvents(meta.rh);
  const tracker = new FakeTracker(solo, { bps: meta.initialBps || 1 });
  const keys = tr.events.filter((e) => e.type === 'input.key');
  const accomp = tr.events.filter((e) => e.type === 'follow.accomp');
  let accIdx = 0;
  // Auto-skips are part of the session, not noise: skipCurrentNote (app.js:6008)
  // moves the tracker AND calls _scoreFollower.seek(state.tracker.position),
  // which rebuilds the beam at the new index. A replay that drops them keeps
  // arguing for the event the live engine stepped over, so the two engines are
  // no longer running the same session. Measured on 20260916-154809 (7 skips,
  // all `auto`): without this the replay reads 212 advances / last index 241 /
  // 1 spurious resync and diverges at advance #1; with it, 230 / 252 / 0 and
  // the first divergence is at advance #153, against the live 237 / 254 / 0.
  // `skip.toIndex` IS the `state.tracker.position` app.js passes to seek().
  // reason 'out-of-bank' (app.js:7788) is the one skip path that does NOT seek
  // the follower — it only walks the tracker — so it must not seek here either.
  const skips = tr.events.filter((e) => e.type === 'skip' && Number.isFinite(e.toIndex));
  let skIdx = 0;
  // The app does TWO things between `new ScoreFollower` and the first frame
  // (resetScoreFollower: seek() then seedFollowerAccompaniment()), and a replay
  // that skips them is not replaying the same engine. Without the seek the
  // committed state is unstamped, so it looks like it has been sounding since
  // the page loaded; without the seed the notes already ringing when the engine
  // started are missing from the bleed model. Measured on 20260915-083040:
  // unpatched, index 0 committed at t=191ms — 1141ms before the first key and
  // 1523ms before the live engine's own commit — on frames peaking at MIDI 60,
  // i.e. the seeded C4; the replay reported recall 0.45 with `false` 2 for a
  // session whose recorded precision is 100 %. With both steps it commits at
  // t=1628ms (86ms from the live 1714ms) and reports recall 0.46 / false 1 —
  // the live figures — and 0.48 / false 0 once `fl` is used as well.
  const seekEv = tr.events.find((e) => e.type === 'follow.seek');
  const seedEv = tr.events.find((e) => e.type === 'follow.accomp.seed');
  const t0 = frames[0].t;
  const seekPos = seekEv && Number.isFinite(seekEv.position) ? seekEv.position : 0;
  follower.seek(seekPos, seekEv && Number.isFinite(seekEv.atMs) ? seekEv.atMs : t0);
  tracker.position = seekPos;
  // Pre-`follow.accomp.seed` traces (before follower 2026-09-15.10) still carry
  // the information: an accomp.fire with no follow.accomp beside it is a note
  // that sounded before the engine existed, which is exactly what the seed is.
  const seedList = seedEv ? (seedEv.list || []) : (() => {
    const firstAcc = accomp.length ? accomp[0].t : Infinity;
    const durMs = accomp.length ? quantile(accomp.map((a) => a.durMs).filter(Number.isFinite), 0.5) : 1000 / (meta.initialBps || 1);
    // "No follow.accomp beside it", not merely "before the first one": the fire
    // and the engine's notification are logged microseconds apart (2422.7 vs
    // 2422.9 ms in 20260915-083040), so a plain t < firstAcc test seeds the
    // first reported fire a second time and doubles its bleed.
    const reported = (e) => accomp.some((a) => Math.abs(a.t - e.t) <= 100 && String(a.pitches) === String(e.pitches));
    return tr.events.filter((e) => e.type === 'accomp.fire' && e.t < firstAcc && !reported(e))
      .map((e) => ({ pitches: e.pitches, atMs: e.t, durMs, beat: e.beat }));
  })();
  if (seedList.length) follower.seedAccompaniment(seedList);
  const hopMs = hop / sr * 1000;
  let Lprev = null; const fluxHist = [];
  let floorDb = null;
  // The level floor comes from the frame's own `fl` (app.js records it for
  // exactly this). Only pre-`fl` traces fall back to recomputing it from the
  // logged `db` with the analyzer's own rise rate: floorDb drives
  // lambda = clamp((db - floorDb - gateDb) / rangeDb, 0, 1), the weight behind
  // `informative`, the onset bar and every evidence term, and on
  // 20260915-083040 the reconstruction was off by 0.39 dB mean / 1.78 dB max
  // over 5750 frames — up to 15 % of rangeDb = 12. Not cosmetic: on that trace
  // it moves the replay from recall 0.46 / false 1 / p90 313 ms to 0.48 / 0 /
  // 361 ms.
  const floorRise = new FrameAnalyzer(sr, { win, hop }).floorRiseDbPerSec * hop / sr;
  const advances = [], resyncs = [];
  for (const fe of frames) {
    while (skIdx < skips.length && skips[skIdx].t <= fe.t) {
      const sk = skips[skIdx++];
      if (sk.reason !== 'out-of-bank') follower.seek(sk.toIndex, sk.t);
      tracker.position = sk.toIndex;
    }
    while (accIdx < accomp.length && accomp[accIdx].t <= fe.t) { const a = accomp[accIdx++]; follower.accompanimentFired({ pitches: a.pitches, atMs: a.atMs, durMs: a.durMs, beat: a.beat }); }
    const sal = salience.dequantiseSal(Uint8Array.from(fe.sal), fe.pk ?? 0);   // pk = peak salience power in dB
    const db = fe.db ?? -60;
    floorDb = Number.isFinite(fe.fl) ? fe.fl : (floorDb === null ? db : Math.min(floorDb + floorRise, db));
    let flux = 0; const L = new Float32Array(sal.length);
    for (let b = 0; b < sal.length; b++) { L[b] = Math.log(sal[b] + 1e-10); if (Lprev) { const d = L[b] - Lprev[b]; if (d > 0) flux += d; } }
    Lprev = L;
    let onset = 0;
    if (fluxHist.length >= 8) { const s = fluxHist.slice().sort((a, b) => a - b); const med = s.length & 1 ? s[s.length >> 1] : 0.5 * (s[(s.length >> 1) - 1] + s[s.length >> 1]); onset = flux / (med + 1e-6); }
    fluxHist.push(flux); if (fluxHist.length > 64) fluxHist.shift();
    // The onset ratio comes from the frame's own `on` (app.js records it, like
    // `fl` above, for exactly this); the reconstruction beside it is only for
    // traces that predate the field. `on` is the ONLY frame field observe()
    // reads besides sal/db/floorDb/gated/attack, and reconstructing it from
    // the recorded salience cannot be exact: `sal` is quantised to half-dB
    // steps against a 0.1 dB frame peak (quantiseSal), and the ratio is a
    // sum of 64 LOG differences over a 64-frame median, so the quantisation
    // does not average out. Measured over both sessions the error is tiny —
    // mean |d| 0.024, p95 0.06 — and that is exactly why it was not caught:
    // only 19 frames of 15604 on 20260915-204851 cross the level-scaled flux
    // bar at all (onsetThreshRepeat + (onsetThresh - onsetThreshRepeat)*λ),
    // and ONE of them disagrees. That one frame is k10074 at 4919 ms, live
    // ratio 3.39 against a bar of 3.375 at λ 0.35, reconstructed 3.31: it is
    // the onset evidence that confirms index 0, so without it the replay held
    // `no-onset-evidence` through the whole first phrase and committed index 0
    // at 54.9 s instead of the live engine's 4.99 s. The rest of the session
    // followed from there: hits 6 either way, but every posterior after the
    // first minute belonged to a session the user never played. With `on` the
    // replay reproduces the live commits to the frame (i0 4994 vs 4995 ms,
    // i1 8994 vs 8996, i2 10178 vs 10179), which is what a replay is for.
    if (Number.isFinite(fe.on)) onset = fe.on;
    const rms = 10 ** (db / 20);
    // `g`/`a` are the worklet's gate and attack flags as recorded (older verbose traces lack them: treated as false).
    const frame = { type: 'frame', seq: fe.k, at: fe.at ?? Math.round(fe.t / 1000 * sr), sr, win, hop, rms, db, floorDb, flux, onset, hfc: 0, sal, peak: 33 + sal.indexOf(Math.max(...sal)), noiseFloor: null, gated: !!fe.g, attack: !!fe.a };
    const out = follower.observe(frame, fe.t);
    // See the resync block in runEngine: its own emission, its own explicit
    // maxJump, never scored as a detection.
    if (out.resync) {
      const beat = tracker.advanceTo(out.resync.toIndex, { maxJump: out.resync.maxJump, inputPitch: 0 }, fe.t / 1000);
      follower.ack(out.resync.id, beat !== null);
      if (beat !== null) resyncs.push({ ...out.resync, tMs: out.tMs });
    }
    if (out.advance) {
      const beat = tracker.advanceTo(out.advance.toIndex, { maxJump: out.advance.maxJump, inputPitch: 0 }, fe.t / 1000);
      follower.ack(out.advance.id, beat !== null);
      if (beat !== null) advances.push({ ...out.advance, tMs: out.tMs });
    }
  }
  // Score against typed keys: same hit rule as the sim (−50..+400 ms), match on midi or ±12.
  let hits = 0; const used = new Set(); const lat = [];
  for (const k of keys) {
    const cand = advances.find((a) => !used.has(a.id) && a.tMs >= k.t - 50 && a.tMs <= k.t + 400 && a.pitches.some((p) => p === k.midi || Math.abs(p - k.midi) === 12));
    if (cand) { used.add(cand.id); hits++; lat.push(cand.tMs - k.t); }
  }
  // GROUND TRUTH WITHOUT A KEYBOARD. `hits`/`falses` above are scored against
  // typed keys, and a MICROPHONE session has none: 20260916-154809 carries 0
  // `input.key` events, so the unguarded `advances.length - used.size` read
  // `false 212` for a session whose recorded precision is 237 advances with
  // ZERO false ones — a phantom precision collapse, and the exact reading this
  // file's header warns about. When there are no keys, score the replay against
  // what the LIVE engine did instead: the trace's own accepted `follow.advance`
  // records. Same index, within ±2 s of the recorded commit, each record used
  // once and in order; recall is then "how much of the live session did this
  // engine reproduce" and `false` is "advances the live engine never made".
  let falses = keys.length ? advances.length - used.size : null;
  let recall = keys.length ? hits / keys.length : null;
  let truth = 'keys';
  if (!keys.length) {
    const live = tr.events.filter((e) => e.type === 'follow.advance' && e.accepted !== false);
    if (live.length) {
      const usedA = new Set();
      let matched = 0;
      for (const rec of live) {
        const cand = advances.find((a) => !usedA.has(a.id) && a.toIndex === rec.toIndex && Math.abs(a.tMs - rec.t) <= 2000);
        if (cand) { usedA.add(cand.id); matched++; lat.push(cand.tMs - rec.t); }
      }
      hits = matched;
      falses = advances.length - usedA.size;
      recall = matched / live.length;
      truth = 'follow.advance x' + live.length;
    }
  }
  const ceil = replayCeiling(keys, normaliseEvents(meta.rh));
  // SINGLE NOTES AND CHORDS, SCORED SEPARATELY. A two-note event is a different
  // problem from a one-note event — the fit has a voice BALANCE to get right as
  // well as a pitch (see chordVoices in follower.js) — and one recall number
  // over a score that is 95 % single notes hides it completely: on
  // 20260916-154809 the session that reads 95 % overall is 95 % on its 241
  // single notes and 64 % on its 14 chords. "Detected" is "some advance landed
  // on that index", over the events up to the furthest one reached, which is
  // the same population the engine had a chance at.
  const det = new Set(advances.map((a) => a.toIndex));
  const reached = advances.length ? Math.max(...advances.map((a) => a.toIndex)) : -1;
  let nS = 0, hS = 0, nC = 0, hC = 0; const missC = [];
  for (let i = 0; i <= reached; i++) {
    const ps = solo[i] ? solo[i].pitches : [];
    if (ps.length > 1) { nC++; if (det.has(i)) hC++; else missC.push(`${i}[${ps.join(',')}]`); }
    else if (ps.length === 1) { nS++; if (det.has(i)) hS++; }
  }
  // PRECISION WITHOUT A KEYBOARD. `false` above counts advances the live engine
  // did not make at the same time, and most of those are the SAME event
  // committed earlier or later than the live session managed — not a note the
  // player never played. The failure that actually matters is the engine
  // getting AHEAD of the player, so this reports the worst the replay's
  // committed index ever ran over the live session's, that session's own
  // precision being the reference (20260916-154809: 237 advances, zero false).
  let lead = null;
  if (truth !== 'keys') {
    const liveAdv = tr.events.filter((e) => e.type === 'follow.advance' && e.accepted !== false).slice().sort((a, b) => a.t - b.t);
    let li = 0, lc = -1, ri = 0, rc = -1;
    lead = -Infinity;
    for (const t of [...new Set(liveAdv.map((a) => a.t).concat(advances.map((a) => a.tMs)))].sort((a, b) => a - b)) {
      while (li < liveAdv.length && liveAdv[li].t <= t) lc = liveAdv[li++].toIndex;
      while (ri < advances.length && advances[ri].tMs <= t) rc = advances[ri++].toIndex;
      if (rc - lc > lead) lead = rc - lc;
    }
  }
  const m = { keys: keys.length, hits, recall, false: falses, truth, advances: advances.length, latencyMedian: quantile(lat, 0.5), latencyP90: quantile(lat, 0.9), ceiling: ceil.exact, ceilingOctave: ceil.octave, resyncs: resyncs.length,
    reached, singles: `${hS}/${nS}`, chords: `${hC}/${nC}`, chordRate: nC ? hC / nC : null, leadOverLive: Number.isFinite(lead) ? lead : null };
  if (args.json) console.log(JSON.stringify(m));
  else {
    console.log(`replay ${path.basename(args.replay)}: keys ${m.keys}  hits ${hits}  recall ${fmt(m.recall, 2)}  false ${fmt(falses, 0)}  adv ${advances.length}  latency med ${fmt(m.latencyMedian)} p90 ${fmt(m.latencyP90)} ms  resync ${resyncs.length}`);
    if (truth !== 'keys') console.log(`  no keyboard ground truth in this trace (0 input.key); scored against the session's own ${truth} records, so recall = share of the live commits this engine reproduced`);
    console.log(`  reached event ${reached}  singles ${m.singles} (${fmt(100 * hS / Math.max(1, nS))}%)  CHORDS ${m.chords} (${nC ? fmt(100 * hC / nC) : '-'}%)${missC.length ? '  missed: ' + missC.join(' ') : ''}`);
    if (m.leadOverLive !== null) console.log(`  worst lead over the live engine's committed index: ${m.leadOverLive >= 0 ? '+' : ''}${m.leadOverLive} events`);
    for (const r of resyncs) console.log(`  resync t ${(r.tMs / 1000).toFixed(1)}s  ${r.fromIndex} -> ${r.toIndex} (+${r.distance})  margin ${fmt(r.marginNats, 2)}  lead ${fmt(r.leadNats, 2)}  new ${fmt(r.meanNewShare, 2)}  ctx ${r.contextOnsets}/${r.contextMs}ms  ${r.mode}`);
    // The ceiling is a statement about TYPED KEYS (see the note below); with no
    // keys there is nothing to take a ceiling of, and printing 0/0 reads as a
    // failure rather than an absence.
    if (keys.length) console.log(`  ceiling ${ceil.exact}/${keys.length} keys are reachable in score order (${ceil.octave} under the scorer's own ±12 rule); hits ${hits} = ${fmt(100 * hits / Math.max(1, ceil.octave))}% of it`);
  }
}

// THE CEILING, and why the line above exists. `recall` here is hits over TYPED
// KEYS, and a typed key is not a thing a score follower can always answer: it
// may be a note the score does not contain at that point, and the engine is
// RIGHT to refuse it. On 20260915-083040 that is not a detail — 24 of the 28
// keys the shipped engine "misses" are G2 presses made while the score asks
// for C3 or B2 (the player held G2 down 15.7-21.5 s and again 38.5-53.7 s,
// testing the very note this workflow is about), so they are unreachable by
// anything that does not advance on notes the player did not play.
//
// The bound below is generous on purpose: the longest subsequence of keys that
// can be matched to score events in increasing order, one event per key. It
// ignores timing, ignores the onset gates and lets the engine skip ahead for
// free, so nothing real can beat it. On 20260915-083040 it is 30 of 56 keys
// (35 if octave answers are allowed, which is what the ±12 hit rule does) —
// i.e. "recall 0.50" against a reachable 0.54/0.63, not against 1.00, and the
// headroom this session can show is TWO keys, not twenty-eight. Five template
// rewrites were judged on the difference between 28 and 23; that difference is
// within the alignment noise of a session where the scored hits include a jump
// of five events credited to a G2 because the event landed on was its octave.
function replayCeiling(keys, events) {
  const K = keys.map((k) => k.midi), E = events.map((e) => e.pitches);
  const lcs = (match) => {
    const n = K.length, m = E.length;
    let next = new Int32Array(m + 1), cur = new Int32Array(m + 1);
    for (let i = n - 1; i >= 0; i--) {
      cur = new Int32Array(m + 1);
      for (let j = m - 1; j >= 0; j--) {
        let best = Math.max(next[j], cur[j + 1]);
        if (E[j].some((p) => match(K[i], p))) best = Math.max(best, 1 + next[j + 1]);
        cur[j] = best;
      }
      next = cur;
    }
    return cur[0];
  };
  return { exact: lcs((k, p) => k === p), octave: lcs((k, p) => k === p || Math.abs(k - p) === 12) };
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------

function main() {
  const args = parseArgs(process.argv.slice(2));
  if (args.replay) { replay(args); return; }
  const names = args.all ? Object.keys(SCENARIOS) : [args.scenario || 'bleed'];
  for (const n of names) if (!SCENARIOS[n]) { console.error('unknown scenario ' + n + '; known: ' + Object.keys(SCENARIOS).join(', ')); process.exit(2); }
  const seeds = []; for (let s = 0; s < Math.max(1, args.seeds); s++) seeds.push(args.seed + s);
  const summary = {};
  const rows = [];
  let anyFail = false;
  for (const name of names) {
    const sc = SCENARIOS[name];
    const variants = sc.sweep ? sc.sweep : [sc.mut];
    const runs = [];
    for (const mut of variants) for (const seed of seeds) {
      const fires = [];
      // SIM_DEBUG=t0,t1 (seconds of audio) prints one line per frame in that window.
      const dbg = process.env.SIM_DEBUG ? process.env.SIM_DEBUG.split(',').map(Number) : null;
      const onFrame = dbg ? (frame, out, follower) => {
        const t = (out.tMs - SIM_TRANSPORT_MS) / 1000;
        if (t < dbg[0] || t > dbg[1]) return;
        const post = out.posterior.map((p) => `${p.i}:${p.p.toFixed(2)}`).join(' ');
        const ex = out.explained ? `b${out.explained.bleed.toFixed(2)} p${out.explained.prev.toFixed(2)} n${out.explained.new.toFixed(2)} o${out.explained.other.toFixed(2)}` : '-';
        let hj = '';
        if (process.env.SIM_DEBUG_H && (out.hold || out.advance)) {
          const idx = out.hold ? out.hold.index : out.advance.toIndex;
          const h = follower._hyps.filter((q) => q.i === idx).sort((a, b) => b.logW - a.logW)[0];
          const par = follower._hyps.filter((q) => q.i === follower._committed).sort((a, b) => b.logW - a.logW)[0];
          if (h) hj = ` H[dist${(h.distinct || 0).toFixed(3)} mask${(() => { try { const m = follower._distinctMask(h.i); const rows = []; for (let b = 0; b < m.length; b++) if (m[b]) rows.push(b + 33); return rows.join('/'); } catch (e) { return '?'; } })()} cap${h.pvCap === Infinity ? 'inf' : h.pvCap.toFixed(0)} gPv${h.gPv.toFixed(0)} gNw${h.gNw.toFixed(0)} born${((h.born - SIM_TRANSPORT_MS) / 1000).toFixed(2)} tOn${((h.tOn - SIM_TRANSPORT_MS) / 1000).toFixed(2)} ${h.onsetDriven ? 'on' : 'soft'}]`;
          if (h && par && process.env.SIM_DEBUG_ELL) {
            let X2 = 0; for (const v of follower._x) X2 += v * v;
            const Bs = follower._bleedVectors(out.tMs);
            const rh = follower._hypLikelihood(h, follower._x, X2, Bs), rp = follower._hypLikelihood(par, follower._x, X2, Bs);
            hj += ` acc[${follower._accomp.map((a) => a.pitches.join('+') + '@' + ((a.atMs - SIM_TRANSPORT_MS) / 1000).toFixed(2) + (follower._envA(a, out.tMs) > 0.05 ? '*' : '')).join(' ')}] nB${Bs.length} gH[${Array.from(rh.gains || []).map((g) => g.toFixed(0)).join(',')}] gP[${Array.from(rp.gains || []).map((g) => g.toFixed(0)).join(',')}]`;
            hj += ` ell H${rh.ell.toFixed(2)} P${rp.ell.toFixed(2)} rssH${(rh.rss / X2).toFixed(3)} rssP${(rp.rss / X2).toFixed(3)} exH n${rh.explained.new.toFixed(2)} p${rh.explained.prev.toFixed(2)} logW H${h.logW.toFixed(1)} P${par.logW.toFixed(1)}`;
          }
          if (process.env.SIM_DEBUG_RES && follower._residual) {
            const res = follower._residual, x = follower._x;
            let X2 = 0; for (const v of x) X2 += v * v;
            const top = Array.from(res).map((v, b) => [v * v / X2, b + 33]).sort((a, b) => b[0] - a[0]).slice(0, 8);
            hj += ` RES[${top.map(([e, r]) => r + ':' + (e * 100).toFixed(1) + '%').join(' ')}]`;
            const hc = follower._hyps.filter((q) => q.i === follower._committed).sort((a, b) => b.logW - a.logW)[0];
            if (hc && hc.res) hj += ` C[${hc.res.comps.map((c, k) => c.name + '=' + hc.res.gains[k].toFixed(0)).join(' ')} caps f0${hc.nwCapF0 === Infinity ? 'inf' : hc.nwCapF0.toFixed(0)} ov${hc.nwCapOv === Infinity ? 'inf' : hc.nwCapOv.toFixed(0)}]`;
            const xt = Array.from(x).map((v, b) => [v * v / X2, b + 33]).sort((a, b) => b[0] - a[0]).slice(0, 8);
            hj += ` X[${xt.map(([e, r]) => r + ':' + (e * 100).toFixed(1) + '%').join(' ')}]`;
          }
          if (h && h.res) { let X2 = 0; for (const v of follower._x) X2 += v * v; hj += ` pitchΔ${follower._pitchCheck(h, follower._x, X2, follower._bleedNow).toFixed(2)}`; }
          // The leading alternatives of the pitch gate (q:accumulated nats), so a 'pitch' hold names its rival.
          if (h && h.alt && h.alt.size) hj += ` alt[${[...h.alt.entries()].sort((a, b) => b[1].acc - a[1].acc).slice(0, 3).map(([q, v]) => pitchName(q) + ':' + v.acc.toFixed(2) + '/n' + (h.altN - v.n0)).join(' ')} n${h.altN}]`;
          hj += ` os${follower._committed + 1 < follower._N ? follower._onsetScore(follower._committed + 1, out.tMs).toFixed(2) : '-'}`;
        }
        // The committed state's caps on every frame (not only on holds): a cap that
        // never armed shows up as 'inf' long before the note it fails to bound.
        if (process.env.SIM_DEBUG_H) {
          const par = follower._hyps.filter((q) => q.i === follower._committed).sort((a, b) => b.logW - a.logW)[0];
          if (par) hj += ` P[cap${par.pvCap === Infinity ? 'inf' : par.pvCap.toFixed(0)} L${par.pvL.toFixed(0)} gPv${par.gPv.toFixed(0)} gNw${par.gNw.toFixed(0)} capF0${par.nwCapF0 === Infinity ? 'inf' : par.nwCapF0.toFixed(0)} ex n${par.lastExplained ? par.lastExplained.new.toFixed(2) : '-'} p${par.lastExplained ? par.lastExplained.prev.toFixed(2) : '-'} o${par.lastExplained ? par.lastExplained.other.toFixed(2) : '-'}]`;
        }
        // SIM_DEBUG_BEAM=1: every live hypothesis (index, weight, evidence, birth, parent) per frame.
        if (process.env.SIM_DEBUG_BEAM) {
          const beam = follower._hyps.slice().sort((a, b) => b.logW - a.logW).map((h) => `${h.i}${h.repeatLike ? 'r' : ''}${h.onsetDriven ? '' : 's'}<${h.from ?? '-'} w${h.logW.toFixed(1)} ev${h.ev.toFixed(1)} n${h.lastExplained ? h.lastExplained.new.toFixed(2) : '-'} b${((h.born - SIM_TRANSPORT_MS) / 1000).toFixed(2)}`);
          hj += ` BEAM[${beam.join(' | ')}] W${follower._wrong.logW.toFixed(1)}`;
        }
        if (process.env.SIM_DEBUG_X) {
          const x = follower._x; const b = follower._best();
          const tpl = b && b.i >= 0 && b.i < follower._N ? follower._ev[b.i].template : null;
          const row = (v) => Array.from(v).map((q, i) => q > 0.05 ? `${i + 33}:${q.toFixed(2)}` : null).filter(Boolean).join(' ');
          console.log('   x  ', row(x)); if (tpl) console.log('   Nw ', row(tpl)); if (b && follower._pv[b.i]) console.log('   Pv ', row(follower._pv[b.i]));
        }
        console.log(`${t.toFixed(3)} ${out.informative ? 'I' : '.'} λ${out.lambda.toFixed(2)} db${frame.db.toFixed(0)} on${out.onset.ratio.toFixed(1)}${out.onset.hit ? '*' : ' '} r${out.onset.rise.toFixed(1)}${out.onset.evidence ? '!' : ' '} pk${frame.peak} best${out.best ? out.best.i : '-'} [${post}] pW${out.pWrong.toFixed(3)} ex ${ex}${out.hold ? ' HOLD ' + out.hold.reason + '@' + out.hold.index + ' p' + out.hold.p.toFixed(2) + ' m' + out.hold.margin.toFixed(2) + ' ev' + out.hold.ev.toFixed(1) + (out.hold.aheadMs !== null ? ' ahead' + Math.round(out.hold.aheadMs) + '/' + Math.round(out.hold.spanMs) + ' bps' + out.hold.bps.toFixed(2) : '') : ''}${out.advance ? ' ADV->' + out.advance.toIndex + ' ev' + out.advance.ev.toFixed(1) + ' new' + out.advance.explained.new.toFixed(2) : ''}${out.state ? ' STATE ' + out.state.mode : ''}${hj}`);
      } : null;
      const run = runOne(args, name, mut, seed, { onFire: (f) => fires.push(f), onFrame });
      run.fires = fires; run.scenario = name; run.seed = seed; run.mut = mut;
      runs.push(run);
    }
    const worst = worstOf(runs, sc.tgt);
    const fails = evaluate(worst.metrics, sc.tgt);
    if (fails.length) anyFail = true;
    const m = worst.metrics;
    summary[name] = { recall: r3(m.recall), false: m.false, early: m.early, missed: m.missed, latencyMedian: r1(m.latencyMedian), latencyP90: r1(m.latencyP90), onsetErrMedian: r1(m.onsetErrMedian), tempoRmse: r3(m.tempoRmse), pass: fails.length === 0, seed: worst.seed, variant: worst.mut };
    rows.push({ name, m, fails, worst });
    if (!args.quiet && !args.json) {
      const label = sc.sweep ? `${name} (${Object.entries(worst.mut).map(([k, v]) => k + ' ' + (v && typeof v === 'object' ? JSON.stringify(v) : v)).join(' ')})` : name;
      console.log(`${(fails.length ? 'FAIL' : 'ok  ')} ${label.padEnd(24)} recall ${fmt(m.recall, 2)} (${m.hits}/${m.played})  false ${m.false}  early ${m.early}  miss ${m.missed}  lat ${fmt(m.latencyMedian)}/${fmt(m.latencyP90)} ms  onset ${fmt(m.onsetErrMedian)}/${fmt(m.onsetErrP90)}  tempo ${fmt(m.tempoRmse, 3)}  rej ${m.rejects}  stall ${fmt(m.stall, 2)}s  drift ${m.driftMax}/${fmt(m.driftRunSec, 1)}s  resync ${m.resyncs}${m.resyncBad ? '!' + m.resyncBad : ''}  pW ${fmt(m.pWrongMedian, 2)}/${fmt(m.pWrongRunMs / 1000, 1)}s  seed ${worst.seed}${fails.length ? '   <- ' + fails.join('; ') : ''}`);
      if (fails.length || process.env.SIM_VERBOSE) {
        if (m.falseList.length) console.log('      false:', JSON.stringify(m.falseList));
        if (m.earlyList.length) console.log('      early:', JSON.stringify(m.earlyList));
        if (m.resyncBadList.length) console.log('      resync landed wrong:', JSON.stringify(m.resyncBadList));
        if (m.missedList.length) console.log('      missed:', JSON.stringify(m.missedList));
        console.log('      holds:', JSON.stringify(m.holdsByReason), ' states:', m.states.join(' '), ' bleedLag med', fmt(m.bleedLagMedian));
        // Every armed wide scan and why it did or did not fire — the resync's
        // own audit line (follower.js out.resyncScan), so a scenario that
        // drifts can be told from one whose search was refused.
        console.log('      wide scans:', m.scans, JSON.stringify(m.scansByReason), ' margin med/p90', fmt(m.scanMarginMed, 2), '/', fmt(m.scanMarginP90, 2), ' lead med', fmt(m.scanLeadMed, 2), ' dist med', fmt(m.scanDistMed),
          ' top-1 err med', fmt(m.scanErrMed), ' |err|<=1', fmt(m.scanErrAbs1, 2), ' |err|<=2', fmt(m.scanErrAbs2, 2), ' ahead', m.scanErrAhead);
      }
    }
    if (args.timeline) printTimeline(worst);
    if (args.wav && names.length === 1) writeWav(args.wav, worst.perf.mix, worst.cfg.sr);
    if (args.out && names.length === 1) fs.writeFileSync(args.out, JSON.stringify(buildTrace(worst, args)));
  }
  if (args.json) console.log(JSON.stringify(summary, null, 1));
  else if (!args.quiet) {
    const passed = rows.filter((r) => !r.fails.length).length;
    console.log(`${passed}/${rows.length} scenarios pass  (seeds ${seeds.join(',')}; engine ${FOLLOWER_VERSION})`);
  }
  process.exit(anyFail ? 1 : 0);
}

main();