๐Ÿ“ฆ EqualifyEverything / equalify-iris

๐Ÿ“„ review.ts ยท 3455 lines
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
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
3452
3453
3454
3455import { extractJson, readArrayPrefix } from "../util/json.ts";
import type { ReviewStopped } from "../store/db.ts";
import { VERIFY_KINDS, type VerifyKind } from "./feedback.ts";
import { mapWithConcurrency } from "../util/concurrency.ts";
import { MAX_EDITOR_IMAGES } from "../providers/imageLimits.ts";
import {
  isRequestTooLargeError,
  isTruncatedResponseError,
  replyExcerpt,
  TruncatedResponseError,
} from "../providers/types.ts";
import { feedbackPreamble, loadImage, type InputImage, type PipelineContext } from "./context.ts";
import { wrapDocument } from "./assembly.ts";
import { stripDeprecatedRoles, stripInvalidRoles } from "./roles.ts";
import { stripNestedMain } from "./landmarks.ts";
import {
  BODY_MARKERS,
  destroyedBody,
  EDITOR_SHRINK_FLOOR,
  MARKER_NOT_LEGIBLE,
  MARKER_PAGE_INCOMPLETE,
  markerCounts,
  structureCounts,
  visibleText,
} from "./correction.ts";
import { runAxe, lintErrorFields, lintDebrisFields, type LintResult } from "./lint.ts";
import { joinSections, splitSections, type Section } from "./sections.ts";
import {
  annotateBlocks,
  applyBlockEdits,
  blocksOf,
  navigationLost,
  proseShortened,
  readBlockEdits,
  stripBlockMarkers,
} from "./patch.ts";
import { flatten } from "./flatten.ts";
import { examplesForPrompt } from "./memory.ts";
import { knownPages, pageIndex, type IndexedPage } from "./pageindex.ts";
import { droppedHrefs } from "./links.ts";
import { sameWordedHeadingNote, sameWordedHeadingRuns } from "./headings.ts";

export interface ReviewIssue {
  issue: string;
  severity: "low" | "medium" | "high";
  suggested_action: string;
  // Source attribution: the 1-based source pages this
  // issue is on. Empty when the Reader could not attribute it โ€” the document is
  // delivered without provenance comments, so page numbers are the
  // only reference available, and the Reader is told not to guess.
  pages?: number[];
}

export interface ReviewResult {
  html: string; // full document
  body: string;
  iterationsCompleted: number;
  unresolved: ReviewIssue[];
  lint: LintResult;
  // How many absolute hrefs the Copy Editor destroyed, totalled over the rounds it
  // ran. Summing across rounds does not double-count: each round
  // compares only its own before/after, so a link dropped in round 1 is already
  // absent from round 2's `before`.
  //
  // Returned rather than left in the run log because this is the loop's one
  // unrecoverable failure โ€” an href came from the source FILE, so nothing later can
  // re-read it โ€” and a per-session log line is invisible in aggregate. The COUNT is
  // what leaves this function: the URLs themselves are content from a user's
  // document and must not reach the quality tally (see Store.recordRunSignals).
  droppedLinks: number;
  // True when a correction round's response hit the model's output ceiling, so the loop
  // stopped early (issue #143). Since #165 the round is re-made a section at a time before it
  // is given up on, so this no longer implies the delivered document is the one that entered
  // the round โ€” `editorTruncatedLost` below is what says whether anything was lost.
  //
  // Returned rather than left in the run log for the same reason `droppedLinks` is: it
  // says something about the document the user received that the document itself cannot,
  // and one line in one session's log is invisible in aggregate. It is also what
  // distinguishes the two ways a document arrives with unresolved issues โ€” the loop ran
  // its rounds and some issues survived them, or a round could not be completed at all.
  editorTruncated: boolean;
  // True when that round did not come back whole: no section could be made of the body, or a
  // section truncated in its turn and kept the text it went in with. Never true with
  // `editorTruncated` false.
  //
  // Since #295 the question is asked of a smaller thing, because the truncated reply itself is read
  // as far as it got: the blocks it reached were corrected by the round, so what can still be lost is
  // the REMAINDER, and both halves have to have failed for this to be true. A reply that reached the
  // last block leaves no remainder and makes no section call at all, so this is false โ€” the one shape
  // of truncation that costs the reader nothing, and the reason "no section could be made" above is
  // not by itself the condition any more (`sectionRound`, and the assignment below).
  //
  // The delivered document has carried this distinction since #165 โ€” `@editor-truncated
  // sections 3 of 4` against a bare `@editor-truncated` โ€” and the quality tally had not, so a
  // deployment could not tell a ceiling it is paying to work around from one that is costing
  // its readers corrections. The two need separate rates because only this one can carry a
  // threshold: the other rises with document length by itself, since the editor's answer is as
  // long as the document it is rewriting (#159).
  editorTruncatedLost: boolean;
  // True when at least one correction round had blocks handed back, or was refused whole, because
  // they would have taken heading elements out of the document with every word left in place
  // (`headings_reverted`, or `headings_lost` where the whole round went; #331).
  //
  // The document is unharmed when this is true โ€” that is what the guard is for โ€” so this is not a
  // defect rate of the deliverable. It is the rate at which the editor tries it, which is the number
  // #331 asks for and the only one that can say whether the guard is earning what it costs: a
  // deployment reading 0.3 has an editor that would be flattening headings out of a third of its
  // documents without it.
  //
  // A 0 is NOT evidence that no round demotes a heading. The GATE behind it is on the block-patch
  // round only, which is where a fall can be attributed to the block that dropped it and that block
  // alone handed back; `editorSectionCall` and the whole-body reply path adopt a reply whole and
  // check a prose floor a demotion cannot move, so a demotion on either is applied and delivered and
  // never reaches this. Since #375 both of those paths compute the same reading and log it as
  // `editor_navigation`, refusing nothing (`reportNavigation`), so the population this number cannot
  // see can be collected โ€” and no lines at all is an empty population, not a reading of 0.
  //
  // Per DOCUMENT, not per round, and accumulated over the loop rather than read off its last round:
  // the round is retried, so the body that ships is normally one a later round corrected cleanly,
  // and by the exit nothing else in this result remembers that a heading was ever at risk. Also why
  // it is here rather than derived from the run log โ€” the log line is one session's, and these
  // sessions are user uploads (see Store.recordRunSignals).
  editorHeadingsGated: boolean;
  // How many windows of the document the LAST read of it came back with no usable answer
  // for โ€” an unparseable reply, or one carrying no issue list this code can read (issue
  // #186). 0 on a document that was reviewed in full, which is almost all of them.
  //
  // Returned for the same reason `droppedLinks` and `editorTruncated` are, and with a
  // sharper edge than either: it is what distinguishes a document the reviewer found
  // nothing wrong with from one the reviewer did not answer about. Those two are the same
  // shape here โ€” an empty issue list, no correction rounds โ€” so without this the second
  // arrives as the first, is delivered as clean, and is counted as clean deployment-wide.
  // An empty `unresolved` is only good news when this is 0.
  unreviewedWindows: number;
  // What the FIRST read of the document came to, before any correction round: how many issues
  // it raised, and how many of its windows it had no usable answer for.
  //
  // The one measurement this loop returns that is about the Reader rather than about the
  // document that shipped. Everything else here is downstream of the editor, so a Reader that
  // stopped finding things and an editor that started fixing them arrive identically โ€” as an
  // empty `unresolved` โ€” and they are opposite facts. The deployment-wide reading is in
  // store/db.ts (SIGNAL_FIRST_READ_ISSUES): a cheaper Reader is BOUGHT with a fall in this, so
  // the fall in `unresolved_rate` that comes with it is not the improvement it resembles.
  //
  // The first read and not the last, unlike `unresolved` and `unreviewedWindows` above, because
  // this is the only read taken on the body every document is guaranteed to have โ€” extraction's
  // output, unrewritten โ€” and therefore the only one comparable between two documents or two
  // models. `unread` travels with it rather than being folded into the count, because a window
  // with no answer makes the count a floor and the two failure modes of a weaker Reader are
  // exactly "found less" and "answered less".
  //
  // Optional for the reason `stoppedAt` is: it is assigned by the read that happened, so a loop
  // that somehow ran no read at all reports no measurement instead of a confident 0 โ€” and a
  // fabricated 0 here would be a clean document in the aggregate.
  firstRead?: { issues: number; unread: number };
  // Which of this loop's exits ended the run (#264). One of five words, assigned at the
  // `return`/`break` that took it and nowhere else, so a stop reason is written by the line
  // that knows it rather than reconstructed afterwards from the other fields โ€” which is
  // exactly what could not be done: `cap` and `converged` are the same shape from out here
  // (issues open, no truncation), and they ask for opposite fixes.
  //
  // Optional, and left undefined rather than defaulted, for the reason #263 settled on for
  // `LintResult.errorWhere`: an exit added later without a stop reason must be VISIBLE as an
  // exit nobody attributed, and inventing one for it is the single thing this field must not
  // do. `Store.qualityStats` publishes the five counts summing to fewer than the documents,
  // which is what that looks like from the outside.
  //
  // The type comes from store/db.ts because these five words are a published vocabulary
  // (`REVIEW_STOPPED`), and the recorder, the aggregate query and this loop have to spell them
  // identically or the rate reads 0% forever. Type-only, so nothing in the pipeline gains a
  // dependency on the store at runtime.
  stoppedAt?: ReviewStopped;
}

// The last thing the Reader is told, appended to the END of `READER_SYSTEM` below โ€” and the
// only sentence in that prompt about the REPLY rather than about the document.
//
// `READER_SYSTEM` already ends "Respond with ONLY JSON:", and the incumbent narrated anyway:
// measured over 5 documents on `us.anthropic.claude-sonnet-4-6`, 40% of the characters it wrote
// sat outside the JSON envelope. Nothing caught it because nothing was looking โ€” `extractJson`
// takes the LAST envelope in a reply (#173), so a preamble parses fine, no call fails, and no
// line in the log says a third of the step's output was prose. It is billed at output rates on
// every chunk of every round. So this is not a new instruction; it is the existing one made
// enforceable, and it is APPENDED rather than merged into that line so that nothing already
// measured in this prompt is edited (#299).
//
// Measured: output 3,635 -> 2,574 tokens per document (-29%), $/doc -13%, prose 40% -> 0% of
// characters and 91% -> 0% of REPLIES (10 of 11 narrating in the control, 0 of 11 in the treated
// arm, over the same five documents) โ€” both units, because the re-measure list below asks for the
// second one.
//
// Re-measured at 8x the size and the incumbent's half holds, on the shipped prompt against the old
// one over 20 documents and two runs per side (#307): output 2,698 -> 1,778 tokens per document
// (-34%), $/doc -13.2% ($0.1072 -> $0.0931), prose 0.0% over 90 replies โ€” not one character outside
// the envelope. The margin is the point: those two runs price within 1.5% of each other, so -13% is
// many times the spread between identical runs. Issues per document did not move (9.93 -> 10.28,
// bracketed by the old prompt's 9.35 / 9.70 / 10.75). And
// it finds MORE rather than less, which is the part that decides it โ€” 12.6 issues per document
// against the control's 10.8, 129 quoted spans against 96, 11 high-severity against 7, and a
// finding's cited page matches the page order 93% of the time against 84%, with citations
// matching neither the order nor a printed folio falling from 15% to 2%. Quote fidelity is
// marginally worse (90% of quoted spans found in the document against 93%); off-document
// references are 0 in both.
//
// Read those against the right floor or this round reads as a loss. Two runs of the IDENTICAL
// prompt over the identical documents reproduce only 57% of each other's quote-anchored findings
// (69% the other way): the Reader does not reproduce itself, so the terse arm reproducing the
// control at 61% is not 39% damage, it is marginally better than the control's own repeat.
// Without that repeat arm, one sentence would look like it had cost a third of the findings.
//
// It is a fact about the model in the seat, NOT about Readers โ€” though not for the reason first
// recorded here, which was "`moonshotai.kimi-k2.5` writes 0% prose in its control". That does not
// reproduce (#305). Over 157 replies and three rounds Kimi's character share is 38.8%, 30.0% and
// 9.6%, never 0%, and in one round its 38.8% is HIGHER than the incumbent's 36.1% over the same
// documents โ€” so the claim was not just a small sample, it inverts. In the deciding round itself
// Kimi's TREATED arm wrote more prose than its control: 1 of 11 replies narrating in the treated
// arm, 0 of 11 in the control, and that one reply carried 51% of the treated arm's characters. The
// sentence did not suppress prose on that model; the number moved with one reply. Nor does it at 40
// documents with the sentence SHIPPED: Kimi's prose is 23.8% of characters in one run of 45 replies
// (two replies, one of them 98%) and 0.0% in the other, where the incumbent goes to 0.0% over 90
// replies and stays there. The cause is the
// shape of the distribution rather than the size of the draw: Kimi's median reply is a bare envelope
// in all three large rounds and it narrates in 7-16% of its replies in each of them, but when it
// does it goes to 87-99% prose, so an aggregate is decided by whether
// the draw caught one of those. The incumbent narrates in 67-75% of its replies across the four
// twenty- and fifty-document rounds, which is why 5 documents were enough to see its 40%. The
// ablation's own five-document control reads 91%, which is not a fifth value so much as what a
// five-document draw of those rounds does at its top edge (p95 86-100%).
//
// What survives is the half that makes this sentence buy Kimi little: ITS MEDIAN REPLY IS ALREADY
// PROSE-FREE, so most windows have nothing here to remove. What does NOT survive is the trade first
// recorded here, "13.6 issues per document down to 8.8, and 6% more per document" โ€” fewer findings
// for more money, from 5 documents. At 20 documents and two runs per side it is 11.75 -> 12.80 issues
// per document at -5.0% $/doc, both signs reversed (#307). Neither figure is the one to carry: both
// changes are smaller than Kimi's own spread between two runs of the IDENTICAL prompt โ€” 13.8 and 9.7
// issues per document at the old prompt, 13.45 and 12.15 at the shipped one, those two shipped runs
// pricing 8% apart. On Kimi neither the finding count nor the price moved resolvably, so this
// sentence is neither the cost the old numbers made it nor the bargain their reversal makes it.
// The Reader model is
// a config line (`providers.per_agent.reader`), so this is re-measured on a swap โ€” the Reader
// bullet in docs/design-notes.md says what to measure, quote fidelity
// included, since that is the one metric this arm moved the wrong way. Record the SHARE OF REPLIES
// THAT CONTAIN ANY PROSE rather than the share of characters: the reply share separates these two
// models in every round measured โ€” the incumbent 67-75% over the four large rounds and 91% in the
// ablation's control, Kimi 7-16% over the three large rounds and 0% (control) to 9% (treated) in the
// ablation โ€” where their character shares overlap, and
// it is what the intervention acts on. It is NOT the cheaper measurement, and the two statistics
// fail at n=5 differently rather than one being tighter: resampled at 5 documents the reply share's
// band is WIDER in points on the incumbent (35-50 against 21-24) and NARROWER on Kimi (20-30
// against 26-66). What they share is the failure on the model in question โ€” the reply share still
// reads 0% for Kimi in 12-48% of draws against the character share's 40-46%. So the reply share
// buys a figure that holds from round to round and buys nothing at n=5; measure two runs of twenty
// documents whichever unit is recorded. Neither form is a model trait to look up.
//
// The prompt side is nearly free on the incumbent and not free off it. This sentence is 180
// characters, and `READER_SYSTEM` clears `cacheableSystemPrompt` on a Claude Reader, so it lands
// inside the cached prefix and a warm deployment reads it at 0.1x. A non-Claude Reader gets no
// breakpoint at all and pays it in full on every chunk of every round โ€” which is the same
// population where the sentence may be buying nothing. The filing's figure for this is +86 prompt
// tokens, and its unit is PER DOCUMENT (29,747 -> 29,833): the system prompt is re-sent once per
// window, so a document's cost is the sentence times its window count, not 86 whatever its length.
//
// Position is load-bearing: "the JSON object" and "before or after it" have no referent unless
// the schema is already on the page, so this goes last, after the schema and after the
// clean-document line. test/reader-json-only.test.ts pins the place and the bytes, because what
// was measured was these bytes in this place.
//
// Limits, from the filing: 5 documents, 2 models, one repeat, and the 57% floor is itself a
// single measurement with no interval of its own. The corpus stitches three source PDFs into each
// document, so some of the findings this arm added are models correctly noticing that โ€” every arm
// saw the identical document, so the comparison holds either way.
//
// One edge is open here, and it was filed as a regression caused by this sentence (#307) but is not
// one. A Reader can file an issue whose own `suggested_action` says nothing needs doing โ€” the model
// decides an observation is not a defect and reports it anyway, which reaches the Copy Editor as work
// on a document that is not broken, every round, since no edit can change this prompt. Per document,
// over two runs at each prompt: kimi-k2.5 1.10, 0.70 then 1.25, 0.75 โ€” about one per document either
// side, 6-9% of everything it files โ€” the incumbent 0.00, 0.05 then 0.30, 0.05, Haiku 0.20, 0.25 then
// 0.15, 0.10, Luna 0.00 throughout. So the behaviour is real and model-specific, and appending this
// sentence is not what causes it: Kimi is flat across the change, Haiku falls, Luna stays at zero, and
// the incumbent's rise is 6 issues in one run against 1 in the other. The same reading kills the
// positional story told with it โ€” that removing prose as a destination pushes discarded reasoning into
// `issues[]` โ€” because #275 window violations move in opposite directions on models given the same
// append (the incumbent +4, Kimi -6, Haiku -1, Luna flat), each shift the size of that model's own
// spread between identical runs. A clause naming nowhere as the destination for a discarded
// observation is a plausible fix and is deliberately NOT in this prompt: it would have to be measured
// as its own arm on the two models that do this, two runs each, scoring self-cancelling issues
// against issues per document so a drop in one is not paid for out of the other. Editing these bytes
// on a per-model rate that a single pair of runs cannot resolve is how the figures above went stale
// the first time.
export const READER_JSON_ONLY =
  "Your entire reply must be the JSON object and nothing else. Do not write any reasoning, " +
  "preamble, commentary or summary before or after it. Do the thinking without writing it down.";

