File size: 117,442 Bytes
c9fa753
 
 
 
 
 
 
 
 
 
 
 
c361f38
c9fa753
 
 
 
 
0b22581
c9fa753
 
 
 
 
 
 
 
dbdc8fd
c9fa753
 
 
 
 
 
 
 
 
 
dbdc8fd
c9fa753
682d244
c9fa753
 
 
 
 
 
dbdc8fd
c9fa753
682d244
c9fa753
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56d36d7
c9fa753
 
 
 
 
 
 
 
 
 
 
0b22581
 
c9fa753
971125f
c9fa753
 
 
 
971125f
 
 
 
c9fa753
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb286dd
 
 
 
 
 
c361f38
bb286dd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c361f38
 
bb286dd
 
 
 
 
 
 
 
 
 
 
 
 
4b4863a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb286dd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9fa753
 
 
 
 
 
1597754
 
 
 
c9fa753
 
 
 
682d244
 
 
 
 
 
 
c9fa753
682d244
 
 
af84a24
682d244
 
56d36d7
 
 
 
0b22581
 
 
56d36d7
682d244
c9fa753
d094c05
 
 
 
 
 
 
 
 
 
0b22581
 
 
 
56d36d7
c9fa753
0b22581
 
56d36d7
 
c9fa753
 
798dcd8
c9fa753
 
 
 
 
682d244
 
798dcd8
92e4b79
 
 
 
798dcd8
 
 
92e4b79
 
 
dbdc8fd
92e4b79
798dcd8
e604f4f
 
 
56d36d7
 
 
c9fa753
 
e1bc5dd
c9fa753
798dcd8
e604f4f
 
b4f6be8
e604f4f
798dcd8
92e4b79
798dcd8
 
56d36d7
 
 
0b22581
 
 
d094c05
 
 
0b22581
 
798dcd8
 
 
 
0b22581
 
 
 
 
 
798dcd8
 
0b22581
 
 
 
 
 
 
 
 
 
 
 
 
 
c9fa753
 
682d244
2b5579d
 
 
30b61f5
 
 
2b5579d
 
b4f6be8
 
 
 
 
30b61f5
af84a24
2b5579d
c9fa753
971125f
 
 
 
 
 
 
c9fa753
798dcd8
 
 
bffe8c6
 
971125f
 
 
 
 
 
4e0c85a
 
 
 
 
 
1406987
af84a24
4e0c85a
 
 
 
 
d092871
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4e0c85a
d092871
 
 
4e0c85a
d092871
 
30b61f5
 
af84a24
4e0c85a
1406987
 
 
2baf418
 
 
 
 
 
 
 
 
1406987
af84a24
c9fa753
 
 
 
 
 
 
30b61f5
 
 
 
c9fa753
 
30b61f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9fa753
af84a24
2baf418
 
 
b6d55b9
 
 
 
 
 
2baf418
 
 
 
af84a24
b6d55b9
 
 
7e513ed
b6d55b9
 
7e513ed
 
 
 
 
c9fa753
 
2baf418
 
c9fa753
 
 
 
 
 
798dcd8
 
 
c9fa753
 
 
 
 
 
3d97f2e
 
 
 
 
c9fa753
3d97f2e
c9fa753
 
 
 
 
682d244
971125f
 
c9fa753
 
931437f
 
 
 
b6d55b9
931437f
c9fa753
 
931437f
 
 
 
 
 
 
 
 
 
 
 
 
 
1597754
d092871
1597754
 
 
b6d55b9
 
1597754
 
 
c9fa753
931437f
 
 
 
c9fa753
 
 
 
 
 
 
 
 
ba553cc
 
 
 
c9fa753
 
ba553cc
 
 
b6e00ed
 
 
ba553cc
 
 
 
c9fa753
 
682d244
c9fa753
 
 
 
4b4863a
 
 
 
1597754
ba553cc
1597754
 
c9fa753
 
 
 
 
af84a24
 
971125f
c9fa753
971125f
931437f
 
bb286dd
 
 
 
 
 
 
30b61f5
65719f8
 
35dbc47
56d36d7
30b61f5
65719f8
d092871
 
 
 
 
 
 
 
 
 
 
 
 
 
30b61f5
c9fa753
 
 
2b5579d
2f68f8d
2b5579d
2f68f8d
2b5579d
 
af84a24
c9fa753
 
 
 
af84a24
c9fa753
 
 
971125f
b9cac75
c9fa753
c3d0cb8
 
 
971125f
 
c9fa753
 
da2ea0f
c9fa753
 
682d244
c9fa753
 
af84a24
 
c9fa753
 
 
 
 
 
 
 
 
 
56d36d7
 
c9fa753
 
971125f
b9cac75
 
da2ea0f
 
 
 
 
 
 
af84a24
 
 
da2ea0f
af84a24
 
b9cac75
af84a24
 
b9cac75
af84a24
 
 
da2ea0f
 
 
c9fa753
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
798dcd8
 
c9fa753
dbdc8fd
c9fa753
 
 
682d244
 
 
 
c9fa753
 
 
 
 
 
 
 
 
 
 
 
56d36d7
 
 
 
 
 
 
 
 
0b22581
 
 
 
 
 
 
 
 
 
cbaf3be
 
 
0b22581
 
cbaf3be
 
 
 
 
 
0b22581
cbaf3be
0b22581
cbaf3be
0b22581
1cddb1f
 
 
0b22581
 
56d36d7
 
 
 
 
 
 
0b22581
 
56d36d7
0b22581
56d36d7
0b22581
56d36d7
 
 
0b22581
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56d36d7
 
 
 
 
 
 
 
 
 
b4f6be8
 
 
 
 
 
 
 
 
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b22581
 
 
 
 
 
 
 
 
56d36d7
b4f6be8
0b22581
 
56d36d7
 
 
 
 
b4f6be8
56d36d7
 
 
 
 
 
0b22581
 
56d36d7
 
 
0b22581
 
 
56d36d7
 
 
 
 
 
 
0b22581
 
 
 
 
56d36d7
0b22581
 
56d36d7
0b22581
 
 
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dbdc8fd
 
 
 
 
 
 
 
 
 
682d244
bffe8c6
682d244
 
c9fa753
 
682d244
c9fa753
 
 
682d244
 
 
c9fa753
 
 
682d244
 
dbdc8fd
 
 
 
 
56d36d7
 
 
 
 
 
 
 
 
 
dbdc8fd
 
 
 
 
 
 
682d244
 
 
 
 
 
 
bffe8c6
 
 
 
c9fa753
798dcd8
 
bffe8c6
c9fa753
 
 
bffe8c6
c9fa753
 
 
bffe8c6
c9fa753
682d244
c9fa753
 
 
 
bffe8c6
 
 
 
 
 
 
 
 
 
 
 
 
 
4b4863a
 
bffe8c6
 
 
b6d55b9
 
 
 
 
 
 
 
 
 
 
 
d092871
 
 
 
b6d55b9
 
931437f
 
c9fa753
27aa0ac
 
 
 
6ce2729
27aa0ac
 
c9fa753
 
 
 
 
0b22581
 
 
c9fa753
 
 
 
 
 
 
 
 
 
 
 
 
 
931437f
c9fa753
 
 
 
 
 
 
cf737ec
c9fa753
 
 
 
 
 
 
3d97f2e
c9fa753
 
3d97f2e
931437f
c9fa753
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
971125f
c9fa753
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e2239f2
 
 
c9fa753
 
 
 
 
 
 
 
931437f
 
 
 
 
 
0b22581
56d36d7
931437f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30b61f5
971125f
f9517e4
 
30b61f5
 
 
971125f
30b61f5
 
971125f
 
 
 
 
 
 
 
 
 
 
 
 
 
138163a
 
 
971125f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cf737ec
971125f
 
 
0ecc5ad
0ae86f3
0ecc5ad
 
138163a
 
e1704e4
abc0015
138163a
 
 
 
 
 
 
 
 
 
 
 
 
259245a
 
 
 
 
e1704e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cf737ec
e1704e4
 
 
 
 
 
 
 
 
 
 
cf737ec
e1704e4
 
 
 
f954d8a
e1704e4
 
 
 
7eb3696
 
e1704e4
 
 
f954d8a
 
 
 
 
e1704e4
 
 
 
35dbc47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0ae86f3
 
 
 
 
 
bb286dd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
772f644
 
 
bb286dd
 
 
 
 
 
 
 
 
 
 
4b4863a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb286dd
 
 
 
 
 
 
 
b6d55b9
 
 
 
 
 
 
7e513ed
 
b6d55b9
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b22581
56d36d7
0b22581
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b22581
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35dbc47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
971125f
 
 
 
 
 
 
 
 
 
30b61f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
931437f
 
 
 
 
 
 
c9fa753
 
 
 
 
 
 
931437f
 
 
 
30b61f5
 
 
971125f
 
 
 
 
 
 
 
 
 
0ecc5ad
138163a
 
 
259245a
e1704e4
 
 
f954d8a
 
 
35dbc47
 
 
 
 
0ae86f3
8f67d3b
0ae86f3
 
 
 
 
 
 
 
 
 
 
b6d55b9
 
 
 
 
 
 
 
 
 
f612cc5
 
 
 
a89faee
 
 
 
 
 
56d36d7
 
0b22581
 
b6d55b9
bb286dd
 
 
 
 
 
 
c9fa753
 
 
 
 
 
c3d0cb8
7e513ed
 
c3d0cb8
7e513ed
 
c3d0cb8
7e513ed
 
 
c3d0cb8
 
 
 
 
7e513ed
 
 
 
 
 
c3d0cb8
c9fa753
 
 
 
 
 
 
 
 
 
 
 
 
7e513ed
 
 
 
 
 
 
 
c9fa753
 
 
56d36d7
c9fa753
 
56d36d7
c9fa753
 
 
 
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
"""UserReport, the canonical renderer, the final claim audit, and the InternalQARecord.

One structured report is built once, from the frozen AnalysisState and the ingested prose. HTML
and markdown are two serialisations of the same fragment list, so they cannot say different
things. Every number is a claim fragment that resolves to one state path; the audit re-resolves
each fragment against the frozen state and rejects any material number that is not a claim.
The audited fragments ARE the delivered document: the web page, the download and the saved report
all use these bytes. Internal QA (validator ids, lineage, rejected sentences, engine logs) lives in
a separate record and never enters the user report.
"""
from __future__ import annotations

from collections.abc import Mapping
import html as _html
import re
from dataclasses import dataclass, field

import catalog as C
from prose import BENIGN, DENIAL, IDENTIFIER, MATERIAL_NUMBER, Fragment

# ═══════════════════════════════════════════════════════════════════════════════════════
# structure
# ═══════════════════════════════════════════════════════════════════════════════════════


@dataclass
class Block:
    kind: str                     # heading | para | bullets | points | table | tiles | callout | kv
    section: str
    content: object = field(default_factory=list)   # para: [Fragment]; bullets: [[Fragment]]; table: {"head": [...], "rows": [[[Fragment]]]}
    title: str | None = None
    css: str | None = None

    def units(self):
        """Fragment sequences that read as one phrase: a paragraph, one bullet, one table row.
        Label adjacency is judged inside a unit, never across table cells or bullets."""
        if self.kind in ("para", "callout", "heading"):
            return [list(self.content)]
        if self.kind in ("bullets", "points"):
            return [list(item) for item in self.content]
        if self.kind in ("table", "kv", "tiles"):
            return [[f for cell in row for f in cell] for row in self.content.get("rows", [])]
        return []

    def fragments(self):
        if self.kind in ("para", "callout", "heading"):
            return list(self.content)
        if self.kind in ("bullets", "points"):
            return [f for item in self.content for f in item]
        if self.kind in ("table", "kv", "tiles"):
            out = [Fragment(h) for h in self.content.get("head", [])]
            for row in self.content.get("rows", []):
                for cell in row:
                    out.extend(cell)
            return out
        return []


@dataclass
class UserReport:
    ticker: str
    company: str
    as_of: str
    version: str
    blocks: list
    claims: dict                  # claim_id → {value, rendered, path, label}
    limitations: list
    arguments: dict = field(default_factory=dict)   # role → validated crucible.args/2 set (engine/arguments.py)

    def fragments(self):
        return [f for b in self.blocks for f in b.fragments()]


class _R:
    """Fragment helpers bound to a frozen state and its catalog. The renderer never computes a
    number: it formats a state value or it writes words."""

    def __init__(self, state, cat):
        self.s, self.cat, self.used = state, cat, {}
        self.seen_evidence = set()      # evidence printed in full once per report; later mentions are compact
        self.authored_seen = set()      # an authored text is printed once per report

    def c(self, cid, cap=False):
        cl = self.cat.get(cid)
        if cl is None:
            return Fragment("unavailable")
        self.used[cid] = cl
        txt = cl.rendered
        if cap and cl.stype == C.REL and txt:
            txt = txt[0].upper() + txt[1:]             # a relation clause that opens a sentence
        return Fragment(txt, cid, cl.value)

    def p(self, path, stype, label):
        cl = C.path_claim(self.s, path, stype, label)
        if cl.rendered is None:
            return Fragment("—")
        self.used[cl.claim_id] = cl
        return Fragment(cl.rendered, cl.claim_id, cl.value)

    @staticmethod
    def t(text):
        return Fragment(text)


def _para(section, *frags, css=None, title=None):
    return Block("para", section, [f if isinstance(f, Fragment) else Fragment(str(f)) for f in frags], title=title, css=css)


# ── HistoricalMetricQuality and forecast lineage: every label below derives from typed state ──
def historical_quality(s):
    """HistoricalMetricQuality of the trailing figures (reconcile.ttm_quality). A state built before it existed is read
    conservatively: any unchecked quarter makes the series at most partially reconciled."""
    rec = s.historical.get("reconciliation") or {}
    q = rec.get("ttm_quality")
    if isinstance(q, Mapping) and q.get("status"):
        return q
    st, pe = rec.get("status"), str(rec.get("period_end") or "")
    import datetime as _dt
    try:                                                    # only the quarters inside the trailing year count
        lo = (_dt.date.fromisoformat(pe[:10]) - _dt.timedelta(days=360)).isoformat()
    except ValueError:
        lo = ""
    unv = [e for e in rec.get("unverified_quarters") or [] if str(e) > lo]
    status = ("SEC_RECONCILED" if st in ("RECONCILED", "SEC_ONLY") and not unv else
              "PARTIALLY_RECONCILED" if st == "RECONCILED" else "PROVIDER_ONLY" if st and st != "NO_STATEMENTS" else "UNVERIFIED")
    return {"status": status, "verified_through": None, "unverified_periods": unv, "source_mix": [], "quarters": []}


def ttm_header(q, separated=False):
    """The trailing column's label from its quality state: only SEC_RECONCILED reads as reconciled without qualification."""
    st, unv = q.get("status"), list(q.get("unverified_periods") or [])
    ends = " and ".join(unv)
    if st == "SEC_RECONCILED":
        parts = ["reconciled to SEC filings"]
    elif st == "PARTIALLY_RECONCILED":
        parts = [f"reconciled to SEC filings through {q['verified_through']}" if q.get("verified_through")
                 else "partly reconciled to SEC filings",
                 f"quarter{'s' if len(unv) > 1 else ''} ended {ends} from the data provider, unverified"]
    elif st == "PROVIDER_ONLY":
        parts = ["the data provider's figures, not checked against SEC filings"]
    else:
        parts = ["unverified"]
    if separated:
        parts.append("before the separation")
    return "Trailing twelve months (" + "; ".join(parts) + ")"


def anchored_forecasts(s):
    """The forecast keys set from sell-side consensus (model weight 0), and those shrunk toward it (0 < weight < 1)."""
    anc = s.forecast.get("consensus_anchor") or {}
    full, part = [], []
    for k in ("revenue", "eps"):
        a = anc.get(k) if isinstance(anc, Mapping) else None
        if isinstance(a, Mapping) and a.get("model_weight") is not None:
            (full if float(a["model_weight"]) <= 0.0 else part if float(a["model_weight"]) < 1.0 else []).append(k)
    return full, part


def forecast_header(s, separated=False):
    full, part = anchored_forecasts(s)
    name = {"revenue": "revenue", "eps": "EPS"}
    notes = (["continuing company"] if separated else []) + \
        ([" and ".join(name[k] for k in full) + " set from sell-side consensus"] if full else []) + \
        ([" and ".join(name[k] for k in part) + " shrunk toward sell-side consensus"] if part else [])
    return "Engine forecast, next 12 months" + (" (" + "; ".join(notes) + ")" if notes else "")


# compact render fields: a table header carries a short label and a marker; the full provenance sentence is the
# footnote printed directly under the table (a long header pushed the value columns off a printed page)
HIST_MARK, FCST_MARK = "\u2020", "\u2021"          # † ‡ (an asterisk would start a Markdown list item)


def historical_quality_render(q, separated=False):
    """{full_description, table_header, footnote} for the trailing column, all from HistoricalMetricQuality."""
    st, unv = q.get("status"), list(q.get("unverified_periods") or [])
    ends = " and ".join(unv)
    what = "Trailing twelve months" + (", before the separation" if separated else "")
    one = len(unv) == 1
    unchecked = (f"the quarter{'' if one else 's'} ended {ends} could not be verified against SEC filings and "
                 f"use{'s' if one else ''} the data provider's figures")
    if st == "SEC_RECONCILED":
        body = "reconciled to SEC filings"
    elif st == "PARTIALLY_RECONCILED":
        body = (f"reconciled to SEC filings through {q['verified_through']}" if q.get("verified_through")
                else "partly reconciled to SEC filings") + f"; {unchecked}"
    elif st == "PROVIDER_ONLY":
        body = "the data provider's figures, not checked against SEC filings"
    else:
        body = "not verified against SEC filings"
    return {"full_description": ttm_header(q, separated),
            "table_header": ("TTM, pre-separation" if separated else "TTM") + HIST_MARK,
            "footnote": f"{HIST_MARK} {what}: {body}."}


def forecast_lineage_render(s, separated=False):
    """{full_description, table_header, footnote} for the forecast column, from the typed consensus anchor."""
    full, part = anchored_forecasts(s)
    name = {"revenue": "revenue", "eps": "EPS"}
    anc = s.forecast.get("consensus_anchor") or {}
    why = {"RESTATEMENT": "the reported statements still include a separated business",
           "MISFIRE": "the model's own estimate was set aside"}
    reasons = [why.get(str((anc.get(k) or {}).get("reason"))) for k in full]
    reasons = [x for x in dict.fromkeys(reasons) if x]
    parts = ["Engine forecast" + (" for the continuing company" if separated else "") + "."]
    if full:
        subj = " and ".join(name[k] for k in full)
        parts.append(f"{subj[:1].upper() + subj[1:]} {'are' if len(full) > 1 else 'is'} set from sell-side consensus in "
                     f"this run" + (f" ({'; '.join(reasons)})" if reasons else "") + ", not independent engine estimates.")
    if part:
        subj = " and ".join(name[k] for k in part)
        parts.append(f"{subj[:1].upper() + subj[1:]} {'are' if len(part) > 1 else 'is'} shrunk toward sell-side consensus.")
    if not full and not part:
        parts.append("A model estimate, not guidance or consensus.")
    return {"full_description": forecast_header(s, separated),
            "table_header": ("Next 12 months, continuing company" if separated else "Next 12 months") + FCST_MARK,
            "footnote": f"{FCST_MARK} " + " ".join(parts)}


_ANCHOR_WHY = {"RESTATEMENT": "the reported statements still include a separated business, so it is set from sell-side "
                              "consensus for the continuing company",
               "MISFIRE": "the model's own estimate was set aside and replaced by sell-side consensus"}
_DIMENSION_WORDS = {"DEFINITION": "earnings definition", "ACCOUNTING_BASIS": "accounting basis", "PERIOD": "period",
                    "PERIMETER": "business perimeter", "CURRENCY": "currency", "SECURITY_BASIS": "share basis"}


def consensus_absence_sentence(s, rid, noun):
    """Why the engine's figure is not compared with consensus, from relations.absence (typed), or None."""
    import relations as _RL
    ab = _RL.absence(s, rid)
    if ab is None:
        return None
    if ab["kind"] == "ANCHORED":
        why = _ANCHOR_WHY.get(str(ab["anchor"].get("reason")), "it is set from sell-side consensus")
        return (f"It is not compared with the engine's forecast: the engine's {noun} forecast is not an independent "
                f"estimate ({why}).")
    if ab["kind"] == "INCOMPATIBLE":
        dims = [_DIMENSION_WORDS.get(d, "basis") for d in ab["dimensions"]]
        return ("It is not compared with the engine's forecast: the two are not on the same "
                + " or ".join(dict.fromkeys(dims)) + ".")
    return "It is not compared with the engine's forecast."