// Exported so a test can assert the marker vocabulary it advertises is the one
// `flatten` actually emits: a marker the prompt promises but the code never produces
// teaches the Reader to expect something that will not appear.
export const READER_SYSTEM = `You are the Reader Agent. You review accessible HTML for reading-order problems, semantic
inconsistencies, duplicated/redundant content, and missed WCAG 2.2 AA requirements. You do NOT
see source images โ€” you read the document the way a screen-reader user would.

What you are shown is the BODY CONTENT of the delivered document, not the whole of it. The
document supplies the rest and already has it: a <!DOCTYPE html>, an <html> with a lang
attribute, a <head> with a <title>, a <body>, and a <main> that holds everything you can see.
So this document does have a main landmark, a title and a declared default language โ€” none of
them is missing and none of them is yours to ask for. WHICH language it declares comes from the
content, and only where the content is unanimous and names a language a tag can carry: the shell
declares a language when EVERY top-level element of this body that has text of its own names that
same language with a real tag for a real language โ€” ko or kor, never Korean, ko_KR or cn โ€” and
English in every other case, including where only some of them carry one, since a half-labelled
body gives it nothing it can trust, and including where they agree on something that is not a
language tag. The page-break separators standing between pages hold no text of their own, so they
have no language and are not asked for one. So content in the language the document ends up declaring needs no lang attribute of its
own, and lang="en" on the parts of a document that is English throughout adds nothing. Content
in any OTHER language does need one, and that is worth reporting: an English abstract inside a
document whose top-level parts all say Korean, the Korean quotation inside an English one, and โ€”
because a half-labelled document falls back to English โ€” an unlabelled Korean passage standing
next to a labelled one. Report what is IN the content you were given.

You get two views of the same content: the HTML (structural reference) and a flattened
text-only view (what a screen reader announces, in order). Cross-check them, and also consider
the axe-core lint results provided.

In the flattened view, anything in square brackets is a structural annotation, not content:
[Heading 1-6], [List item], [List item N], [Link], [Image], [Image alt], [Table],
[Header row], [Row], [Field input|textarea|select|button|summary], [Label], [Quote],
[Caption], [Term], [Definition], [Abbr title], plus [N rows, M columns], [empty], [no caption],
[spans N columns], [spans N rows], [alt missing] and [decorative, alt empty]. Two bracketed
tokens are the exception, because the extractor wrote them into the document rather than the
flattener adding them: [not legible] and [page not fully transcribed] are content โ€” what a page
said where the source could not be read, or could not be returned in full โ€” and are dealt with
below. A field's own announced name follows its marker, so [Field input text] with nothing after
it is a control with no accessible name at all. Tables are expanded row by row with cells separated by " | ".
[Abbr title] carries the name an abbreviation or a symbol holds in its title attribute: a glyph
followed by "[Abbr title] Stop" is a named control and correct markup, and only a symbol with
nothing after it is unnamed. Do not ask for that name to be moved into the text โ€” the words
belong to the page and the attribute is where they are announced from.

An item of an ORDERED list carries the marker it is announced with โ€” [List item 5] โ€” and an
item of an unordered or definition list carries none, because there is no marker there. That
marker is not in the items' text: an <ol> marks its items by itself whatever they contain, so a
source's own numbering survives only in start on the <ol> and value on an <li>. It need not be a
number. A list the page prints (a), (b), (c) is an <ol type="a">, and you will see [List item a]
โ€” letters from type="a" or type="A", roman numerals from type="i" or type="I", and digits with no
type at all. The count underneath is still a number in every case, so [List item e] is the fifth
item of a lettered list and value="5" is how the document says so.
Read markers the way you read table cells that hold numbers, and report a contradiction you can
point at: a list numbered 1, 2, 3 sitting under a note that says items 3 and 4 are not listed,
a numbering note beside a sequence that is in fact unbroken, or an announced marker that
disagrees with the same list in the source-page excerpt below. An item whose own text opens with a
marker as well as being announced with one is that content twice, and there are two of those with
different repairs. Where the two are THE SAME MARKER โ€” [List item a] (a) Estimating, or
[List item 1] (1) โ€” a reader hears "a" and then "(a)", and the copy that goes is the TEXT's: the
list is what announces a marker to a screen reader and what a browser prints, so an item's text
should hold only the words that follow the marker. Where the list announces DIGITS and its items
print letters or roman numerals โ€” [List item 1] (a) Estimating โ€” the marker is in
the one place that is not announced, and the repair is the other way round: the list is missing the
type that would announce the letters, and the letters are the document's only record of what the
page printed. Say that, and say the text's copy must stay until the list carries it. And where the
text opens with a marker that is NEITHER of those โ€” [List item 1] 12. Payments to the state, or
[List item a] 12. Payments โ€” the two markers are not one marker printed twice, whatever else they
are, so NEVER ask for either copy to be dropped. What is left to report is decided by what the list
can be made to announce, and type carries a marker's KIND while start carries only its COUNT. Where
the printed markers are the SAME KIND as the announced one and run consecutively from somewhere else
โ€” 12., 13., 14. under a list counting 1, 2, 3, or (c), (d) under a list announcing a, b โ€” the list
is missing the start that would announce those very markers, and that is the report: the numbering
is the document's and only start can carry it. Where they are a different kind from the announced
marker, or are not one consecutive run, no start announces them โ€” start="12" on an <ol type="a">
announces l., m., n. โ€” so ask for no repair at all: say the two disagree, and leave the text's
markers where they are, the document's own numbering under the list's marker, a clause number
rather than a second copy of anything.
Never ask for a marker to be dropped from an item's text while the list announces a different one,
and never ask for the list's own marker to be dropped in favour of the copy in the text โ€” an <ol>
stripped of its type prints 1, 2, 3, a marker no page showed. You do NOT see the source
images, so a plain 1, 2, 3 with nothing to contradict it is not evidence of anything โ€” do not
report a list for being consecutive, and never suggest a marker the document does not show.

Headings are the document's outline, and two defects in it only the assembled document shows.
The same words announced twice in a row at the same level โ€” [Heading 2] Operation, then another
[Heading 2] Operation โ€” tells a reader navigating by heading that the second section is the same
subject as the first, or a copy of it. And a section title reprinted at the top of every page it
continues on is that defect arriving one page at a time: each extractor saw one page and could not
know the title had already been used. Report both, with the pages both headings are on, and say
which of the two it looks like: one section whose title repeats, where the second heading goes and
what followed it belongs under the first, or two sections the document labels alike, where each
heading keeps the label and gains the words that tell it apart โ€” words already in that section's
own content, never a phrase of your own. Do not report two same-level headings that merely share a
level, or identical headings with other sections in between: what is ambiguous is the pair with
nothing but its own subject's content between them.

Those pairs are found for you. Where the document has any, a section below lists them, computed
from the WHOLE document rather than from the HTML you were given โ€” so a heading it names may sit
outside the HTML you were given, and is to be reported anyway. Report every entry in that list as an issue,
and say which of the two cases it is; where the excerpts do not tell you, say that instead of
choosing. The list decides only that a pair EXISTS: no entry is a false positive to be argued
with, and finding a pair the list missed is still worth reporting.

One class of content marked up two ways is a defect of the same kind, and only the joined document
shows it either. The pages were extracted one at a time by calls that could not see each other, so
the line of website, e-mail and revision that every page prints may announce [Term]/[Definition]
pairs on one page and a plain sentence on the next, and sections of one sort may open at
[Heading 2] on four pages and [Heading 1] on the fifth. Report the group as ONE issue, naming the
pages on both sides and which shape most of them use, so what follows is a page brought into line
rather than a document rewritten. The words the fix needs have to be on the page: a footer printing
"Website: example.com" as a sentence carries its own labels, and those are what the [Term]s are
built from. Where one side prints no labels at all, a labelled footer against a line of bare
values, leave it alone โ€” the difference is between the two pages and not in their markup, nobody
downstream may supply words a page never printed, and an issue that cannot be closed is reported
again every round. Two things have to hold first. The content has to be the same KIND on both pages and the
excerpts have to show you that โ€” two footers with the same fields, two parts tables โ€” since
sections that merely differ are not an inconsistency: a table of contents and a parts list are not
one class, and a page whose content has no counterpart is not either. And you can only judge what
you were given: where your HTML is one window of several, a shape you meet once here may be the
majority shape in the rest of the document, so report a difference between two pages you can both
see, never a page that looks unlike the pages you cannot.

A [not legible] marker is what the extractor wrote where the marks on its page did not resolve
into characters, and a [page not fully transcribed] marker is what it wrote where it could not
return the whole page. Report every one of them with the page it is on, and nothing more. The page
is what matters: the Copy Editor is given the images for the pages your issues name, and looking at
that page again is the only thing that can settle the first marker โ€” the second is settled by
re-extracting that page, which is nobody's job in this loop, so it is reported and left standing.
You do not see the source images, so never suggest what a marker stood for, and never ask for one to
be deleted โ€” a document that once said a word could not be read, or a page not finished, and now
says nothing tells every reader that the page arrived whole.

A sentence or a word broken at a page turn is not a defect in the markup, and it is not yours to
report. Where a paragraph ends "public serv-" and the next begins "ices in a State", or ends
mid-sentence with the next continuing it in lower case, those are two pages transcribed exactly as
they printed, by calls that could not see each other. The Copy Editor is told to leave both halves as
they are and to invent no completion, so an issue naming one is an issue nobody may close: reported
again every round, about text that is already right. Joining the halves belongs to a pass that holds
both, and is not this one โ€” the halves you can see are the ones it could not safely join. Say nothing
about it.

What tells you this is the case is a page break
between the two halves, and you have to look in the HTML view to find one: it is an <hr> carrying
role="doc-pagebreak", it announces nothing, and the flattened view shows the halves adjacent with
nothing in between. Where the HTML puts no page break between them, the page index below is the other sign, and you need
it: a page that prints no number emits no marker at all, so a document of unnumbered scans has page
turns and nothing marking them. Each entry in that index is the START of a page, which is
where the half that carries on will be: find "ices in a State" at or near the head of some page's
excerpt โ€” a numbered page puts its marker there first, so the words may be a little way in โ€” and this
is a page turn, marker or no marker, and there is nothing to report. Silence is also the answer where
you cannot place either half โ€” the halves as printed are right, and an issue about them is one nobody
may close. Only where both halves and the words they break between sit inside one page's own excerpt
did the sentence break inside a page, and that is content the page did not return: a finding of the
ordinary kind, and yours to make.

One shape is the exception to that last rule, and it is easy to mistake for a typo: a word with a
hyphen INSIDE it, "Simi-larly" or "public serv-ices". A page turn split that word and the halves have
already been put back together, so both of them do sit inside one page's excerpt with nothing between
them โ€” the page break stands immediately BEFORE the paragraph holding the joined word, not inside it,
which is the opposite of the sign described above. Do not look for a marker between the halves; the
hyphen mid-word is itself the sign. It is what the source printed, and it is kept because nothing can
tell a hyphen printed to fill a line from one that belongs to the word. Say nothing about it, and never
ask for it to be closed up or for the hyphen to be deleted.

Treat a table that reports [0 rows], a [Field ...] with nothing announced after it, and an
[Image] [alt missing] as evidence of a real problem. Do NOT report these, which are correct
markup: [decorative, alt empty] (an empty alt is right for a decorative image); a row with
fewer cells than the table has columns, when some cell is marked [spans N columns] or
[spans N rows]; or a field whose name follows its marker but which has no separate [Label]
line, since the name may come from an attribute.

You are also given an index of the document's source pages (page number + an excerpt of the
HTML extracted from that page). For every issue, attribute it to the source "pages" it appears
on, by matching the offending content against those excerpts. This is what lets the Copy Editor
fetch the right page images. Name only pages you have concrete evidence for; if you cannot tell
which page an issue is on, return an empty "pages" list rather than guessing or listing them all.

Some entries in that index say the page contributed no content instead of showing an excerpt, and
neither kind of entry is an issue to report. A page whose extraction FAILED is content this pipeline
lost: the document already records it, both in that entry and in a @page-failed comment where the
content would have been, and no edit to the HTML can bring the page back โ€” so reporting it spends a
correction round on the one defect this loop cannot fix, and reports it again on every round after.
A page that is BLANK in the source contributed nothing because there was nothing on it to
transcribe, which makes the document correct as it stands. Say nothing about either.

Be careful in the other direction too, about content you cannot find. Where the HTML section is
labelled as one window of several, the rest of the document is another call's to read, so a page
whose content is not in your window is not a missing page and is not yours to report. Where it
carries no such label you have the whole body, and content genuinely absent from it โ€” a page the
index shows as extracted with nothing of it in the document โ€” is a real finding and yours to make.
That label is never itself a defect, and neither is the window it describes. It says how this call
was made, not anything the document does: do not report that the HTML is one window of several, do
not report that reading one window leaves the rest unverified, and never ask for the other windows
to be reviewed โ€” they are already being read by their own calls, and no edit to the document could
close an issue whose subject is this prompt, so it would come back every round. A labelled window
is cut by character count rather than at an element or a sentence, so the edges it shares with the
windows either side of it may begin or end mid-sentence, mid-word or mid-tag: that edge is the cut,
not content the document lost, and it is not yours to report either. This covers only the edges the
cut made. The document's own opening and its own close are never a cut โ€” where window 1 begins,
where the last window ends, and both ends of an unlabelled body are the document as it really is,
so a body that ends mid-sentence there is a real finding and yours to make. Nothing above is relaxed by this โ€” a [page not fully
transcribed] marker is still reported wherever you meet one, and a break you can see both sides of
inside one page's own excerpt is still a real finding.

Respond with ONLY JSON:
{ "issues": [ { "issue": "...", "pages": [3], "severity": "low|medium|high", "suggested_action": "..." } ] }
Return {"issues": []} when the document is clean.

${READER_JSON_ONLY}`;

// Exported for the same reason READER_SYSTEM is: the two halves of the duplicate-heading
// rule have to agree โ€” the Reader classifies the pair and the editor resolves it โ€” and a
// test pins both.
export const EDITOR_SYSTEM = `You are the Copy Editor Agent. You are given an accessible HTML document (body content only),
a list of issues found by the reviewer, and the source page image(s) for the pages those issues
were attributed to. Return the blocks you are CHANGING, and only those.

The document is shown to you as numbered blocks: a comment of the form <!-- @block 7 --> stands
before each of the body's top-level elements. Those comments are not part of the document โ€” they
are there so you can name a block instead of retyping the document around it. Every block you do
not name is delivered exactly as it stands, character for character, so content you are leaving
alone is already safe and needs nothing from you. Do not return the document. Do not return a
block whose markup you did not change.

You may do whatever it takes to fix the issues: remove duplicated or redundant content
(e.g. the same content rendered as both a form and a table โ€” keep the best single
representation), reorder blocks, fix heading hierarchy, correct labels and table headers, etc.
Preserve all genuine content and transcribed text; do not invent content. Content on pages whose
image is NOT attached is not yours to change unless an issue names it. A replacement is body
content only (no <html>/<head>/<body>/<main> wrapper โ€” the document these blocks are placed into
supplies all four, so a <main> of your own would be a second one and would take away the landmark a
screen-reader user jumps to in order to skip the furniture).

An edit is a block's number, copied from the comment above it, and the markup that takes its
place. That markup replaces the WHOLE block โ€” a block is one top-level element with everything
inside it โ€” so what you write must be complete markup: whole elements, opened and closed, and
never a piece of one. A block left open at the end of your replacement cannot be used, and that
block keeps its original text instead. Say "html": "" to remove a block entirely, which is how
content the document prints twice goes. Where a fix needs the block to become more than one element
โ€” a heading lifted out of a section, say โ€” return them all under that one block number. Where it
moves content from one block to another, name both: the block it lands in with the content in
place, and the block it came from with what is left of it, or "" if nothing is. Name each block
once; a second edit for a block already named is discarded. Do not copy the <!-- @block N -->
comments into what you return.

Two headings with the same words at the same level are yours to resolve โ€” whether they sit next to
each other or with one page's worth of content between them, which is what a title reprinted where
its section continued looks like once the pages are joined. The source images say which way it
goes: a title the pages reprint because the section runs across them is ONE heading โ€” drop the
repeat and put what followed it under the first, at the level its content calls for โ€” while two
sections the document really does label alike keep the label and each gain the words that
distinguish them. Those words come from that section's own content, which is one of the two texts you
may add here (the other is under the markers below, and there is no third); never write a subtitle
of your own, and never merge two sections that are merely named
alike. And where nothing you were given decides it โ€” the reviewer says it could not tell, or the
pages those headings are on were not attached โ€” leave both headings exactly as they are and resolve
the other issues. An outline that says the same thing twice is a smaller harm to a reader than a
section merged into another one or a heading dropped, and an issue left alone comes back next round
or is reported as unresolved, while content you removed on a guess is gone from the document.

A [not legible] marker is not a defect in the markup: it is the extractor saying the marks on that
page did not resolve into characters. Where that page's image IS attached, look at that region again
โ€” if the marks resolve for you, put the words the page shows in the marker's place, which is the
second and last text you may add here, because it comes from the page and not from you. If they do
not resolve, or that page was not attached, leave the marker exactly where it stands. Never replace
it with a plausible word, and never simply delete it: a guess reaches a reader as something the page
says, and a deletion tells every later reader that the page was read in full. A number, a part code
or a measurement is the case to be strictest about โ€” nothing in the surrounding sentence can confirm
one, and it is the string a reader will act on.

A [page not fully transcribed] marker is not yours to resolve at all, even with that page's image in
front of you. It stands where an extraction could not return the whole of one page, so filling it in
means transcribing the rest of that page from its image โ€” a re-extraction, which is a pass with a
whole response for that one page and its own gates on what came back, and not a correction to the
markup around it. What that pass produces is a transcription of a page; what this one would produce
is a paragraph you wrote while looking at a page, delivered where nobody downstream can tell the two
apart. So leave the marker exactly
where it stands, resolve the other issues around it, and never delete it โ€” an unfinished page that
says so can be finished, and one that does not looks complete to everyone downstream.

A sentence or a word broken at a page boundary is not a defect in the markup either, and finishing
one is the third text you may not add. Where a paragraph ends "public serv-" and the next begins
"ices in a State", or ends mid-sentence with the next continuing it in lower case, those halves came
off two pages extracted by calls that could not see each other, and the page-break marker between
them is why: it is the first thing a page emits, so the split falls between two replies rather than
inside either. Do not complete the word, do not rewrite either half into a sentence that reads
whole, and do not delete the fragment that looks broken โ€” every word of both halves is a word some
page printed, and the completion is a word no page printed. Leave the text as it stands, and leave it
off your observation list as well: a split straddles two pages, usually neither of them one whose
image you hold, and none of the kinds below names it, so filing it there is a mistagged fidelity
report rather than a record anyone acts on. The record already exists โ€” the page that opened
mid-sentence said so in its own log โ€” and joining the halves belongs to a pass that holds both,
because a plausible completion reaches the reader as what the page says.

A link's target is content, and it is the one kind no later pass can recover: an href came from the
source FILE, not from the page image, so a URL you drop or alter is gone and a URL you invent
cannot be checked. Carry every href through exactly as written โ€” including on content you
restructure or move โ€” and never add a link that is not already in the document. You may change
the TEXT of a link when an issue calls for it (link text that does not describe its
destination is a real 2.4.4 problem); keep its href.

A list's marker is content too, and on an ordered list it is held in an ATTRIBUTE rather than in
any text you can see. type="a" on an <ol> is the (a), (b), (c) the page printed, type="A", type="i"
and type="I" are its capitals and its roman numerals, and start on an <ol>, value on an <li> and
reversed on an <ol> are the numbers a sequence shows where it does not begin at one, does not run
consecutively, or counts downwards. The extractor is told NOT to transcribe those markers into the
items as well, so the attribute is the document's only record of them: an <ol type="a"> returned as
a bare <ol> is marked 1, 2, 3 by the browser, and the letters the page printed are gone with
nothing left in the text to recover them from. Carry type, start, value and reversed through
exactly as written on any list you rewrite, whatever the issue you are rewriting it for. Never add
one โ€” a type you chose marks the list with letters the page does not show โ€” and never move a marker
into an item's text, which delivers it twice, since the browser prints the list's own marker
whether the text repeats it or not.

One shape is the exception, and it is the only one: a bare <ol> whose EVERY item's text opens with
the marker of a single sequence โ€” (a), (b), (c), or (i), (ii), (iii) โ€” running consecutively from
the ordinal that list starts counting at. There the marker was transcribed into the items instead of
set on the list, so the letters are already in the document and you are not adding any: set the type
those markers show AND remove the marker from every item's text. That is ONE change, not two โ€” do
both or neither. A type added with the text left alone delivers the marker twice, and text stripped
without the type delivers 1, 2, 3 where the page printed letters. Where the sequence is broken,
where any item's text carries no marker, or where the markers do not begin where the list's own
count does, change none of it and report it instead: a list you convert on a guess announces a
marker no page printed, and one you leave alone still reads the letters out.

On a page whose image IS attached you may notice a fidelity problem nobody asked you about:
content the page shows that the HTML does not have, a number or a name that disagrees with the
page, a table the page prints as a table and the HTML renders as paragraphs. REPORT those and do
NOT act on them. Fixing one means reading that page again in full, which is a re-extraction and not
this loop's job, and rewriting content the reviewer did not raise is worse than saying it looks
wrong: an observation costs someone a look at the page, and an edit made on one reading of an
image reaches the reader as what the page says. Report only pages whose image is attached โ€”
anything else is a guess about a page you cannot see โ€” and keep the list to what you would want a
person to check, not everything you might improve. An empty list is the ordinary answer.

This takes nothing off your list. An issue the reviewer raised is still yours to fix, and a
[not legible] marker on an attached page is still yours to resolve as described above, even though
both are content the page shows and the HTML does not: reporting is for what nobody asked you
about. When a problem is both โ€” the reviewer raised it AND you can see more of it on the page than
the issue names โ€” fix what was raised and report the rest.

Give each observation the page it is on, one sentence, and its kind: ${VERIFY_KINDS.join(", ")} โ€”
the same five the fidelity check uses, by what a reader LOSES, with the earliest of them that
applies winning (content absent from the HTML is content_missing even though it is also a WCAG
failure; a11y_only is a problem the page's own content does not lose, and alt_quality is a
description that could be better rather than absent).

Respond with ONLY JSON, with the edits first:
{ "edits": [ { "block": 7, "html": "<h2 id=\\"benefits\\">Benefits</h2>" },
              { "block": 12, "html": "" } ],
  "fidelity_observed": [ { "page": 7, "observation": "the second table's third row is absent from the HTML", "kind": "content_missing" } ] }
Return {"edits": []} when there is nothing in the markup to change.`;

// The same editor, asked for one section of a document instead of the whole of it โ€” because the
// whole of it did not fit in one response (issue #165, and `correctBySection` below for when
// this is used).
//
// Built on EDITOR_SYSTEM rather than written separately: every content rule above still holds
// for a section (a dropped href is just as lost, a [not legible] marker just as unresolvable
// without its page), and two prompts that had to be kept in step would drift. What follows
// overrides one instruction โ€” the answer's shape โ€” and adds the one hazard that only exists when
// the editor cannot see the rest of the document.
//
// The override says what does NOT apply, at length, rather than leaving it to be inferred from
// what this half asks for. EDITOR_SYSTEM's contract is now most of a page of instruction about
// naming blocks and returning only those, and a section request carries no block markers at all:
// an editor reading the two halves together has to be told that the first half's answer shape is
// off, or the likeliest reply is an edits list whose block numbers name nothing (issue #250, and
// the five rounds READER_SYSTEM's language clause took for the same reason โ€” a prompt that is
// true about one request and silent about the other reads as true about both).
export const EDITOR_SECTION_SYSTEM = `${EDITOR_SYSTEM}

## This request is ONE SECTION of the document

The document was too long for its correction to be returned in a single response, so it has been
cut at top-level boundaries and each section is corrected on its own. Every content rule above
still applies, with one change to the answer and one warning.

The change: there are no numbered blocks in this request and no edits list in its answer. Nothing
in front of you carries a <!-- @block N --> comment, so there is no number to name, and everything
above about returning only the blocks you changed, emptying one, or leaving the rest alone is about
the other kind of request. Here, content you do not return is content nobody returns.

So return the corrected version of THIS SECTION whole โ€” the parts you changed and the parts you
did not โ€” and nothing from outside it. The other sections are being corrected by their own requests
and will be joined back around yours in order, so anything you repeat from elsewhere would be
delivered twice, and anything you leave out is simply gone. Do not add a heading, a wrapper or a
summary to make the section read as a whole document โ€” it is not one, and the sections around it
supply what it appears to be missing.

The warning: you cannot see the rest of the document, so some of the issues you are given are
about content that is not in front of you. Fix the ones that are here and return the rest of this
section unchanged; an issue you cannot find is in the section that holds it, and is that
request's to fix. Above all, never remove content because it looks duplicated: the copy you can
see may be the only one in the document. Two headings with the same words are yours to resolve
only when BOTH of them are in this section โ€” a heading whose twin is elsewhere stays exactly as
it is, because dropping the one you can see is how a section loses its title.

Respond with ONLY JSON: { "html": "<corrected section>" }`;

// The markers themselves live in correction.ts, beside the other comparisons between two versions
// of one body, because #373 directive 5 needs them at the PAGE correction too โ€” a correction that
// answers "content is missing" by appending `[page not fully transcribed]` is a marker arriving in
// exactly the way the paragraphs below describe, and one string constant cannot be the authority for
// two passes from inside the module of one of them. Re-exported here rather than moved out of reach:
// `orchestrator.ts` reads `markerCounts` off this file, and the reasoning below is about the review
// loop and belongs where the loop is.
//
// Counted, not restored, and the asymmetry between the two is the reason. A [not legible] marker
// SHOULD disappear when the editor reads that region off the attached page image โ€” that is the
// resolution EDITOR_SYSTEM asks for โ€” so a fall in its count is a record and not a verdict.
// [page not fully transcribed] is never the editor's to resolve, so every one of those that goes
// missing is a loss. Re-inserting either has no honest position: the words that surrounded it
// were rewritten by the same round that dropped it.
//
// Both directions, because the other one is the harm the page prompt spends a paragraph on. A
// round that ADDS a marker has put a placeholder where words were โ€” "a placeholder standing for a
// paragraph you could mostly read costs a reader the part you had" โ€” and it reaches a reader as
// the source being unreadable when no pass that saw the source said so. The editor is never given
// that as an option (nothing in EDITOR_SYSTEM writes a marker), which is exactly why an appearance
// is worth a line: it is the closed enumeration having failed, and the words it replaced are
// invisible to contentCoverage, which strips [...] before comparing.
// Named separately because one of them is asked for by name elsewhere. A surviving
// `[page not fully transcribed]` is what the quality tally counts as a document that could not
// have finished the review loop clean (SIGNAL_UNFINISHED_PAGE): READER_SYSTEM reports every one
// of them every round and says settling it is nobody's job in this loop. `[not legible]` carries
// no such guarantee โ€” EDITOR_SYSTEM is given that page's image and asked to resolve it โ€” which
// is the same asymmetry the paragraph above turns on, so the two are not interchangeable and a
// positional `BODY_MARKERS[1]` would be the wrong way to say which is meant.
export { BODY_MARKERS, MARKER_NOT_LEGIBLE, MARKER_PAGE_INCOMPLETE, markerCounts };