def build_user_report(s, cat, prose, limitations):
    """``prose`` holds ingested fragment lists: summary, thesis[], risks[], seats{role: {...}}, pm{...}."""
    r = _R(s, cat)
    B = []
    eng = s.valuation.get("engine") or {}
    terms = eng.get("terms") or {}
    iv_word = s.valuation.get("intrinsic_label") or terms.get("intrinsic") or "intrinsic value"
    per = s.historical.get("perimeter") or {}
    sep = per.get("event")
    pol_methods = set((s.issuer.get("policy") or {}).get("pricing_methods") or ())

    # ── verdict ──
    B.append(Block("heading", "verdict", [r.t(f"{s.identity.get('company_name')} ({s.identity.ticker})")]))
    B.append(_para("verdict", r.c("RATING"), r.t(" · "), r.c("HEADLINE_RETURN"),
                   r.t(" model-implied 12-month price return · share price "), r.c("PRICE"), css="verdict"))
    # the few figures a reader needs first; each tile is one phrase for the label checks
    tiles = []
    if "INTRINSIC_VALUE" in cat:
        tiles.append([[r.t(iv_word[:1].upper() + iv_word[1:])], [r.c("INTRINSIC_VALUE")], [r.t("price "), r.c("PRICE_VS_INTRINSIC")]])
    if "MARKET_COMPOSITE" in cat:
        tiles.append([[r.t("Market pricing")], [r.c("MARKET_COMPOSITE")], [r.t("price "), r.c("PRICE_VS_COMPOSITE")]])
    if "RANGE_LOW" in cat and "RANGE_HIGH" in cat:
        tiles.append([[r.t("Analyst scenarios")], [r.c("RANGE_LOW"), r.t(" to "), r.c("RANGE_HIGH")],
                      [r.t("probability-weighted "), r.c("PM_RETURN")]])
    if "MOMENTUM" in cat:
        tiles.append([[r.t("Momentum")], [r.c("MOMENTUM")], []])
    if tiles:
        B.append(Block("tiles", "verdict", {"rows": tiles}))
    args = prose.get("arguments") or {}
    s_args = args.get("summary") or {}
    if s_args.get("thesis") or (not prose.get("summary") and (args.get("pm") or {}).get("thesis")):
        B.append(Block("heading", "summary", [r.t("In brief")]))
        _bt = s_args.get("thesis") or args["pm"]["thesis"]
        r.authored_seen.add(_bt)
        B.append(Block("para", "summary", [_authored(_bt)]))
    elif prose.get("summary"):
        B.append(Block("heading", "summary", [r.t("In brief")]))
        B.append(Block("para", "summary", prose["summary"]))
    else:
        # no authored summary survived ingestion: state the two value relations directly (template sentences built from
        # the typed RelationshipClaims; nothing authored, nothing rewritten)
        brief = [cid for cid in ("REL_PRICE_INTRINSIC__FULL_SENTENCE", "REL_PRICE_MARKET_COMPOSITE__FULL_SENTENCE") if cid in cat]
        if brief:
            B.append(Block("heading", "summary", [r.t("In brief")]))
            frs = []
            for i, cid in enumerate(brief):
                frs += ([r.t(" ")] if i else []) + [r.c(cid)]
            B.append(Block("para", "summary", frs))
    _why = _admit(r, s_args.get("items_thesis"))
    _wrong = _admit(r, s_args.get("items_risks"))
    if _why:
        B.append(Block("bullets", "summary", [_arg_item(r, s, cat, args, p) for p in _why], title="Why"))
    elif prose.get("thesis"):
        B.append(Block("bullets", "summary", prose["thesis"], title="Why"))
    if _wrong:
        B.append(Block("bullets", "summary", [_arg_item(r, s, cat, args, p) for p in _wrong],
                       title="What would make this wrong"))
    elif prose.get("risks"):
        B.append(Block("bullets", "summary", prose["risks"], title="What would make this wrong"))

    # ── debate: every seat in full, the portfolio manager last ──
    pm = (prose.get("seats") or {}).get("pm") or {}
    seats = prose.get("seats") or {}
    order = (("neutral", "Neutral analyst", "SCENARIO_NEUTRAL_RETURN", "SCENARIO_NEUTRAL_PROB"),
             ("bear", "Bear", "SCENARIO_BEAR_RETURN", "SCENARIO_BEAR_PROB"),
             ("bull", "Bull", "SCENARIO_BULL_RETURN", "SCENARIO_BULL_PROB"))
    B.append(Block("heading", "debate", [r.t("The analyst debate")] +
                   ([r.t(" · "), r.c("RANGE_LOW"), r.t(" to "), r.c("RANGE_HIGH")] if ("RANGE_LOW" in cat and "RANGE_HIGH" in cat) else [])))

    def _notes(entry, title=None, opponent=None):
        """One seat's reasoning and key points; a title labels the round (opening case, rebuttal). A rebuttal point's
        topic is, by the prompt's contract, the OPPOSING claim: it is labelled as the opponent's, never shown as if the
        author asserted it."""
        if entry.get("reasoning"):
            B.append(Block("para", "debate", entry["reasoning"], title=title))
        if entry.get("key_points"):
            pts = entry["key_points"]
            if opponent:
                pts = [[Fragment(f"The {opponent}'s claim — ")] + list(kp) for kp in pts]
            # headlines only: each point shows its topic, the full point opens on a tap
            B.append(Block("points", "debate", pts, title=None if entry.get("reasoning") else title))

    def _has(e):
        return bool((e or {}).get("reasoning") or (e or {}).get("key_points"))

    if any(k in args for k in ("neutral", "bear", "bull", "bear_open", "bull_open", "pm")):
        _structured_debate(B, r, s, cat, args, seats, order, _notes)
        order = ()                                   # rendered above (legacy seats included, in round order)
    for role, name, ret_id, p_id in order:
        st = seats.get(role) or {}
        B.append(_para("debate", r.t(f"{name} — "), r.c(ret_id),
                       r.t(f" · {(s.scenarios.get(role) or {}).get('label') or ''} · probability "), r.c(p_id), css="seat-head"))
        op = seats.get(f"{role}_open") or {}
        if not (_has(op) or _has(st)):
            # nothing this analyst wrote met the report's evidence standards: say so instead of an empty section
            B.append(_para("debate", r.t(_empty_seat_note(s, role))))
        elif op:
            _notes(op, "Opening case")
            _notes(st, "Rebuttal", opponent={"bear": "bull", "bull": "bear"}.get(role))
        else:
            _notes(st)
    if order or not args:                            # otherwise the structured debate rendered the PM block
        B.append(_para("debate", r.t("Portfolio manager — probability-weighted "), r.c("PM_RETURN"),
                       r.t(f" · {s.scenarios.get('pm_label') or ''}"), css="seat-head"))
    if (order or not args) and all(c in cat for c in ("SCENARIO_BEAR_PROB", "SCENARIO_NEUTRAL_PROB", "SCENARIO_BULL_PROB",
                                                       "PM_RETURN_PHRASE")):
        # the PM's decision, always stated from its typed claims (then any authored PM prose that survived ingestion)
        B.append(_para("debate", r.t("The portfolio manager weights the bear case at "), r.c("SCENARIO_BEAR_PROB"),
                       r.t(", the neutral case at "), r.c("SCENARIO_NEUTRAL_PROB"), r.t(" and the bull case at "),
                       r.c("SCENARIO_BULL_PROB"), r.t(", for "), r.c("PM_RETURN_PHRASE"), r.t(".")))
    if (order or not args) and (pm.get("reasoning") or pm.get("key_points")):
        _notes(pm)
    trg = s.scenarios.get("triggers") or []
    if trg:
        items = []
        for i, t in enumerate(trg[:3]):
            # the quantity is named by the claim the threshold is measured against (its reader label and today's value),
            # never by the author's free "metric" string; a trigger with no resolvable source claim stays in QA
            src = t.get("source_claim_id") or t.get("source_guidance_id")
            subj = trigger_subject(s, cat, src)
            if not subj:
                continue
            dest = "the bull case" if t.get("scenario_destination") == "BULL" else "the bear case"
            verb = "rises to" if t.get("side") == "above" else "falls to"
            thr = r.p(f"scenarios.triggers.{i}.threshold", t.get("threshold_kind") or C.TEXT, "trigger threshold (hypothetical)")
            side = f" or {'above' if t.get('side') == 'above' else 'below'}"
            cl = cat.get(src)
            if getattr(cl, "stype", None) == C.GUIDANCE:
                # measured against a guided range: the realised metric moves, the guidance is the reference
                items.append([r.t(f"If {subj} {verb} "), thr, r.t(f"{side} (company guidance: "), r.c(src),
                              r.t(f"), the view moves toward {dest}.")])
            elif set(getattr(C.claim_def(s, src), "prohibited", ()) or ()) & {"now", "current", "today"}:
                items.append([r.t(f"If {subj} {verb} "), thr, r.t(f"{side}, the view moves toward {dest}.")])
            else:
                items.append([r.t(f"If {subj}, now "), r.c(src), r.t(f", {verb} "), thr,
                              r.t(f"{side}, the view moves toward {dest}.")])
        if items:
            B.append(Block("bullets", "debate", items, title="What would change the view"))

    # ── valuation ──
    B.append(Block("heading", "valuation", [r.t("Intrinsic value")] + ([r.t(" · "), r.c("INTRINSIC_VALUE")] if "INTRINSIC_VALUE" in cat else [])))
    lc = s.valuation.get("lifecycle") or {}
    if "LIFECYCLE_STAGE" in cat:
        lp = [r.t("Life-cycle stage: "), r.c("LIFECYCLE_STAGE")]
        # the two growth rates are shown together only when they describe comparable businesses (compatible())
        _cmp = C.comparison(s, "LIFECYCLE_TRAILING_GROWTH", "LIFECYCLE_FORWARD_GROWTH")
        if "LIFECYCLE_TRAILING_GROWTH" in cat and (_cmp is None or _cmp[0] or "LIFECYCLE_FORWARD_GROWTH" not in cat):
            lp += [r.t(", with trailing revenue growth of "), r.c("LIFECYCLE_TRAILING_GROWTH")]
        if "LIFECYCLE_FORWARD_GROWTH" in cat:
            # named by the claim's own semantic label (its source and period): "forward revenue growth" alone read as the
            # same quantity as the engine's next-twelve-month forecast growth, a different metric
            import arguments as _A
            _fg = _A._READER["LIFECYCLE_FORWARD_GROWTH"][0].strip()
            lp += [r.t(f" and {_fg} of " if (lp[-1:] and len(lp) > 2) else f", with {_fg} of "),
                   r.c("LIFECYCLE_FORWARD_GROWTH")]
        lp += [r.t(".")]
        B.append(_para("valuation", *lp))
    if "INTRINSIC_VALUE" in cat:
        # the complete relations, rendered from their RelationshipClaims (subject, direction, size, object)
        if "REL_PRICE_INTRINSIC" in cat:
            vp = [r.c("REL_PRICE_INTRINSIC", cap=True), r.t(".")]
        else:
            vp = [r.t(f"The {iv_word} is "), r.c("INTRINSIC_VALUE"), r.t(".")]
        if "REL_PRICE_MECHANICAL" in cat and not s.valuation.get("same_model"):
            vp += [r.t(" "), r.c("REL_PRICE_MECHANICAL", cap=True), r.t(".")]
        B.append(_para("valuation", *vp))
    stories = prose.get("stories") or []
    adopted = next((x for x in stories if x.get("adopted")), None)
    if adopted and adopted.get("story"):
        risk = ([r.t(" Key risk: ")] + list(adopted["risk"]) + [r.t(".")]) if adopted.get("risk") else []
        B.append(Block("para", "valuation", list(adopted["story"]) + risk, title="The story behind the value"))
    # what today's price requires, as typed relations: each break-even figure against the story figure it measures,
    # the growth bound to its own pair (start → break-even revenue), and the computed expectations polarity
    implied = [cid for cid in ("REL_BREAK_EVEN_REVENUE_STORY", "REL_BREAK_EVEN_REVENUE_GROWTH", "REL_BREAK_EVEN_MARGIN_STORY",
                               "REL_EXPECTATIONS_STORY") if cid in cat]
    if implied:
        B.append(Block("bullets", "valuation", [[r.c(cid, cap=True), r.t(".")] for cid in implied], title="What the price implies"))
    dmi = s.valuation.get("damodaran_inputs") or {}
    if dmi.get("available") and "DMD_ERP" in cat:
        rows = []
        if "DMD_INDUSTRY" in cat:
            rows.append([[r.t("Industry (his classification)")], [r.c("DMD_INDUSTRY")]])
        if "DMD_BETA" in cat:
            basis = "business-mix" if dmi.get("business_mix") else "industry"
            rows.append([[r.t("Beta, unlevered → levered" + (" (business mix)" if basis == "business-mix" else ""))],
                         [r.c("DMD_UNLEVERED_BETA"), r.t(" → "), r.c("DMD_BETA")]])
        erp_cell = [r.c("DMD_ERP"), r.t(" (implied "), r.c("DMD_IMPLIED_ERP"), r.t(", "), r.c("DMD_ERP_MONTH")]
        if (dmi.get("crp") or 0) > 0:
            erp_cell += [r.t(", plus country risk premium "), r.c("DMD_CRP"), r.t(" for "), r.c("DMD_COUNTRY")]
        rows.append([[r.t("Equity risk premium")], erp_cell + [r.t(")")]])
        def _applicability(ind, own, own_words):
            """The industry figure beside the company's own when they differ by more than 2x either way: a benchmark
            that does not describe the business is labelled as such, never presented as its norm. ``own`` is a claim id
            or a (state path, stype, label) triple."""
            iv = getattr(cat.get(ind), "value", None)
            if isinstance(own, tuple):
                ov = s.value(own[0])
                own_frag = lambda: r.p(*own)
            else:
                ov = getattr(cat.get(own), "value", None)
                own_frag = lambda: r.c(own)
            try:
                ratio = float(ov) / float(iv) if iv and ov else None
            except (TypeError, ValueError):
                ratio = None
            if ratio is None or 0.5 <= ratio <= 2.0:
                return [r.c(ind)]
            return [r.c(ind), r.t(f" (not representative of this company: {own_words} "), own_frag(), r.t(")")]
        if "DMD_S2C" in cat:
            rows.append([[r.t("Industry sales-to-capital")],
                         _applicability("DMD_S2C", ("valuation.story.assumptions.sales_to_capital", C.RATIO, "sales-to-capital"),
                                        "the story uses")])
        if "DMD_MARGIN" in cat:
            rows.append([[r.t("Industry operating margin")],
                         _applicability("DMD_MARGIN", "STORY_START_MARGIN", "the continuing company's starting margin is")])
        if "WACC_BENCHMARK" in cat:
            rows.append([[r.t("Mature-market cost-of-capital benchmark")], [r.c("WACC_BENCHMARK")]])
        B.append(Block("table", "valuation", {"head": ["Input (Damodaran's data)", "Value"], "rows": rows},
                       title="Market and industry inputs"))
        mix = dmi.get("business_mix") or []
        if mix:
            base = "valuation.damodaran_inputs.business_mix."
            by_value = all(g.get("estimated_value") for g in mix)
            head = ["Filed segment", "Share of revenue"] + (["Industry EV/Sales", "Weight (estimated value)"] if by_value else []) + \
                   ["Industry (his)", "Unlevered beta"]
            B.append(Block("table", "valuation", {"head": head,
                                                  "rows": [[[r.t(str(g.get("name")))],
                                                            [r.p(f"{base}{i}." + ("revenue_weight" if by_value else "weight"), C.PCT, "segment share of revenue")]]
                                                           + ([[r.p(f"{base}{i}.industry_ev_sales", C.MULTIPLE, "segment industry EV/Sales")],
                                                               [r.p(f"{base}{i}.weight", C.PCT, "segment value weight")]] if by_value else [])
                                                           + [[r.t(str(g.get("industry")))], [r.p(f"{base}{i}.unlevered_beta", C.RATIO, "segment industry unlevered beta")]]
                                                           for i, g in enumerate(mix)]},
                           title="Business mix"))
    stry = s.valuation.get("story") or {}
    if stry.get("ran"):
        A = "valuation.story.assumptions."
        rows = [[[r.t("Market size at maturity")], [r.c("STORY_TAM")]],
                [[r.t("Peak market share")], [r.c("STORY_SHARE")]],
                [[r.t("Revenue at maturity")], [r.c("STORY_REVENUE_MATURITY")]],
                [[r.t("Years to maturity")], [r.c("STORY_YEARS")]],
                ] + ([[[r.t(str((s.valuation.get("story_base") or {}).get("revenue_label") or "Starting revenue").capitalize())],
                       [r.c("STORY_START_REVENUE")]]] if "STORY_START_REVENUE" in cat else []) + [
                ] + ([[[r.t(str((s.valuation.get("story_base") or {}).get("label") or "Starting operating margin").capitalize())],
                       [r.c("STORY_START_MARGIN")]]] if "STORY_START_MARGIN" in cat else []) + [
                [[r.t("Operating margin at maturity (story assumption)")], [r.c("STORY_MARGIN")]],
                [[r.t("Sales to capital")], [r.p(A + "sales_to_capital", C.RATIO, "sales-to-capital")]],
                ] + ([[[r.t("Initial cost of capital")], [r.c("STORY_WACC")]]] if "STORY_WACC" in cat else [])
        # the discount-rate path shown is the vector the valuation used (valuation.discount_rate_path)
        _dp = s.valuation.get("discount_rate_path") or {}
        if "STORY_WACC_STABLE" in cat and "STORY_WACC_HOLD" in cat:
            if abs((_dp.get("terminal_wacc") or 0) - (_dp.get("initial_wacc") or 0)) < 1e-9:
                rows.append([[r.t("Cost of capital path")], [r.t("the initial rate in every year")]])
            else:
                rows.append([[r.t("Cost of capital path")], [r.c("STORY_WACC_HOLD"), r.t(" years at the initial rate, then a straight line over "),
                                                             r.p("valuation.discount_rate_path.transition_years", C.COUNT, "transition years"),
                                                             r.t(" years to the terminal rate")]])
            rows.append([[r.t("Terminal cost of capital used")], [r.c("STORY_WACC_STABLE")]])
        _yr = _dp.get("yearly_rates") or []
        if _yr:
            cell = []
            for i in range(len(_yr)):
                cell += ([r.t(" · ")] if i else []) + [r.p(f"valuation.discount_rate_path.yearly_rates.{i}", C.PCT, f"discount rate year {i + 1}")]
            rows.append([[r.t("Discount rate by year")], cell])
        rows += [[[r.t("Terminal growth")], [r.c("STORY_TERMINAL_GROWTH")]]]
        if _dp.get("terminal_spread") is not None:
            rows.append([[r.t("Terminal cost of capital less terminal growth")],
                         [r.p("valuation.discount_rate_path.terminal_spread", C.PP, "terminal spread")]])
        rows += [[[r.t("Failure probability")], [r.c("STORY_FAILURE")]]]
        B.append(Block("table", "valuation", {"head": ["Story assumption (adopted storyteller)", "Value"], "rows": rows},
                       title="Story assumptions"))
        segs = stry.get("segments") or []
        if segs:
            SB = "valuation.story.segments."
            _a0 = stry.get("assumptions") or {}
            _capped = (_a0.get("margin_proposed") is not None and _a0.get("margin_target") is not None
                       and abs(float(_a0["margin_proposed"]) - float(_a0["margin_target"])) > 0.0005)
            B.append(Block("table", "valuation", {"head": ["Business (adopted story)", "Revenue at maturity (USD bn)",
                                                           "Operating margin at maturity" + (" (storyteller's proposal)" if _capped else ""),
                                                           "Sales to capital"],
                                                  "rows": [[[r.t(str(g.get("name")))], [r.p(f"{SB}{i}.rev_usd_b", C.RATIO, "segment revenue at maturity (USD bn)")],
                                                            ([r.p(f"{SB}{i}.margin", C.PCT, "segment maturity margin")] if g.get("margin") is not None else [r.t("n/a")]),
                                                            ([r.p(f"{SB}{i}.sales_to_capital", C.RATIO, "segment sales-to-capital")] if g.get("sales_to_capital") else [r.t("story-level")])]
                                                           for i, g in enumerate(segs)]},
                           title="Segment build"))
            _a = stry.get("assumptions") or {}
            if _a.get("margin_proposed") is not None and _a.get("margin_target") is not None \
                    and abs(float(_a["margin_proposed"]) - float(_a["margin_target"])) > 0.0005:
                _raised = _a.get("margin_floor") is not None and float(_a["margin_proposed"]) < float(_a["margin_target"])
                B.append(_para("valuation", r.t("The segments' revenue-weighted operating margin at maturity ("),
                               r.p("valuation.story.assumptions.margin_proposed", C.PCT, "segment-weighted maturity margin"),
                               r.t(") is raised to " if _raised else ") is capped at "),
                               r.p("valuation.story.assumptions.margin_target", C.PCT, "maturity operating margin used"),
                               r.t(", the maturity operating margin the valuation uses"
                                   + (": a currently profitable company is not valued below the lower of today's margin and "
                                      "the best sustained margin in its sector." if _raised else "."))))
        srows = []
        for i, x in enumerate(stry.get("stories") or []):
            srows.append([[r.t(f"Storyteller {i + 1}" + (" (adopted)" if x.get("adopted") else "")
                               + (f", {x['n_segments']} businesses" if x.get("n_segments") else ""))],
                          [r.p(f"valuation.story.stories.{i}.value", C.CURRENCY, "storyteller value")],
                          [r.p(f"valuation.story.stories.{i}.derived.margin_target", C.PCT, "storyteller maturity margin")],
                          [r.p(f"valuation.story.stories.{i}.derived.years", C.COUNT, "storyteller years")]])
        if srows:
            B.append(Block("table", "valuation", {"head": ["Storyteller", "Value", "Maturity margin", "Years"], "rows": srows},
                           title="The storytellers"))
            items = [[r.t(f"Storyteller {i + 1}: ")] + x["story"] for i, x in enumerate(stories) if x.get("story")]
            if len(items) > 1:
                B.append(Block("bullets", "valuation", items, title="Their stories"))
        div = s.valuation.get("storyteller_diversity") or {}
        if div.get("status") == "DUPLICATE_STORY":
            B.append(_para("valuation", r.t("Two storytellers used effectively identical assumptions, so the range reflects fewer "
                                            "independent views than the number of storytellers.")))
        sens = s.valuation.get("sensitivity") or []
        if sens:
            def _case(i, x):
                if x.get("driver") and x.get("shift") is not None:
                    kind = C.PP if x.get("shift_kind") == "PP" else C.PCT_SIGNED
                    return [r.t(f"{x['driver']} "), r.p(f"valuation.sensitivity.{i}.shift", kind, "sensitivity shift")]
                return [r.t(str(x.get("case")))]
            B.append(Block("table", "valuation", {"head": ["Change", "Value", "Change vs base"],
                                                  "rows": [[_case(i, x),
                                                            [r.p(f"valuation.sensitivity.{i}.value", C.CURRENCY, "sensitivity value")],
                                                            [r.p(f"valuation.sensitivity.{i}.vs_base", C.PCT_SIGNED, "change vs base")]]
                                                           for i, x in enumerate(sens)]}, title="How sensitive is the value?"))

    # ── pricing ──
    B.append(Block("heading", "pricing", [r.t("Market pricing")] + ([r.t(" · "), r.c("MARKET_COMPOSITE")] if "MARKET_COMPOSITE" in cat else [])))
    if "REL_PRICE_MARKET_COMPOSITE" in cat:
        B.append(_para("pricing", r.c("REL_PRICE_MARKET_COMPOSITE", cap=True), r.t(".")))
    meth = s.pricing.get("methods") or []
    if meth:
        B.append(Block("table", "pricing", {"head": ["Method", "Implied price", "Multiple", "Share of composite"],
                                            "rows": [[[r.t(m.get("label") or m.get("name"))],
                                                      [r.p(f"pricing.methods.{i}.price", C.CURRENCY, "method price")],
                                                      ([r.p(f"pricing.methods.{i}.target_multiple", C.MULTIPLE, "target multiple"),
                                                        r.t(_method_basis(m))]
                                                       if m.get("target_multiple") else [r.t("—")]),
                                                      [r.p(f"pricing.methods.{i}.share", C.PCT, "method share") if m.get("in_composite") else r.t("excluded")]]
                                                     for i, m in enumerate(meth)]}))
        # raw → trim → cap → final, so the published composite can be reproduced from the table (shown when a step moved it)
        steps = [[[r.t("Weighted average of the methods")], [r.c("MARKET_COMPOSITE_RAW")]]]
        _moved = (s.value("pricing.raw_composite") is not None and s.value("pricing.market_composite") is not None
                  and abs(s.value("pricing.raw_composite") - s.value("pricing.market_composite")) > 0.005)
        if s.value("pricing.post_trim_composite") is not None and s.value("pricing.raw_composite") is not None \
                and abs(s.value("pricing.post_trim_composite") - s.value("pricing.raw_composite")) > 1e-6:
            steps.append([[r.t("After dropping outlier methods")], [r.c("MARKET_COMPOSITE_POST_TRIM")]])
        if (s.pricing.get("cap") or {}).get("applied"):
            steps.append([[r.t("Bounded to "), r.p("pricing.cap.lower", C.MULTIPLE, "cap lower"), r.t("–"),
                           r.p("pricing.cap.upper", C.MULTIPLE, "cap upper"), r.t(" the price")], [r.c("MARKET_COMPOSITE")]])
        else:
            steps.append([[r.t("Market composite")], [r.c("MARKET_COMPOSITE")]])
        if _moved:
            B.append(Block("table", "pricing", {"head": ["Step", "Value"], "rows": steps}, title="How the composite is built"))
    if not pol_methods or "P/E" in pol_methods:          # an issuer whose policy excludes earnings multiples gets none
        pe = [r.t("Forward P/E on the engine's EPS forecast: "), r.c("PE_FORWARD")]   # whose EPS, never implied
        if "PE_TRAILING" in cat:
            pe += [r.t("; trailing P/E "), r.c("PE_TRAILING"), r.t(" on adjusted EPS and "), r.c("PE_TRAILING_GAAP"), r.t(" on reported EPS")]
        elif sep:
            pe += [r.t("; the company's own trailing multiples are not shown because its trailing earnings include the "
                       "separated business")]
        if "PEG" in cat:
            pe += [r.t("; forward PEG "), r.c("PEG")]
        B.append(_para("pricing", *pe, r.t(".")))
    if "ANALYST_TARGET" in cat:
        at = [r.t("Mean sell-side price target "), r.c("ANALYST_TARGET"), r.t(" ("), r.c("TARGET_VS_PRICE"), r.t(" from the price)")]
        if "ANALYST_TARGET_ADJUSTED" in cat and s.pricing.get("analyst_target_in_composite"):
            at += [r.t("; it enters the composite at "), r.c("ANALYST_TARGET_ADJUSTED")]
        B.append(_para("pricing", *at, r.t(".")))
    peers = (s.pricing.get("peers") or {}).get("peers") or []
    if peers:
        prow = []
        for i, pe in enumerate(peers[:8]):
            base = f"pricing.peers.peers.{i}."
            prow.append([[r.t(str(pe.get("ticker")))], [r.p(base + "pe", C.MULTIPLE, "peer P/E")],
                         [r.p(base + "ev_ebitda", C.MULTIPLE, "peer EV/EBITDA")], [r.p(base + "op_margin", C.PCT, "peer operating margin")],
                         [r.p(base + "rev_growth", C.PCT_SIGNED, "peer revenue growth")]])
        sj = "pricing.peers.subject."
        if (s.pricing.get("peers") or {}).get("subject"):
            prow.insert(0, [[r.t(f"{s.identity.ticker} (subject)")], [r.p(sj + "pe", C.MULTIPLE, "subject P/E (peer panel)")],
                            [r.p(sj + "ev_ebitda", C.MULTIPLE, "subject EV/EBITDA")], [r.p(sj + "op_margin", C.PCT, "subject operating margin (peer panel)")],
                            [r.p(sj + "rev_growth", C.PCT_SIGNED, "subject revenue growth (peer panel)")]])
        B.append(Block("table", "pricing", {"head": ["Company", "P/E (trailing)", "EV/EBITDA", "Operating margin", "Revenue growth"],
                                            "rows": prow}, title="Relative to peers (Sharadar panel, trailing)"))
        notes = []
        if (s.pricing.get("peers") or {}).get("subject_withheld"):
            notes.append(f"{s.identity.ticker}'s own row is not shown: {(s.pricing.get('peers') or {}).get('subject_withheld')}.")
        subj = (s.pricing.get("peers") or {}).get("subject") or {}
        wh = ([s.identity.ticker] if subj.get("multiples_withheld") else []) + \
             [str(pe.get("ticker")) for pe in peers[:8] if pe.get("multiples_withheld")]
        if wh:
            notes.append("Valuation multiples are not shown for " + ", ".join(wh) + ": the panel reports them in a different currency.")
        if notes:
            B.append(_para("pricing", r.t(" ".join(notes))))

    # ── fundamentals and forecast ──
    B.append(Block("heading", "forecast", [r.t("Fundamentals and forecast")]))
    rows = [[[r.t("Revenue")], [r.c("REVENUE_TTM")], [r.c("FORECAST_REVENUE")]],
            [[r.t("Operating margin")], [r.c("OPERATING_MARGIN_TTM")], [r.c("FORECAST_OPERATING_MARGIN")]],
            [[r.t("Net margin")], [r.c("NET_MARGIN_TTM")], [r.c("FORECAST_NET_MARGIN")]],
            [[r.t("EPS (adjusted basis)")], [r.c("EPS_ADJUSTED_TTM")], [r.c("FORECAST_EPS")]]]
    _hq, _fl = historical_quality_render(historical_quality(s), bool(sep)), forecast_lineage_render(s, bool(sep))
    B.append(Block("table", "forecast", {"head": ["", _hq["table_header"], _fl["table_header"]], "rows": rows}, css="rp-fund"))
    B.append(_para("forecast", r.t(_hq["footnote"]), css="rp-foot"))
    B.append(_para("forecast", r.t(_fl["footnote"]), css="rp-foot"))
    if sep:
        B.append(_para("forecast", r.t(f"The trailing figures include a business separated from the company on {sep.get('effective')}. "
                                       "The forecast and sell-side estimates describe the continuing company, so the two columns "
                                       "describe different businesses: no growth rate is computed between them.")))
    fc = (s.forecast.get("coherence") or {}).get("status")
    words = {"CONSISTENT": "The revenue, earnings and operating-income models agree.",
             "DIVERGENT": "The independent operating-income and net-income models diverge from the reconciled forecast; confidence is reduced.",
             "CONFLICTING": "The independent operating-income and net-income models conflict with the reconciled forecast; treat the margin path as uncertain.",
             "NOT_VERIFIABLE": "The forecast components could not be cross-checked against each other."}.get(fc)
    if not sep and not s.issuer.get("is_financial") and fc in ("DIVERGENT", "CONFLICTING", "NOT_VERIFIABLE"):
        B.append(_para("forecast", r.t(words)))          # a caveat only; agreement needs no sentence
    if "CONSENSUS_EPS_FY1" in cat and "REL_FORECAST_EPS_CONSENSUS" in cat:
        B.append(_para("forecast", r.t("Sell-side consensus EPS is "), r.c("CONSENSUS_EPS_FY1"), r.t(" for the current fiscal year ("),
                       r.c("CONSENSUS_ANALYSTS"), r.t(" contributors). "), r.c("REL_FORECAST_EPS_CONSENSUS", cap=True), r.t(".")))
    elif "CONSENSUS_EPS_FY1" in cat:
        B.append(_para("forecast", r.t("Sell-side consensus EPS is "), r.c("CONSENSUS_EPS_FY1"), r.t(" for the current fiscal year ("),
                       r.c("CONSENSUS_ANALYSTS"), r.t(" contributors). "
                                                      + consensus_absence_sentence(s, "REL_FORECAST_EPS_CONSENSUS", "EPS"))))
    if "REL_ENGINE_REVENUE_CONSENSUS" in cat:
        B.append(_para("forecast", r.c("REL_ENGINE_REVENUE_CONSENSUS", cap=True), r.t(".")))
    # company guidance against consensus: only as its typed relation (same fiscal period and horizon, compatible basis)
    if "REL_GUIDANCE_EPS_CONSENSUS" in cat:
        B.append(_para("forecast", r.c("REL_GUIDANCE_EPS_CONSENSUS", cap=True), r.t(".")))
    # company guidance (its own release): company-level rows of the headline concepts, each value a verified claim
    g = s.guidance.get("company") or {}
    if g.get("status") == "AVAILABLE":
        # EVERY verified company-level row: a guidance value an author cites must also be in the table
        rows_g = guidance_table_rows(s, cat)
        if rows_g:
            src = g.get("source") or {}
            # one row per guided figure: a GAAP and a non-GAAP row with the same label, period and value are one fact
            # printed once with both bases (the table repeated four such pairs)
            merged = {}
            for i, m in rows_g:
                _cl = cat.get(f"GUIDANCE_{i}")
                key = (str(m.get("label")), str(m.get("period_label")), str(_cl.rendered) if _cl is not None else f"#{i}")
                merged.setdefault(key, (i, m, []))[2].append(
                    {"GAAP": "GAAP", "ADJUSTED": "non-GAAP"}.get(m.get("accounting_basis"), "—"))
            rows_out = []
            for (_lab, _per, _v), (i, m, bases) in merged.items():
                bs = [b for b in dict.fromkeys(bases) if b != "—"]
                rows_out.append([[r.t(str(m.get("label")))], [r.t(str(m.get("period_label")))], [r.c(f"GUIDANCE_{i}")],
                                 [r.t(" and ".join(bs) if bs else "—")]])
            B.append(Block("table", "forecast", {"head": ["Company guidance", "Period", "Guided", "Basis"], "rows": rows_out},
                           title=f"Company guidance ({src.get('form')} filed {src.get('filed')})"))
    ins = s.insiders.get("canon") or {}
    if ins.get("characterization"):
        B.append(_para("forecast", r.t("Insider transactions (SEC Form 4): "), r.c("INSIDER_CHARACTERIZATION"), r.t(".")))
    if s.ownership.get("status") == "CURRENT" and "OWN_13F_TRACKED" in cat:
        prev = [r.t(", against "), r.c("OWN_13F_HOLDERS_PREV"), r.t(" the quarter before")] if "OWN_13F_HOLDERS_PREV" in cat else []
        B.append(_para("forecast", r.t("Institutional ownership (SEC Form 13F, quarter ended "), r.c("OWN_13F_AS_OF"), r.t("): "),
                       r.c("OWN_13F_HOLDERS"), r.t(" institutions reported a position"), *prev, r.t(". "), r.c("OWN_13F_TRACKED"), r.t(" tracked long-term investors hold the stock, including "),
                       r.c("OWN_13F_NAMES"), r.t("; last quarter "), r.c("OWN_13F_NEW"), r.t(" opened a position, "), r.c("OWN_13F_ADDS"),
                       r.t(" added, "), r.c("OWN_13F_TRIMS"), r.t(" trimmed and "), r.c("OWN_13F_EXITS"),
                       r.t(" exited.")))
    if (s.leadership.get("roles") or {}).get("CEO"):
        B.append(_para("forecast", r.t("Chief executive: "), r.c("CEO"), r.t(".")))

    # ── blend ──
    B.append(Block("heading", "blend", [r.t("What drives the headline")]))
    src = s.blend.get("sources") or []
    if src:
        B.append(Block("table", "blend", {"head": ["Source", "View", "Share of the blend"],
                                          "rows": [[[r.t(str(x.get("label") or x.get("name")))],
                                                    [r.p(f"blend.sources.{i}.return", C.PCT_SIGNED, "source view")],
                                                    [r.p(f"blend.sources.{i}.share", C.PCT, "source share")]] for i, x in enumerate(src)]}))
    for i, a in enumerate(s.blend.get("adjustments") or ()):
        B.append(_para("blend", r.t(f"Adjustment applied to the weighted sum — {a.get('name')}: ×"),
                       r.p(f"blend.adjustments.{i}.factor", C.RATIO, "blend adjustment factor"), r.t(".")))
    exc = [str(x.get("name")) for x in (s.blend.get("excluded_sources") or ())
           if x.get("name") and x.get("kind") != "PRICE_MODEL"]       # the ML lines below cover the price model
    if exc:
        B.append(_para("blend", r.t("Carrying no weight in this analysis: " + ", ".join(exc) + ".")))
    B += _ml_paragraphs(s, r, cat)

    # ── limitations and sources (plain language only) ──
    B.append(Block("heading", "limits", [r.t("Sources and limitations")]))
    if limitations:
        B.append(Block("bullets", "limits", [[r.t(x)] for x in limitations]))
    B.append(_para("limits", r.t(f"Statements through {s.historical.get('statements_as_of')} · price as of {s.market.get('price_as_of')} · "
                                 "not investment advice.")))
    B.append(_para("limits", r.t("Analysis version "), Fragment(s.version, "VERSION", s.version), r.t("."), css="version"))

    claims = {cid: cl.to_dict() for cid, cl in r.used.items()}
    # claims that arrived inside ingested prose are registered too: every rendered claim is listed
    for b in B:
        for f in b.fragments():
            if f.claim_id and f.claim_id not in claims and f.claim_id in cat:
                claims[f.claim_id] = cat[f.claim_id].to_dict()
    claims["VERSION"] = {"claim_id": "VERSION", "path": "version", "stype": "TEXT", "label": "analysis version",
                         "value": s.version, "rendered": s.version}
    return UserReport(s.identity.ticker, s.identity.get("company_name"), s.identity.get("as_of"), s.version, B, claims, limitations,
                      arguments=dict(prose.get("arguments") or {}))


# blend source labels come from the typed BlendSource kind (state_builder.BLEND_KIND_LABEL), never from a legacy name


def _ml_paragraphs(s, r, cat):
    """Machine learning, in plain words. Two models are checked on every run; each counts only as far
    as its out-of-sample record allows. A model that is not admitted is described without its figures."""
    pm = (s.quant.get("price_model") or {}).get("reliability") or {}
    pr = (s.quant.get("panel_rank") or {}).get("reliability") or {}
    out = []
    if pr.get("persona_visibility") and "ML_RANK_SHARE" in cat:
        out.append(_para("blend", r.t("Machine-learning rank: above "), r.c("ML_RANK_SHARE"), r.t(" of the "), r.c("ML_RANK_UNIVERSE"),
                         r.t(" largest US companies (decile "), r.c("ML_RANK_DECILE"), r.t("); walk-forward rank correlation "),
                         r.c("ML_RANK_IC"), r.t(". Weak evidence only.")))
    elif pr.get("state") == "UNRELIABLE":
        why = ("failed in the most recent test period" if pr.get("failed_recently") else "out-of-sample record too weak")
        out.append(_para("blend", r.t(f"Machine-learning rank: not used ({why}).")))
    if pm.get("state") in ("LIMITED", "VALIDATED"):
        out.append(_para("blend", r.t("Machine-learning price model: " + ("used with a capped share (limited out-of-sample record)."
                                                                          if pm.get("state") == "LIMITED" else "used (validated out of sample)."))))
    elif pm.get("state") in ("UNRELIABLE", "UNAVAILABLE"):
        out.append(_para("blend", r.t("Machine-learning price model: not used ("
                                      + ("direction calls no better than chance)." if pm.get("state") == "UNRELIABLE"
                                         else "not enough history to test)."))))
    return out


# ═══════════════════════════════════════════════════════════════════════════════════════
# serialisation: both formats walk the same blocks
# ═══════════════════════════════════════════════════════════════════════════════════════
def _frag_md(f):
    return f.text


def _frag_html(f):
    t = _html.escape(f.text)
    return f'<span class="cl" data-claim="{_html.escape(f.claim_id)}">{t}</span>' if f.claim_id else t