// An ordered item as a reader meets it: the marker the list announces, and the marker the item's own
// text prints. Read off `flatten` rather than off the markup, because what a reader hears is the whole
// question here and the view is where the answer already is โ€” `type="a"` and `value="5"` together
// announce "e", and no attribute read on its own says that.
//
// The head of the item's text only, and a marker's SHAPE rather than any bracketed thing: up to three
// digits, a roman number, or a single letter CLOSED by ")" or "]". Each narrowing is a false positive
// this had, and each one names what it gives up:
//
//   * The roman run must be a roman NUMBER, not a run of roman letters โ€” "cm." and "ml." are both
//     letters from that alphabet and neither is a numeral anything counts with, and "(see)" is three
//     more. "(a)", "(iii)" and "(12)" do count.
//   * The roman alphabet here is "i", "v", "x" only, which puts a ceiling of xxxix = 39 on a roman
//     marker. Admitting "l", "c", "d" and "m" is exactly what made "cm." and "ml." matches, and a list
//     that reaches its fortieth roman item is a shape no page in the corpus prints.
//   * A LETTERED marker is one letter, so a list past its twenty-sixth item โ€” announcing "aa" with an
//     item printing "(aa)" โ€” is invisible to BOTH branches and not just to the doubling one. Same
//     reasoning as the ceiling above, and stated for the same reason: every narrowing here names what it
//     gives up, and a two-letter run is the shape a wider class would have to distinguish from any
//     ordinary two-letter word at the head of an item.
//   * A single letter must be CLOSED by ")" or "]" โ€” an opening bracket is not enough, because
//     "(e.g. the totals)" and "(i.e. โ€ฆ)" are a bracketed abbreviation and not a marker, and
//     "J. Smith chaired the committee" is an initial. A copy-edit round recasting either of those is
//     ordinary work for this pass. Two or more roman letters keep the looser closer, and the asymmetry
//     is the ambiguity itself rather than an inconsistency: "ii." cannot be an initial, "i." can, and
//     what a single letter and a full stop mean is unreadable without the sentence. The stated cost is
//     a marker genuinely printed "a." or "i." with no bracket, which this does not see.
const ANNOUNCED_ITEM = /\[List item ([^\]]+)\]([^[]*)/g;
const PRINTED_MARKER = /^\s*(\(|\[)?\s*([a-z]|[ivx]{2,5}|\d{1,3})\s*(\)|\]|\.)/i;
const ROMAN_NUMBER = /^x{0,3}(?:ix|iv|v?i{0,3})$/i;

// Whether the head of an item's own text is a printed marker, whether it is a DIGIT, and the token
// itself. The digit is a separate question because the repair for each is the opposite of the other's:
// a digit transcribed into an item is a copy of what the list already announces and the text's copy is
// the one that goes, while a letter transcribed into an item is the only record of what the page
// printed. The token is returned because `doubled` compares it against the marker the list announces โ€”
// see `ListMarkers`.
function printedMarker(text: string): { marker: boolean; digit: boolean; token: string } {
  const m = PRINTED_MARKER.exec(text);
  if (!m) return { marker: false, digit: false, token: "" };
  const token = m[2];
  if (/^\d+$/.test(token)) return { marker: true, digit: true, token };
  if (token.length > 1) return { marker: ROMAN_NUMBER.test(token), digit: false, token };
  return { marker: m[3] !== ".", digit: false, token };
}

interface ListMarkers {
  // Every announced ordered item, which is what makes the counts below comparable across a
  // round: see `listMarkerHalfEdit` for why a round that changed this number is not read at all.
  items: number;
  // Items whose ANNOUNCED marker is not a digit, which is a list carrying its letters in `type`.
  lettered: number;
  // Items whose own text opens with a marker of any shape, announced or not.
  printed: number;
  // Items whose own text opens with a marker that is NOT a digit โ€” the shape a page's letters take
  // when they were transcribed into the item instead of set on the list, and the only shape whose
  // deletion loses something: a digit the text repeats is a copy of what an `<ol>` announces by
  // itself, so stripping it is the repair `READER_SYSTEM` asks for rather than a loss.
  printed_lettered: number;
  // Items that print THE MARKER THE LIST ANNOUNCES, compared token against token and case-insensitively:
  // announced "a" while the text reads "(a)", announced "1" while the text reads "(1)", announced "c"
  // under `start="3"` while the text reads "(c)". Counted per item rather than inferred from the two
  // totals, because the state this names is a property of one item and a round can create it on some
  // items and not others.
  //
  // The comparison is the announced marker's own VALUE, and every weaker version of it reported
  // something a reader does not hear twice. Matching nothing but "the list announces letters" called a
  // statute's clause number a doubling โ€” "(a) 12. Payments โ€ฆ" is an ordinary shape and the reader hears
  // a marker and a number. Matching on KIND fixed that one and kept two more: "(a) (i) Payments" is a
  // marker and a roman SUB-marker, both non-digits; and a bare `<ol>` whose item prints "12." announces
  // "1" and reads "12", both digits, which is the same clause number one alphabet over. Only the value
  // settles it, and it is also the definition the prompt gives โ€” an item repeating the marker it is
  // announced with. Where a DIGIT-announced list's items print letters โ€” announced "1", text reads
  // "(a)" โ€” nothing is doubled either, which is `READER_SYSTEM`'s second branch (named for that shape,
  // and not "they disagree in kind", which also covers announced "a" beside a printed "12." โ€” a shape
  // whose repair cannot be a missing `type`, since the list already has one): that list is missing the
  // `type` that would announce its letters, and the text's copy must STAY until it has one.
  //
  // An item printing a marker that CONTRADICTS the announced one โ€” "(b)" under an `<ol type="a">`'s
  // first item โ€” is not counted and is not this check's question. Neither half of the licensed
  // conversion can produce it: the licence sets the `type` those very markers show, so its half-edits
  // leave the two agreeing by construction, and a disagreeing pair is a mis-set `type` or `start`
  // rather than half a conversion.
  doubled: number;
}

export function listMarkers(html: string): ListMarkers {
  let items = 0;
  let lettered = 0;
  let printed = 0;
  let printedLettered = 0;
  let doubled = 0;
  for (const m of flatten(html).matchAll(ANNOUNCED_ITEM)) {
    items++;
    const announced = m[1].trim();
    const printedHead = printedMarker(m[2]);
    if (!/^\d+$/.test(announced)) lettered++;
    if (printedHead.marker) printed++;
    if (printedHead.marker && !printedHead.digit) printedLettered++;
    if (printedHead.marker && printedHead.token.toLowerCase() === announced.toLowerCase()) doubled++;
  }
  return { items, lettered, printed, printed_lettered: printedLettered, doubled };
}

// The two halves of the conversion EDITOR_SYSTEM licenses, each of which is a defect on its own. That
// licence is the one edit in the prompt that asks for visible text to be REMOVED as its whole point โ€”
// the letters move out of the items and onto the list โ€” so the two states to watch for are the ones
// where only half of it happened, and nothing else in the pipeline can see either:
// `contentCoverage` strips [...] before comparing words so the announced marker is invisible to it,
// `markerCounts` watches BODY_MARKERS only, and the item count `navigation_lost` reads does not move
// when a marker changes shape.
//
// `text_markers_gone` is the loss: LETTERED markers left the items and the list did not gain them, so
// a list the page printed (a), (b), (c) now prints 1, 2, 3 and no copy of the letters is left anywhere
// in the document. It reads `printed_lettered` and not `printed`, because a DIGIT leaving an item's
// text is the repair `READER_SYSTEM` asks for on the one list in #334 whose rule already existed โ€” an
// `<ol>` announces 1, 2, 3 by itself, so nothing is lost and calling it a loss would put the wrong
// label on the branch the Reader fires on first.
//
// `marker_announced_twice` is the other half: an item that prints THE MARKER THE LIST ANNOUNCES, which
// is #334's own defect arriving from this loop instead of from an extraction. It reads
// `doubled`, a per-ITEM count, because the halfway state the totals cannot see is a round that sets the
// `type` and strips SOME of the items โ€” the list gains its letters, `printed` falls rather than holding,
// and the item still carrying its own marker is announced "b" and then reads "(b)" out.
//
// A COMPLETE conversion fires neither, which is the point of counting these four things instead of
// watching the prose shorten: the lettered markers leaving the text are exactly balanced by the list
// announcing them, and no item ends up holding both.
//
// TWO SILENCES, both stated rather than approximated:
//
// Silent where the ROUND CHANGED THE NUMBER OF ITEMS. An item the editor deleted takes its printed
// marker out of the count with it, and removing content the document printed twice is this loop's job.
// A fall that is one deleted item and a fall that is a stripped marker are the same two numbers, so a
// round that resized a list is not read here at all. The cost is a half-edit made in the same round as
// a deletion, which this cannot see; the alternative is a line that calls the loop's own licensed
// deletions a lost marker, and a signal that fires on correct work is one nobody reads.
//
// Silent where ONE LIST'S CONVERSION PAYS FOR ANOTHER'S DESTRUCTION, because every count here is a
// BLOCK total. A round that converts the first `<ol>` properly and strips the second one's letters
// without giving it a `type` leaves `lettered` risen and `printed_lettered` fallen โ€” the same two
// numbers a correct single conversion produces โ€” and logs nothing. Not narrowed, and not for want of
// noticing: `flatten` announces items and never the list they belong to, so splitting these counts per
// list means a second renderer of the announced marker beside `markerStyle`, and the cheap substitute
// (a new list wherever the sequence restarts) is wrong on any list carrying `start`. The block is the
// grain the rest of this file's loss accounting uses, and buying this one case with a duplicate
// marker renderer is the worse trade.
//
// Those two are silences about edits the editor may MAKE. There is a third thing this cannot see, and
// it is the reason `EDITOR_SYSTEM`'s conversion licence stays scoped to a sequence that begins where
// the list's own count does. `READER_SYSTEM` reports a same-kind offset run โ€” items printing 12., 13.
// under a list counting 1, 2 โ€” as a list missing its `start`, and the editor is told to report that
// shape rather than convert it, so nothing here moves either way today. Were the licence widened to
// let it set `start` and strip the text, the DESTRUCTIVE half of that change would be invisible:
// markers stripped with no `start` set deletes the document's only record of its numbering, and on the
// DIGIT half of the shape it produces the same five counts as the whole change (`printed_lettered` was
// already 0 and stays 0), so this cannot tell them apart. The lettered half of the same shape IS
// caught. That asymmetry is a silence to close before the licence moves, not after.
export type ListMarkerHalfEdit = "text_markers_gone" | "marker_announced_twice";

export function listMarkerHalfEdit(before: string, after: string): ListMarkerHalfEdit | null {
  const was = listMarkers(before);
  const now = listMarkers(after);
  if (now.items !== was.items) return null;
  // The double marker first, because a round can produce both readings at once โ€” strip two items and
  // leave a third โ€” and of the two states that is the one a reader meets in the delivered document.
  if (now.doubled > was.doubled) return "marker_announced_twice";
  if (now.printed_lettered < was.printed_lettered && now.lettered <= was.lettered) return "text_markers_gone";
  return null;
}

const CHUNK_BUDGET = 24000;
const CHUNK_OVERLAP = 2000;

function chunk(s: string): string[] {
  if (s.length <= CHUNK_BUDGET) return [s];
  const out: string[] = [];
  let start = 0;
  while (start < s.length) {
    out.push(s.slice(start, start + CHUNK_BUDGET));
    start += CHUNK_BUDGET - CHUNK_OVERLAP;
  }
  return out;
}

// What the elements under each violation are, said once above the list.
//
// Here rather than in READER_SYSTEM for the same reason the no-verdict sentence is: the lines it
// describes are two lines below it, and every clause of it is a thing a Reader would otherwise
// have to infer from a selector it has never been told the provenance of.
//
// The last sentence is the half of #161 that is not about the Reader at all. The Copy Editor is
// never shown the lint โ€” `editorCall` sends the body and the Reader's issue list, nothing else โ€”
// so a selector that stops at the Reader has moved the search one agent down rather than ended
// it. Round 8's `aria-deprecated-role` is the worked example: the Reader named the rule in its
// analysis, the editor ran and changed the document, and the deprecated role shipped.
const LINT_NODE_NOTE =
  `Each violation lists the elements axe reported it on: a CSS selector, then that element's ` +
  `markup folded to one line and cut short. The list is computed from the WHOLE document ` +
  `rather than from the HTML you were given, and this call is the only one that has it, so an ` +
  `element it names may sit outside your window โ€” report it anyway. Only the first few elements ` +
  `of a rule are listed and the node count is the whole of it, so a rule listing three elements ` +
  `out of forty is forty places to fix. Quote BOTH the selector and the markup in the issue you ` +
  `write: the Copy Editor is not shown these results, so what you write is all it has to find ` +
  `the element by โ€” and it is sometimes given one section of the document rather than all of it, ` +
  `where a selector counting position (\`section:nth-child(4) > p\`) counts something else and ` +
  `the markup is what still identifies the element.`;

// The most elements the whole summary lists, across every rule.
//
// MAX_EXAMPLE_NODES bounds one rule; this bounds the section, which is the thing that competes
// with the document for the window. The two arguments are different: three of a rule's forty
// nodes is a sample, and there is no such thing as a sample of the rule LIST โ€” ~60 rules are
// enabled, so a badly extracted scan failing fifteen of them would add every one of their
// examples to a prompt that also has to hold 24000 characters of document.
//
// Spent in the order the violations are listed, and what it cut is said in the prompt: a list
// that stops without saying so reads as the rules after it having had nothing to point at.
export const MAX_EXAMPLES_TOTAL = 24;

// What the Reader is told the linter found. The no-verdict case is spelled out rather than
// stated as a failure, because this text sits under a "## axe-core lint" heading in a prompt
// that also says the review is against "the axe-core lint results provided": a Reader given
// only an error message can read the section as an empty result and take the document to
// have been checked (#164). It is told the opposite, in the sentence it would otherwise
// have to infer.
//
// `withExamples` is false for every chunk but the first, and the elements are the reason. The
// lint is one verdict on the WHOLE document while the Reader is called per window, so a note
// telling every call to report an element outside its own window tells N calls to report the
// same one โ€” the defect arrives N times and is carried to `@unresolved` N times if no editor
// round clears it (#192, through the lint path this time). It is the same constraint the
// duplicate-heading list is under and it takes the same answer: one call owns the
// whole-document input. What the other chunks keep is exactly what they had before this
// section listed elements at all โ€” the rule, its impact, its description and its count.
function lintSummary(lint: LintResult, withExamples: boolean): string {
  if (lint.violations === undefined) {
    return (
      `axe-core could not run, so NOTHING in this document has been checked for ` +
      `accessibility violations. Treat this section as absent, not as empty: there is no ` +
      `machine verdict on this document either way, and anything a linter would have caught ` +
      `is still in it unless you catch it. (${lint.error ?? "no result"})`
    );
  }
  if (lint.ok) return "axe-core: no violations";
  let budget = withExamples ? MAX_EXAMPLES_TOTAL : 0;
  let listed = 0;
  let unlisted = 0;
  const lines = lint.violations.map((v) => {
    const head = `- ${v.id} (${v.impact}): ${v.description} [${v.nodes} nodes]`;
    const examples = (v.examples ?? []).slice(0, budget);
    if (examples.length === 0) {
      // Counted once the budget is gone, whether or not this rule HAD examples to spend it
      // on. The paragraph below points at "the last N rules", which is a claim about position
      // and is only true if N is every unlisted rule: a violation carrying no examples at all
      // (hand-built, or stored before #161) sitting between two that were cut would otherwise
      // be skipped in the tally and read as one of the rules whose elements are listed.
      if (withExamples && listed > 0 && budget === 0) unlisted++;
      return head;
    }
    budget -= examples.length;
    listed += examples.length;
    // The list says how much of the rule it is showing, on the line the selectors hang off. A
    // reader of three selectors under a `[40 nodes]` count has to be told that the three are a
    // sample and not the forty, or the count reads as having been enumerated.
    const shown = examples.length < v.nodes ? ` โ€” showing ${examples.length} of ${v.nodes}:` : ":";
    return (
      head + shown + "\n" + examples.map((n) => `    - \`${n.target}\` โ€” ${n.html}`).join("\n")
    );
  });
  // Only where there is something for it to describe. A lint whose violations carry no examples
  // โ€” a chunk that is not the first, a violation built without them (see LintViolation) โ€” would
  // otherwise be introduced by a paragraph about lines that are not there.
  const note = listed > 0 ? `${LINT_NODE_NOTE}\n\n` : "";
  const cut =
    unlisted > 0
      ? `\n(No elements are listed for the last ${unlisted} ${unlisted === 1 ? "rule" : "rules"} ` +
        `above: the list had already reached ${MAX_EXAMPLES_TOTAL}. Their counts are the whole of ` +
        `them and they are no less real for having no example here.)`
      : "";
  return note + lines.join("\n") + cut;
}

// The page index is repeated on every Reader call (once per chunk per round), so
// excerpts are shorter here than the scoping call's โ€” just enough to match content
// back to a page.
const READER_INDEX_EXCERPT_CHARS = 200;

// What the index says instead of an excerpt for a page the document has no content for.
// Both are under READER_INDEX_EXCERPT_CHARS, so neither is delivered half-said.
//
// The two are worded apart because they are opposite facts about the run and a reader of a
// log or a prompt has to be able to tell them apart: one is content this pipeline lost, the
// other is a page with nothing on it, correctly delivered as such (issue #184). What they
// share is that no correction round can act on either, which is why each says so where the
// Reader reads it rather than only in READER_SYSTEM โ€” the rule is one paragraph away from a
// numbered entry the Reader is matching content against, and the entry it is about is the
// one that used to read as a hole.
const FAILED_PAGE_NOTE =
  "(no content โ€” this page could not be extracted, so the document has none of it. " +
  "Already recorded, and no edit can fix it: not an issue to report.)";
const BLANK_PAGE_NOTE =
  "(no content โ€” this page is blank in the source, so there was nothing to extract. " +
  "The document is correct as it stands: not an issue to report.)";

// The pages the document has no content for, and which of the two reasons it is.
//
// `failed` comes from the caller, because only extraction knows it: the fragment of a failed
// page is not empty โ€” it is the `@page-failed` comment that says where the hole is โ€” so
// nothing about its own bytes distinguishes it from a page whose content happens to be short.
// `blank` is read off the fragment, because an empty fragment can only be a page the page
// agent declared blank: extraction throws on an empty reply it was not told to expect, and a
// throw takes the `failed` path above (see extraction.ts `declaredBlank`).
//
// Both are things the review loop is asked about today and can do nothing with. The failed
// page is the case runReview's `failedPages` comment already names โ€” "the Reader would raise
// 'this page is missing' every round against a body no editor can repair" โ€” and the guard it
// describes only kept the disclosure out of the editor's reach, never the question out of the
// Reader's prompt (issue #188). The blank page is the same shape with nothing wrong behind it:
// its index entry was an empty line, an empty line reads as a hole, and #184's correctly
// delivered blank pages came back as unresolved issues on the next round of the bench.
export function noContentPages(pages: IndexedPage[], failedPages: number[]): Map<number, "failed" | "blank"> {
  const failed = new Set(failedPages);
  const out = new Map<number, "failed" | "blank">();
  for (const p of pages) {
    if (failed.has(p.order)) out.set(p.order, "failed");
    else if (!p.innerHtml.trim()) out.set(p.order, "blank");
  }
  return out;
}

// The Reader's view of the index: those pages' entries say what they are instead of showing
// an excerpt of content that is not there.
//
// Annotated rather than REMOVED, which was the other half of #188's proposal. A removed entry
// leaves a gap in the numbering โ€” page 7 then page 9 โ€” and a gap is exactly what invites the
// report it was meant to prevent, from a Reader that now has to guess what happened to 8. It
// would also cost the entry its one honest use: a failed page still narrows a nearby issue's
// attribution, because "the content around here is on 7 and 9" is what the pages either side
// of it say.
//
// A copy, not a mutation: `pages` is the array runReview holds for the whole loop and hands to
// `knownPages`, and an attribution to a page with no content must stay valid โ€” it is how the
// one report this still allows carries its page number.
export function readerIndexPages(pages: IndexedPage[], noContent: Map<number, "failed" | "blank">): IndexedPage[] {
  if (noContent.size === 0) return pages;
  return pages.map((p) => {
    const state = noContent.get(p.order);
    if (!state) return p;
    return { ...p, innerHtml: state === "failed" ? FAILED_PAGE_NOTE : BLANK_PAGE_NOTE };
  });
}

// One report per page with no content, however many chunks reported it.
//
// The prompt above is the primary fix and this is the backstop, in the order the pipeline
// prefers everywhere else: say it where it can be understood, then make the thing that cannot
// be understood impossible. The Reader is a sampled model told not to raise these, and on the
// round that filed #188 every chunk raised them anyway โ€” six reports of one page in six
// different wordings, six of that document's 26 unresolved issues.
//
// Per CHUNK, and only the final round's chunks: `@unresolved` is written from `lastIssues`,
// which every round overwrites, so the delivered list is one read of the document and the
// number of copies in it is that read's chunk count. What the ROUNDS multiplied is the spend โ€”
// every round's editor was handed the same reports about a page it cannot repair, and paid a
// whole-body correction to say nothing about them.
//
// Which is why the key is `(no content, page)` rather than the issue text. The reports come
// from independent calls that never see each other, so no two are worded alike and exact-string
// dedupe catches none of them; what makes them the same issue is the page, and the pipeline
// already knows which pages these are. An issue is one of them when it is attributed AND every
// page it names has no content in the document โ€” a page with no content cannot hold content
// that is wrong, so an issue about nothing else is an issue about the absence.
//
// The FIRST is kept, not all of them dropped, and the difference is deliberate. Dropping them
// all would make this code decide that a report is worthless on evidence it does not have: the
// attribution is the Reader's, and a misattributed real issue โ€” structure the Reader could not
// place, pinned on a failed page โ€” would vanish with no trace anywhere. Keeping one costs a
// reader of `@unresolved` a line they can act on (it names the page, and the document's
// `@page-failed` comment says the rest) and leaves one copy where there was one per chunk. It
// does mean a document with a failed page still cannot end the loop clean, which is the
// behaviour it has today and a separate question from counting it once.
//
// Unattributed reports are not caught and cannot be: "the document is missing a page" with an
// empty `pages` list is indistinguishable here from any other issue the Reader could not place.
// The prompt is the only reach into that case, which is the other reason it is not the backstop.
export function dedupeNoContentIssues(
  issues: ReviewIssue[],
  noContent: Map<number, "failed" | "blank">,
): { issues: ReviewIssue[]; dropped: ReviewIssue[] } {
  if (noContent.size === 0) return { issues, dropped: [] };
  const reported = new Set<number>();
  const dropped: ReviewIssue[] = [];
  const kept = issues.filter((issue) => {
    const pages = issue.pages ?? [];
    if (pages.length === 0 || !pages.every((p) => noContent.has(p))) return true;
    if (pages.every((p) => reported.has(p))) {
      dropped.push(issue);
      return false;
    }
    for (const p of pages) reported.add(p);
    return true;
  });
  return { issues: kept, dropped };
}

// The index, as the head of a Reader prompt.
//
// It is the one part of that prompt which is about the DOCUMENT rather than about the
// chunk in front of it, and it does not change while the loop runs: it is built from the
// fragments as they entered review and deliberately not rebuilt as the editor rewrites
// the body, because it exists to attribute content to a SOURCE page and the source does
// not change (see runReview). So every chunk of every round sends these same bytes โ€” on a
// 25-page document ~1.5k tokens, over several chunks and up to `max_review_iterations + 1`
// rounds, which is the same paragraph re-sent dozens of times at full price.
//
// Which is why it LEADS the message now, where it used to sit near the end: a cache
// breakpoint marks a prefix, so what repeats has to come before what varies or it cannot
// be cached at all. Nothing else moved, and the sections are self-labelled โ€” the Reader
// is told it is "given an index of the document's source pages", not told where to look
// for it โ€” so this is the same prompt with its stable half first. Below the minimum
// length the breakpoint is declined and the message is sent as one piece, which is what
// it was: at READER_INDEX_EXCERPT_CHARS that is a document of fewer than about ten pages,
// which is also where there was least to save.
//
// This entry's economics are NOT the system prompt's, and the argument a few lines below
// for why concurrent chunks may all pay a write does not transfer. READER_SYSTEM is static
// across sessions, so a busy deployment finds it warm; an index is built from THIS
// document, so it is cold once per session by construction and the chunks of the first
// round โ€” sent together โ€” each pay 1.25x where they used to pay 1x. That is the whole
// cost, and one further round clears it several times over: every later chunk reads the
// index at 0.1x instead of paying for it again. Concretely, three chunks pay +0.75 of one
// index on the first round and save 2.7 of it on each round after, so break-even is at
// roughly a quarter of a second round โ€” where "each round after" means each round that
// arrives while the entry is still live. The TTL is ~5 minutes refreshed on read, and what
// sits between two Reader rounds is an editor pass carrying page images, which is the
// slowest call in the loop: a round that arrives after it expires writes again instead of
// reading, saving nothing and costing the same +0.25x it cost on the first. That is the
// floor of this trade rather than a regression โ€” the bytes are the bytes either way. The document that does not win is the one that
// reads clean on the first look and has no second round โ€” it pays about a quarter of its
// index, ~300 tokens per chunk on a 25-page document โ€” and that is the trade: a small
// certain cost on the documents that need no fixing, against a large one on every
// document that iterates, which is the expensive case.
function readerIndexHead(index: string): string {
  return index ? `## Source pages in this document (extracted HTML, truncated)\n${index}\n\n` : "";
}

// One window's worth of review: what the Reader said about it, and whether it said
// anything this code can read.
//
// The two are separate for the same reason `EditorRound.usable` is separate from its body:
// an empty issue list can mean the Reader read the window and found nothing โ€” a verdict โ€”
// or that the reply could not be used at all, which is a call paid for and no verdict
// obtained. Folding them together (which returning a bare `ReviewIssue[]` did) makes the
// second look like the first, and the first is the one thing that ends the loop clean.
interface ReaderWindow {
  issues: ReviewIssue[];
  // False when the reply carried no readable issue list: unparseable, `issues` missing or
  // not an array, or every entry in it too malformed to be an issue. NOT false for a reply
  // that legitimately said `{"issues": []}` โ€” that is the verdict this whole loop is for.
  usable: boolean;
}

// What one read of the whole document came to: the issues, and how much of the document
// nobody got an answer about (see ReaderWindow).
interface ReaderRead {
  issues: ReviewIssue[];
  unread: number;
  windows: number;
}

async function runReader(
  ctx: PipelineContext,
  body: string,
  lint: LintResult,
  pages: IndexedPage[],
  // Only so the line this logs can say which round it belongs to, the way `reader` and
  // `editor` already do. Nothing here reads it.
  iteration: number,
  // The pages extraction lost, so this call can say so in the index instead of showing an
  // empty entry and being asked about it once per chunk (see noContentPages).
  failedPages: number[],
): Promise<ReaderRead> {
  const noContent = noContentPages(pages, failedPages);
  const index = pages.length ? pageIndex(readerIndexPages(pages, noContent), READER_INDEX_EXCERPT_CHARS) : "";
  // Computed over the whole body, once, and given to the FIRST chunk only. Both halves
  // of that matter. Whole-body, because a chunk is a character window and the pair this
  // finds is a page apart (see sameWordedHeadingRuns). First chunk only, because every
  // call that receives the list reports it, and the chunks are independent calls โ€” the
  // same defect would arrive two or three times and be carried to @unresolved that many
  // times if no editor round cleared it.
  const duplicateHeadings = sameWordedHeadingNote(sameWordedHeadingRuns(body));
  // The invariant head of every chunk's prompt (see readerIndexHead).
  const head = readerIndexHead(index);
  // The two per-run tails of the prompt, read once instead of once per chunk:
  // `examplesForPrompt` reads and parses the agent's example bank off disk, and it
  // cannot change while a round is in flight.
  //
  // These stay at the END, where they were, rather than joining the cached head. Both are
  // instructions rather than reference material โ€” the user's feedback for this run, and
  // the lessons past corrections taught โ€” and where an instruction sits in a prompt is a
  // question about whether it is followed, not about what it costs. The index has no such
  // claim on a position: the Reader is told it is "given an index of the document's source
  // pages" and matches content against it wherever it appears. Between them they are a
  // fraction of the index's size on any document with pages in it.
  const tail = feedbackPreamble(ctx) + examplesForPrompt(ctx.paths, "page.md", ["a11y_policy"]);
  const chunks = chunk(body);
  // Chunks are independent calls over disjoint windows of a body nothing mutates while
  // they run, so they are sent CONCURRENTLY rather than one after another. On a long
  // document this is the review loop's dominant latency term and it was strictly serial:
  // a 25-page body is several CHUNK_BUDGET windows, each a full text call, and the whole
  // ladder is re-climbed on every round of the loop (up to max_review_iterations + 1
  // times) because the Reader has to re-read what the editor changed.
  //
  // Nothing about what is SENT changes โ€” same prompts, same chunk order โ€” so no verdict
  // can move and no extra token goes over the wire.
  //
  // What a COLD round is billed does change, on one term. READER_SYSTEM clears
  // `cacheableSystemPrompt`, so it carries a cache breakpoint: serially, chunk 0 paid the
  // 1.25x write and the rest read it at 0.1x, while chunks sent together all miss an entry
  // that does not exist yet and each pay a write. That is ~1.15x of one system prompt per
  // extra chunk (~1.4k tokens), once, and only on a round whose cache entry has expired โ€”
  // the prompt is static across sessions and every read refreshes the five-minute TTL, so
  // a deployment doing any work at all is warm and pays none of it. Priming the entry with
  // a serial first chunk would buy that back by putting a whole call's latency into every
  // round, warm ones included, to save a fraction of one prompt on the rare cold one.
  //
  // Bounded by the same knob as page extraction: it is the deployment's answer to how
  // many model calls one run may have in flight (`defaults.extraction_concurrency`), and
  // a Reader chunk is that same kind of call. So a run's peak stays where the operator
  // set it, in this phase as in the other, and an operator who lowered it for a
  // rate-limited provider gets the review bounded too. Defensive `|| 1` for a
  // directly-constructed context (tests, embedders) that never set it: serial is what
  // this function did before, so an unset knob degrades to exactly the old behaviour.
  const limit = Math.max(1, Math.floor(ctx.extractionConcurrency) || 1);
  ctx.log.event("reader_start", { iteration, chunks: chunks.length, concurrency: limit });
  // The first error any chunk threw. `mapWithConcurrency` rejects with it โ€” matching the
  // serial loop, and the round is discarded either way โ€” but its workers go on pulling
  // items until the list is exhausted, so a chunk that fails early would otherwise be
  // followed by a full-price call for every chunk still queued behind it. Whoever fails
  // first records it here and the rest decline to send. This is the first caller that can
  // reject at all: extraction contains each page in a `.catch`, so nothing before it ever
  // reached this path.
  //
  // Whether one failed is its own flag rather than a test on the error, because the value
  // thrown is not ours: a `throw undefined` from an adapter or a mock is still a chunk
  // that failed, and reading the guard off the error itself would leave it disarmed on
  // exactly that call โ€” every queued chunk then paying in full, which is the case the
  // guard exists for.
  let failed = false;
  let failure: unknown = null;
  const perChunk = await mapWithConcurrency(chunks, limit, async (c, i): Promise<ReaderWindow> => {
    if (failed) throw failure;
    // Whether this call has the whole body or a window of it, said where the body is handed
    // over. The Reader is told not to report a page as missing on the strength of content it
    // cannot find (READER_SYSTEM, issue #188) โ€” which is right for a window and wrong for the
    // whole document, where a page whose content an editor round dropped is a finding nothing
    // else in this loop can make. `contentCoverage` and `destroyedPage` guard extraction, not
    // this loop, so on a single-chunk document the Reader is the only check there is. Absent
    // rather than "window 1 of 1", because a label that has to be read as "you have all of it"
    // is one more thing to get wrong.
    const window = chunks.length > 1 ? ` (window ${i + 1} of ${chunks.length} of the document)` : "";
    const user =
      head +
      // `i === 0` for the offending elements, on the same argument as `duplicateHeadings` two
      // lines down: the lint is one verdict on the whole document, and a whole-document input
      // given to every independent chunk call comes back as the same finding once per chunk.
      // See lintSummary.
      `## HTML${window}\n\`\`\`html\n${c}\n\`\`\`\n\n## Flattened screen-reader view\n${flatten(c)}\n\n## axe-core lint\n${lintSummary(lint, i === 0)}` +
      (i === 0 && duplicateHeadings
        ? `\n\n## Headings with the same words at the same level, nothing but their own content between them (whole document)\n${duplicateHeadings}`
        : "") +
      tail;
    let res;
    try {
      res = await ctx.router.complete(
        "reader",
        "text",
        [
          { role: "system", content: READER_SYSTEM },
          // The head is this run's page index and nothing else, so it is the same bytes on
          // every chunk of every round โ€” declared so the adapter can cache it rather than
          // charge for it dozens of times (providers/types.ts `cachedPrefix`). Undefined
          // rather than "" when there is no index, which is a document with no pages to
          // attribute to: an empty head is not a prefix worth naming.
          { role: "user", content: user, cachedPrefix: head || undefined },
        ],
        { step: "read" },
      );
    } catch (e) {
      // The first one wins, so the error the round rejects with is the one that
      // actually happened rather than whichever chunk noticed the flag.
      if (!failed) {
        failed = true;
        failure = e;
      }
      throw e;
    }
    ctx.log.agentCall({
      agent: { name: "reader", file: "reader.md", content: READER_SYSTEM, capabilities: ["text"], sha: null, sessionBuilt: false },
      phase: "review",
      output: res.text,
    });
    // Nothing about the reply's SHAPE is ours, so none of it is assumed. `issues` arrives
    // as `unknown` and is narrowed here, which is two fixes in one place: a reply whose
    // `issues` is a string used to throw a TypeError out of the loop โ€” a failed session,
    // extraction and assembly discarded, for a badly shaped answer โ€” and a reply with no
    // issue list at all used to read as `{"issues": []}`, i.e. as a clean document (#186).
    const parsed = extractJson<{ issues?: unknown }>(res.text);
    const raw = Array.isArray(parsed?.issues) ? parsed.issues : null;
    // An entry that is not an object cannot be an issue, and reading `.pages` off one
    // throws โ€” `null` in the list is the same crash as a string `issues`, one level in.
    // Dropped rather than fatal, for the reason the whole file is built on: a reply that is
    // partly usable is worth its usable part.
    const shaped = (raw ?? []).filter(
      (issue): issue is ReviewIssue & { pages?: unknown } => typeof issue === "object" && issue !== null,
    );
    if (raw !== null && shaped.length < raw.length) {
      ctx.log.event("reader_issues_dropped", {
        iteration,
        window: i + 1,
        of: chunks.length,
        dropped: raw.length - shaped.length,
        of_entries: raw.length,
      });
    }
    // A reply that listed issues and had none of them survive the shape check said nothing
    // readable either, so it is not a verdict โ€” while `{"issues": []}` IS one, which is why
    // this is not simply `shaped.length > 0`. That empty list is what the whole loop is for.
    if (raw === null || (raw.length > 0 && shaped.length === 0)) {
      // Said the way `editor_no_output` is said, because it is the same event about the other
      // agent: a call that was paid for and produced nothing to act on. One line per window
      // that has no verdict, whichever way it failed โ€” `window` and `of` because a document
      // is read in windows and only one of them may have failed, and `reason` because "there
      // was no list" and "there was a list of nothing usable" are different replies.
      ctx.log.event("reader_no_output", {
        iteration,
        window: i + 1,
        of: chunks.length,
        reason: raw === null ? "no_issue_list" : "no_readable_issue",
        chars: res.text.length,
      });
      return { issues: [], usable: false };
    }
    // Drop hallucinated page numbers here rather than downstream, so a bad
    // attribution degrades to "no attribution" (all images) instead of
    // silently sending the editor the wrong page.
    return {
      issues: shaped.map((issue) => ({ ...issue, pages: knownPages(issue.pages, pages) })),
      usable: true,
    };
  });
  // mapWithConcurrency returns results in INPUT order, so the issue list is the one a
  // serial loop produced โ€” which matters downstream: `imagesForIssues` unions the pages
  // and `unresolved` is written in this order, so a document's unresolved list must not
  // depend on which chunk's call happened to finish first.
  //
  // Which is also why the dedupe runs here, on the flattened list, rather than inside the
  // per-chunk callback: what it removes is the SECOND report of a page, and which report is
  // second is a fact about the assembled list. Applied per chunk it would be a no-op โ€” no
  // chunk reports a page twice on its own โ€” and applied to whichever call finished first it
  // would keep a different one each run.
  const { issues, dropped } = dedupeNoContentIssues(
    perChunk.flatMap((w) => w.issues),
    noContent,
  );
  // Logged only when something was dropped, and with the reports themselves rather than a count
  // alone. The count says how much of `@unresolved` this round would have spent on repeats and
  // the pages say which entries the kept report stands for, but neither would let anyone read
  // what went: keeping the first is defended above on the grounds that a misattributed real
  // issue must not vanish without a trace, and WHICH report is first is an accident of chunk
  // order โ€” the chunk that pinned a real defect on a lost page may not be chunk 0. So the text
  // and severity of each dropped report are here, whitespace-folded and bounded the way every
  // other model-written string this pipeline logs is bounded.
  if (dropped.length > 0) {
    ctx.log.event("reader_page_reports_deduped", {
      iteration,
      dropped: dropped.length,
      pages: [...new Set(dropped.flatMap((i) => i.pages ?? []))].sort((a, b) => a - b),
      // `String(... ?? "")` because these two fields are the model's own: `runReader` normalizes
      // only `pages`, and everything else that touches an issue interpolates the text into a
      // prompt or a comment, where a missing one prints as `undefined` and costs a line. This is
      // the first place that calls a METHOD on it, and a reply that omitted `issue` would throw a
      // TypeError out of the review loop into the orchestrator's outer catch โ€” a failed session,
      // extraction and assembly discarded, for a log line about a report being dropped.
      reports: dropped.map(
        (i) => `${i.severity ?? "unrated"}: ${String(i.issue ?? "").replace(/\s+/g, " ").trim().slice(0, 300)}`,
      ),
    });
  }
  return { issues, unread: perChunk.filter((w) => !w.usable).length, windows: chunks.length };
}

// Which source images the Copy Editor needs this round: the union of the pages the
// Reader attributed its issues to โ€” but ONLY when it attributed every issue.
//
// One unattributed issue re-broadens the whole round to every image. This follows
// the same asymmetric-cost bias as the rest of the pipeline: narrowing wrongly can
// leave an issue permanently unfixable, while broadening wrongly costs no more than
// the behaviour this optimization replaced.
//
// The tempting alternative โ€” narrow to whatever WAS attributed and let the loop
// recover later โ€” is worse than it looks. An unattributed issue is usually
// structural (duplication, reading order, heading levels) and fixable from the HTML
// alone, but it is also what you get when the editor has rewritten the body far
// enough that the Reader can no longer match it to a source excerpt. That drift
// grows every round, so a genuine content issue can go unattributed in exactly the
// late rounds where the iteration budget is thinnest. Recovery costs a full
// iteration (the leftover must become the ONLY issue before images come back), and
// the loop may not have one to spend: at the cap it never happens, and since the loop
// also stops on a round that changes nothing, a round whose issues are all
// unattributable can end it sooner than that โ€” the editor answers with the body it was
// handed and there is no later round to narrow in. That makes this the stronger reason
// to broaden, not a weaker one: the issue is written to @unresolved having never
// been shown its own page.
//
// The cost of being generous is bounded by `capEditorImages` below, which is what
// makes the paragraph above true. It did not used to be: the claim was that a
// chronically unattributable issue pins the document to all-images, "which is
// precisely the status quo" โ€” and that reasoning holds only while all-images is
// merely expensive. At MAX_PDF_PAGES it is over the context window, so on a 25-page
// document the fallback was not a cost bound but a refused request, arriving after
// extraction and assembly had both been paid for and ending the run with nothing
// delivered (issue #134). The savings case โ€” every issue attributed โ€” is unchanged.
export function imagesForIssues(images: InputImage[], issues: ReviewIssue[]): InputImage[] {
  if (issues.some((i) => !i.pages?.length)) return images;
  const wanted = new Set(issues.flatMap((i) => i.pages ?? []));
  if (wanted.size === 0) return images;
  const selected = images.filter((img) => wanted.has(img.order));
  return selected.length ? selected : images;
}

// Fit `imagesForIssues`'s selection inside one request (providers/imageLimits.ts
// MAX_EDITOR_IMAGES for why that number).
//
// Kept separate from the selection rule on purpose: which pages the editor WANTS is a
// question about the issues, and how many of them fit is a question about the model.
// Folding the second into the first would make the answer to the first untestable, and
// the two change for different reasons.
//
// Pages an issue actually NAMED come first, because those are the ones the editor
// cannot fix without them โ€” an unattributed issue is usually structural and fixable
// from the HTML alone, which is the fallback's own justification for being safe to
// broaden. Past the cap, attribution is the only evidence available about which image
// is worth a slot. What survives is re-sorted into document order, since the prompt
// tells the editor the images arrive in the order it names them.
export function capEditorImages(
  selected: InputImage[],
  issues: ReviewIssue[],
  max: number = MAX_EDITOR_IMAGES,
): InputImage[] {
  if (selected.length <= max) return selected;
  const attributed = new Set(issues.flatMap((i) => i.pages ?? []));
  const preferred = [
    ...selected.filter((img) => attributed.has(img.order)),
    ...selected.filter((img) => !attributed.has(img.order)),
  ];
  return preferred.slice(0, Math.max(1, max)).sort((a, b) => a.order - b.order);
}

// What one editor round produced, and whether the editor actually answered.
//
// The two are separate because they are separate questions and the loop acts on both.
// `body` unchanged can mean the editor read every issue and decided the document was
// better left alone โ€” a decision, and one it would make again on the same input โ€” or it
// can mean the reply could not be used at all, which is a call paid for and nothing
// learned. Folding them together (which returning a bare string did) makes the second
// look like the first.
interface EditorRound {
  body: string;
  // False when the model returned nothing usable โ€” an unparseable reply, an empty `html`, or a
  // list of edits of which not one could be applied โ€” in which case `body` is what went in. Not
  // evidence about what the editor would do next time, because it never said.
  //
  // An empty edits list is NOT this case: that is the editor answering that the markup needs
  // nothing, and the loop is entitled to read an unchanged body as a convergence on it.
  usable: boolean;
  // True when the response hit the model's output ceiling (issue #143). A third answer to the
  // same question, and the only one that also says the NEXT round cannot succeed โ€” because it
  // would make the same request against the same body, and whatever did not fit is still there.
  // Under the block-patch contract (#250) that is a reply carrying one block bigger than the
  // ceiling, or a reply that answered with a whole document; either way the round after it hits
  // the same wall. The loop must not treat this as the retryable case that `usable: false`
  // otherwise means.
  //
  // It no longer implies `usable: false`, which is issue #165: the round is retried a section
  // at a time before it is given up on, so a truncated round can come back with corrections in
  // it. `truncated` still says the ceiling was hit and the loop still ends on it; `sections`
  // and `salvaged` say what was rescued, and by which of the two routes.
  truncated: boolean;
  // Set when the round was answered section by section: how many sections the body was cut
  // into, and how many of them came back corrected. Absent on a round that was answered whole
  // and on one that could not be sectioned at all โ€” so its presence is what distinguishes a
  // truncation the document survived with corrections from one it survived without them.
  //
  // Since #295 it is the sections of whatever `salvaged` below did not cover, which on a salvaged
  // round is the REMAINDER of the document and not the whole of it. The two fields are read
  // together for that reason: `sections: { of: 3, corrected: 3 }` beside `salvaged` is three
  // sections of the part the reply never reached, and the same pair without `salvaged` is three
  // sections of the document.
  sections?: { of: number; corrected: number };
  // Set when the truncated reply's own edits were read and applied (issue #295): how many of them
  // were used, how many top-level blocks of the body they cover, and how many blocks the body has.
  // `blocks` equal to `of` is a reply that answered about the whole document before the ceiling cut
  // it, which needs no sections at all.
  //
  // Its presence changes what a truncation means for the document, which is why it is reported
  // rather than folded into `sections`: the corrections in the delivered body came from the round's
  // own answer, at the length that answer was billed for, instead of from calls made afterwards
  // that could see neither the whole document nor its pages.
  // `cutBack` is #317's retreat: `blocks` stops where the reply gave content up rather than where
  // the ceiling stopped it, so the reply itself answered about MORE of the document than `blocks`
  // says โ€” up to all of it. Only the delivered marker needs the distinction, and it needs it
  // because the sentence it would otherwise write ("the answer hit the ceiling partway through")
  // is the one thing that did not happen at that boundary.
  salvaged?: { edits: number; blocks: number; of: number; cutBack: boolean };
  // How many heading elements this round would have taken out of the document, on the one round
  // shape that is acted on for it: every edit applied, nothing refused, the prose no shorter, and the
  // body that would have shipped left with fewer headings than it had (`applyEditorPatch`, #331).
  //
  // Present ONLY on that round, and only where something was actually done about it โ€” blocks handed
  // back (`headings_reverted`) or the round refused whole (`headings_lost`) โ€” so it is not a general
  // "headings moved" reading and must not be summed with anything. `usable` says nothing about it
  // either way: the usual outcome is a `usable: true` round with its demoting blocks reverted, and
  // reading this off `usable` would report a guard that fired all day as one that never fired. The
  // loop reads it to record that the editor did this at all; nothing else in the result says a
  // heading was ever at risk.
  headingsLost?: number;
}

// Under this contract a reply names one block per edit, so counting the key says how many edits the
// model got through before the ceiling โ€” the difference between one enormous block and forty small
// ones. Not authoritative in either direction: a model that wrote `'block':` or `block:` counts
// zero here while `reply_head` plainly shows an edits list, and a document that quotes `"block":`
// in its own text โ€” a transcribed API reference, a JSON sample โ€” counts its own prose. Which is why
// the excerpt is logged beside the count rather than replaced by it.
const BLOCK_KEY = /"block"\s*:/g;

// What the log line about a truncation says. The ceiling and the size of the response are
// the two numbers an operator needs โ€” they are the difference between "raise max_tokens"
// and "this document cannot fit under any ceiling" โ€” and they are on the error when Iris
// raised it, which is every case except one that lost its prototype on the way here.
//
// Plus a short look at the reply itself (issue #277). A round that hits the ceiling produces
// nothing usable and cannot be asked again, so the fragment was thrown away โ€” and with it the
// answer to the only question this line leaves open: whether the model answered with the WHOLE
// DOCUMENT out of habit, which is a prompt problem, or with an `edits` array that genuinely did not
// fit, which is the block-size problem `patch.ts` describes. The two want different fixes and cost
// the same $0.73 to observe, so a round paid for once should not have to be paid for again to tell
// them apart. `reply_head` answers it directly, because the contract asks for the edits first: a
// patch opens `{"edits":[{"block":`, and a document opens with the document. `reply_tail` is where
// the model ran out, and `blocks_named` is how many edits it managed on the way.
//
// Shared with `editor_section_failed`, where the same three fields read differently in one respect:
// a section round asks for the section's HTML and not for an edits list, so `blocks_named` is
// expected to be 0 there and the head is what says whether the model answered about the section it
// was given. A count above 0 on that row is not noise but the same prompt problem in its other
// form โ€” a model answering a plain-HTML request in the shape of the whole-document contract.
// docs/API.md says so on both rows.
//
// The excerpt pair itself โ€” its width, its whitespace folding, and the rule that quotes a short
// fragment whole instead of as a head โ€” is `replyExcerpt` in providers/types.ts, shared with the
// page-correction path (issue #293) so the two lines can be read against each other. Absent
// altogether on a truncation that returned nothing, and on an error that reached here having lost
// its prototype: there is no fragment to quote in either case. `blocks_named` follows the excerpts
// rather than standing alone, because a count with no text beside it is a number no one can check.
function truncation(e: unknown): Record<string, unknown> {
  const message = e instanceof Error ? e.message : String(e);
  if (!(e instanceof TruncatedResponseError)) return { error: message };
  const text = e.text;
  return {
    max_tokens: e.maxTokens,
    chars: e.chars,
    ...(text === "" ? {} : { ...replyExcerpt(text), blocks_named: (text.match(BLOCK_KEY) ?? []).length }),
    error: message,
  };
}

// Document-level correction: the editor sees the whole body + all issues + the
// source images, so it can fix structural problems (dedup, reorder, heading
// hierarchy) that a view of one block at a time cannot โ€” a duplicate is only
// visible beside its twin.
//
// What it SEES and what it RETURNS are two decisions, and only the first is settled here. It
// answers with the blocks it changed rather than the document (#250, patch.ts), which is a
// contract about the reply and takes nothing away from the reading: the whole body is in the
// request either way. The fallback below cuts the READING down too, and that is the cost of a
// truncation and the reason it is a fallback.
//
// It sees only the images for the pages the Reader attributed issues to. On a
// 25-page document that is the difference between re-uploading 25 base64 PNGs on
// every one of up to max_review_iterations rounds and uploading the one or two
// that are actually in question.
//
// Two things bound the request, in that order, because they answer different
// questions: `capEditorImages` decides what fits BEFORE sending, and the retry below
// handles a payload the model refuses anyway โ€” a document body large enough to leave
// no room, a page whose image is heavier than the estimate the cap is derived from.
// Neither alone is sufficient: without the cap the refusal is the common case on a
// long document, and without the retry the cap has to be right about a limit it can
// only estimate.
async function runEditor(ctx: PipelineContext, body: string, issues: ReviewIssue[]): Promise<EditorRound> {
  const wanted = imagesForIssues(ctx.images, issues);
  const selected = capEditorImages(wanted, issues);
  // Logged only when the cap actually dropped something, so an ordinary round's line
  // is unchanged โ€” but never silently: a page the editor asked for and did not get is
  // the only reason it could fail to fix an issue it was shown.
  const dropped = wanted.length - selected.length;
  ctx.log.event("editor_images", {
    attached: selected.length,
    of: ctx.images.length,
    pages: selected.map((i) => i.order),
    ...(dropped > 0 ? { dropped } : {}),
  });

  try {
    return { ...(await editorCall(ctx, body, issues, selected)), truncated: false };
  } catch (e) {
    // A response that hit the output ceiling is this round producing nothing usable,
    // arriving as an exception instead of as an empty string โ€” and `editorCall` already
    // treats nothing usable as "keep the current body" two dozen lines down. Left to
    // throw, it ends the run: extraction, assembly and a Reader pass have all been paid
    // for, the assembled document is sitting in `body`, and the user is handed a failure
    // instead of it. On the two documents that reported this (#143) that was $8.59 of a
    // $13.19 round, every dollar spent before the call that failed.
    //
    // Delivering with the round's issues unfixed is a state the loop already supports
    // and reports โ€” @unresolved in the document, `unresolved` in the result,
    // `unresolved_rate` deployment-wide โ€” so this is #135's principle one layer up: a
    // round may fail without the document. It is NOT the same case as the size refusal
    // below and must not be retried, either: the refusal is about the request, which
    // Iris can make smaller by dropping images, while a truncation is about the
    // response โ€” and the next round would ask the same question about the same body, so
    // whatever did not fit does not fit then either. The caller stops the loop instead.
    //
    // Rarer than it was, and for a reason that does not change what to do about it. Asking for
    // the blocks that changed instead of the document (#250) takes the length of an ordinary
    // reply well under the ceiling, so what reaches this line now is a reply carrying one block
    // that is over it on its own โ€” the largest single top-level node measured across the bench
    // corpus is around 24,000 tokens, three quarters of the default ceiling on its own, so a
    // document holding one enormous table still has very little room โ€” or a reply that answered
    // with the whole document anyway. Both are the same fact about the next round.
    if (isTruncatedResponseError(e)) {
      ctx.log.event("editor_truncated", { attached: selected.length, of: ctx.images.length, ...truncation(e) });
      // The round is not over yet: what cannot be returned in one response can be returned in
      // several, and the ceiling has just measured how long one of them may be (#165). If that
      // comes to nothing the result is what it always was โ€” `usable: false` for the same reason
      // an unparseable reply is, nothing came back to use โ€” but either way the caller must
      // branch on `truncated` FIRST. An unusable round is allowed to run again, because the
      // editor never said anything; this one has said all it can say about a document asked for
      // whole.
      return sectionRound(ctx, body, issues, e);
    }
    // The images are the only part of this request Iris can give up, and giving them
    // up is far better than what refusing to do so costs: the run ends here, after
    // extraction and assembly have been paid for in full, and the user gets nothing
    // (issue #134). A text-only correction pass still has the whole body and every
    // issue the Reader raised โ€” which are already text โ€” so it can fix everything
    // except a fidelity problem that has to be checked against the source.
    //
    // Only for a size refusal, and only when there were images to drop. Anything else
    // (a stall, a stream error, a bad key) is not made better by asking again, and
    // retrying it would double the cost of every real failure.
    if (!selected.length || !isRequestTooLargeError(e)) throw e;
    // `error` is on the event because "refused" is not always literally true: one case
    // this predicate matches is a Converse stop reason that arrives after a full, billed
    // generation (see isRequestTooLargeError), so the message is what distinguishes a
    // request that cost nothing from one that cost a round of output.
    ctx.log.event("editor_images_refused", {
      attached: selected.length,
      of: ctx.images.length,
      error: e instanceof Error ? e.message : String(e),
    });
    // A retry without images can truncate in its turn โ€” same document, same
    // instruction โ€” so it is contained the same way rather than left to end the run.
    try {
      return { ...(await editorCall(ctx, body, issues, [])), truncated: false };
    } catch (retryError) {
      if (!isTruncatedResponseError(retryError)) throw retryError;
      ctx.log.event("editor_truncated", {
        attached: 0,
        of: ctx.images.length,
        ...truncation(retryError),
        after: "images_refused",
      });
      // And salvaged the same way. The section calls carry no images either (see
      // `editorSectionCall`), so a request the model refused with them is not made again with
      // them โ€” this path arrives already text-only and stays that way.
      return sectionRound(ctx, body, issues, retryError);
    }
  }
}

// One Copy Editor call, with whichever images it was given. Split out so the same
// prompt can be re-sent without them; `selected` empty is a normal shape here, and the
// prompt says so rather than promising attachments that are not there.
//
// It answers about the reply it got, and a truncation is not one: the provider raises it
// instead of returning a reply, so `truncated` is `runEditor`'s to fill in from the catch
// and this function cannot state it either way.
async function editorCall(
  ctx: PipelineContext,
  body: string,
  issues: ReviewIssue[],
  selected: InputImage[],
): Promise<Omit<EditorRound, "truncated">> {
  const images = selected.map(loadImage);
  const pageList = selected.map((i) => i.order).join(", ");
  // The body's own top-level nodes, numbered, and the numbers written into the copy the editor
  // reads (patch.ts). The blocks are computed from `body` and not from what is sent, so the
  // markers are not themselves blocks and the numbers survive the round trip.
  //
  // It costs the request one comment per block โ€” about 20 bytes each, against a body measured in
  // tens of thousands โ€” and it is spent on the input side, where a document this size is already
  // being sent in full every round. What it buys is on the output side, which is where the
  // ceiling is and where the tokens cost several times as much (#250).
  const blocks = blocksOf(body);
  const user =
    `## Current document (body content, in numbered blocks)\n${annotateBlocks(blocks)}\n\n` +
    `## Issues to fix\n${issues
      .map((i) => {
        const where = i.pages?.length ? ` (page ${i.pages.join(", ")})` : "";
        return `- [${i.severity}]${where} ${i.issue} โ€” ${i.suggested_action}`;
      })
      .join("\n")}\n\n` +
    (images.length
      ? `The source image(s) for page ${pageList} are attached, in that order. ` +
        `Return only the blocks you are changing.`
      : `No source images are available. Return only the blocks you are changing.`) +
    feedbackPreamble(ctx);
  const res = await ctx.router.complete(
    "copy_editor",
    images.length ? "vision" : "text",
    [
      { role: "system", content: EDITOR_SYSTEM },
      { role: "user", content: user },
    ],
    { step: "edit", images },
  );
  ctx.log.agentCall({
    agent: { name: "copy_editor", file: "copy_editor.md", content: EDITOR_SYSTEM, capabilities: ["vision"], sha: null, sessionBuilt: false },
    phase: "review",
    output: res.text,
  });
  const parsed = extractJson<{ edits?: unknown; html?: string; fidelity_observed?: unknown }>(res.text);
  // Read before the usable check, because an unusable BODY does not make the observations
  // unusable: the editor was looking at the page either way, and a reply this code cannot use
  // as a document is one of the cases where knowing what it saw is worth most.
  logFidelityObserved(ctx, parsed?.fidelity_observed, selected);
  // The contract this prompt asks for. An `edits` array is the answer even when it is empty โ€”
  // that is the editor saying the markup needs nothing โ€” so the check is on the field's SHAPE and
  // not on its contents.
  if (Array.isArray(parsed?.edits)) return applyEditorPatch(ctx, body, blocks, parsed.edits);
  // And the contract it used to ask for, still read (issue #250). A model that answers with the
  // whole body is answering a question this prompt no longer asks, but it is answering: the reply
  // holds a corrected document, the floor below is the check that decides whether it is one, and
  // taking it costs nothing that refusing it would save. Refusing would spend the round โ€” and on
  // a model that reverts to a familiar shape under load, every round of the run.
  //
  // How often this fires is the measurement that says whether the contract reads: a deployment
  // whose editor answers in whole bodies is paying #250's bill in full and is not truncating any
  // less for the new prompt, so it is logged even though nothing about it failed.
  //
  // What it hands back is the document it was SHOWN, which under this contract is the annotated
  // copy โ€” so the markers come out of it here, and how many there were goes on the line. Adopting
  // them would write Iris's own request scaffolding into the delivered HTML, and it compounds: a
  // comment is a top-level node, so the next round is shown the markers as blocks in their own
  // right and the body doubles every round while every round reads as `changed`.
  let whole = parsed?.html;
  if (typeof whole === "string") {
    const { html: clean, markers } = stripBlockMarkers(whole);
    whole = clean;
    ctx.log.event("editor_whole_body", {
      blocks: blocks.length,
      chars: clean.length,
      ...(markers ? { markers } : {}),
    });
  }
  // If the editor returns nothing usable, keep the current body unchanged โ€” and say
  // that is what happened, so the loop does not read a reply it could not use as the
  // editor having decided the document was fine.
  const corrected = whole?.trim();
  if (!corrected) {
    ctx.log.event("editor_no_output", { chars: res.text.length });
    return { body, usable: false };
  }
  // And the floor #174 asked for: a reply that came back with less than half the prose of the
  // document it was given did not correct that document, whatever it parsed as. This is the one
  // path where the model's `html` is adopted for the WHOLE body with nothing compared against what
  // went in, and the blast radius is the deliverable โ€” so the reply that answers and then quotes
  // the contract back, the reply that returned section three, the reply that summarised, all
  // arrive here indistinguishable from a corrected document. See `destroyedBody` for the number
  // and for why it reads the visible text rather than the characters or the structure counts.
  //
  // Reported as `usable: false`, the same as an unparseable reply, because the two are the same
  // fact about the round: nothing came back that can be used as this document. That keeps the body
  // that entered โ€” the loop reads `body === before` with `usable` false and runs another round
  // rather than crediting a convergence โ€” so a floor that fires on a sampled fluke costs one
  // request, not the document's corrections. Both length pairs on the line, because the ratio
  // that tripped and the ratio that did not are the evidence for moving this number.
  if (destroyedBody(body, corrected)) {
    ctx.log.event("editor_shrank", {
      chars_before: body.length,
      chars_after: corrected.length,
      text_chars_before: visibleText(body).length,
      text_chars_after: visibleText(corrected).length,
      floor: EDITOR_SHRINK_FLOOR,
    });
    return { body, usable: false };
  }
  // #375. The structure reading the block-patch path gates on, on the path that cannot: this reply
  // is adopted for the whole body, so a `<h2>` rewritten as `<p><strong>` moves nothing the floor
  // above can see. Reported, gated on nothing โ€” see `reportNavigation`.
  reportNavigation(ctx, body, corrected, { stage: "whole_body" });
  return { body: corrected, usable: true };
}

// The `navigationLost` reading on the two apply paths that do not gate on it (#375).
//
// #331 made a heading fall hand blocks back, and it did so inside `applyBlockEdits` โ€” where a fall
// can be attributed to the block that dropped it and that block alone handed back. Two other paths
// adopt an editor's reply: the whole-body branch of `editorCall`, and each section of a sectioned
// round. Both checked only `destroyedBody`, a prose floor at half the document, which a demotion
// cannot move by construction: `<h2>Costs</h2>` -> `<p><strong>Costs</strong></p>` keeps every word
// and grows the bytes. So the outline could fall silently on either, and on the sectioned path that
// is the loop's LAST round (see `runEditorSections`), which means it ships with no retry behind it.
//
// This REPORTS and refuses nothing, which is what #375 asked for first and is also the only thing
// available here. #331's remedy is a block handed back; neither path has blocks. The whole-body
// reply is one string, so the only refusal expressible is the whole round โ€” and that is what #331's
// own first version did on the patch path and what it was changed away from, because on the
// commonest false positive (a stray `<h4>Name</h4>` corrected into a `<label>`) it threw away every
// other correction in the reply, every round, until the budget ran out. Repeating that here would
// be worse, not better: a section reply IS the section, so refusing it costs that whole section's
// corrections, and the round is the last one.
//
// Which leaves the number this cannot yet be decided without: how often a fall happens on these two
// paths at all. That is the line's job, and it is why it prints on EVERY delivered reply rather than
// only where something fell โ€” a rate needs its denominator on the record, and a log that speaks only
// when it has a finding cannot tell a path nothing fell on from a path that was never taken. So a
// clean whole-body round logs `{ stage: "whole_body" }` and that is the denominator.
//
// `shortened` is the third state and is on the line because otherwise it reads as the second: the
// reading is silenced wherever the prose shortened (a deletion the prompt sanctions takes its own
// words, and that round is the ordinary shape of every correction, not damage), and an empty reading
// there means "not asked" rather than "nothing fell". Rolling the two together would put rounds the
// reading never looked at into the denominator of a rate about rounds it did.
//
// The grain is the unit the reply was about, and on the sectioned path that is the SECTION rather
// than the joined document. Sound, and finer than the patch path can manage: sections are corrected
// independently and joined, so no heading can move between them, which is the one thing that forces
// the patch path to read a whole body (a sanctioned reorder is a fall in one block and a gain in
// another). It also costs less than the patch path's coarseness โ€” one sanctioned deletion in
// section 1 silences section 1, not the round โ€” so a demotion in section 3 is still on the record.
function reportNavigation(
  ctx: PipelineContext,
  before: string,
  after: string,
  where: { stage: "whole_body" | "section"; section?: number; of?: number; covers?: "remainder" },
): void {
  ctx.log.event("editor_navigation", {
    ...where,
    ...(proseShortened(before, after) ? { shortened: true } : navigationLost(before, after)),
  });
}

// The editor's edits, applied to the body they were about (issue #250).
//
// The reply is read as a patch โ€” see patch.ts for what a block is and why the anchor is its
// position โ€” and the counters this logs are the round's own evidence about the contract: how many
// blocks a round actually touches is the number the whole change rests on, and it is not knowable
// from a whole-body reply at all.
//
// An empty `edits` array is a usable round that changed nothing, which is exactly what a
// whole-body reply identical to its input used to be, and the loop reads it the same way
// (`review_converged`). That equivalence is deliberate: the contract changed the shape of the
// answer, not what the loop may conclude from it.
function applyEditorPatch(
  ctx: PipelineContext,
  body: string,
  blocks: Section[],
  raw: unknown[],
): Omit<EditorRound, "truncated"> {
  const { edits, unreadable } = readBlockEdits(raw);
  const patched = applyBlockEdits(blocks, edits);
  const used = patched.applied + patched.deleted + patched.unchanged;
  const refused = patched.unknown.length + patched.duplicate + patched.incomplete + unreadable;
  // Which of the two ways this round can be unusable, decided before the line is written so the
  // log says what became of the round and not only what became of each edit.
  //
  // `all_refused`: edits were sent and not one of them could be used โ€” a reply about a document
  // this is not, or about blocks that were all unfinished.
  //
  // `refusal_with_loss`: a refusal in the same reply as a block that gave content up. Per-edit
  // refusal is the right rule for independent edits and the wrong one here, because this contract
  // makes a MOVE a pair of edits โ€” the block the content lands in, and the block it came from โ€” and
  // the two halves are one change. Take the source half and refuse the landing half and the content
  // is simply gone: `destroyedBody` cannot see one paragraph, the next Reader round reads a
  // document that no longer mentions it, and the heading it belonged under is left with nothing.
  //
  // BOTH forms the source half can take count, because EDITOR_SYSTEM offers both: the block emptied
  // (`deleted`), or returned "with what is left of it" โ€” a replacement carrying less of the document
  // than the block it replaces (`shrunk`, which reads prose, images, links, and the navigable
  // structure a reader finds content by; see `gaveContentUp`).
  // A rule that read only the first would let the commoner half of a move through, since a move
  // usually leaves something behind.
  //
  // So a reply holding a refusal beside either is treated as one that cannot be applied in part,
  // whether or not those edits were actually a pair. Both are ordinary corrections on their own โ€”
  // this fires only where a reply ALREADY has a defect in it โ€” so the cost of being wrong about the
  // pairing is one round, and the cost of being wrong the other way is in the deliverable.
  //
  // `headings_lost`: the third case, and the one that needs no refusal beside it (#331). The
  // document came out of this round with fewer heading elements in it than it went in with, and
  // every word of it still there. That is not a pairing this has to guess at โ€” it is a measurement
  // of the whole delivered body, so it says the heading is GONE from the document rather than moved
  // within it, which is the reading the two conjuncts above cannot make.
  //
  // Why it gates where `items` and `rows` on the same field do not: content can land in a different
  // announced structure with every word intact โ€” `<ul>` -> `<dl>` is the correction agents/page.md
  // asks for โ€” and reading either as a loss would report a working round as damage. There is no
  // sanctioned correction that removes a heading and keeps its text (`GATED` in patch.ts sets out
  // the one exception, below). `<h2>Standby Pay.</h2>` -> `<p><strong>Standby Pay.</strong></p>` is
  // not a reclassification into something else a reader can navigate by: it is a removal from the
  // heading outline with a visual imitation left in its place. Nothing about the rendered page
  // changes, no length pair moves, `destroyedBody` cannot see it โ€” and screen-reader heading
  // navigation loses the item permanently, because a truncation-free round is not looked at again.
  //
  // The measured round behind this: five `<h2>`s rewritten to `<p><strong>` on blocks no issue had
  // named, `word_delta: 0`, text characters identical either side at 29,709, every guard in
  // `patch.ts` passed, and the reason the reply gave for doing it was factually false (#331, from
  // #329's editor round). `navigation_lost: {headings: 5}` went on the line below and gated nothing.
  //
  // WHAT IS REFUSED IS THE BLOCK, NOT THE ROUND, and that is the correction to the first version of
  // this gate rather than a detail of it. The first version discarded the whole reply. Its likeliest
  // false positive is in `GATED`'s own account and is a correction this pipeline ASKS FOR: a field
  // label the extractor emitted as `<h4>Name</h4>` rewritten as `<label for="name">Name</label>`,
  // which keeps every word and takes `headings` down by one. axe's `label` rule is `wcag2a` and
  // `lint.ts` runs `wcag2a`, so that is a violation the Reader is shown and the editor is told to fix.
  // Discarding the round threw away every OTHER correction in the same reply โ€” and because the retry
  // re-sends the same body and the same issues to the same model, it threw them away again on every
  // round until `max_review_iterations` ran out. That default is 3. So the cost was never "one retried
  // round": it was the document's entire set of corrections, in exchange for a heading that had not
  // gone anywhere.
  //
  // `applyBlockEdits`'s own header comment is where that rule already lived โ€” "nothing here rejects
  // the WHOLE reply when one edit is unusable โ€” under the old contract a bad reply cost the document's
  // corrections, and under this one it costs the block it was about" โ€” and a gate does not get an
  // exemption from it. `salvageRound` reaches the same answer from the other end: it applies the part
  // of a cut reply in front of the loss rather than vetoing the reply.
  //
  // So: hand back the blocks that dropped a heading and kept their words (`headings_dropped` minus
  // `lost`), apply everything else, and let the loop go round again. The heading is restored because a
  // document-wide fall means no other block took it; the false positive costs that one block's
  // correction for one round; and the worst case stops being a document with nothing corrected in it.
  //
  // TWO shapes this cannot attribute, and they are the same hazard from opposite ends: re-seating a
  // block is safe only when nothing else in the reply is now holding what that block held.
  //
  // The first, and the reason `headings_gained` exists. A reply may reorder
  // (a heading leaves block 3 and arrives in block 9 โ€” sanctioned by name) AND demote in block 14, in
  // which case the document-wide fall is 1 while THREE blocks' counts moved. Handing back every block
  // that dropped one would restore block 3's heading while block 9 still has it: one heading printed
  // twice, invented here. Nothing binds a departure to an arrival, so this does not guess โ€” a non-zero
  // `headings_gained` refuses the round whole, which is the old behaviour kept for the case that needs
  // it.
  //
  // The second is that case with the migrant not a heading, which is why `headings_gained` cannot see
  // it: a block emptied of the words another edit re-seated as a `<label>`, a `<caption>`, a `<th>` or a
  // `<dt>`. The words never leave the document, so the joined prose is unmoved and the fall is read as
  // ordinary. `content_landed` is what catches it โ€” the per-block record of content that turned up in
  // another block of the same reply โ€” and a block in it is never re-seated. See the exclusion below.
  //
  // `headings_gained === 0` and at least one block still seatable is the licence, and it is the same
  // condition the guarantee rests on. The re-applied report is then checked for a remaining fall before
  // it is trusted, because a guarantee worth stating in a comment is worth failing closed on.
  //
  // NOT read as `refusal_with_loss` although #331 proposed that shape: there is no refusal in this
  // reply, and a log line naming one would send the next reader of it looking for the edit that was
  // not used.
  //
  // The narrower predicate #331 preferred โ€” a heading fall in a block NO ISSUE NAMED โ€” is still not
  // available: `ReviewIssue` attributes an issue to source PAGES, not to blocks, and nothing in the
  // pipeline binds an edit's block number to the issue that asked for it. What is below is narrower
  // than the round and wider than that, which is as close as the data reaches.
  const gaveUpContent = patched.deleted > 0 || patched.shrunk > 0;
  const headingsLost = patched.navigation_lost.headings ?? 0;
  const roundRefused = (used === 0 && refused > 0) || (refused > 0 && gaveUpContent);
  // Which blocks were handed back, and the body that shipped instead. `shipped` is `patched` itself on
  // every round that did not reach the salvage, so nothing below this needs to know whether it ran.
  let shipped = patched;
  let reverted: number[] = [];
  // The blocks whose re-seat was computed and then given up with the round. Empty on every route but
  // the one where part of the reply was salvageable and the rest was not, which is the only place the
  // difference between "this block could not be handed back" and "this block dropped a heading" exists.
  let abandoned: number[] = [];
  let unattributable: "reorder" | "migrated" | "recheck" | null = null;
  if (headingsLost > 0 && !roundRefused) {
    // Only a block that DID NOT GIVE ITS CONTENT TO ANOTHER EDIT can be re-seated. `content_landed` is
    // the per-block record of words or media that turned up in another block of the same reply, which
    // the joined prose cannot see because they never left the document. Handing such a block back
    // restores text that is now in two places and a heading over content that has moved out from under
    // it. Read `content_landed` and not `lost`, which every block here is in already: a heading falling
    // is one of the things `gaveContentUp` reads, so `lost` cannot sort these at all.
    // `headings_gained` does not cover it either:
    // that reads a heading arriving, and the commonest migrant here is a heading's words arriving as
    // something `structureCounts` does not count at all โ€” a `<label>` seated inside the `<form>` while
    // the stray `<h4>` sibling that held it is emptied, which is the two-edit form of the same `label`
    // correction the same-block case above is the one-edit form of.
    //
    // Read as WHERE THE WORDS WENT and not as "are these the words it had", which is the narrowing #376
    // asked for. The block reading is an inequality on its own text, so a block that demotes a heading
    // and corrects its own words in the same edit โ€” a typo fixed in the same `<div>`, one of the
    // ordinary things this loop asks for โ€” was unseatable, and a reply whose only demotion was that
    // block was refused whole with nothing having moved anywhere. The length licence that suggests
    // itself instead is refuted by measurement in #376 and `content_landed`'s own comment: `kept <=
    // patched` is 24 against 59 on the duplication hazard and passes it by a mile, because the edit that
    // grew is the one being reverted.
    //
    // Excluding them can still empty the list, and then there is nothing to salvage: the round is
    // refused whole, which is the right answer for a reply that both dropped a heading and moved words
    // out of the block it was in. Deliberately not the mirror remedy โ€” seating the block back and
    // dropping the edit that took the words โ€” because nothing binds a departure to an arrival (see
    // `headings_gained` below), so which other edit received them is not a question this can ask.
    const seatable = patched.headings_dropped.filter((at) => !patched.content_landed.includes(at));
    if (patched.headings_gained > 0) {
      unattributable = "reorder";
    } else if (seatable.length === 0) {
      unattributable = "migrated";
    } else {
      reverted = [...seatable].sort((a, b) => a - b);
      // Re-applied over the shorter edit list rather than patched back out of the joined body, for the
      // reason `salvageRound` gives: an edit that must not be applied has already been spliced in by
      // the time there is a body to undo it in, and `joinSections` is the only thing that knows how a
      // block was seated.
      const kept = applyBlockEdits(blocks, edits.filter((x) => !reverted.includes(x.block)));
      // A fall that survived the revert is the one outcome this must never ship, and the two ways of
      // asking are not equivalent โ€” so the PER-BLOCK question is asked first, and it is the one the
      // guarantee rests on.
      //
      // ONLY THE SEATABLE EDITS WERE DROPPED, so a block that dropped a heading AND gave its content away
      // still has its edit, and its fall is still in the re-applied body. `kept.headings_dropped` is
      // exactly that set โ€” a reverted block has no edit left to drop anything with โ€” and it does not
      // depend on what the reply did to the prose. The joined reading does: `navigationLost` is silent
      // wherever the body it reads is shorter, and REVERTING IS A WAY TO GET UNDER THAT FLOOR. One block
      // holds a demotion and sheds a redundant sentence, another demotes and adds more prose than the
      // first shed; the second is seatable, and handing it back takes the re-applied body below the body
      // that came in, so the joined reading reports nothing and the first block's demotion would ship.
      // The revert is the pipeline's own edit, so this is the guard's own remedy defeating its own check
      // โ€” asking `kept.navigation_lost` alone shipped a document one heading short of the one it was
      // given (found in review of #376, and it is a round `content_moved` refused).
      //
      // Logged as `migrated`, because it IS that case reached from the other side: a block whose content
      // landed elsewhere cannot be handed back and its fall cannot be delivered, so the round goes back
      // whole. That covers the reply that demoted in two places, one of them the `<label>` migration this
      // file calls the commonest form of the hazard โ€” ordinary output, and not evidence of anything.
      if (kept.headings_dropped.length > 0) {
        unattributable = "migrated";
        abandoned = reverted;
        reverted = [];
      } else if ((kept.navigation_lost.headings ?? 0) > 0) {
        // A joined fall with NO per-block fall behind it. Unreachable by construction, not merely by
        // argument: the joined count is the sum of the blocks' own counts, so a document that lost a
        // heading has a block that lost one, and the branch above has it. Kept, and named apart on the
        // log line (`headings_recheck`), because a check whose firing would mean the reading is wrong has
        // to be legible when it fires โ€” and a marker an ordinary mixed reply can trip is not that
        // marker, which is what this was until it became two branches (#376).
        unattributable = "recheck";
        abandoned = reverted;
        reverted = [];
      } else {
        shipped = kept;
      }
    }
  }
  // `headings_lost` covers both ends of the salvage: the round it could not attribute, and the round
  // where handing the blocks back left nothing that changes the document. The second is not a
  // technicality โ€” an editor whose only edits are demotions reverts to the body it was sent, and
  // calling that usable would credit an untouched document as a converged one.
  const changed = shipped.applied + shipped.deleted > 0;
  const discarded = used === 0 && refused > 0
    ? "all_refused"
    : refused > 0 && gaveUpContent
      ? "refusal_with_loss"
      : unattributable !== null || (headingsLost > 0 && !changed)
        ? "headings_lost"
        : null;
  // The counts below are `shipped`, not the reply as sent: `applied` has always meant "applied to the
  // body that goes on", which is why a block refused for being `incomplete` is not in it, and a block
  // handed back for dropping a heading is refused in exactly that sense. `headings_reverted` is what
  // says the reply proposed more than this line counts, and it is the only place that difference is
  // visible โ€” so a line carrying it is read as `applied` shipped AND `headings_reverted` did not.
  ctx.log.event("editor_patch", {
    blocks: blocks.length,
    edits: raw.length,
    applied: shipped.applied,
    deleted: shipped.deleted,
    // The five below are absent on an ordinary round, so a line with any of them on it is a
    // reply that did not follow the contract in some way โ€” and which way is the question a
    // person reading the log asks next. `unchanged` is not a failure and is here for the cost:
    // it is output spent to say nothing.
    ...(shipped.unchanged ? { unchanged: shipped.unchanged } : {}),
    ...(patched.unknown.length ? { unknown: patched.unknown } : {}),
    ...(patched.duplicate ? { duplicate: patched.duplicate } : {}),
    ...(patched.incomplete ? { incomplete: patched.incomplete } : {}),
    ...(patched.markers ? { markers: patched.markers } : {}),
    ...(unreadable ? { unreadable } : {}),
    // Which blocks gave content up, so `refusal_with_loss` says which half of a move it saw. On the
    // line whenever it happened, not only when the round was discarded: a round of shrinking
    // replacements that all applied is the ordinary way this contract removes duplicated content,
    // and how often that happens is worth reading on its own.
    ...(shipped.shrunk ? { shrunk: shipped.shrunk } : {}),
    // Headings, list items or table rows that stopped existing while every word stayed (#271) โ€” the
    // loss no other reading on any line can see. Present whether or not the round was discarded, and
    // whether or not it counted as `shrunk`: only the headings part of it does (see `GATED`), and a
    // line carrying `navigation_lost` with no `shrunk` beside it is the population that would decide
    // whether a fall in the other two can ever be read as damage. Nothing on file measures that rate,
    // so the `items` and `rows` halves are still collected here before they are acted on.
    //
    // The `headings` half no longer is: since #331 it is the predicate above. Read as the reply AS
    // SENT and not as what shipped โ€” on a salvaged round the body that goes on has no fall in it at
    // all, and a line that reported 0 there would be a line with no record of why blocks were handed
    // back. What is worth reading off N is the magnitude: one heading gone is a repeated title
    // resolved a little too thoroughly and 84 is a document flattened, and the two say different
    // things about the editor even though both are refused.
    ...(Object.keys(patched.navigation_lost).length ? { navigation_lost: patched.navigation_lost } : {}),
    // The blocks handed back untouched because their own heading count fell, in block order (#331,
    // narrowed by the review of #336). Numbers rather than a count, for the reason `unknown` is: which
    // block a demotion happened in is the difference between an editor that mangles one form and one
    // that is flattening the document as it goes, and the run log is the only place either is visible.
    //
    // Absent on the round that could not be attributed, and then one of the other two says WHICH
    // attribution failed, because `discarded: "headings_lost"` has three reasons behind it and a log
    // that cannot separate them answers "why was this round refused" for only one of them:
    //
    //   - `headings_gained` โ€” a heading arrived somewhere in the same reply, so a departure cannot be
    //     matched to an arrival. Counted, because one arrival beside one fall is a move and eleven is a
    //     restructure.
    //   - `headings_dropped` โ€” no heading arrived, but a block that dropped one gave content to another
    //     edit in the same reply, so re-seating it would print those words twice: either every dropping
    //     block did, leaving nothing seatable, or the seatable ones were handed back and the fall the
    //     others explain survived it. EVERY block whose own count fell, which is the reading of the
    //     model's behaviour โ€” not the subset that could not be handed back, which is the next field. On
    //     the first route they are the same list and on the second they are not (found in review of
    //     #376, where this comment claimed the narrower thing and logged the wider one).
    //   - `headings_abandoned` beside it โ€” the blocks that COULD have been handed back and were refused
    //     with the round anyway, because the fall of the ones that could not would have outlived their
    //     revert. Absent where nothing was salvageable, so it is present exactly on the mixed reply, and
    //     that is the rate the deferred question needs: how often refusing the round throws away a safe
    //     salvage. Subtracting it from `headings_dropped` leaves the blocks that could not be handed
    //     back, which is what makes both readings available from one line.
    //   - `headings_recheck` beside `headings_dropped` โ€” a fall survived the revert that NO block still
    //     dropping a heading explains. Its own marker because the counts on the line are otherwise
    //     identical to the case above (#376), and this one is unreachable by argument: if it ever fires,
    //     which one fired is the whole finding.
    //   - `headings_reverted` present WITH `discarded` โ€” blocks were handed back and nothing was left to
    //     apply, so the round changed nothing. That shape is already distinguishable, which is why it
    //     needs no field of its own.
    //
    // So `headings_reverted` without `discarded` means part of the reply was kept, and the first two are
    // mutually exclusive with each other.
    ...(reverted.length ? { headings_reverted: reverted } : {}),
    ...(unattributable === "reorder" ? { headings_gained: patched.headings_gained } : {}),
    ...(unattributable === "migrated" || unattributable === "recheck"
      ? { headings_dropped: patched.headings_dropped }
      : {}),
    ...(abandoned.length ? { headings_abandoned: abandoned } : {}),
    ...(unattributable === "recheck" ? { headings_recheck: true } : {}),
    ...(discarded ? { discarded } : {}),
  });
  // `usable: false` for the same reason an unparseable reply is one โ€” nothing came back that can
  // be used as this document โ€” which lets the loop run another round rather than crediting the
  // unchanged body as a convergence. `edits: []` does not come through here, because nothing was
  // refused: that is an answer, and it converges.
  //
  // `headingsLost` travels on every round where the guard ACTED โ€” the salvaged one included, which is
  // now the common case and is `usable: true`. The loop is the only place the deployment-wide count can
  // be taken from: whatever this round did, the round after it has nothing to say about the blocks that
  // were handed back (see `ReviewResult.editorHeadingsGated`). Gating the field on `discarded` instead
  // would count only the two failures and report a working guard as one that never fired.
  const gated = headingsLost > 0 && (reverted.length > 0 || unattributable !== null) ? { headingsLost } : {};
  if (discarded) {
    return { body, usable: false, ...gated };
  }
  // #174's floor, on the JOINED body rather than on any one replacement. The patch contract makes
  // a catastrophic loss harder to reach โ€” an untouched block cannot be lost, so only deletions
  // and shrunken replacements can move this โ€” but "harder to reach" is not a guarantee, and the
  // blast radius is the same deliverable it always was: an editor that empties two thirds of the
  // document's blocks has destroyed it as thoroughly as one that summarised it.
  //
  // Read on `shipped` and not on the reply as sent, because this floor is a statement about the
  // deliverable: a round whose blocks were handed back is smaller than the reply proposed, not larger,
  // so reading the proposal here could refuse a body that never shrank.
  if (destroyedBody(body, shipped.body)) {
    ctx.log.event("editor_shrank", {
      stage: "patch",
      chars_before: body.length,
      chars_after: shipped.body.length,
      text_chars_before: visibleText(body).length,
      text_chars_after: visibleText(shipped.body).length,
      floor: EDITOR_SHRINK_FLOOR,
      // What a shrink under this contract is made of, which the length pairs cannot say: the blocks
      // the editor emptied, and the ones it returned with less in them than they had. Both, because
      // this path is only reached when nothing was refused โ€” so the commonest shape here is a round
      // of shrinking replacements with `deleted: 0`, and `deleted` alone would leave the line saying
      // nothing about where the document went.
      deleted: shipped.deleted,
      shrunk: shipped.shrunk,
      of: blocks.length,
    });
    return { body, usable: false, ...gated };
  }
  return { body: shipped.body, usable: true, ...gated };
}

// One fidelity discrepancy the Copy Editor noticed on a page whose image it had, and was not
// asked about (issue #183).
export interface FidelityObservation {
  // The source page it is on, or null when the reply named none โ€” which is not the same
  // thing as page 0, and is counted apart below for that reason.
  page: number | null;
  kind: VerifyKind | null;
  observation: string;
}

// Fidelity โ€” does the HTML say what the page says โ€” was checked at exactly one point in the
// pipeline, and that check's blind spots are correlated with the transcriber's by construction:
// same model family, same image, same failure modes. Neither half of the review loop could
// originate a second opinion. The Reader cannot see the source images at all and is told not to
// speculate about what it cannot see, so a dropped table row is perfectly self-consistent to it
// and a misread number contradicts nothing. The Copy Editor CAN โ€” `imagesForIssues` hands it the
// images for the pages the Reader's issues name, which is the one position in the pipeline where
// an image and the HTML are side by side after extraction โ€” but it was asked to fix what the
// issues named and carry everything else over unchanged, so it could be looking straight at a
// dropped row on a page it was sent to fix a heading level and have nowhere to say so (#183).
//
// So it reports them, as observations and not as edits. Reporting is the whole of the change: it
// costs no model call (the images are attached and the model is already reading them), and the
// marginal output is a sentence. Acting on one would mean re-reading that page in full, which is
// a re-extraction, and an edit made from one reading of an image reaches a reader as what the
// page says โ€” where an observation costs a person a look.
//
// What it is NOT is a rate. The pages the editor sees are the pages the Reader flagged for some
// other reason, which is not a sample of the document โ€” it skews toward pages that already had
// problems, and a document the Reader found nothing wrong with attaches no images at all. So it
// is evidence that misses exist and roughly where, and the calibration issue (#180) and a
// sampled second opinion (#183's second proposal, which does cost calls) are what could turn it
// into a number.
//
// Nothing is dropped for being unreadable, the same rule `readProblems` follows one file over:
// an entry with no recognizable prose key is stringified rather than discarded, because a lost
// label costs a label and a lost observation costs whatever it was about. `unattached` and
// `unplaced` are counted apart from each other and reported beside the total, because an
// observation about a page whose image was not attached is a guess about a page the editor could
// not see โ€” the prompt says to report only attached pages โ€” and one that names no page cannot be
// checked at all. Both are still logged: a reader who wants only the checkable ones can subtract.
export function readFidelityObserved(
  raw: unknown,
  attached: number[],
): { observations: FidelityObservation[]; unattached: number; unplaced: number } {
  if (!Array.isArray(raw)) return { observations: [], unattached: 0, unplaced: 0 };
  const observations: FidelityObservation[] = [];
  let unattached = 0;
  let unplaced = 0;
  for (const entry of raw) {
    if (entry === null || entry === undefined) continue;
    let text: string;
    let page: number | null = null;
    let kind: VerifyKind | null = null;
    if (typeof entry === "string") {
      text = entry;
    } else if (typeof entry === "object") {
      const rec = entry as Record<string, unknown>;
      const prose = [rec.observation, rec.problem, rec.text, rec.description].find(
        (v) => typeof v === "string" && v.trim(),
      );
      text = typeof prose === "string" ? prose : JSON.stringify(entry);
      // A page number, however the reply wrote it: `page: 7`, `page: "7"`, or the `pages` list
      // the Reader's own issues use โ€” the editor is given those issues and echoing their shape
      // is the likelier mistake than inventing a third one. Only a whole positive number is a
      // page; anything else is left unplaced rather than rounded into a page that exists.
      const named = rec.page ?? (Array.isArray(rec.pages) ? rec.pages[0] : undefined);
      const n = typeof named === "number" ? named : typeof named === "string" ? Number(named.trim()) : NaN;
      if (Number.isInteger(n) && n > 0) page = n;
      const label = typeof rec.kind === "string" ? rec.kind.trim().toLowerCase().replace(/[\s-]+/g, "_") : "";
      kind = VERIFY_KINDS.find((k) => k === label) ?? null;
    } else {
      text = String(entry);
    }
    if (!text.trim()) continue;
    observations.push({ page, kind, observation: text.trim() });
    if (page === null) unplaced += 1;
    else if (!attached.includes(page)) unattached += 1;
  }
  return { observations, unattached, unplaced };
}

// Logged only when there is something to say, so an ordinary round's log is unchanged โ€” and the
// pages the editor HAD are on the line too, because an observation is only as good as whether
// its page was in front of the model, and `editor_images` is a separate line that a reader of
// this one may not have. Section calls carry no images (see `editorSectionCall`), so there is
// nothing for them to observe and they do not read this field.
function logFidelityObserved(ctx: PipelineContext, raw: unknown, selected: InputImage[]): void {
  const attached = selected.map((i) => i.order);
  const { observations, unattached, unplaced } = readFidelityObserved(raw, attached);
  if (!observations.length) return;
  ctx.log.event("editor_fidelity_observed", {
    count: observations.length,
    attached,
    ...(unattached > 0 ? { unattached } : {}),
    ...(unplaced > 0 ? { unplaced } : {}),
    observations,
  });
}

// --- a round the editor could not answer in one response ---

// How much of one response is known to fit, as a fraction of what came back when the ceiling
// was hit.
//
// Measured, not estimated, and that distinction is what makes this safe to do at all.
// `TruncatedResponseError.chars` is how many characters THIS model produced for THIS document
// before it ran out of ceiling, so it prices this document's HTML in characters per token
// without anyone having to guess at a ratio โ€” and the guess is the thing this design rules out,
// because measured characters per token vary enough between documents that a wrong one skips
// corrections the editor would have made. Nothing here is computed until the ceiling has
// actually been reached, which is why this is a measurement and not a pre-flight estimate.
//
// Half of it, so a corrected section has room to come back longer than it went in: a correction
// adds characters (a `<th>`, a caption, a heading gaining the words that tell it from its twin)
// and the budget is applied to the section's ORIGINAL text. The same factor absorbs the
// difference in the other direction โ€” `chars` counts the escaped `{"html":"โ€ฆ"}` the model
// wrote, which is longer than the HTML inside it โ€” so the headroom is wider than it reads.
export const SECTION_HEADROOM = 0.5;

// Under this, sectioning is declined. A budget this small would cut a document into dozens of
// pieces, each carrying the whole issue list and none of them holding enough of the document to
// be judged in context. It also means the response was cut off almost immediately, which says
// something went wrong with the call rather than that the document is long โ€” the failure this
// exists for is a full ceiling of correct output that had nowhere left to go.
export const MIN_SECTION_BUDGET = 4_000;

// The most requests one salvaged round may make. Every section is a full text call, so this is
// the round's cost bound, and a document that needs more than this is one whose ceiling is too
// low for it by more than a factor this loop should be papering over: the deployment's remedy
// (raise `providers.<name>.max_tokens`, or lower `max_pages`) is the honest one, and
// `editor_sections_declined` names the number that says so.
export const MAX_SECTIONS = 12;

// One section, corrected. Returns null when the editor answered with nothing usable, which the
// caller keeps the original section for.
//
// `part` is the caller's `covers` marker and it is on every line this function writes, because
// `section N of M` means two different things without it: the sections of the document, or the
// sections of the TAIL a truncated reply never reached (`correctBySection`). A rate grouped per
// round off a line that dropped it reads `of: 3` as "the document was cut in three" and mixes the
// two populations.
async function editorSectionCall(
  ctx: PipelineContext,
  section: string,
  issues: ReviewIssue[],
  index: number,
  of: number,
  part: { covers?: "remainder" } = {},
): Promise<string | null> {
  const user =
    `## Section ${index + 1} of ${of} (body content)\n${section}\n\n` +
    `## Issues found in the whole document โ€” some are in other sections\n${issues
      .map((i) => {
        const where = i.pages?.length ? ` (page ${i.pages.join(", ")})` : "";
        return `- [${i.severity}]${where} ${i.issue} โ€” ${i.suggested_action}`;
      })
      .join("\n")}\n\n` +
    `No source images are available. Return the corrected version of THIS SECTION only.` +
    feedbackPreamble(ctx);
  // Text-only, deliberately. The images are what made the failed whole-body call expensive and
  // they would be re-sent with every section โ€” the same pages, several times over, on a round
  // that has already paid for one ceiling of output. What that costs is the corrections only a
  // page image can settle: a [not legible] marker stays where it is, which is what EDITOR_SYSTEM
  // tells the editor to do when the page is not attached, so the loss is bounded to the issues
  // the images were for and is the same trade `editor_images_refused` already makes.
  const res = await ctx.router.complete(
    "copy_editor",
    "text",
    [
      { role: "system", content: EDITOR_SECTION_SYSTEM },
      { role: "user", content: user },
    ],
    { step: "edit_section" },
  );
  ctx.log.agentCall({
    agent: {
      name: "copy_editor",
      file: "copy_editor.md",
      content: EDITOR_SECTION_SYSTEM,
      capabilities: ["text"],
      sha: null,
      sessionBuilt: false,
    },
    phase: "review",
    output: res.text,
  });
  const corrected = extractJson<{ html?: string }>(res.text)?.html?.trim();
  if (!corrected) {
    ctx.log.event("editor_section_failed", {
      section: index + 1,
      of,
      reason: "no_output",
      chars: res.text.length,
      ...part,
    });
    return null;
  }
  // #174's floor at the other unit. The same reply shapes reach here โ€” this prompt asks for one
  // section and a model that answers with a sentence about it, or with the first paragraph of it,
  // produces markup that parses โ€” and the same containment already exists for them: a section that
  // came back unusable keeps the text it went in with (`joinSections`), so this costs that
  // section's corrections rather than the document's.
  //
  // Same number as the whole-body path, and the sectioned rounds are part of what places it: 13
  // section calls across three rounds, every one of them answered, and the joined bodies land at
  // 0.998โ€“1.006 of their input. A section that had returned under half its own prose would have
  // moved a five-section join by a tenth, and none of them moved by more than 0.6%.
  if (destroyedBody(section, corrected)) {
    ctx.log.event("editor_section_failed", {
      section: index + 1,
      of,
      reason: "shrank",
      chars_before: section.length,
      chars_after: corrected.length,
      text_chars_before: visibleText(section).length,
      text_chars_after: visibleText(corrected).length,
      floor: EDITOR_SHRINK_FLOOR,
      ...part,
    });
    return null;
  }
  // #375, at this unit. `joinSections` puts an unanswered section back as it stood, so a section
  // that came back with one fewer heading is a document with one fewer heading โ€” nothing downstream
  // compares the join against what went in, and this is the loop's last round.
  reportNavigation(ctx, section, corrected, { stage: "section", section: index + 1, of, ...part });
  return corrected;
}

// What the reply DID say before the ceiling cut it (issue #295).
//
// The waste this exists to stop is the largest single one the bench has measured: 24 truncated
// editor calls across 10 deployment rounds, $17.23 of a $158.67 bill, every dollar of it spent on
// a response that was thrown away unread. And unread is the word โ€” until now the fragment on the
// error was quoted for a person (`reply_head`, #277) and nothing acted on it, so a round that got
// through sixteen corrections delivered none of them and the body was then asked for again, a
// section at a time, by a weaker call that cannot see the whole document or the page images.
//
// Why a prefix of THIS reply is usable where half an envelope never is. The contract makes the
// answer a list of independent edits, each naming its own block (#250, patch.ts), so an entry that
// arrived complete is a whole correction to a whole top-level node and does not depend on the
// entries behind it. That is exactly what `readArrayPrefix` reads and exactly why it is safe here
// and not on a page render, where the one field is the page and half of it is half a page.
//
// The one way a prefix is NOT independent is the reason for the strictest rule below. This contract
// makes a MOVE a pair of edits โ€” the block the content came from, and the block it lands in โ€” and a
// cut between the two halves would take the source half alone, which deletes content nothing
// downstream can miss (`applyEditorPatch`, and #250's `refusal_with_loss`). Under the ordinary
// contract that rule fires only where the reply already holds a refusal; here the CUT is the
// refusal, of everything after it, so a block that gave content up ends the claim there.
//
// ENDS it, rather than refusing the whole reply, since #317. That issue's round is what changed it:
// the salvage fired twice on a 100-page round and declined both times over one block and two, and
// the fallback then re-requested all 148 and all 132 blocks in 6 and 5 section calls at $0.2243
// each โ€” while the whole-document reply that had already been paid for held 6 and 7 edits made by a
// call that could see every block and the page images. Retreating to the first block that gave
// content up keeps the safety argument above intact and costs nothing extra: the loss-bearing edit
// is not applied, that block and everything after it become the remainder, and the remainder is
// asked for by the same section calls that would have covered the whole body anyway โ€” never more of
// them than before, and fewer whenever the loss is not in the first blocks.
//
// What the retreat DOES risk is worth naming, because it is not nothing and nothing in this run
// takes it back. A move that carries content BACKWARDS โ€” landing half before the cut, source half at
// or after it โ€” leaves the landing edit applied and the source block untouched, so the content is
// duplicated rather than lost, and it SHIPS that way: a truncated round is the loop's last round
// (`lastRound` in `runReview`), so there is no further read and no further document-level call, and
// the section calls over the remainder are handed the remainder alone, so the section holding the
// source block cannot know the content is now also in the prefix. What can see it is a feedback
// re-run, which is the user's action and not this loop's.
//
// The trade is still the right way round, and this is the whole of the argument for it: a deletion is
// invisible in the delivered document and permanent, while a duplicate is in the delivered document
// where a reader, a re-run and the next round of anything can find it. `lost_at` on the log line is
// what makes the rate countable, since nothing else in the pipeline can say how often a retreat
// happened โ€” and it is a rate worth watching precisely because the remedy is a re-run.
//
// What it returns is the document in two pieces, because the second half of #295 is not to pay for
// the first half twice: `prefix` is the part of the body the reply reached, corrected, and `rest`
// is the part it never got to, untouched. `reached` is a count of BLOCKS, not of edits โ€” the blocks
// the reply passed over without naming are answered too, since "return only the blocks you are
// changing" makes silence about a block an answer about it, which is the same reading the ordinary
// round gives an `edits` list that names three blocks of a hundred.
function salvageRound(
  ctx: PipelineContext,
  body: string,
  e: unknown,
): { prefix: string; rest: string; edits: number; reached: number; of: number; lostAt: number | null } | null {
  // Nothing to read: an error that lost its prototype on the way here (see
  // `isTruncatedResponseError`), or a truncation that returned no text at all โ€” which is a real
  // shape, and the one the `EMPTY_REPLY` note on the message is about.
  if (!(e instanceof TruncatedResponseError) || e.text === "") return null;
  const blocks = blocksOf(body);
  const read = readArrayPrefix<unknown>(e.text, "edits");
  // The editor finding nothing to change is an ANSWER, and a list that closed empty is that answer
  // arriving whole: every block was considered and none of them was wrong. v1.5 lets an ordinary
  // round say exactly that and converge on it (`applyEditorPatch`), and all the ceiling took here is
  // whatever the model went on to write after the list closed. So this is a salvage with no edits in
  // it rather than a decline โ€” it covers the whole document, leaves no remainder and makes no section
  // call โ€” where declining it would spend up to `MAX_SECTIONS` further calls re-correcting a document
  // the editor has just passed, and would log `no_complete_edit`, whose whole meaning is the opposite:
  // a document too big for one of its own blocks to fit the ceiling.
  if (read !== null && read.closed && read.entries.length === 0) {
    ctx.log.event("editor_salvaged", {
      edits: 0,
      applied: 0,
      closed: true,
      reached: blocks.length,
      of: blocks.length,
      chars: e.chars,
      rest: 0,
    });
    // `body` and not a re-join of `blocks`: the two are the same string by `splitBlocks`'s identity
    // property, and the original is the one nothing can have rounded.
    return { prefix: body, rest: "", edits: 0, reached: blocks.length, of: blocks.length, lostAt: null };
  }
  // Two different failures, and the log says which: a reply with no edits list in it at all is the
  // model answering in some other shape โ€” the whole document, or prose about the document, which is
  // what `reply_head` on the line above is for โ€” while an edits list whose first entry did not
  // finish is the contract followed and the ceiling reached inside one enormous block. Only the
  // second is a document too big for its ceiling; the first is a prompt that was not followed.
  if (read === null || read.entries.length === 0) {
    ctx.log.event("editor_salvage_declined", {
      reason: read === null ? "no_edits_list" : "no_complete_edit",
      chars: e.chars,
      of: blocks.length,
    });
    return null;
  }
  const { edits, unreadable } = readBlockEdits(read.entries);
  // An entry this cannot read, or a block this document does not have. Either one is tolerated by
  // the ordinary round, which applies what it recognises and reports the rest (`applyBlockEdits`) โ€”
  // and neither can be tolerated here, because this round claims coverage of every block up to the
  // last one named, and that claim is an inference about a reply walking THIS document in order. An
  // entry whose block cannot be read might have named a block past the cut; a block number the
  // document has no such block for says the reply is not about this document. Both make the
  // inference unsound, and its whole value is that the blocks it covers are not asked for again.
  //
  // `edits` empty implies `unreadable`, since every entry read either became an edit or was counted
  // here โ€” which is why nothing below has to guard `Math.max` against an empty list.
  const unknown = edits.filter((x) => !Number.isInteger(x.block) || x.block < 0 || x.block >= blocks.length).length;
  if (unreadable > 0 || unknown > 0) {
    ctx.log.event("editor_salvage_declined", {
      reason: unknown > 0 ? "unknown_block" : "unreadable_edit",
      edits: edits.length,
      ...(unknown ? { unknown } : {}),
      ...(unreadable ? { unreadable } : {}),
      chars: e.chars,
      of: blocks.length,
    });
    return null;
  }
  // Which blocks this reply has answered about. `closed` is the edits list itself having finished โ€”
  // the cut fell after it, in `fidelity_observed` or in trailing prose โ€” and that is a COMPLETE
  // patch: every block was considered, so the whole body is covered and nothing is left to section.
  //
  // Otherwise the claim is bounded by the last block the reply named, and it needs the names to be
  // in document order. A reply that jumps backwards was not written in one pass through the
  // document, so the blocks between two named ones cannot be read as deliberately left alone, and
  // the coverage this round rests on is not claimable. The edits would still apply โ€” but applying
  // them and then sectioning the whole body is paying for the same blocks twice and letting the
  // weaker call overwrite the stronger one's work, which is what #295 is about.
  const named = edits.map((x) => x.block);
  const ordered = named.every((n, i) => i === 0 || n >= named[i - 1]!);
  if (!read.closed && !ordered) {
    ctx.log.event("editor_salvage_declined", {
      reason: "out_of_order",
      edits: edits.length,
      chars: e.chars,
      of: blocks.length,
    });
    return null;
  }
  const claimed = read.closed ? blocks.length : Math.max(...named) + 1;
  // What the reply's own edits do to the blocks it claimed, asked before anything is kept: the
  // answer decides how much of the claim survives. Applied to a copy that is thrown away when it
  // does, which costs one join of a body already in memory and is the only way to learn WHERE the
  // loss is โ€” `gaveContentUp` is a comparison between a block and its replacement, so there is no
  // reading of the reply alone that could tell.
  const proposed = applyBlockEdits(blocks.slice(0, claimed), edits);
  // The move-pair rule above, as a position rather than a veto: the first block that gave content
  // up is where the claim stops. `null` when none did, and then the claim stands whole.
  //
  // The minimum and not the head of the list, because a reply whose edits list CLOSED is under no
  // ordering obligation โ€” `ordered` is only checked when it did not โ€” so the first block in
  // document order is not necessarily the first edit that lost something.
  //
  // #174's whole-body floor is NOT read here as well, and it is worth saying why rather than leaving
  // a guard that cannot fire. `shrunk` is `gaveContentUp` on each named block, which is any loss of
  // visible text at all, and a block nobody named comes back byte for byte โ€” so a prefix that ends
  // before every block on this list cannot have less text in it than it went in with. The floor is a
  // halving of the whole; the rule above it is stricter than the floor everywhere the floor could
  // apply.
  const lostAt = proposed.lost.length ? Math.min(...proposed.lost) : null;
  const reached = lostAt ?? claimed;
  // The identity property `splitBlocks` guarantees โ€” every block's `pre` and `html` concatenated is
  // the body, character for character โ€” is what makes this split exact: the tail of the body from
  // the first unreached block is `rest`, and the blocks before it are what the reply was about, put
  // back together the same way (`applyBlockEdits` joins through `joinSections`).
  const rest = blocks.slice(reached).map((b) => b.pre + b.html).join("");
  // The retreat re-applies over the shorter prefix rather than trimming the joined body, because the
  // two are not the same operation: an edit naming a block at or past the cut must not be applied at
  // all, and `joinSections` has already spliced it in by the time there is a body to trim. Every
  // edit kept here was applied once already without giving content up, so this pass cannot produce a
  // `lost` of its own โ€” which is why nothing below re-checks for one.
  const keep = lostAt === null ? edits : edits.filter((x) => x.block < reached);
  const patched = lostAt === null ? proposed : applyBlockEdits(blocks.slice(0, reached), keep);
  const used = patched.applied + patched.deleted + patched.unchanged;
  // `unknown` and `unreadable` are 0 by the guard above and are not summed in: a count that cannot
  // be non-zero on this line would read as a claim that it can be.
  const refused = patched.duplicate + patched.incomplete;
  // Two ways this is given up on entirely, and they are one condition read against the retreat.
  // Nothing was applied because every edit was refused for its own reasons โ€” a contradicted block, a
  // reply ending inside an element โ€” or because the retreat left nothing in front of it to apply:
  // the loss is in the first block the reply claimed, or in the first one it named. Either way there
  // is no correction to keep, and covering the blocks would claim an answer nothing was applied to.
  //
  // The counts on the line are the WHOLE claim's, not the retreat's, because that is what a reader
  // told the salvage was abandoned needs: what the reply did to the blocks it reached, and where the
  // first loss in it was. `dropped` is left off โ€” nothing was applied, so there is no partial
  // application for it to be the complement of.
  const declined = used === 0 ? (lostAt === null ? "all_refused" : "loss_before_cut") : null;
  if (declined) {
    ctx.log.event("editor_salvage_declined", {
      reason: declined,
      edits: edits.length,
      applied: proposed.applied,
      ...(proposed.deleted ? { deleted: proposed.deleted } : {}),
      ...(proposed.shrunk ? { shrunk: proposed.shrunk } : {}),
      ...(proposed.duplicate + proposed.incomplete ? { refused: proposed.duplicate + proposed.incomplete } : {}),
      ...(lostAt === null ? {} : { lost_at: lostAt }),
      reached: claimed,
      of: blocks.length,
      chars: e.chars,
    });
    return null;
  }
  // The counts an operator needs to read this round as a round: what the reply managed before the
  // ceiling, and how much of the document that came to. `blocks` and `of` together are the number
  // #295 asks for โ€” the share of the document a truncated call had already answered โ€” and the one
  // that says whether this is a rescue or a rounding error. `closed` marks the reply whose edits
  // list finished: a complete patch that hit the ceiling on its way out of the envelope, which
  // needs no sections at all.
  //
  // `lost_at` and `dropped` are the retreat (#317), present only when there was one: the block the
  // claim was cut back to, and how many of the reply's edits were left unapplied because they named
  // it or a block behind it. A `closed` reply with a `lost_at` is the one combination that reads
  // oddly and is real โ€” the patch was complete and part of it is still being re-asked for โ€” so the
  // remainder is not empty there and `rest` says so.
  ctx.log.event("editor_salvaged", {
    edits: edits.length,
    applied: patched.applied,
    ...(patched.unchanged ? { unchanged: patched.unchanged } : {}),
    ...(refused ? { refused } : {}),
    ...(patched.markers ? { markers: patched.markers } : {}),
    ...(Object.keys(patched.navigation_lost).length ? { navigation_lost: patched.navigation_lost } : {}),
    ...(read.closed ? { closed: true } : {}),
    ...(lostAt === null ? {} : { lost_at: lostAt, dropped: edits.length - keep.length }),
    reached,
    of: blocks.length,
    chars: e.chars,
    rest: rest.length,
  });
  return { prefix: patched.body, rest, edits: used, reached, of: blocks.length, lostAt };
}

// The round again, a section at a time, after the answer did not fit.
//
// Why this exists: the editor used to be asked to return the complete corrected body, so the
// length of its answer followed the length of the DOCUMENT rather than the number of things wrong
// with it, and a 25-page document is longer than one response may be. Under a fixed ceiling that
// scales the wrong way โ€” the bigger the document, the more certain it is that its corrections
// cannot be applied, which is the opposite of where corrections matter most. Two documents of
// four in one bench round were delivered whole and uncorrected for exactly this reason (issue
// #165). Cutting the body at top-level boundaries makes the response length a property of the
// SECTION instead, and a section's size is something this code chooses.
//
// The contract has since taken most of that away: the editor answers with the blocks it changed,
// so an ordinary reply is a fraction of the document and has no reason to reach the ceiling
// (#250). This is not dead code for it. A reply can still be too long โ€” one top-level node bigger
// than the ceiling, or a model that answers with the whole document out of habit โ€” and this path
// is what stands between that and a document delivered uncorrected. It is the fallback now rather
// than the salvage of a common case, which is the same code doing a smaller job.
//
// What it costs, honestly: one text call per section, on a round that has already paid for a
// full ceiling of output it could not use. That is roughly one more body's worth of output for
// the document, and it buys corrections where the alternative buys none. What it loses is the
// corrections that need the whole document in view at once โ€” deduplicating content that appears
// on two pages, resolving a heading whose twin is in another section โ€” and the editor is told
// exactly that (EDITOR_SECTION_SYSTEM), because a section that guesses at what is outside it can
// delete the only copy of something. Those issues stay unresolved and are reported as such,
// which is where they already were.
//
// Returns null when nothing was attempted or nothing came back, and the caller then behaves as
// it did before this existed. Every decline is logged with the reason: a round that quietly
// declines to try is indistinguishable in a log from one that tried and failed.
// `covers` is which of two things `body` is: the whole document the failed call was about, or the
// REMAINDER of it that a salvaged prefix did not reach (`salvageRound`). Only two decisions turn on
// it, and both are the same question โ€” whether asking again would be the identical request at the
// identical length. For the document it can be; for a remainder it cannot, because a remainder is a
// strictly smaller request than the one that truncated.
async function correctBySection(
  ctx: PipelineContext,
  body: string,
  issues: ReviewIssue[],
  e: unknown,
  covers: "document" | "remainder",
): Promise<{ body: string; of: number; corrected: number } | null> {
  // On every line this function writes, so a log reader never has to work out which body a budget
  // was applied to. Absent on the ordinary path, which is what every log before #295 holds.
  const part = covers === "remainder" ? { covers } : {};
  // No measurement, no budget. `chars` is on the error Iris raised, which is every truncation
  // except one that lost its prototype at some boundary (see `isTruncatedResponseError`), and
  // inventing a budget for that case would be the pre-flight guess this deliberately is not.
  if (!(e instanceof TruncatedResponseError) || !Number.isFinite(e.chars)) {
    ctx.log.event("editor_sections_declined", { reason: "unmeasured", ...part });
    return null;
  }
  const budget = Math.floor(e.chars * SECTION_HEADROOM);
  if (budget < MIN_SECTION_BUDGET) {
    ctx.log.event("editor_sections_declined", { reason: "budget_too_small", budget, chars: e.chars, ...part });
    return null;
  }
  // A budget that already covers the whole body says the response was longer than the document
  // it was correcting, so the sections would be one section: the same request, at the same
  // length, to the same ceiling. That is a reply that ran away with itself โ€” a repetition, a
  // preamble that never ended โ€” and not a document too long to answer, so it is reported as
  // what it is rather than as a body that could not be cut. Reachable, on a short document
  // whose editor call returned more than twice its characters.
  //
  // A REMAINDER under the budget is the opposite case and is asked for in one call. The failed
  // request was about the whole document; this one is about the part of it the reply never reached,
  // it carries no images, and it is under a length this model has just been measured producing โ€” so
  // it is not the same question at the same length, which is the whole of the objection above.
  if (budget >= body.length && covers === "document") {
    ctx.log.event("editor_sections_declined", {
      reason: "budget_exceeds_body",
      budget,
      chars: e.chars,
      body: body.length,
    });
    return null;
  }
  const sections = splitSections(body, budget);
  // One section is the body itself: a document with no top-level boundary under the budget โ€”
  // one enormous table, say โ€” cannot be cut, and asking for it again in one piece would hit the
  // same ceiling. This is the case a section-size bound genuinely does not solve, and it is
  // reported rather than retried.
  //
  // Which is a statement about a piece that is OVER budget, and that is now how it is written: a
  // remainder short enough to be one section is short enough to ask for, and one call is the
  // cheapest way this round can end. On the document path the two readings are the same, because a
  // body under the budget has already been declined above.
  if (sections.length < 2 && body.length > budget) {
    ctx.log.event("editor_sections_declined", { reason: "indivisible", budget, chars: body.length, ...part });
    return null;
  }
  if (sections.length > MAX_SECTIONS) {
    ctx.log.event("editor_sections_declined", {
      reason: "too_many_sections",
      sections: sections.length,
      max: MAX_SECTIONS,
      budget,
      chars: body.length,
      ...part,
    });
    return null;
  }
  // Concurrent, bounded by the same knob as page extraction and the Reader's chunks: these are
  // independent calls over disjoint slices of a body nothing mutates while they run, and the
  // operator's answer to "how many model calls may one run have in flight" is the answer here
  // too. `|| 1` for a directly-constructed context that never set it (tests, embedders).
  const limit = Math.max(1, Math.floor(ctx.extractionConcurrency) || 1);
  ctx.log.event("editor_sections", {
    sections: sections.length,
    budget,
    chars: body.length,
    concurrency: limit,
    ...part,
  });
  const corrected = await mapWithConcurrency(sections, limit, async (section, i) => {
    try {
      return await editorSectionCall(ctx, section.html, issues, i, sections.length, part);
    } catch (err) {
      // Per-section containment, and only for the two failures that are about the size of one
      // request or one response: a section that cannot be returned costs that section, and its
      // original text is what goes back into the document (`joinSections`). Anything else โ€” a
      // stall, a stream error, a bad key โ€” is a deployment that is not working, and swallowing
      // it here would deliver a partly corrected document while reporting nothing wrong.
      if (!isTruncatedResponseError(err) && !isRequestTooLargeError(err)) throw err;
      ctx.log.event("editor_section_failed", {
        section: i + 1,
        of: sections.length,
        reason: isTruncatedResponseError(err) ? "truncated" : "too_large",
        ...truncation(err),
        ...part,
      });
      return null;
    }
  });
  const kept = corrected.filter((c) => c !== null).length;
  return { body: joinSections(sections, corrected), of: sections.length, corrected: kept };
}

// The truncated round's result, with whatever the section calls rescued. Shared by both
// truncation paths in `runEditor` โ€” the first call and the images-refused retry โ€” because the
// remedy for a response that did not fit is the same whatever the request that produced it
// looked like.
async function sectionRound(
  ctx: PipelineContext,
  body: string,
  issues: ReviewIssue[],
  e: unknown,
): Promise<EditorRound> {
  // First, what the reply already said (#295). The part of the document it reached is corrected by
  // the model's own whole-document answer โ€” which saw every block and every attached page image โ€”
  // and only the part it never got to is asked for again, in sections that see neither. So the
  // section calls are made over the REMAINDER, which is fewer of them and none of them overwriting
  // work that has already been paid for.
  const rescued = salvageRound(ctx, body, e);
  if (rescued) {
    // Nothing left to ask about: the reply's edits list finished, or it named the last block of the
    // document. A round that hit the ceiling and still answered in full โ€” the ceiling was reached
    // on the way out of the envelope โ€” so `sections` is absent because there were none to make,
    // which is not the same as a round given up on (see `editorTruncatedLost`).
    const sectioned = rescued.rest === "" ? null : await correctBySection(ctx, rescued.rest, issues, e, "remainder");
    return {
      body: rescued.prefix + (sectioned?.body ?? rescued.rest),
      // The editor said something usable about this document, and it is in the delivered body.
      usable: true,
      truncated: true,
      // `cutBack` travels with it because the delivered marker is written from this and would
      // otherwise blame the ceiling for a boundary Iris chose (`salvagedNote`): on a retreat the
      // reply reached FURTHER than `blocks` says, and on a `closed` retreat it reached the end.
      salvaged: { edits: rescued.edits, blocks: rescued.reached, of: rescued.of, cutBack: rescued.lostAt !== null },
      ...(sectioned ? { sections: { of: sectioned.of, corrected: sectioned.corrected } } : {}),
    };
  }
  const sectioned = await correctBySection(ctx, body, issues, e, "document");
  // Nothing to use: either the round could not be divided at all, or it was and no section came
  // back. Both are the state this feature started in โ€” the body that entered the round is the
  // body that leaves it โ€” and both are reported as that, WITHOUT `sections`. `sections` is what
  // tells the delivered document it carries corrections made a piece at a time, and a round
  // that rescued nothing carries none; the `editor_sections` and `editor_section_failed` lines
  // are where a log reader sees that the attempt was made.
  if (!sectioned || sectioned.corrected === 0) return { body, usable: false, truncated: true };
  return {
    body: sectioned.body,
    // `usable` is about whether the editor SAID anything, and a section it answered is the
    // editor having answered.
    usable: true,
    truncated: true,
    sections: { of: sectioned.of, corrected: sectioned.corrected },
  };
}

// Reader -> Editor -> re-verify, with three ways out: the Reader reports zero issues,
// a round changes nothing (see `review_converged` below), or the iteration cap is
// reached. The loop only stops CLEAN on the first of those โ€” the Reader has actually
// re-confirmed it โ€” so reported issues are verified-fixed, not assumed; the other two
// deliver the body with what is left written to @unresolved.
export async function runReview(
  ctx: PipelineContext,
  initial: {
    body: string;
    lint: LintResult;
    pages?: IndexedPage[];
    failedPages?: number[];
    uncorrectedPages?: number[];
  },
): Promise<ReviewResult> {
  let body = initial.body;
  let lint = initial.lint;
  let iterations = 0;
  let lastIssues: ReviewIssue[] = [];
  // The last read's answer about how much of the document it did not answer about, and out
  // of how many windows (see ReaderRead). The LAST read, like `lastIssues`, because what
  // ships is one reading of the document: an earlier round's unreadable window says nothing
  // about the body that is being delivered, which a later round re-read in full.
  let lastUnread = 0;
  let lastWindows = 0;
  // And the FIRST read's, which is a different question and is why this is not `lastIssues`
  // read at round 0: see ReviewResult.firstRead. Set by the first read to complete and never
  // reassigned, so it survives every exit below without each of them having to know about it.
  let firstRead: { issues: number; unread: number } | undefined;
  let droppedLinks = 0;
  let editorTruncated = false;
  let editorTruncatedLost = false;
  // Whether any round in this loop was refused for demoting a heading (#331). A latch and not a
  // count, for the reason `editorTruncatedLost` is a boolean: the rate is over documents, and "3 of
  // 4 rounds" is not something a document-level rate can divide. How many headings each refused
  // round would have taken is on that round's `editor_patch` line.
  let editorHeadingsGated = false;
  // What the truncated round's section calls rescued, when there was one: the difference
  // between "this document was not corrected" and "it was corrected a piece at a time, and the
  // pieces could not see each other". The document says it in those terms and the store says it
  // as a boolean (`editorTruncatedLost`), because a rate over documents cannot use "3 of 4" โ€”
  // but it is the same fact and this local is where both readings are taken from.
  let editorSections: { of: number; corrected: number } | undefined;
  // And what the truncated reply itself corrected before the ceiling cut it (#295). Read beside
  // `editorSections` everywhere: the sections of a salvaged round are the sections of what this did
  // not cover, so either number alone describes a different round from the one that ran.
  // `cutBack` is required here rather than optional, and that is the point of stating the type at
  // all: `wrapDocument` takes it optionally, so a site that rebuilt this literal without it would
  // deliver the ceiling's wording for a boundary Iris chose, with a clean typecheck. Required, that
  // is a compile error instead of a marker that lies.
  let editorSalvaged: { edits: number; blocks: number; of: number; cutBack: boolean } | undefined;
  // The page index is built from the fragments as they entered review. Pages are
  // deliberately NOT re-indexed as the editor rewrites the body: the index exists
  // to attribute content to a SOURCE page, and the source doesn't change.
  const pages = initial.pages ?? [];
  // Re-stated in the wrapper at the end, and โ€” since #188 โ€” given to the Reader as well. The
  // review loop cannot fix a page that was never extracted, and must not be asked to: the
  // Reader would raise "this page is missing" every round against a body no editor can
  // repair, spending the whole iteration budget on it. See wrapDocument.
  //
  // "Must not be asked to" was the intent all along; for a while it was only the disclosure that
  // was kept out of the editor's reach, and the question went to the Reader unchanged โ€” once per
  // chunk, per round, so a longer document raised the same lost page more times. `runReader` is
  // where that is now said (noContentPages), which is also the only place that can say it: the
  // index it builds is the route the reports came in by.
  const failedPages = initial.failedPages ?? [];
  // Re-stated in the wrapper at the end and given to nobody else, which is the difference from
  // `failedPages` above (#328). The Reader is not told, and must not be: it reads the assembled
  // HTML and cannot see the source images at all, so what it would see is a page whose content is
  // present and plausible, and telling it "page 5 failed its fidelity check" invites an issue it
  // has no way to state a remedy for. The editor is not the way out of that either, and NOT for
  // want of the image: `imagesForIssues` would hand it that page, but the standing instruction on
  // a fidelity discrepancy is to REPORT it and carry the block over, precisely because an edit made
  // from one reading of an image reaches a reader as what the page says (#183). So the issue could
  // not be resolved by the only round able to act on it, and the iteration budget would go on
  // trying before it landed in `@unresolved` anyway. The correction pass with the page in front of
  // it is the one thing that could have repaired it, and it has already had its one attempt.
  const uncorrectedPages = initial.uncorrectedPages ?? [];
  // Set by the `return` or `break` that ends the loop, and by nothing else (#264). Deliberately
  // not initialised: the value has to come from the exit taken, so an exit added without one
  // leaves it undefined and the deployment's tally shows an unattributed document rather than a
  // plausible wrong reason. See ReviewResult.stoppedAt.
  let stoppedAt: ReviewStopped | undefined;

  while (iterations <= ctx.maxReviewIterations) {
    const read = await runReader(ctx, body, lint, pages, iterations, failedPages);
    const issues = read.issues;
    lastIssues = issues;
    lastUnread = read.unread;
    lastWindows = read.windows;
    // Not `if (iterations === 0)`: the counter is the editor's round number and the day it
    // starts at 1, or a read is added ahead of the loop, this would silently start measuring a
    // different read. "The first one that landed" is the fact wanted, so it is the condition.
    firstRead ??= { issues: issues.length, unread: read.unread };
    // `unread` only when there is any, so an ordinary round's line is the one it always was.
    ctx.log.event("reader", {
      iteration: iterations,
      issues: issues.length,
      ...(read.unread ? { unread: read.unread, windows: read.windows } : {}),
    });
    // Clean, and only on a read that was clean AND answered. A read with an unreadable
    // window has no verdict on that window โ€” see ReviewResult.unreviewedWindows โ€” and this
    // is the return that says the document was reviewed and found to need nothing, which is
    // the claim the deployment's public clean rate is made of (#186).
    if (issues.length === 0 && read.unread === 0) {
      return {
        // `editorTruncated` is false on this path today, because a truncated round breaks
        // out of the loop instead of reaching another Reader pass. It is passed anyway:
        // the one thing this feature must not do is report a truncation to the store while
        // handing the user a document that does not say so, and a later change that lets
        // the loop continue past a truncation would otherwise create exactly that
        // disagreement here, in the return that looks like the clean one.
        //
        // `lintUnavailable` matters most on THIS return, which is the one that means "the
        // Reader looked again and found nothing left". That verdict is the Reader's alone
        // when the linter could not run, and a document that says so is the difference
        // between a clean document and an unchecked one (#164).
        html: wrapDocument(body, {
          failedPages,
          // On THIS return above all, for the same reason `lintUnavailable` is here: this is the
          // exit that means the Reader looked again and found nothing left, and a page the
          // fidelity check rejected is invisible to it โ€” the Reader never sees the source. A
          // clean review of a document containing a page known to be wrong is exactly the
          // document that must still say so (#328).
          uncorrectedPages,
          editorTruncated,
          editorSections,
          editorSalvaged,
          lintUnavailable: lint.error,
        }),
        body,
        iterationsCompleted: iterations,
        unresolved: [],
        lint,
        droppedLinks,
        editorTruncated,
        editorTruncatedLost,
        // Reachable on THIS exit and worth saying why, because it reads like a contradiction: a
        // round refused for demoting a heading is retried, the retry corrects the document, the
        // Reader then finds nothing left and the document leaves by the clean exit. The document is
        // clean and the editor still tried it, which is exactly the pair this field exists to keep.
        editorHeadingsGated,
        // 0 by the guard above. Passed rather than written as a literal for the same reason
        // `editorTruncated` is: the field's value is the loop's, and a return that states it
        // itself is a place where the two can come apart.
        unreviewedWindows: lastUnread,
        // Which on this exit is `{ issues: 0, unread: 0 }` on the first round and something
        // else on any later one โ€” the document read clean AFTER correction, and how much the
        // Reader found in the first place is exactly what this field is for.
        firstRead,
        // The only exit that re-read the finished document and found nothing, which is the
        // whole distinction this field carries: every other one delivers `@unresolved`.
        stoppedAt: "clean",
      };
    }
    // Nothing to correct and no verdict either โ€” the read came back empty because part of it
    // could not be read, not because the document is right. There is nothing to hand an
    // editor (inventing an issue to fix would be worse than the silence), so the loop ends
    // here and the document is delivered saying what happened, with `unresolved` empty
    // because nothing was found rather than because nothing is there.
    //
    // `unread` rather than `clean` for the tally, and `read.unread` is non-zero by the guard
    // above: this is the exit where the deployment has no verdict on the document at all, and
    // the empty `unresolved` below is the reason it must not be counted as a good one.
    if (issues.length === 0) {
      stoppedAt = "unread";
      break;
    }
    if (iterations === ctx.maxReviewIterations) {
      stoppedAt = "cap"; // cap reached, issues remain
      break;
    }

    iterations++;
    const before = body;
    const round = await runEditor(ctx, body, issues);
    // Blocks were refused for taking headings out of the document (#331). Latched here, ahead of every
    // exit below, because this is the only place it is visible: the usual outcome is that the rest of
    // the round applies, `body` moves on, and nothing downstream carries any trace of the blocks that
    // were handed back. Read off `headingsLost` rather than off `usable`, which is why it survives the
    // narrowing โ€” the round that is salvaged is `usable: true`, and gating this on a refused round
    // would report a guard that fired all day as one that never fired at all.
    if (round.headingsLost) editorHeadingsGated = true;
    // The round could not be answered as one response, and the next one would make the same
    // request against the same body โ€” the response length follows the length of the document,
    // not the number of issues in it. So this is the loop's last round however it turned out:
    // another Reader pass and another ceiling of output would only learn the same thing. See
    // runEditor for why the whole-body call is not retried and not fatal, and
    // `correctBySection` for what is asked instead.
    //
    // Read before `body` is taken from the round, and before the two exits below, because both
    // of those would read this round as something it is not. A round that came back with
    // nothing leaves `body === before`, as a converged round does โ€” but a converged round is
    // one the editor ANSWERED and would answer the same way again, which is why it stops the
    // loop with rounds to spare and nothing to disclose; and `usable` is false here, which is
    // the state the loop otherwise treats as a retryable non-answer. A truncation is neither:
    // it is the one outcome that says this document cannot be corrected at this length at all.
    const lastRound = round.truncated;
    if (round.truncated) {
      // The ceiling was hit, whatever was rescued afterwards. This is what the store counts
      // and what the document discloses, because the remedy is the deployment's either way:
      // `providers.<name>.max_tokens` is too low for the documents it accepts, or `max_pages`
      // is too high for that ceiling.
      editorTruncated = true;
      editorSections = round.sections;
      editorSalvaged = round.salvaged;
      // And whether it cost the document anything, which is the half of this a threshold can
      // be put on. `sections` absent is a round given up on entirely โ€” declined, or every
      // section failed (`sectionRound`) โ€” and `corrected` short of `of` is a section that kept
      // the text it went in with. Either way those issues are in the delivered document
      // uncorrected and this is the last round, so nothing looks for them again.
      //
      // A salvaged round asks the same question of a smaller thing (#295). The blocks the reply
      // reached were corrected by the round itself, so what a section could still cost is the
      // REMAINDER โ€” and a reply that reached the end of the document leaves no remainder and
      // therefore no loss, which is the one case where a truncation costs the reader nothing at all.
      const sectionsHeld = !round.sections || round.sections.corrected < round.sections.of;
      editorTruncatedLost = round.salvaged ? round.salvaged.blocks < round.salvaged.of && sectionsHeld : sectionsHeld;
      // Nothing came back from the section calls either โ€” or there were none to make โ€” so the
      // round ends where it used to: the body that entered it is delivered with that round's
      // issues unresolved. It still counts as a round; it was made and paid for, a full
      // ceiling of output at that, so `iterationsCompleted` reporting it is the honest
      // arithmetic and the `editor_truncated` line beside it is what says it changed nothing.
      if (!round.usable) {
        stoppedAt = "truncated";
        break;
      }
    }
    body = round.body;
    // A deprecated role the editor introduced is dropped on the way in, the same way assembly
    // drops one extraction introduced (roles.ts, issue #187). Both ends are needed and for
    // different reasons: assembly cannot see a rewrite that has not happened yet, and this
    // loop is where #187's role actually survived โ€” the Copy Editor was told the rule failed,
    // rewrote five sections, and left it. Ahead of `changed`, the re-lint and the marker diff
    // below, so every one of them is about the body that will ship rather than about a
    // predecessor of it, and ahead of the `body === before` comparisons so a round whose only
    // effect was a role this strips is not credited as a change. A round that introduced none
    // leaves the string untouched.
    const roles = stripDeprecatedRoles(body);
    if (roles.nodes > 0) {
      body = roles.html;
      ctx.log.event("deprecated_roles_stripped", {
        stage: "correction_round",
        iteration: iterations,
        roles: [...new Set(roles.stripped)].sort(),
        nodes: roles.nodes,
      });
    }
    // And a role that is not a role at all (roles.ts, #345), at the same point and for the same
    // three reasons: ahead of `changed` so a round whose only effect was a name this strips is not
    // credited as a change, ahead of the re-lint so the gate is not shown a violation about to be
    // removed, and at this end as well as assembly's because the editor writes fresh markup on
    // every round. It has the editor's own precedent: #187's deprecated role survived a round in
    // which the editor was TOLD the rule failed and rewrote five sections around it.
    const invalid = stripInvalidRoles(body);
    if (invalid.nodes > 0) {
      body = invalid.html;
      ctx.log.event("invalid_roles_stripped", {
        stage: "correction_round",
        iteration: iterations,
        roles: [...new Set(invalid.stripped)].sort(),
        nodes: invalid.nodes,
      });
    }
    // A `<main>` the editor introduced, dropped here for the same reason and at the same point
    // (landmarks.ts, issue #251). This end is not redundant with assembly's: every reply the
    // editor sends is body content with no shell around it, so any of them is a fresh chance to
    // write one โ€” a replacement block that wraps what it was given, and, on the section path, a
    // whole section, which is exactly the prompt under which a model reaches for a wrapper to
    // stand for the piece of document it holds. EDITOR_SYSTEM says not to, in the same sentence
    // it has always said it; this is the end that does not depend on the model reading it. Ahead of `changed` and the re-lint, so a round
    // whose only effect was a `<main>` this removes is not credited as a change.
    const mains = stripNestedMain(body);
    if (mains.unwrapped > 0 || mains.downgraded > 0 || mains.dropped > 0 || mains.declined > 0) {
      body = mains.html;
      ctx.log.event("page_main_stripped", {
        stage: "correction_round",
        iteration: iterations,
        unwrapped: mains.unwrapped,
        downgraded: mains.downgraded,
        dropped: mains.dropped,
        declined: mains.declined,
      });
    }
    // `sections` on this line is how a run log tells a round that was answered whole from one
    // answered piece by piece โ€” and `corrected` from `of` says how much of the document the
    // second kind actually reached, since a section that truncated in its turn kept its
    // original text.
    //
    // The four sizes are what #174 asked for, and the reason is that a successful whole-body
    // replacement used to destroy the only copy of its own input: `parsed.html` is adopted for the
    // body verbatim, so once the round has run, the body that went in is gone and the ratio it
    // moved by is unrecoverable from the log. That left the size distribution of a legitimate
    // review round measurable only on the rounds that FAILED โ€” where the delivered body is still
    // the body that entered โ€” which is n=3 across four bench rounds, all three of them
    // `editor_no_output`. #174's whole point is that a floor on the whole-body path cannot be
    // given a number off three samples, and this is what turns three into one per round.
    //
    // Both length pairs, because a length cannot answer the question a floor is asked, which is whether a
    // round lost CONTENT or lost wrappers. `chars_*` is the whole fragment and `text_chars_*` is
    // what a reader receives; markup-only work leaves the second pair equal and moves the first, and
    // a round that deleted a paragraph moves both. That is the same argument, and the same two
    // readings, that #166 needed on `page_corrected` โ€” so a round and a page correction can be read
    // against each other, which is why `visibleText` is shared rather than reimplemented.
    //
    // Note what the three measured rounds do NOT establish, and what quantity they are. Their
    // 0.982โ€“0.984 span is the REPLY against the body that went in, reconstructed off `agent_call`,
    // and this line reports the DELIVERED body against it โ€” which for those same three rounds is
    // 1.000, because a reply with nothing usable in it is a body handed back untouched. Same
    // caveat as the `sections` one below: read as one population, a fresh 1.000 here and a
    // published 0.982 there are the same round measured two ways. What the three do show beyond
    // length is their structure counts moving (0.714โ€“1.333, one round dropping 5 of 7 lists and 13
    // of 47 list items while its length moved 1.6%), so the evidence for a second signal is
    // evidence for a STRUCTURE count โ€” which is why `structure_before`/`structure_after` are on this
    // line as well.
    //
    // The next round settled which of them a floor reads, and it was not the structure counts: see
    // `EDITOR_SHRINK_FLOOR`, placed on `text_chars_*` because the whole-body round in `runs-231`
    // moved `terms` from 55 to 3 while moving its prose 0.3%. The direction of the evidence above
    // is what that confirmed โ€” the structure counts are the LESS stable number, moving in both
    // directions on rounds that were doing their job โ€” and the conclusion is that a floor on them
    // catches nothing a useful threshold could survive. All three readings stay on this line
    // regardless: two of them are now what a person reads when the third has fired.
    // The `text_chars_*` corpus for a review round is four rounds deep and starts here: 0.997 on
    // the round answered whole, 0.998 / 1.006 / 1.001 on the three answered section by section.
    // Note it is not the same quantity as the published page span โ€” 0.62โ€“2.32 over 265 page
    // corrections is RAW length, delivered against given โ€” so the two cannot be read as one band,
    // and the review floor is set off these four rather than off that one.
    //
    // Measured on the body, which is the `<main>` content: the wrapper and the markers after
    // `</main>` are added downstream and are not what any round returned. Taken AFTER the role
    // strip above for the same reason `changed` is โ€” this is the body that will ship. And a
    // sectioned round's pair is the whole body either way, so `sections` on this line is what
    // separates the two populations: a section reply is 0.016โ€“0.379 of the body it belongs to,
    // because it IS one section, and anything reading these numbers as one distribution would
    // read every sectioned round as a catastrophe.
    ctx.log.event("editor", {
      iteration: iterations,
      changed: body !== before,
      chars_before: before.length,
      chars_after: body.length,
      text_chars_before: visibleText(before).length,
      text_chars_after: visibleText(body).length,
      // The third reading, and the one the three measured rounds actually moved (see
      // `structureCounts`): a length pair cannot tell a round that deleted a list from a round
      // that unwrapped one, and both pairs above answer in characters. Full counts rather than
      // only what changed, because a ratio needs its denominator โ€” the question a threshold is
      // chosen against is "how much of the structure is left", not "did any of it move", and the
      // second question is already answered by `changed` on this same line.
      structure_before: structureCounts(before),
      structure_after: structureCounts(body),
      // `sections` and `corrected` are read as how much of the document the corrections reached, and
      // on a salvaged round they are not: the sections are the sections of the REMAINDER the reply
      // never got to (#295). So the two block counts come with them โ€” `blocks_reached` of `blocks`,
      // the same pair `editor_salvaged` calls `reached` and `of` โ€” and `covers` says which thing the
      // section counts on THIS line are over. Recoverable from the log as a whole either way; this is
      // the one line a reader greps per round, and it was the one overstating its coverage.
      ...(round.salvaged ? { blocks_reached: round.salvaged.blocks, blocks: round.salvaged.of } : {}),
      ...(round.sections
        ? {
            sections: round.sections.of,
            corrected: round.sections.corrected,
            ...(round.salvaged ? { covers: "remainder" } : {}),
          }
        : {}),
    });

    // A round that changed nothing has said what the next one would say.
    //
    // The Reader is about to be handed the same body, the same lint and the same page
    // index, and โ€” if it raises the same issues, which is what an unchanged document
    // invites โ€” the editor would be handed the same request it has just answered with
    // "no change". So the remaining rounds are the most expensive call in the run (whole
    // body in, a whole body out at max_tokens) plus a full re-read of the document,
    // spent to deliver the document already in hand. That is not hypothetical: a
    // [page not fully transcribed] marker is reported by the Reader every round BY
    // DESIGN and can only be settled by re-extracting the page, which is nobody's job in
    // this loop โ€” so a document with one spends its whole budget rewriting itself into
    // itself.
    //
    // What is delivered is unchanged: this body, with the issues just raised written to
    // @unresolved โ€” which is what the cap would have produced, since neither the body nor
    // the issues about it were going to move.
    //
    // Exactly so for the BODY. The @unresolved list is one Reader sample short of it: the
    // cap path takes a final read of the finished body, and that read can come back with
    // nothing โ€” the same body, the same prompt, a different sample โ€” which returns early
    // and credits the document clean. Breaking here stops at the read that preceded this
    // round, so a document that would have won that coin toss is now reported with the
    // issues it actually has. The direction is the conservative one (this rate goes up,
    // never down, and the delivered HTML is the same either way), and the reading it
    // costs is the less trustworthy of the two: a Reader that says "issues" and then
    // "clean" about one unchanged document has not found the document clean, it has
    // disagreed with itself.
    //
    // Only when the editor ANSWERED. A reply that could not be parsed leaves the body
    // untouched for a different reason โ€” the editor never said anything โ€” and the next
    // round is a real retry rather than a repeat, so it is allowed to run.
    //
    // The honest caveat: the editor is sampled, so a second identical request could
    // decide differently. `review_converged` is logged for exactly that reason โ€” how
    // often this fires, and on which issues, is measurable from a run log, so the policy
    // can be revisited from evidence rather than from either of our guesses.
    //
    // And not for a round that was answered section by section, even when every section came
    // back as it went in. `review_converged` claims the editor read the whole document and
    // decided it was better left alone, with rounds to spare โ€” here it was never shown the
    // whole document, and there are no rounds to spare because the next one would truncate
    // before any section call was made. Those are different facts and the log must not
    // conflate them; `editor_truncated` beside `editor` is what this round has to say.
    if (round.usable && body === before && !lastRound) {
      ctx.log.event("review_converged", {
        iteration: iterations,
        issues: issues.length,
        rounds_left: ctx.maxReviewIterations - iterations,
      });
      // The same fact the event carries, in the one place a deployment-wide question can be
      // asked of it (#264). `rounds_left` above is why the pair matters: this exit and `cap`
      // are indistinguishable from outside the loop, and only this one means the budget was
      // there and declined โ€” so raising `max_review_iterations` is an answer to `cap` and to
      // nothing else. Which is the arithmetic #264 was filed needing.
      stoppedAt = "converged";
      break;
    }
    // Skipped when nothing changed, because every one of these answers a question about
    // a difference: the lint of an unedited body is the lint already in hand, and a link
    // or marker diff against an identical string is empty by construction.
    if (body === before) {
      // Two rounds reach here, and they are told apart by `lastRound` alone. A round that
      // truncated and was still usable โ€” the section calls rescued something, and what came
      // back was the text it went in with โ€” is the ceiling, and it stops: the next round would
      // send the same body whole and hit the same ceiling. A round that was not usable and did
      // not truncate is the model saying nothing, which is a retry, so it falls to the
      // `continue`. (An unusable TRUNCATED round already broke above, and an answered round
      // that changed nothing was taken by the converged exit.)
      if (lastRound) {
        stoppedAt = "truncated";
        break;
      }
      continue;
    }

    lint = await runAxe(wrapDocument(body));
    // The re-lint is the gate on the document that actually ships โ€” the `assembly` event
    // reports the lint of the body BEFORE any correction round โ€” and until now a failure
    // here was logged nowhere at all: the editor could introduce the very attribute that
    // breaks the selector engine (see runAxe) and the only trace would be one signal in
    // the quality table. Logged with the same fields as `assembly`, so both failures read
    // the same way in a run log, and per iteration, because which round broke it is the
    // question a person reading this asks next.
    if (lint.error) {
      ctx.log.event("lint_unavailable", { stage: "correction_round", iteration: iterations, ...lintErrorFields(lint) });
    }
    // Its own event rather than a field on a line above, because the only line this stage logs about
    // its lint is the one that fires when the lint FAILED โ€” and a body carrying debris is now a body
    // that lints (#257). Per iteration and beside the failure line, because the editor is one of the
    // two places these can come from: a round that rewrites a block can put a leaked escape into it
    // as easily as extraction can, and the round it arrived in is the question a reader asks next.
    if (lint.malformedAttributes) {
      ctx.log.event("lint_debris", { stage: "correction_round", iteration: iterations, ...lintDebrisFields(lint) });
    }
    // A link the editor dropped is unrecoverable and invisible to every later check
    // in the loop โ€” see droppedHrefs for why this is checked here and in code.
    const dropped = droppedHrefs(before, body);
    if (dropped.length) {
      droppedLinks += dropped.length;
      ctx.log.event("editor_links_dropped", { iteration: iterations, hrefs: dropped });
    }
    // See BODY_MARKERS: the only place a marker's DISAPPEARANCE is recorded. An arrival is also
    // recorded on the page path, by `markers_added` on `page_corrected` (#373) โ€” additions only,
    // because that corrector is handed the image and resolving an illegible passage is its job. The
    // editor is handed no image, so a marker leaving its body is a claim being dropped rather than
    // answered, and both directions belong on this line.
    const was = markerCounts(before);
    const now = markerCounts(body);
    const fewer = BODY_MARKERS.filter((m) => now[m] < was[m]);
    const more = BODY_MARKERS.filter((m) => now[m] > was[m]);
    if (fewer.length || more.length) {
      ctx.log.event("editor_markers_changed", {
        iteration: iterations,
        ...(fewer.length ? { fewer } : {}),
        ...(more.length ? { more } : {}),
        before: was,
        after: now,
      });
    }
    // See `listMarkerHalfEdit`: half of the one conversion this prompt licenses, in either direction.
    // Beside the two checks above because it is the same kind of fact โ€” something a round took away
    // that no gate can see โ€” and it is the line that tells a reader of `refusal_with_loss` which
    // shrink they are looking at, since the licensed strip lands in `shrunk` exactly as a real loss
    // does and the report cannot tell them apart on its own.
    const halfEdit = listMarkerHalfEdit(before, body);
    if (halfEdit) {
      ctx.log.event("editor_list_markers_split", {
        iteration: iterations,
        shape: halfEdit,
        before: listMarkers(before),
        after: listMarkers(body),
      });
    }
    // Last, so a round that was answered a section at a time is measured like any other โ€” its
    // lint, its dropped links, its markers โ€” before the loop ends on it. Those checks are
    // about the difference between two bodies and this round made one; ending the loop above
    // them would deliver a corrected document with none of them recorded, which is precisely
    // the disclosure the section calls make more likely (each one sees less of the document
    // than a whole-body round does).
    if (lastRound) {
      stoppedAt = "truncated";
      break;
    }
  }

  // Issues remain and the loop has stopped โ€” at the cap, on a round that changed nothing,
  // or on a round whose response hit the output ceiling. All three record them as
  // a comment, with the source page reference the Reader attributed so a human can
  // find them; the third also states itself in the document, because "the editor tried and
  // could not fix these" and "no editor pass ever worked on these" are different facts.
  //
  // On the third, these are the issues the Reader raised BEFORE the section calls ran, and
  // some of them may since have been fixed โ€” nothing re-read the document, because the round
  // that would have done so is the one that could not be made. Over-reporting is the
  // conservative direction and the same one the converged break takes: the list says what is
  // known to have been found, the `@editor-truncated` comment says it was not re-checked, and
  // an issue reported as unresolved that was quietly fixed costs a reader a second look, while
  // the reverse costs them the belief that the document was finished.
  const unresolvedLines = lastIssues.map(
    (i) => `${i.issue} (severity: ${i.severity}${i.pages?.length ? `, page ${i.pages.join(", ")}` : ""})`,
  );
  return {
    html: wrapDocument(body, {
      unresolved: unresolvedLines,
      failedPages,
      uncorrectedPages,
      editorTruncated,
      editorSections,
      editorSalvaged,
      lintUnavailable: lint.error,
      // Said in the document whether or not `unresolved` is empty, because it changes how
      // that list is to be read either way: an empty one is not a clean bill of health, and
      // a non-empty one may be missing whatever the unread windows held.
      ...(lastUnread ? { reviewUnread: { windows: lastUnread, of: lastWindows } } : {}),
    }),
    body,
    iterationsCompleted: iterations,
    unresolved: lastIssues,
    lint,
    droppedLinks,
    editorTruncated,
    editorTruncatedLost,
    editorHeadingsGated,
    unreviewedWindows: lastUnread,
    firstRead,
    // Whichever `break` above got here. Undefined is not a state this loop can reach today โ€”
    // the cap check fires at `iterations === maxReviewIterations`, one round before the `while`
    // condition could fail, so every exit is one of the five โ€” and it is left reachable in the
    // type on purpose, because the exit added by a later change is the one that would need it.
    stoppedAt,
  };
}