def render_markdown(rep: UserReport):
    L = []
    for b in rep.blocks:
        if b.kind == "heading":
            L.append(("# " if b.section == "verdict" else "## ") + "".join(_frag_md(f) for f in b.content))
        elif b.kind in ("para", "callout"):
            txt = "".join(_frag_md(f) for f in b.content)
            if b.title:
                L.append(f"*{b.title}*")
            L.append(f"**{txt}**" if b.css in ("verdict", "seat-head") else txt)
        elif b.kind in ("bullets", "points"):          # the download keeps every point in full
            if b.title:
                L.append(f"**{b.title}**")
            L.extend("- " + "".join(_frag_md(f) for f in item) for item in b.content)
        elif b.kind == "tiles":
            for row in b.content.get("rows", []):
                lab, val, note = ("".join(_frag_md(f) for f in cell) for cell in (row + [[], [], []])[:3])
                L.append(f"- **{lab}:** {val}" + (f" ({note})" if note else ""))
        elif b.kind == "table":
            if b.title:
                L.append(f"**{b.title}**")
            head = b.content.get("head", [])
            L.append("| " + " | ".join(head) + " |")
            L.append("|" + "---|" * len(head))
            for row in b.content.get("rows", []):
                L.append("| " + " | ".join("".join(_frag_md(f) for f in cell) for cell in row) + " |")
        L.append("")
    return "\n".join(L).strip() + "\n"


# ── structured arguments (engine/arguments.py): view first, every figure rendered from its typed claim ──────────
HEAD_END = "head_end"                     # sentinel fragment origin: headline before it, evidence after it
_SEAT_NAMES = {"neutral": "neutral analyst", "bear": "bear", "bull": "bull"}


def _authored(text):
    return Fragment(str(text), origin="authored")


QUOTE = "quote"                          # an authored text quoted by another author (a rebuttal quotes its target)


def trigger_subject(s, cat, cid):
    """The reader's name for the quantity a trigger watches: its source claim's label (a guidance row by its own label,
    period and basis). None when the claim is not in the catalog."""
    cl = cat.get(cid) if cid else None
    if cl is None or cl.rendered is None:
        return None
    if getattr(cl, "stype", None) == C.GUIDANCE:
        # the realised metric named by the guidance row's own label — the same words the guidance table prints, so a
        # scope qualifier ("excluding OSTTRA") is never lost (the concept alone labelled the ex-OSTTRA range as the plain
        # adjusted margin change, run be9ce3de408e3216) — with its period and basis, each written once
        import guidance as _G
        v = cl.value if isinstance(cl.value, dict) else {}
        rows = ((s.guidance.get("company") or {}).get("metrics")) or []
        try:
            label = str((rows[int(str(cid).split("_")[1]) - 1] or {}).get("label") or "").strip()
        except (ValueError, IndexError):
            label = ""
        label = label or _G.concept_label(v.get("concept"))
        basis = C.guidance_basis_label(cat, cid)
        basis = None if (basis and basis.lower() in label.lower()) else basis
        qual = ", ".join(x for x in (v.get("period_label"), basis) if x)
        return label + (f" ({qual})" if qual else "")
    label = re.sub(r"\s*\((?:complete relation|[^)]*form:[^)]*)\)", "", str(cl.label or "")).strip()
    import arguments as _A
    note = _A._perimeter_note(s) if cid in _A._TRAILING else None       # defensive: typed triggers refuse these sources
    return (("the " + label) + (f" ({note})" if note else "")) if label else None


def _rels(s):
    return {rid: {"lhs": x.get("lhs_claim_id"), "rhs": x.get("rhs_claim_id"), "magnitude_claim": x.get("presentation_claim_id"),
                  "type": x.get("relation_type")}
            for rid, x in (((s.get("relations.state") or {}).get("items")) or {}).items()}


def _arg_item(r, s, cat, args, p, head=None):
    """One structured point / item: [head…, view, HEAD_END, " — ", evidence…]. Evidence the report has already printed
    in full is printed compactly (the report's evidence registry)."""
    import arguments as A
    r.authored_seen.add(p["view"])
    frs = list(head or []) + [_authored(p["view"]), Fragment("", origin=HEAD_END)]
    ev = A.render_evidence(r, p.get("evidence") or [], s, cat, _rels(s), seen=r.seen_evidence)
    return frs + ([Fragment(" — ")] + ev if ev else [])


def _fresh(r, text):
    """True the first time an authored text is printed in this report (a text is never printed twice)."""
    return bool(text) and text not in r.authored_seen


def _admit(r, objs):
    """The objects whose text has not been printed yet, each registered as it is admitted (two identical views in
    one list print once)."""
    out = []
    for p in objs or []:
        if _fresh(r, p.get("view")):
            r.authored_seen.add(p["view"])
            out.append(p)
    return out


def _stance_text(role, stance, label):
    who = _SEAT_NAMES.get(role, role)
    if stance == "REVISED_LABEL":
        return f"The {who} changes its rating after the exchange, to {label}." if label else f"The {who} changes its rating after the exchange."
    if stance == "REVISED_RETURN":
        return f"The {who} revises its return after the exchange; its rating stays {label}." if label else \
            f"The {who} revises its return after the exchange."
    return f"The {who} holds its case."


def _empty_seat_note(s, role):
    """A seat with no publishable reasoning: whose the return and the probability are, and what the portfolio manager
    knew when it set the probability (scenarios.pm_evidence_state, recorded before the PM round)."""
    st = (s.scenarios.get("pm_evidence_state") or {}).get(role) or {}
    told = ", who was told this seat had no accepted supporting arguments" if st.get("accepted") == 0 else ""
    return ("This analyst's written reasoning is not shown: none of it met the report's evidence standards. The scenario "
            f"return is the analyst's own; its probability was assigned by the portfolio manager{told}.")


def _structured_debate(B, r, s, cat, args, seats, order, _notes):
    """The debate in round order: opening cases, rebuttals (each answer quotes the point it answers), the decision."""
    import arguments as A

    def opening(role):
        o = args.get(f"{role}_open")
        if o is None and args.get(role) is not None and "answers" not in args[role]:
            o = args[role]
        return o

    for role, name, ret_id, p_id in order:
        if role not in seats and opening(role) is None:
            continue
        B.append(_para("debate", r.t(f"{name} — "), r.c(ret_id),
                       r.t(f" · {(s.scenarios.get(role) or {}).get('label') or ''} · probability "), r.c(p_id), css="seat-head"))
        o = opening(role)
        if o is not None:
            th = o.get("thesis") if _fresh(r, o.get("thesis")) else None
            if th:
                r.authored_seen.add(th)
            pts = _admit(r, o.get("points"))
            if th:
                B.append(Block("para", "debate", [_authored(th)], title="Opening case" if role != "neutral" else None))
            if pts:
                B.append(Block("points", "debate", [_arg_item(r, s, cat, args, p) for p in pts],
                               title=None if th else ("Opening case" if role != "neutral" else None)))
            if not o.get("thesis") and not o.get("points"):
                B.append(_para("debate", r.t(_empty_seat_note(s, role))))
            elif not th and not pts:
                B.append(_para("debate", r.t("This analyst's validated points are printed above.")))
        else:                                             # a seat that ignored the schema: its legacy prose
            leg = seats.get(f"{role}_open") or seats.get(role) or {}
            if leg.get("reasoning") or leg.get("key_points"):
                _notes(leg)
            else:
                B.append(_para("debate", r.t(_empty_seat_note(s, role))))
    replies = [(role, opp) for role, opp in (("bear", "bull"), ("bull", "bear"))
               if (args.get(role) or {}).get("answers") is not None and f"{role}_open" in args]
    for role, opp in replies:
        a = args[role]
        B.append(_para("debate", r.t(f"The {role} replies to the {opp}"), css="seat-head"))
        B.append(_para("debate", r.t(_stance_text(role, a.get("stance"), (s.scenarios.get(role) or {}).get("label")))))
        if _fresh(r, a.get("thesis")):
            r.authored_seen.add(a["thesis"])
            B.append(Block("para", "debate", [_authored(a["thesis"])]))
        items = []
        for x in a.get("answers") or []:
            if not _fresh(r, x.get("view")):
                continue
            head = [Fragment(f"On the {opp}'s point “"), Fragment(str(x["to_view"]), origin=QUOTE),
                    Fragment(f"” — {A.VERDICT_CHIP.get(x['verdict'], x['verdict'])}: ")]
            items.append(_arg_item(r, s, cat, args, x, head=head))
        if items:
            B.append(Block("points", "debate", items))
    pmx = args.get("pm")
    B.append(_para("debate", r.t("Portfolio manager — probability-weighted "), r.c("PM_RETURN"),
                   r.t(f" · {s.scenarios.get('pm_label') or ''}"), css="seat-head"))
    # the decision is always stated from its typed claims, whatever authored text survived
    if all(c in cat for c in ("SCENARIO_BEAR_PROB", "SCENARIO_NEUTRAL_PROB", "SCENARIO_BULL_PROB", "PM_RETURN_PHRASE")):
        B.append(_para("debate", r.t("The portfolio manager weights the bear case at "), r.c("SCENARIO_BEAR_PROB"),
                       r.t(", the neutral case at "), r.c("SCENARIO_NEUTRAL_PROB"), r.t(" and the bull case at "),
                       r.c("SCENARIO_BULL_PROB"), r.t(", for "), r.c("PM_RETURN_PHRASE"), r.t(".")))
    if pmx and (pmx.get("thesis") or pmx.get("points")):
        if _fresh(r, pmx.get("thesis")):
            r.authored_seen.add(pmx["thesis"])
            B.append(Block("para", "debate", [_authored(pmx["thesis"])]))
        pts = _admit(r, pmx.get("points"))
        if pts:
            B.append(Block("points", "debate", [_arg_item(r, s, cat, args, p) for p in pts], title="Deciding factors"))
    elif (seats.get("pm") or {}).get("reasoning") or (seats.get("pm") or {}).get("key_points"):
        _notes(seats["pm"])


def guidance_table_rows(s, cat):
    """[(n, row)] of the company-level guidance rows the report prints (segment-named rows are left out). A guidance
    value an author cites must be one of these."""
    g = s.guidance.get("company") or {}
    if g.get("status") != "AVAILABLE":
        return []
    segn = {str(x).lower() for sg in ((s.segments.get("canonical") or {}).get("segments") or [])
            for x in [sg.get("name")] + [p.get("name") for p in sg.get("product_lines") or []] if x}
    return [(i, m) for i, m in enumerate(g.get("metrics") or [], 1)
            if f"GUIDANCE_{i}" in cat and not any(n and n in str(m.get("label", "")).lower() for n in segn)]


def _split_topic(frags):
    """("Topic", rest) for a key point written as "Topic: point" (the analysts' format), else (None, frags)."""
    if frags and not frags[0].claim_id:
        t = frags[0].text
        k = t.find(": ")
        if 0 < k <= 80 and (t[k + 2:].strip() or len(frags) > 1):
            return t[:k], ([Fragment(t[k + 2:])] if t[k + 2:] else []) + list(frags[1:])
    return None, frags


OPEN_SECTIONS = ("verdict", "summary")     # shown in full; every other section is a collapsed, expandable panel
HEADLINE_SECTIONS = ("valuation",)        # inside these, every titled part shows its title and opens on a tap


def render_html(rep: UserReport):
    H = [f'<article class="crucible-report" data-version="{_html.escape(rep.version)}">']
    cur, fold = None, False
    for b in rep.blocks:
        if b.section != cur:
            if cur is not None:
                H.append("</details>" if fold else "</section>")
            fold = b.section not in OPEN_SECTIONS and b.kind == "heading"
            H.append(f'<details class="rp-sec rp-more rp-{b.section}">' if fold else f'<section class="rp-sec rp-{b.section}">')
            cur = b.section
        if b.kind == "heading":
            tag = "h1" if b.section == "verdict" else "h2"
            inner = f"<{tag}>" + "".join(_frag_html(f) for f in b.content) + f"</{tag}>"
            H.append(f"<summary>{inner}</summary>" if (fold and tag == "h2") else inner)
        elif b.kind == "points":
            if b.title:
                H.append(f"<h3>{_html.escape(b.title)}</h3>")
            items = []
            for item in b.content:
                k = next((i for i, f in enumerate(item) if f.origin == HEAD_END), None)
                if k is not None:                    # structured: the view is the headline, the evidence opens on a tap
                    hh = "".join(_frag_html(f) for f in item[:k])
                    rest = [f for f in item[k + 1:]]
                    if rest and not rest[0].claim_id and rest[0].text.strip() == "—":
                        rest = rest[1:]
                    body = "".join(_frag_html(f) for f in rest)
                    items.append(f"<li><details><summary>{hh}</summary><p>{body}</p></details></li>" if body.strip()
                                 else f"<li>{hh}</li>")
                    continue
                topic, rest = _split_topic(item)
                if topic:
                    items.append(f'<li><details><summary>{_html.escape(topic)}</summary><p>'
                                 + "".join(_frag_html(f) for f in rest) + "</p></details></li>")
                else:
                    items.append("<li>" + "".join(_frag_html(f) for f in item) + "</li>")
            H.append('<ul class="rp-points">' + "".join(items) + "</ul>")
        elif b.kind == "tiles":
            cells = []
            for row in b.content.get("rows", []):
                lab, val, note = ("".join(_frag_html(f) for f in cell) for cell in (row + [[], [], []])[:3])
                cells.append(f'<div class="rp-tile"><div class="rp-tile-l">{lab}</div><div class="rp-tile-v">{val}</div>'
                             f'<div class="rp-tile-n">{note}</div></div>')
            H.append('<div class="rp-tiles">' + "".join(cells) + "</div>")
        elif b.kind in ("para", "callout", "bullets", "table") and b.title and b.section in HEADLINE_SECTIONS:
            H.append(f'<details class="rp-sub"><summary>{_html.escape(b.title)}</summary>')
            H.append(_block_body(b))
            H.append("</details>")
        elif b.kind in ("para", "callout"):
            if b.title:
                H.append(f'<h4 class="rp-round">{_html.escape(b.title)}</h4>')
            H.append(_block_body(b))
        elif b.kind == "bullets":
            if b.title:
                H.append(f"<h3>{_html.escape(b.title)}</h3>")
            H.append(_block_body(b))
        elif b.kind == "table":
            if b.title:
                H.append(f"<h3>{_html.escape(b.title)}</h3>")
            H.append(_block_body(b))
    if cur is not None:
        H.append("</details>" if fold else "</section>")
    H.append("</article>")
    return "\n".join(H)


def _block_body(b):
    """A paragraph, list or table without its title."""
    if b.kind in ("para", "callout"):
        return f'<p class="{b.css or ""}">' + "".join(_frag_html(f) for f in b.content) + "</p>"
    if b.kind == "bullets":
        return "<ul>" + "".join("<li>" + "".join(_frag_html(f) for f in item) + "</li>" for item in b.content) + "</ul>"
    if b.kind == "table":
        heads = b.content.get("head", [])
        head = "".join(f"<th>{_html.escape(h)}</th>" for h in heads)
        # each cell carries its column name, so a phone can show a row as a labelled card
        rows = "".join("<tr>" + "".join(f'<td data-label="{_html.escape(heads[j] if j < len(heads) else "")}">'
                                        + "".join(_frag_html(f) for f in cell) + "</td>" for j, cell in enumerate(row)) + "</tr>"
                       for row in b.content.get("rows", []))
        kv = " rp-kv" if len(b.content.get("head", [])) == 2 else ""        # label/value tables stack on phones
        css = f" {b.css}" if b.css else ""
        return f'<div class="rp-scroll"><table class="rp-t{kv}{css}"><thead><tr>{head}</tr></thead><tbody>{rows}</tbody></table></div>'
    return ""


def _method_basis(m):
    """What a composite multiple is applied to, and the basis it was set on: a target multiple set on a trailing basis
    and applied to next-twelve-month figures is not the company's trailing multiple."""
    raw = str(m.get("raw_input_basis") or "").lower()
    period = ("next-12-month " if ("ntm" in raw or "next" in raw) else "forecast " if "forecast" in raw
              else "trailing " if ("ttm" in raw or "trailing" in raw) else "")
    noun = ("adjusted EPS" if "adjusted eps" in raw else "EPS" if "eps" in raw else "free cash flow per share" if "fcf" in raw
            else "revenue per share" if "revenue" in raw else "ebitda" if "ebitda" in raw else None)
    set_on = {"TRAILING_ADJUSTED": "trailing", "TRAILING": "trailing", "TRAILING_GAAP": "trailing",
              "FORWARD_ADJUSTED": "forward"}.get(m.get("multiple_basis"))
    if noun is None:
        return _BASIS_WORDS.get(m.get("multiple_basis"), "")
    # whose figure: the engine's reconciled forecast (the P/E leg applies the multiple to the engine's EPS, 7.8% above
    # consensus in the SPGI runs, which a reader could not tell from "next-12-month adjusted EPS")
    whose = "the engine's " if ("reconciledforecaststate" in raw or raw.startswith("forecast")) else ""
    return f" on {whose}{period}{noun}" + (f" (multiple set on a {set_on} basis)" if set_on and not period.startswith(set_on) else "")


_BASIS_WORDS = {"TRAILING_ADJUSTED": " trailing, on adjusted EPS", "FORWARD_ADJUSTED": " forward, on adjusted EPS",
                "TRAILING": " trailing", "TRAILING_GAAP": " trailing, on reported EPS"}
_SPAN = re.compile(r'<span class="cl" data-claim="([^"]+)">(.*?)</span>')
# internal QA vocabulary. A check id (A14, S3, F6 …) counts only next to QA words: "A14" is a TSMC process node and
# "S3" an AWS service, so a bare id in persona prose is ordinary text
_QA_ID = r"[ADFXRS]\d{1,2}"
QA_VOCAB = re.compile(r"\bSEMANTIC_[A-Z_]+|\bFINAL_RENDER\w*|publication[- ](?:blocked|ready)|\bStage [AB]\b|"
                      r"\bvalidator\b|(?-i:\bPASS\b|\bFAIL\b)|NOT_APPLICABLE|"
                      rf"\b(?:check|checks|gate|audit|rule|validation)\s+{_QA_ID}\b|"
                      rf"\b{_QA_ID}\s+(?:check|gate|audit|passed|failed|warning|blocked|blocking)\b", re.I)


# ═══════════════════════════════════════════════════════════════════════════════════════
# REPORT_VALIDATED: the audit runs on the delivered fragments and bytes
# ═══════════════════════════════════════════════════════════════════════════════════════
_IDENT = IDENTIFIER


def audit(rep: UserReport, state, md: str, html_doc: str, quant_hidden_values=()):
    from validation import FAIL, NA_, PASS, CheckResult, ValidationResult, _run  # noqa: F401
    frags = rep.fragments()

    def numbers():
        bad = [f.text for f in frags if not f.claim_id and MATERIAL_NUMBER.search(BENIGN.sub(" ", f.text))]
        return (FAIL, f"{len(bad)} material number(s) outside a claim: {bad[:3]}") if bad else \
            (PASS, f"every material number is one of {len(rep.claims)} registered claims")

    def values():
        bad = []
        for f in frags:
            if not f.claim_id or f.claim_id == "VERSION":
                continue
            cd = C.claim_def(state, f.claim_id)
            if cd is not None:
                want = C.claim_for(state, cd).rendered
            elif f.claim_id.startswith("P:"):
                want = C.path_claim(state, f.claim_id[2:], rep.claims[f.claim_id]["stype"], "").rendered
            else:
                bad.append(f"{f.claim_id}: unknown claim")
                continue
            if want != f.text and not (cd is not None and (cd.stype == C.REL or cd.clause) and want and f.text == want[0].upper() + want[1:]):
                bad.append(f"{f.claim_id}: shows '{f.text}', state gives '{want}'")
        vf = [f for f in frags if f.claim_id == "VERSION"]
        if not vf or any(f.text != state.version for f in vf):
            bad.append("the document does not carry the frozen state's version")
        return (FAIL, "; ".join(bad[:4])) if bad else (PASS, "every claim fragment equals its value in the frozen state")

    def identity():
        from prose import label_violations
        bad = []
        for unit in (u for b in rep.blocks for u in b.units()):
            bad += [f"{cid} labelled '{w}'" for cid, w in
                    label_violations(unit, lambda c: getattr(C.claim_def(state, c), "prohibited", ()))]
        return (FAIL, "; ".join(bad[:4])) if bad else (PASS, "no claim carries a label its type forbids")

    def terminology():
        terms = (state.valuation.get("engine") or {}).get("forbidden_renderer_terms") or []
        bad = set()
        for unit in (u for b in rep.blocks for u in b.units()):
            for sent in re.split(r"(?<=[.!?])\s+", "".join(f.text for f in unit).lower()):
                for t in terms:
                    # a sentence that states the method does not apply is not using it
                    if re.search(r"\b" + re.escape(t.lower()) + r"(?![a-z])", sent) and not DENIAL.search(sent):
                        bad.add(t)
        return (FAIL, f"forbidden valuation terms present: {sorted(bad)}") if bad else (PASS, "valuation vocabulary is the selected engine's")

    def no_qa():
        text = "".join(f.text for f in frags if not f.claim_id)
        m = QA_VOCAB.search(text)
        return (FAIL, f"internal QA vocabulary in the user report: '{m.group(0)}'") if m else (PASS, "no internal QA vocabulary")

    def quant_hidden():
        leaks = []
        pm_vis = (((state.quant.get("price_model") or {}).get("reliability") or {}).get("persona_visibility"))
        pr_vis = (((state.quant.get("panel_rank") or {}).get("reliability") or {}).get("persona_visibility"))
        for f in frags:
            if f.claim_id and f.claim_id.startswith("P:quant.") and not (pm_vis if "price_model" in f.claim_id else pr_vis):
                leaks.append(f.claim_id)
        for i, src in enumerate(state.blend.get("sources") or ()):
            if src.get("kind") == "PRICE_MODEL" and not pm_vis:
                leaks.append(f"blend source {src.get('name')}")
        prose_text = "".join(f.text for f in frags if not f.claim_id)
        leaks += [v for v in quant_hidden_values if v and v in prose_text]
        return (FAIL, f"an unreliable model's figures appear: {leaks}") if leaks else (PASS, "unreliable models contribute no user-visible figure")

    def same_bytes():
        spans = _SPAN.findall(html_doc)
        want = [(f.claim_id, _html.escape(f.text)) for f in frags if f.claim_id]
        if spans != want:
            return FAIL, f"HTML claim spans ({len(spans)}) differ from the report fragments ({len(want)})"
        if render_markdown(rep) != md:
            return FAIL, "markdown is not the serialisation of the audited fragments"
        return PASS, f"HTML and markdown carry the same {len(want)} claim fragments"

    def required():
        need = ["RATING", "HEADLINE_RETURN", "PRICE", "PM_RETURN", "VERSION"]
        need += [cid for cid, path in (("MARKET_COMPOSITE", "pricing.market_composite"), ("FORECAST_EPS", "forecast.eps"))
                 if state.value(path) is not None]
        if state.value("valuation.intrinsic_value") is not None:
            need.append("INTRINSIC_VALUE")
        if state.value("consensus.eps_fy1") is not None:
            need.append("CONSENSUS_EPS_FY1")
        have = {f.claim_id for f in frags if f.claim_id}
        miss = [x for x in need if x not in have]
        return (FAIL, f"required claims not rendered: {miss}") if miss else (PASS, f"{len(need)} required claims rendered")

    # ── S9–S12: the semantic permissions hold in the delivered text, not only at ingestion ──
    from prose import guidance_violation, motive_violation, qualifier_violation, SENT

    def _sentences():
        for b in rep.blocks:
            for unit in b.units():
                if any(f.origin in ("authored", HEAD_END, QUOTE) for f in unit):
                    continue          # structured arguments: audited by STRUCTURED_ARGUMENTS with the typed validators
                cur = []
                for f in unit:
                    cur.append(f)
                    if not f.claim_id and re.search(r"[.!?](\s|$)", f.text):
                        yield cur
                        cur = []
                if cur:
                    yield cur

    def guidance_evidence():
        bad = ["".join(f.text for f in u)[:120] for u in _sentences() if guidance_violation(u)]
        g = (state.guidance.get("company") or {}).get("status")
        return (FAIL, f"guidance described without a guidance claim: {bad[:2]}") if bad else \
            (PASS, f"company guidance {g}; every mention cites a verified guidance claim")

    def _motive(kind):
        bad = []
        for u in _sentences():
            mv = motive_violation("".join(f.text for f in u))
            if mv and mv[0] == kind:
                bad.append("".join(f.text for f in u)[:120])
        return (FAIL, f"motive attributed to {kind} filings: {bad[:2]}") if bad else (PASS, f"no motive inferred from {kind} filings")

    def _pairs_ok():
        from prose import implicit_comparison_violation, ordering_violation
        _cat = C.build_catalog(state, include_deferred=False)
        cp, ds = C.implicit_counterparts(state, _cat), C.claim_descriptors(state, _cat)
        bad = []
        for u in _sentences():
            tok = "".join(f"[[{f.claim_id}]]" if (f.claim_id and not f.claim_id.startswith("P:")) else f.text for f in u)
            cv = implicit_comparison_violation(tok, cp, ds) or ordering_violation(tok, lambda c: C.claim_range(state, c))
            if cv:
                bad.append(f"{cv[0]} vs {cv[1]}: {'; '.join(cv[2])[:80]}")
        return (FAIL, f"incompatible comparisons in the report: {bad[:2]}") if bad else (PASS, "no stated ordering contradicts the values")

    # ── structural relation / evidence / epistemic checks on what the authors wrote (origin = llm) ──
    import evidence as _EV
    import guidance as _GD
    import prose as _P
    import relations as _RL
    _rs = state.relations.get("state") or {}
    _pairs = _RL.pair_index(_rs)
    _R = _P.Rules(catalog=C.build_catalog(state, include_deferred=True), pair_fn=lambda a, b: C.pair_status(state, a, b, _pairs),
                  magnitude_bindings=_RL.magnitude_bindings(_rs), claim_model=_EV.claim_model,
                  evidence_targets=lambda c: _EV.evidence_targets(state, c),
                  guidance_terms={c: (_GD.epistemic_state(state.guidance.get("company"), c), n) for c, n in _GD.CONCEPT_TERMS.items()},
                  expectations_polarity=((_rs.get("items") or {}).get("REL_EXPECTATIONS_STORY") or {}).get("relation_type"))
    import pipeline as _PL
    for _k, _v in _PL.REL_RULES(state, _R.catalog).items():
        setattr(_R, _k, _v)

    def _authored():
        for u in _sentences():
            if any(getattr(f, "origin", "system") == "llm" for f in u):
                yield u, "".join(f"[[{f.claim_id}]]" if (f.claim_id and not f.claim_id.startswith("P:")) else f.text for f in u)

    def _rel_kind(kind):
        def run():
            bad = []
            for u, tok in _authored():
                v = _P.relation_violation(tok, _R)
                if v and v[0] == kind:
                    bad.append(v[1][:110])
            if kind == "RELATION_IDENTITY":
                pres = set(C.RELATION_PRESENTATION)
                bad += [f"[[{f.claim_id}]] in authored prose (a relation's size without its subject)"
                        for u, _t in _authored() for f in u if f.claim_id in pres and f.origin == "llm"]
                bad += [p for k, p in _RL.check(state) if k == "REL1"]
            if kind == "RELATION_MAGNITUDE":
                bad += [p for k, p in _RL.check(state) if k == "REL2"]
            return (FAIL, "; ".join(bad[:2])) if bad else (PASS, f"{len(_rs.get('items') or {})} typed relations hold in the text")
        return run

    def _evidence_scope():
        bad = [(_P.evidence_scope_violation(tok, _R) or "")[:110] for u, tok in _authored() if _P.evidence_scope_violation(tok, _R)]
        return (FAIL, "; ".join(bad[:2])) if bad else (PASS, "model evidence describes only its own model")

    def _evidence_visibility():
        bad = [f.claim_id for u, _t in _authored() for f in u
               if f.claim_id and f.origin == "llm" and _EV.claim_model(f.claim_id)
               and not _EV.model_visible(state, _EV.claim_model(f.claim_id))]
        return (FAIL, f"hidden models' figures in authored prose: {sorted(set(bad))}") if bad else (PASS, "no hidden model's figure in authored prose")

    def _epistemic():
        bad = [tok[:110] for u, tok in _authored() if _P.guidance_epistemic_violation(tok, _R.guidance_terms)]
        return (FAIL, f"guidance presence/absence asserted beyond its epistemic state: {bad[:2]}") if bad else \
            (PASS, "no guidance presence or absence asserted beyond its epistemic state")

    def _polarity():
        bad = [tok[:110] for u, tok in _authored() if _P.polarity_violation(tok, _R.expectations_polarity)]
        return (FAIL, f"expectation polarity not from the structured relation: {bad[:2]}") if bad else \
            (PASS, "expectation polarity only as REL_EXPECTATIONS_STORY states it")

    def _returns():
        bad = [f.claim_id for u, _t in _authored() for f in u if f.origin == "llm" and f.claim_id in C.PHRASE_OF]
        for f in frags:
            cd = C.claim_def(state, f.claim_id) if f.claim_id and not f.claim_id.startswith("P:") else None
            if cd is not None and cd.stype == C.PHRASE:
                rc = C.return_claim(state.value(cd.path), cd.phrase)
                if rc is None or f.text not in (rc["rendered"], rc["rendered"][:1].upper() + rc["rendered"][1:]):
                    bad.append(f"{f.claim_id}: phrase does not carry the sign of its value")
        return (FAIL, f"signed figures without their own direction: {bad[:3]}") if bad else (PASS, "signed quantities carry their own direction")

    def _doubled():
        bad = [tok[:110] for u, tok in _authored() if _P.qualifier_doubled(tok, _R.catalog) or _P.operand_doubled(tok, _R.catalog)]
        return (FAIL, f"a claim's own qualifier written twice: {bad[:2]}") if bad else (PASS, "no qualifier written twice")

    def _clause_grammar():
        bad = [tok[:110] for u, tok in _authored()
               if _P.clause_grammar_violation(tok, _R.full_clause) or _P.mode_subject_violation(tok, _R.rel_subjects)
               or _P.relation_slot_violation(tok, _R.rel_subjects, _R.catalog)]
        return (FAIL, f"relationship clauses used as noun phrases or attached to another subject: {bad[:2]}") if bad else \
            (PASS, "every relationship token sits in a clause position, at most two per sentence")

    def _trends():
        bad = [tok[:110] for u, tok in _authored() if _P.trend_violation(tok, _R)]
        return (FAIL, f"trend conclusions without an approved trend relationship: {bad[:2]}") if bad else \
            (PASS, f"trend conclusions only from approved trend relationships ({len(_R.trends_allowed)} available)")

    def _interpretation():
        bad = [tok[:110] for u, tok in _authored() if _P.interpretation_violation(tok, _R.interpretation)]
        perm = {f: d.get("permission") for f, d in (_R.interpretation or {}).items()}
        return (FAIL, f"descriptive-only evidence interpreted: {bad[:2]}") if bad else (PASS, f"evidence interpretation within permission {perm}")

    def _guidance_binding():
        bad = [tok[:110] for u, tok in _authored() if _P.guidance_binding_violation(tok, _R.guidance_names)]
        return (FAIL, f"guidance values attached to another subject: {bad[:2]}") if bad else \
            (PASS, "every authored guidance value is named by its own concept")

    def _final_claim_only():
        """Cover and summary: every material number is a claim fragment whose text and value are the frozen state's."""
        bad = []
        for b in rep.blocks:
            if b.section not in ("verdict", "summary"):
                continue
            for f in b.fragments():
                if not f.claim_id:
                    if MATERIAL_NUMBER.search(BENIGN.sub(" ", f.text)):
                        bad.append(f"authored number '{MATERIAL_NUMBER.search(BENIGN.sub(' ', f.text)).group(0).strip()}'")
                    continue
                cd = C.claim_def(state, f.claim_id) if not f.claim_id.startswith("P:") else None
                if f.claim_id.startswith("P:") or f.claim_id == "VERSION":
                    continue
                if cd is None:
                    bad.append(f"{f.claim_id}: not in the final claim registry")
                    continue
                cl = C.claim_for(state, cd)
                want = cl.rendered or ""
                if f.text != want and not ((cd.stype == C.REL or cd.clause) and want and f.text == want[0].upper() + want[1:]):
                    bad.append(f"{f.claim_id}: shows '{f.text}', final registry has '{want}'")
                elif f.value is not None and cl.value is not None and not isinstance(cl.value, dict) and f.value != cl.value:
                    bad.append(f"{f.claim_id}: value {f.value} ≠ final {cl.value}")
        return (FAIL, "; ".join(bad[:3])) if bad else (PASS, "cover and summary numbers are final claims only")

    def _ownership_phrase():
        bad = [f.claim_id for u, _t in _authored() for f in u if f.origin == "llm" and f.claim_id == "OWN_13F_HOLDERS_CHANGE"]
        d = C.BY_ID.get("OWN_13F_HOLDERS_CHANGE_PHRASE")
        for f in frags:
            if f.claim_id == "OWN_13F_HOLDERS_CHANGE_PHRASE":
                rc = C.return_claim(state.value(d.path), d.phrase)
                if rc is None or f.text not in (rc["rendered"], rc["rendered"][:1].upper() + rc["rendered"][1:]):
                    bad.append("the ownership-change phrase does not own its direction, magnitude and period")
        return (FAIL, "; ".join(map(str, bad[:3]))) if bad else (PASS, "ownership change appears only as its complete phrase")

    def _relation_logic():
        bad = [tok[:110] for u, tok in _authored() if _P.relation_logic_violation(tok, _R.alternative_cases, _R.case_variables)]
        return (FAIL, f"alternative conditional cases stated jointly: {bad[:2]}") if bad else \
            (PASS, "alternative break-even cases are never stated as a joint requirement")

    def _valuation_relations():
        bad = [tok[:110] for u, tok in _authored() if _P.valuation_relation_violation(tok, _R.valuation_relations)
               or _P.universal_valuation_violation(tok, _R.valuation_relations)]
        return (FAIL, f"valuation direction words without their relation: {bad[:2]}") if bad else \
            (PASS, "valuation direction words cite and agree with their relation")

    def _segment_perimeter():
        bad = [tok[:110] for u, tok in _authored() if _P.segment_perimeter_violation(tok, _R.segment_perimeter)]
        return (FAIL, f"segment counts outside their perimeter: {bad[:2]}") if bad else \
            (PASS, "segment counts cited with their perimeter")

    def _evidence_domain():
        bad = [tok[:110] for u, tok in _authored() if _P.evidence_domain_violation(tok, _R)]
        return (FAIL, f"evidence used outside its domain: {bad[:2]}") if bad else (PASS, "evidence establishes only its own domain")

    def _lineage():
        """Every material number in the document is a claim of the FINAL frozen state (text and value), a guidance value
        is an AVAILABLE row of the final guidance state, and every guidance value an author cites is in the table."""
        bad = []
        rows = (state.guidance.get("company") or {}).get("metrics") or []
        for f in frags:
            if not f.claim_id:
                mm = MATERIAL_NUMBER.search(BENIGN.sub(" ", f.text))
                if mm:
                    bad.append(f"'{mm.group(0).strip()}' has no claim")
                continue
            if f.claim_id.startswith("P:") or f.claim_id == "VERSION":
                continue
            cd = C.claim_def(state, f.claim_id)
            if cd is None:
                bad.append(f"{f.claim_id}: not in the final state")
                continue
            if f.claim_id.startswith("GUIDANCE_"):
                try:
                    row = rows[int(f.claim_id.split("_")[1]) - 1]
                except (ValueError, IndexError):
                    bad.append(f"{f.claim_id}: no such final guidance row")
                    continue
                if (state.guidance.get("company") or {}).get("status") != "AVAILABLE" or not row.get("verified"):
                    bad.append(f"{f.claim_id}: not an available, verified row")
        table = {f.claim_id for b in rep.blocks if b.kind == "table" and b.section == "forecast"
                 for f in b.fragments() if f.claim_id and f.claim_id.startswith("GUIDANCE_")}
        cited = {f.claim_id for u, _t in _authored() for f in u if f.claim_id and f.claim_id.startswith("GUIDANCE_")}
        bad += [f"{c}: cited in prose but absent from the guidance table" for c in sorted(cited - table)]
        return (FAIL, "; ".join(bad[:3])) if bad else (PASS, "every material number resolves to a final claim")

    def _typed_rule(fn, what, ok):
        def run():
            bad = [tok[:110] for u, tok in _authored() if fn(tok)]
            return (FAIL, f"{what}: {bad[:2]}") if bad else (PASS, ok)
        return run

    # ── claim lineage and historical data quality (typed; the relations are re-derived from state) ──
    _lin = {}

    def _lineage_hits():
        """Every authored object (own evidence) and thesis (named counterparts) through arguments.lineage_reasons, and
        every template sentence by co-citation: (code, where, text)."""
        if "v" in _lin:
            return _lin["v"]
        import arguments as _A
        hits = []
        cat0 = C.build_catalog(state, include_deferred=False)
        ctx = _A.context(state, cat0, _PL.rules_for(state, cat0), _PL.INTERNAL_TERMS, QA_VOCAB)
        for role, a in (rep.arguments or {}).items():
            if not a:
                continue
            for key in ("points", "items_thesis", "items_risks", "answers"):
                for p in a.get(key) or []:
                    ev = list(p.get("evidence") or []) + list(p.get("to_evidence") or [])
                    hits += [(x.split(":")[0], f"{role}.{key}", p.get("view") or "") for x in _A.lineage_reasons(ctx, p.get("view"), ev)]
            if a.get("thesis"):
                sc = _A._scope(_A._rest_objects(role, a))
                hits += [(x.split(":")[0], f"{role}.thesis", a["thesis"])
                         for x in _A.lineage_reasons(ctx, a["thesis"], list(sc), cocite=False)]
        for u in _sentences():
            ids = [f.claim_id for f in u if f.claim_id and not f.claim_id.startswith("P:")]
            txt = "".join(f.text for f in u)
            hits += [(x.split(":")[0], "template", txt) for x in _A.lineage_reasons(ctx, txt, ids, named=False)]
        _lin["v"] = hits
        return hits

    def _guidance_consensus():
        bad = []
        it = (_rs.get("items") or {}).get("REL_GUIDANCE_EPS_CONSENSUS")
        if it:
            bad += [f"REL_GUIDANCE_EPS_CONSENSUS: {w}" for w in _RL.verify_guidance_relation(state, it)]
        bad += [f"{w}: {x[:90]}" for c, w, x in _lineage_hits() if c == "GUIDANCE_CONSENSUS_COMPATIBILITY"]
        return (FAIL, f"guidance compared with consensus without a compatible typed relation: {bad[:2]}") if bad else \
            (PASS, "guidance meets consensus only through a relation on one fiscal period, horizon and basis"
             + ("" if it else " (none is compatible: stated separately)"))

    def _forecast_relation():
        bad = []
        for rid in ("REL_FORECAST_EPS_CONSENSUS", "REL_ENGINE_REVENUE_CONSENSUS"):
            it = (_rs.get("items") or {}).get(rid)
            if it:
                bad += [f"{rid}: {w}" for w in _RL.verify_forecast_relation(state, rid, it)]
        bad += [f"{w}: {x[:90]}" for c, w, x in _lineage_hits() if c == "FORECAST_RELATION_REQUIRED"]
        return (FAIL, f"engine forecasts compared with consensus without their relation: {bad[:2]}") if bad else \
            (PASS, "engine-vs-consensus comparisons only through independent, same-horizon relations")

    def _historical_quality():
        """The trailing column's label and the TTM metrics' quality derive from HistoricalMetricQuality; a series with an
        unchecked quarter is never labelled simply reconciled."""
        q = historical_quality(state)
        bad = []
        stored = (state.historical.get("reconciliation") or {}).get("ttm_quality")
        if stored is not None and dict(q) != dict(stored):
            bad.append("a quality state exists but the labels were derived without it")
        qs = q.get("quarters") or []
        if qs:
            chk = [x.get("source") not in ("PROVIDER_ONLY", "UNAVAILABLE") for x in qs]
            want = "SEC_RECONCILED" if all(chk) else "PARTIALLY_RECONCILED" if any(chk) else "PROVIDER_ONLY"
            if q.get("status") != want:
                bad.append(f"quality status {q.get('status')} but its quarters give {want}")
            unv = [x["period"] for x, ok in zip(qs, chk) if not ok]
            if list(q.get("unverified_periods") or []) != unv:
                bad.append(f"unverified periods {q.get('unverified_periods')} ≠ {unv}")
        if q.get("status") == "SEC_RECONCILED" and q.get("unverified_periods"):
            bad.append("SEC_RECONCILED with unverified periods")
        fund = [i for i, b in enumerate(rep.blocks) if b.kind == "table" and b.css == "rp-fund"]
        if not fund:
            bad.append("the fundamentals table is missing")
        for i in fund:
            h = rep.blocks[i].content.get("head") or ["", "", ""]
            want = [historical_quality_render(q, sep_) for sep_ in (False, True)]
            if h[1] not in {w["table_header"] for w in want}:
                bad.append(f"trailing column labelled '{h[1]}'")
            foot = ""
            for nxt in rep.blocks[i + 1:]:                   # the footnote paragraphs directly under the table
                if nxt.css != "rp-foot":
                    break
                foot += " ".join(f.text for f in nxt.content) + " "
            if not any(w["footnote"] in foot for w in want):
                bad.append("the trailing column's quality footnote is not printed under the table")
            if FCST_MARK in str(h[2]) and FCST_MARK + " " not in foot:
                bad.append("the forecast column's lineage footnote is not printed under the table")
        mq = {"SEC_RECONCILED": "RECONCILED", "PARTIALLY_RECONCILED": "PARTIALLY_RECONCILED"}.get(q.get("status"), "UNVERIFIED")
        for mid in ("revenue_ttm", "operating_income_ttm", "net_income_ttm"):
            m = state.historical.get(mid)
            if m is not None and m.value is not None and m.quality_status != mq:
                bad.append(f"{mid} carries {m.quality_status}, its quarters give {mq}")
        return (FAIL, "; ".join(bad[:3])) if bad else \
            (PASS, f"{q.get('status')} (verified through {q.get('verified_through')}; unverified {q.get('unverified_periods') or 'none'})")

    def _segment_note():
        a = ((state.valuation.get("story") or {}).get("assumptions")) or {}
        if a.get("margin_proposed") is None or a.get("margin_target") is None or \
                abs(float(a["margin_proposed"]) - float(a["margin_target"])) <= 0.0005 or not (state.valuation.get("story") or {}).get("segments"):
            return PASS, "segment margins are the margin the valuation uses"
        shown = {f.claim_id for f in frags if f.claim_id}
        ok = {"P:valuation.story.assumptions.margin_proposed", "P:valuation.story.assumptions.margin_target"} <= shown
        return (PASS, "the segments' weighted margin and the margin used (capped or floored) are both shown") if ok else \
            (FAIL, "segment margins shown without the cap or floor to the margin the valuation uses")

    def _structured():
        """Every authored fragment belongs to a validated structured object, and every object still validates with
        the pipeline's own rule inputs (the same functions ingestion used)."""
        sets = rep.arguments or {}
        texts = set()
        for a in sets.values():
            if not a:
                continue
            if a.get("thesis"):
                texts.add(a["thesis"])
            for key in ("points", "items_thesis", "items_risks", "answers"):
                for p in a.get(key) or []:
                    texts.add(p["view"])
                    if p.get("to_view"):
                        texts.add(p["to_view"])
        quotes = {p["to_view"] for a in sets.values() if a for p in a.get("answers") or [] if p.get("to_view")}
        bad = [f.text[:80] for f in frags if f.origin == "authored" and f.text not in texts]
        bad += [f"quote: {f.text[:70]}" for f in frags if f.origin == QUOTE and f.text not in quotes]
        if sets:
            import arguments as _A
            import pipeline as _PLs
            try:
                cat0 = C.build_catalog(state, include_deferred=False)
                ctx = _A.context(state, cat0, _PLs.rules_for(state, cat0), _PLs.INTERNAL_TERMS, QA_VOCAB)
                for role, a in sets.items():
                    if not a or role == "summary":
                        continue
                    _a, drops = _A.revalidate(ctx, a, own={"value": None, "label": None, "return_claim": None})
                    bad += [f"{role}: {d['reason'][:90]}" for d in drops if not str(d["reason"]).startswith(
                        ("REVALIDATION: VIEW_RATING", "REVALIDATION: VIEW_RETURN_DIRECTION"))]
            except Exception as e:  # noqa: BLE001
                bad.append(f"revalidation raised {type(e).__name__}: {str(e)[:80]}")
        return (FAIL, f"structured arguments that do not validate: {bad[:3]}") if bad else \
            (PASS, f"{sum(1 for f in frags if f.origin == 'authored')} authored fragments, all from validated objects")

    def _identifiers():
        """No internal identifier (snake_case or UPPER_SNAKE) reaches the reader, in template or authored text: the
        PM's triggers printed "If revenue_growth_next_fy falls to 5.0%" in run 8d12481bffa6e2e6."""
        bad = []
        for f in frags:
            if f.claim_id == "VERSION":
                continue
            m = _IDENT.search(f.text or "")
            if m:
                bad.append(m.group(0))
        return (FAIL, f"internal identifiers in the document: {sorted(set(bad))[:4]}") if bad else \
            (PASS, "no internal identifiers")

    def _authored_once():
        from collections import Counter
        cnt = Counter(f.text for f in frags if f.origin == "authored")
        dup = [t[:70] for t, n in cnt.items() if n > 1]
        return (FAIL, f"authored text printed more than once: {dup[:2]}") if dup else \
            (PASS, "every authored text is printed once")

    def _public_language():
        bad = [tok[:110] for u, tok in _authored() if _P.public_language_violation(tok, _PL.INTERNAL_TERMS)]
        return (FAIL, f"internal QA language in the document: {bad[:2]}") if bad else (PASS, "public language only")

    def _conclusion(kind):
        def run():
            bad = []
            for u, tok in _authored():
                v = _P.evidence_conclusion_violation(tok, _R.evidence_uses)
                if v and v[0] == kind:
                    bad.append(tok[:110])
            return (FAIL, f"evidence used beyond its allowed conclusion types: {bad[:2]}") if bad else \
                (PASS, "evidence supports only its allowed conclusion types")
        return run

    def _blend_labels():
        from state_builder import BLEND_KIND_LABEL
        rows = [b for b in rep.blocks if b.kind == "table" and b.section == "blend"]
        shown = ["".join(f.text for f in row[0]) for b in rows for row in b.content.get("rows", [])]
        bad = []
        for x, lab in zip(state.blend.get("sources") or [], shown):
            want = BLEND_KIND_LABEL.get(x.get("kind")) if x.get("kind") != "OTHER" else f"Other source ({x.get('name')})"
            if lab != want:
                bad.append(f"'{lab}' shown for a {x.get('kind')} source (want '{want}')")
        return (FAIL, "; ".join(bad[:2])) if bad else (PASS, "blend labels derive from typed source kinds")

    def _directional():
        from prose import directional_violation
        restricted = tuple(x for x, blocked in (
            ("13F", not (state.ownership.get("permissions") or {}).get("use_as_return_signal", False)),
            ("insider", not (((state.insiders.get("canon") or {}).get("materiality") or {}).get("material")))) if blocked)
        bad = []
        for u in _sentences():
            tok = "".join(f"[[{f.claim_id}]]" if f.claim_id else f.text for f in u)
            if directional_violation(tok, restricted):
                bad.append(tok[:100])
        return (FAIL, f"restricted evidence used directionally: {bad[:2]}") if bad else (PASS, "no restricted evidence carries a direction")

    def _relative():
        from prose import relative_violation
        allowed = tuple(k for k, v in (state.pricing.get("relative") or {}).items() if v.get("comparison_allowed"))
        bad = [("".join(f.text for f in u))[:100] for u in _sentences() if relative_violation("".join(f.text for f in u), allowed)]
        return (FAIL, f"relative valuation without a comparable peer basis: {bad[:2]}") if bad else (PASS, "relative valuation only on comparable bases")

    def stable_wacc():
        bad = []
        for u in _sentences():
            bad += [cid for cid, _q in qualifier_violation(u, lambda c: getattr(C.claim_def(state, c), "required_qualifiers", ()))]
        return (FAIL, f"stable-period rate written without its qualifier: {bad[:3]}") if bad else \
            (PASS, "every stable-period rate carries its stable / mature / terminal qualifier")

    checks = [(numbers, "R1", "every material number is a registered claim", True, "presentation"),
              (values, "R2", "every rendered claim equals the frozen state", True, "identity"),
              (identity, "R3", "claims keep their semantic identity", True, "identity"),
              (terminology, "R4", "valuation vocabulary from the selected engine", True, "policy"),
              (no_qa, "R5", "no internal QA content in the user report", True, "presentation"),
              (quant_hidden, "R6", "unreliable models invisible to users", True, "policy"),
              (same_bytes, "R7", "HTML and markdown are the audited fragments", True, "identity"),
              (required, "R8", "required claims present", True, "presentation"),
              (guidance_evidence, "S9", "guidance only with canonical guidance evidence", True, "policy"),
              (lambda: _motive("13F"), "S10", "no motive inferred from 13F ownership", True, "policy"),
              (lambda: _motive("insider"), "S11", "no motive inferred from insider filings", True, "policy"),
              (stable_wacc, "S12", "stable-period cost of capital never presented as current", True, "identity"),
              (_pairs_ok, "S16", "prose comparisons are between compatible typed claims", True, "identity"),
              (_directional, "S24", "evidence without return-signal permission carries no direction", True, "policy"),
              (_relative, "S26", "relative valuation requires the same basis and perimeter", True, "policy"),
              (_rel_kind("RELATION_IDENTITY"), "RELATION_IDENTITY", "relations keep their subject and object", True, "identity"),
              (_rel_kind("RELATION_MAGNITUDE"), "RELATION_MAGNITUDE", "magnitudes stay bound to the pair they measure", True, "identity"),
              (_rel_kind("RELATION_REQUIRED_FOR_PAIR"), "RELATION_REQUIRED_FOR_PAIR",
               "related quantities share a sentence only through an approved relation", True, "identity"),
              (_evidence_scope, "MODEL_EVIDENCE_SCOPE", "model evidence evaluates only its allowed target models", True, "policy"),
              (_evidence_visibility, "MODEL_EVIDENCE_VISIBILITY", "hidden models contribute no authored figure", True, "policy"),
              (_epistemic, "GUIDANCE_EPISTEMIC_STATE", "no absence asserted from rejected or unfound guidance", True, "policy"),
              (_polarity, "EXPECTATIONS_POLARITY", "expectation polarity only from the structured relation", True, "identity"),
              (_returns, "RETURN_DIRECTION_RENDER", "signed quantities carry their own direction", True, "presentation"),
              (_blend_labels, "BLEND_SOURCE_KIND", "blend labels derive from typed source kinds", True, "presentation"),
              (_doubled, "CLAIM_QUALIFIER_DOUBLED", "no claim's own direction or bound is written twice", True, "presentation"),
              (_clause_grammar, "RELATION_CLAUSE_GRAMMAR", "relationship clauses keep clause positions", True, "presentation"),
              (_trends, "TREND_RELATION_REQUIRED", "trend conclusions need an approved trend relationship", True, "identity"),
              (_interpretation, "EVIDENCE_INTERPRETATION", "descriptive-only evidence carries no interpretation", True, "policy"),
              (_guidance_binding, "GUIDANCE_LABEL_BINDING", "guidance values are named by their own concept", True, "identity"),
              (_final_claim_only, "FINAL_CLAIM_ONLY", "cover and summary numbers resolve to final claims", True, "identity"),
              (_ownership_phrase, "OWNERSHIP_CHANGE_PHRASE", "ownership change only as its complete phrase", True, "presentation"),
              (_relation_logic, "RELATION_LOGIC_PRESERVATION", "paraphrase preserves each relation's logical structure", True, "identity"),
              (_valuation_relations, "VALUATION_RELATION_LANGUAGE", "valuation direction language follows its relation", True, "identity"),
              (_segment_perimeter, "SEGMENT_PERIMETER_LANGUAGE", "segment counts carry their perimeter", True, "identity"),
              (_evidence_domain, "EVIDENCE_DOMAIN_SCOPE", "evidence establishes only its own domain", True, "policy"),
              (_lineage, "FINAL_MATERIAL_CLAIM_LINEAGE", "every material number resolves to a final claim", True, "identity"),
              (_public_language, "PUBLIC_LANGUAGE_ONLY", "no internal QA language in the document", True, "presentation"),
              (_conclusion("EVIDENCE_CONCLUSION_SCOPE"), "EVIDENCE_CONCLUSION_SCOPE", "evidence supports only allowed conclusions",
               True, "policy"),
              (_conclusion("NEWS_EVIDENCE_SCOPE"), "NEWS_EVIDENCE_SCOPE", "news supports only its classified uses", True, "policy"),
              (_typed_rule(lambda t: _P.performance_violation(t, _R.stock_returns, _R.company_words), "stock performance without its return claim",
                           "stock performance stated only with its signed return claim"),
               "STOCK_PERFORMANCE_CLAIM", "stock performance only from the signed return claim", True, "identity"),
              (_typed_rule(lambda t: _P.assessment_violation(t, _R.assessments, _R.assumption_claims),
                           "assumptions judged beyond their typed assessment",
                           "assumptions called conservative or aggressive only as assessed"),
               "ASSUMPTION_ASSESSMENT", "assumption judgements follow their typed assessment", True, "identity"),
              (_typed_rule(lambda t: _P.quote_violation(t, _R.transcript_turns, _R.company_words),
                           "call quotations attributed to the wrong speaker", "call quotations keep their speaker"),
               "QUOTE_ATTRIBUTION", "call quotations keep their speaker's role", True, "identity"),
              (_typed_rule(lambda t: _P.statement_domain_violation(t, _R.statements), "figures placed on the wrong statement",
                           "figures stay on their own financial statement"),
               "STATEMENT_DOMAIN", "figures stay on their own financial statement", True, "identity"),
              (_typed_rule(lambda t: _P.phrase_slot_violation(t, _R.catalog), "predicate or list claims out of their slot",
                           "predicate and list claims sit in their render slots"),
               "CLAIM_RENDER_SLOT", "predicate and list claims keep their render slot", True, "presentation"),
              (_typed_rule(lambda t: _P.family_comparison_violation(t), "comparisons across metric families",
                           "no comparison across metric families without a typed relation"),
               "METRIC_FAMILY_RELATION", "comparisons across metric families need a typed relation", True, "identity"),
              (_typed_rule(lambda t: _P.proximity_violation(t, _R.valuation_magnitudes), "closeness to value outside the band",
                           "closeness to value only within the band of a cited anchor"),
               "VALUE_PROXIMITY", "'close to fair value' only within the closeness band of a cited anchor", True, "identity"),
              (_typed_rule(lambda t: _P.named_counterpart_violation(t, _R) or _P.claim_label_violation(t, _R.catalog),
                           "a figure compared with an uncited quantity, or not named by its concept",
                           "figures are named by their concept and compared only with cited, compatible claims"),
               "CLAIM_LABEL_BINDING", "figures are named by their concept; named comparisons cite their claim", True, "identity"),
              (_typed_rule(lambda t: _P.magnitude_violation(t, _R.catalog) or _P.spelled_count_violation(t),
                           "verbal magnitudes or spelled counts without a claim", "verbal magnitudes fit their claims"),
               "VERBAL_MAGNITUDE", "verbal magnitudes and counts fit a claim of their metric", True, "identity"),
              (_typed_rule(lambda t: _P.paraphrase_attribution_violation(t, _R.transcript_turns, _R.company_words),
                           "an analyst's words attributed to management", "paraphrased call content keeps its speaker"),
               "PARAPHRASE_ATTRIBUTION", "paraphrased call content keeps its speaker's role", True, "identity"),
              (_structured, "STRUCTURED_ARGUMENTS", "authored judgement comes only from validated structured objects",
               True, "identity"),
              (_identifiers, "NO_INTERNAL_IDENTIFIERS", "no internal identifier reaches the reader", True, "presentation"),
              (_authored_once, "AUTHORED_ONCE", "no authored text is printed twice", True, "presentation"),
              (_segment_note, "SEGMENT_MARGIN_DISCLOSURE", "a capped segment margin is disclosed with the margin used", True,
               "presentation"),
              (_guidance_consensus, "GUIDANCE_CONSENSUS_COMPATIBILITY",
               "guidance against consensus only on one fiscal period, horizon and basis", True, "identity"),
              (_forecast_relation, "FORECAST_RELATION_REQUIRED",
               "engine forecasts against consensus only through their typed relation", True, "identity"),
              (_historical_quality, "HISTORICAL_QUALITY_LABEL", "trailing-figure labels derive from their quality state",
               True, "presentation")]
    return ValidationResult("REPORT_VALIDATED", _run(checks))


# ═══════════════════════════════════════════════════════════════════════════════════════
# records
# ═══════════════════════════════════════════════════════════════════════════════════════
USER_SAFE_MESSAGES = {
    # data and forecast/valuation checks depend only on the company's figures: a retry before the data changes fails the
    # same way, so the message must not invite one
    "INITIAL": ("Some source data for this company could not be reconciled, so a complete analysis could not be "
                "published. This usually lasts until the company's next filing or data update: running the analysis again "
                "now will most likely give the same result."),
    "DATA_VALIDATED": ("The forecast or valuation for this company did not pass its consistency checks, so the analysis "
                       "was not published. This comes from how the model handles this company's figures, so running it again "
                       "will usually give the same result; the case is recorded for review."),
    # these two depend on the analyst models' answers, which differ from run to run: a retry can succeed
    "FORECAST_VALIDATED": ("The analyst panel's conclusions could not be combined into a consistent result, so the analysis "
                           "was not published. Please try again later."),
    "ANALYSIS_FINALIZED": ("The finished report did not pass its final accuracy check, so it was not published. "
                           "Please try again later."),
}
# a withheld run whose cause is known and specific says so (pipeline.Run.withheld_reason)
WITHHELD_REASON_MESSAGES = {
    "PRE_REVENUE": ("This company has little or no revenue yet: its operating losses are larger than its revenue. The model "
                    "values a business from an established revenue base, so pre-revenue and early-revenue companies are not "
                    "covered. Running the analysis again will give the same result."),
}
USER_SAFE_MESSAGE = USER_SAFE_MESSAGES["INITIAL"]


def user_payload(rep: UserReport, md: str, html_doc: str, state):
    """The ONLY object the public API, the web page and the download use."""
    return {"schema": "crucible.user_report/1", "status": "PUBLISHED", "ticker": rep.ticker, "company": rep.company,
            "as_of": rep.as_of, "version": rep.version,
            "rating": state.value("recommendation.rating"),
            "headline": {"value": state.value("blend.headline_return"), "rendered": (rep.claims.get("HEADLINE_RETURN") or {}).get("rendered")},
            "price": {"value": state.value("market.price"), "rendered": (rep.claims.get("PRICE") or {}).get("rendered")},
            "limitations": rep.limitations, "html": html_doc, "markdown": md,
            "claims": {k: {"rendered": v.get("rendered"), "value": v.get("value"), "label": v.get("label")} for k, v in rep.claims.items()}}


def withheld_payload(ticker, company=None, stage_reached="INITIAL", reason=None):
    """The stage reached is the last one that PASSED; the message describes the step after it. A known, specific cause
    (``reason``) replaces the stage's general message."""
    out = {"schema": "crucible.user_report/1", "status": "WITHHELD", "ticker": ticker, "company": company,
           "message": WITHHELD_REASON_MESSAGES.get(reason) or USER_SAFE_MESSAGES.get(stage_reached, USER_SAFE_MESSAGE)}
    if reason in WITHHELD_REASON_MESSAGES:
        out["reason"] = reason
    return out


def qa_record(*, ticker, run_id, stage_reached, validations, state_snapshot, rejections, reconciliation, notes=None,
              report_audit=None, timings=None, arguments=None):
    """Internal QA only: never served publicly, never rendered into the user report."""
    return {"schema": "crucible.internal_qa/1", "ticker": ticker, "run_id": run_id, "stage_reached": stage_reached,
            "arguments": arguments,
            "validations": [v.to_dict() for v in validations if v is not None],
            "report_audit": report_audit.to_dict() if report_audit is not None else None,
            "rejected_sentences": [r.to_dict() for r in rejections], "reconciliation": reconciliation,
            "state": state_snapshot, "notes": notes or [], "timings": timings or {}}