File size: 77,862 Bytes
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b22581
56d36d7
 
 
 
 
 
 
 
 
bb286dd
 
56d36d7
 
bb286dd
56d36d7
 
 
 
 
0b22581
56d36d7
 
 
 
 
 
 
 
 
 
 
 
0b22581
 
 
56d36d7
 
0b22581
 
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b22581
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8127b85
56d36d7
 
 
 
 
 
 
 
 
 
 
 
8127b85
 
 
 
56d36d7
 
 
 
 
 
 
 
 
 
 
bb286dd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56d36d7
 
 
 
bb286dd
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c14a380
 
 
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb286dd
56d36d7
 
 
 
 
 
 
 
 
 
 
0b22581
 
 
 
 
 
 
 
56d36d7
0b22581
 
 
 
56d36d7
0b22581
 
 
 
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d092871
 
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
0b22581
 
 
56d36d7
 
 
 
 
 
d092871
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b22581
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d092871
0b22581
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b22581
 
56d36d7
0b22581
56d36d7
 
 
 
 
 
0b22581
 
56d36d7
 
 
 
0b22581
56d36d7
 
 
0b22581
 
 
 
56d36d7
 
 
 
0b22581
 
 
 
 
56d36d7
bb286dd
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
0b22581
56d36d7
0b22581
 
 
 
56d36d7
 
0b22581
56d36d7
 
 
0b22581
 
 
 
 
 
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b22581
 
 
56d36d7
 
 
 
 
 
 
0b22581
56d36d7
 
0b22581
56d36d7
0b22581
 
 
 
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b22581
 
 
56d36d7
 
0b22581
56d36d7
0b22581
 
 
 
56d36d7
 
0b22581
 
 
56d36d7
 
0b22581
 
 
 
56d36d7
 
 
 
 
 
 
 
 
 
 
 
0b22581
 
56d36d7
 
 
 
 
 
 
0b22581
56d36d7
0b22581
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b22581
 
 
 
56d36d7
 
 
bb286dd
56d36d7
 
 
 
d092871
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b22581
56d36d7
 
 
 
0b22581
 
56d36d7
 
 
 
 
 
0b22581
 
 
 
 
 
 
bb286dd
0b22581
 
 
2ade7c1
0b22581
 
56d36d7
 
 
 
2ade7c1
 
 
 
0b22581
 
 
56d36d7
 
 
0b22581
 
 
56d36d7
 
 
 
2ade7c1
56d36d7
2ade7c1
 
0b22581
 
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b22581
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b22581
 
 
56d36d7
 
 
 
 
 
1cddb1f
 
 
 
 
 
 
 
56d36d7
 
1cddb1f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d092871
 
 
 
 
 
 
0b22581
 
d092871
 
 
 
 
 
 
 
 
 
 
 
56d36d7
 
0b22581
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56d36d7
 
 
 
0b22581
 
2ade7c1
 
 
0b22581
2ade7c1
0b22581
56d36d7
 
 
 
 
 
 
 
 
 
 
0b22581
56d36d7
0b22581
 
 
 
56d36d7
 
 
 
2ade7c1
 
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
2ade7c1
 
 
 
 
 
 
0b22581
2ade7c1
 
 
56d36d7
 
 
2ade7c1
 
 
 
 
 
 
 
 
56d36d7
 
 
 
 
 
0b22581
 
2ade7c1
0b22581
 
 
 
 
 
 
56d36d7
 
 
2ade7c1
56d36d7
 
2ade7c1
 
56d36d7
 
 
 
 
2ade7c1
56d36d7
 
 
2ade7c1
56d36d7
 
0b22581
 
 
 
 
 
 
 
56d36d7
 
 
 
0b22581
 
 
 
 
56d36d7
 
 
0b22581
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b4f6be8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b22581
 
 
 
 
 
 
 
 
 
 
56d36d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b22581
 
 
 
56d36d7
 
 
 
 
 
 
0b22581
 
 
 
56d36d7
0b22581
 
56d36d7
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
"""Structured arguments (contract ``crucible.args/2``): authors choose EVIDENCE, templates render every figure.

Why this exists (measured on 14 live SPGI runs, 1,047 labelled sentences): when authors wrote free English with
number tokens spliced in, 52% of what they wrote was defective, a word-list validator published 30% defects and threw
away 35% of good prose, and 62% of the published defects were semantic — no rule over wording could decide them. The
loop closed only by changing what authors are asked to write.

The contract:
- an author returns typed objects: ``{"kind", "evidence": [claim ids], "view": "<figure-free judgement>"}``, a
  figure-free ``thesis``, rebuttal ``answers`` that point at the opponent's VALIDATED points, PM ``factors``;
- every figure, comparison and direction in the report is rendered by templates from the cited typed claims;
- validation is typed: ids offered and allowed for the kind, pair compatibility, figure-free text, rating and return
  direction, assessments and valuation relations of the point's OWN evidence, quote lineage by speaker role;
- what a typed check cannot decide (does the judgement follow from its evidence?) goes to one semantic gate
  (``gate_prompt`` / ``apply_gate``) with the reviewers' rubric, never to a word list.

Nothing is rewritten: an object that fails is dropped with its reason codes. Parsing applies only the recorded,
meaning-preserving normalizations in ``_normalize_*`` (id spelling, kind and verdict aliases).
"""
from __future__ import annotations

import re
from dataclasses import dataclass, field

import catalog as C
import evidence as EV
import prose as P

SCHEMA = "crucible.args/2"
KINDS = ("OUTLOOK", "EARNINGS", "COMPETITION", "RISK", "MANAGEMENT", "VALUATION", "EXPECTATIONS", "OWNERSHIP")
KIND_LABEL = {"OUTLOOK": "Outlook", "EARNINGS": "Earnings", "COMPETITION": "Competition", "RISK": "Risk",
              "MANAGEMENT": "Management", "VALUATION": "Valuation", "EXPECTATIONS": "What the price requires",
              "OWNERSHIP": "Ownership"}
VERDICTS = ("FAILS", "PARTLY", "HOLDS")
VERDICT_CHIP = {"FAILS": "Disputed", "PARTLY": "Partly conceded", "HOLDS": "Conceded"}
LIMITS = {"view": 30, "thesis": 35, "pm_thesis": 50, "rebuttal_thesis": 35, "summary": 45, "item": 22,
          "points": 4, "answers": 3, "factors": 4, "items": 3, "evidence": 3, "answer_evidence": 3}

# ── KIND_EVIDENCE: explicit and fail-closed (an offered id outside every kind is not offered) ───────────────────
_STORY_ASSUMPTIONS = ("STORY_TAM", "STORY_SHARE", "STORY_REVENUE_MATURITY", "STORY_YEARS", "STORY_MARGIN", "STORY_WACC",
                      "STORY_WACC_STABLE", "STORY_TERMINAL_GROWTH", "STORY_FAILURE", "STORY_START_MARGIN",
                      "STORY_START_REVENUE", "STORY_MARGIN_CHANGE", "STORY_S2C")
_BREAK_EVEN = ("REL_BREAK_EVEN_REVENUE_STORY", "REL_BREAK_EVEN_MARGIN_STORY", "REL_BREAK_EVEN_REVENUE_GROWTH")
_TRENDS = ("TREND_REVENUE", "TREND_REVENUE_GROWTH", "TREND_OPERATING_MARGIN", "TREND_EPS")
KIND_IDS = {
    "OUTLOOK": {"FORECAST_REVENUE", "FORECAST_REVENUE_GROWTH", "REVENUE_TTM", "CONSENSUS_REVENUE_FY1", "CONSENSUS_REVENUE_FY2",
                "CONSENSUS_REVENUE_NTM", "REL_ENGINE_REVENUE_CONSENSUS", "LIFECYCLE_STAGE", "LIFECYCLE_FORWARD_GROWTH",
                "TREND_REVENUE", "TREND_REVENUE_GROWTH"},
    "EARNINGS": {"OPERATING_MARGIN_TTM", "NET_MARGIN_TTM", "EPS_ADJUSTED_TTM", "EPS_GAAP_TTM", "FORECAST_EPS",
                 "FORECAST_EPS_GROWTH", "FORECAST_OPERATING_MARGIN", "FORECAST_REVENUE_GROWTH", "CONSENSUS_EPS_NTM",
                 "CONSENSUS_EPS_FY1", "CONSENSUS_EPS_FY2", "REL_FORECAST_EPS_CONSENSUS", "REL_GUIDANCE_EPS_CONSENSUS", "BEAT_RATE",
                 "TREND_OPERATING_MARGIN", "TREND_EPS"},
    "VALUATION": {"REL_PRICE_INTRINSIC", "REL_PRICE_MECHANICAL", "REL_PRICE_MARKET_COMPOSITE", "REL_TARGET_PRICE",
                  "PE_FORWARD", "PE_TRAILING", "PEG", "LIFECYCLE_STAGE", "DMD_BETA", "DMD_UNLEVERED_BETA", "DMD_ERP",
                  "DMD_IMPLIED_ERP", "DMD_CRP", "DMD_S2C", "REL_EXPECTATIONS_STORY", *_STORY_ASSUMPTIONS, *_BREAK_EVEN},
    "EXPECTATIONS": {"REL_EXPECTATIONS_STORY", *_BREAK_EVEN},
    "RISK": {"STORY_FAILURE", "MOMENTUM", "SMA200", "REL_EXPECTATIONS_STORY", *_BREAK_EVEN, *_TRENDS},
    "COMPETITION": {"REL_OPERATING_MARGIN_INDUSTRY"},
    "MANAGEMENT": set(),
    "OWNERSHIP": set(),                       # filled from the 13F / insider families only when DIRECTIONAL_ALLOWED
}
# guidance rows by concept (the row's typed concept, never its label)
GUIDANCE_KIND = {"REVENUE": ("OUTLOOK", "RISK"), "REVENUE_GROWTH": ("OUTLOOK", "RISK"), "ORGANIC_REVENUE_GROWTH": ("OUTLOOK", "RISK"),
                 "EPS": ("EARNINGS", "RISK"), "EPS_GROWTH": ("EARNINGS", "RISK"), "OPERATING_MARGIN": ("EARNINGS", "RISK"),
                 "OPERATING_MARGIN_CHANGE": ("EARNINGS", "RISK"), "GROSS_MARGIN": ("EARNINGS",),
                 "GROSS_MARGIN_CHANGE": ("EARNINGS",), "NET_MARGIN": ("EARNINGS",), "OPERATING_INCOME": ("EARNINGS", "RISK"),
                 "OPERATING_EXPENSES": ("EARNINGS",), "SHARE_REPURCHASE": ("MANAGEMENT",), "DIVIDEND": ("MANAGEMENT",),
                 "CAPEX": ("MANAGEMENT",), "FREE_CASH_FLOW": ("EARNINGS", "MANAGEMENT")}
# never offered: pipeline internals, reference values, dates, over-read evidence (measured: cited wrongly more often than
# rightly), standalone industry aggregates (the typed benchmark relation carries the comparison) and model machinery.
# Past share-price moves (RETURN_*_PHRASE): every use in the measured runs read a past move as the cause of a future one
# (4 of 4 in run 8d12481bffa6e2e6); the report prints them itself, and no typed technical permission makes them directional.
WITHHELD = frozenset({"WACC_BENCHMARK", "MARKET_COMPOSITE_RAW", "MARKET_COMPOSITE_POST_TRIM", "ANALYST_TARGET_ADJUSTED",
                      "DMD_ERP_MONTH", "DMD_COUNTRY", "OWN_13F_AS_OF", "SEGMENTS_COVERAGE", "ADJ_GAAP_BRIDGE", "CEO",
                      "CFO", "SEGMENT_COUNT_FILED", "SEGMENT_COUNT_CONTINUING", "DMD_MARGIN", "DMD_INDUSTRY", "DMD_DE",
                      "RETURN_12M_PHRASE", "RETURN_3M_PHRASE"})


def kinds_for(cid, cat):
    """The kinds a claim may support (empty = not author evidence)."""
    base = str(cid).split("__")[0]
    out = {k for k, ids in KIND_IDS.items() if base in ids}
    cl = cat.get(base) if cat is not None else None
    if getattr(cl, "stype", None) == C.GUIDANCE:
        out |= set(GUIDANCE_KIND.get(((cl.value or {}) if isinstance(cl.value, dict) else {}).get("concept"), ()))
    return out


# ── context ────────────────────────────────────────────────────────────────────────────────────────────────
@dataclass
class Context:
    state: object
    cat: dict
    rules: object                              # prose.Rules (typed rule inputs: relations, assessments, turns …)
    offer: frozenset
    kind_evidence: dict                        # kind -> frozenset of ids
    relations: dict                            # REL id -> {"lhs", "rhs", "magnitude_claim", "type"}
    internal_terms: tuple = ()
    qa_vocab: object = None
    kinds: tuple = ()
    cases: dict = field(default_factory=dict)  # REL_EXPECTATIONS_STORY alternative cases -> claim ids


def author_offer(state, cat):
    """The claim ids an author may cite: offered ∩ KIND_EVIDENCE − WITHHELD (+ ownership only when directional)."""
    import report_model as RM
    interp = ((state.get("evidence.state") or {}).get("interpretation") or {})
    table = {f"GUIDANCE_{i}" for i, _m in RM.guidance_table_rows(state, cat)}
    trailing_forward_ok = True
    try:
        cmp_ = C.comparison(state, "LIFECYCLE_TRAILING_GROWTH", "LIFECYCLE_FORWARD_GROWTH")
        trailing_forward_ok = cmp_ is None or bool(cmp_[0])
    except Exception:  # noqa: BLE001
        trailing_forward_ok = False
    directional = {fam for fam, key in (("OWN_13F_", "13F"), ("INSIDER_", "insider"))
                   if (interp.get(key) or {}).get("permission") == EV.DIRECTIONAL_ALLOWED}
    out = set()
    for cid in C.offered(state, cat, include_deferred=False):
        if "__" in cid or cid in WITHHELD:
            continue                                   # authors cite a relation, never one of its render forms
        cl = cat.get(cid)
        if cl is None or cl.rendered is None:
            continue
        if any(cid.startswith(pre) for pre in directional):
            out.add(cid)
            continue
        if not kinds_for(cid, cat):
            continue
        if getattr(cl, "stype", None) == C.GUIDANCE and cid not in table:
            continue
        if cid == "LIFECYCLE_TRAILING_GROWTH" and not trailing_forward_ok:
            continue                                   # its only use would be a cross-perimeter comparison
        out.add(cid)
    return frozenset(out)


def context(state, cat, rules, internal_terms=(), qa_vocab=None):
    offer = author_offer(state, cat)
    kev = {}
    for k in KINDS:
        if k == "OWNERSHIP":
            ids = {c for c in offer if c.startswith(("OWN_13F_", "INSIDER_"))}
        else:
            ids = {c for c in offer if k in kinds_for(c, cat)}
        if ids:
            kev[k] = frozenset(ids)
    items = ((state.get("relations.state") or {}).get("items")) or {}
    rels = {rid: {"lhs": r.get("lhs_claim_id"), "rhs": r.get("rhs_claim_id"), "magnitude_claim": r.get("presentation_claim_id"),
                  "years": r.get("years_claim_id"), "type": r.get("relation_type")} for rid, r in items.items()}
    ex = items.get("REL_EXPECTATIONS_STORY") or {}
    cases = dict(ex.get("alternative_case_claims") or {}) if ex.get("logical_structure") == "ALTERNATIVE_CONDITIONALS" else {}
    return Context(state, cat, rules, offer, kev, rels, tuple(internal_terms or ()), qa_vocab, tuple(kev), cases)


def constituents(ctx, rid):
    """The claims a relation states (its sides, magnitude and years); for the expectations frontier, its cases'."""
    if rid == "REL_EXPECTATIONS_STORY":
        out = set()
        for ids in ctx.cases.values():
            for c in ids:
                out |= constituents(ctx, c) if c in ctx.relations else {c}
        return out
    r = ctx.relations.get(rid) or {}
    return {x for x in (r.get("lhs"), r.get("rhs"), r.get("magnitude_claim"), r.get("years")) if x}


# ── parsing: tolerant, meaning-preserving normalizations only ───────────────────────────────────────────────
_KIND_ALIASES = {"OUTLOOK": "OUTLOOK", "BUSINESS OUTLOOK": "OUTLOOK", "GROWTH": "OUTLOOK", "DEMAND": "OUTLOOK",
                 "EARNINGS": "EARNINGS", "MARGINS": "EARNINGS", "PROFITABILITY": "EARNINGS",
                 "COMPETITION": "COMPETITION", "COMPETITIVE LANDSCAPE": "COMPETITION", "MOAT": "COMPETITION",
                 "COMPETITIVE POSITION": "COMPETITION", "RISK": "RISK", "RISKS": "RISK", "BUSINESS RISKS": "RISK",
                 "MANAGEMENT": "MANAGEMENT", "CAPITAL ALLOCATION": "MANAGEMENT", "VALUATION": "VALUATION",
                 "MULTIPLES": "VALUATION", "INTRINSIC VALUE": "VALUATION", "EXPECTATIONS": "EXPECTATIONS",
                 "PRICED IN": "EXPECTATIONS", "WHAT THE PRICE REQUIRES": "EXPECTATIONS", "OWNERSHIP": "OWNERSHIP"}
_VERDICT_ALIASES = {"FAILS": "FAILS", "FAIL": "FAILS", "REFUTED": "FAILS", "WRONG": "FAILS", "FALSE": "FAILS",
                    "DISPUTED": "FAILS", "PARTLY": "PARTLY", "PARTIAL": "PARTLY", "MIXED": "PARTLY",
                    "PARTLY HOLDS": "PARTLY", "HOLDS": "HOLDS", "VALID": "HOLDS", "STANDS": "HOLDS",
                    "CONCEDED": "HOLDS", "AGREE": "HOLDS", "CONCEDE": "HOLDS"}
_ID = re.compile(r"[A-Za-z][A-Za-z0-9_]*")


def coerce_ids(x):
    """A list of base claim ids from a list or "A, B" or "[[A]]" or "A (label)"; never fuzzy-matched."""
    items = x if isinstance(x, list) else ([x] if isinstance(x, str) else [])
    out = []
    for it in items:
        if not isinstance(it, str):
            continue
        for part in re.split(r"[,;\s]+(?=\[?\[?[A-Za-z])", it.strip()):
            m = _ID.search(part.replace("[", " ").replace("]", " ").replace("{", " ").replace("}", " "))
            if not m:
                continue
            cid = m.group(0).upper().split("__")[0]
            cid = C.PHRASE_OF.get(cid, cid)            # a signed return is cited through its phrase claim
            if cid not in out:
                out.append(cid)
    return out


def _kind(x):
    return _KIND_ALIASES.get(re.sub(r"\s+", " ", str(x or "")).strip().upper())


def _text(x):
    if not isinstance(x, str):
        return None
    t = re.sub(r"\s+", " ", x).strip().strip('"').strip()
    t = re.sub(r"^[-•*]\s+", "", t)
    return t or None


def _view_of(d):
    for k in ("view", "point", "text", "judgement", "judgment", "argument"):
        if isinstance(d.get(k), str):
            return _text(d[k])
    return None


def _evidence_of(d):
    for k in ("evidence", "evidence_ids", "claim_ids", "claims", "ids", "tokens", "citations"):
        if k in d:
            return coerce_ids(d[k])
    return []


def is_structured(raw):
    """True when a persona reply carries structured-argument objects."""
    st = (raw or {}).get("structured") or {}
    pts = st.get("points") or st.get("factors") or st.get("answers")
    return isinstance(pts, list) and any(isinstance(p, dict) and _view_of(p) for p in pts)


# ── text rules T1–T11 (typed values or closed grammatical classes only; no lexicon) ─────────────────────────────
_RAW_ID = P.IDENTIFIER                    # one identifier class for authoring, ingestion and the report audit
_QUOTES = re.compile(r"[\"“”]|(?<![A-Za-z])['‘][^'’]{12,}['’](?![A-Za-z])")
_BAND_WORDS = tuple(b for b, _lo, _hi in P.MAGNITUDE_BANDS)
_SPELLED_CARDINAL = re.compile(r"\b(two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|"
                               r"sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety|"
                               r"hundred|thousand|million|billion|trillion)\b", re.I)


def text_reasons(ctx, text, *, max_words, form=True):
    """T1–T5 on an authored text: shape, numerals, rating labels, internal vocabulary, form."""
    r = []
    if not text:
        return ["EMPTY"]
    if "[[" in text or "]]" in text or "{" in text or "}" in text:
        r.append("TOKEN_IN_VIEW: claim ids go in evidence, never in the text")
    m = _RAW_ID.search(text)
    if m or any(re.search(r"\b" + re.escape(c) + r"\b", text) for c in ctx.offer):
        r.append(f"ID_TEXT_IN_VIEW: '{m.group(0) if m else 'claim id'}'")
    if _QUOTES.search(text):
        r.append("QUOTE_IN_VIEW: quotations are not published in authored text")
    if re.search(r"\d", text):
        r.append("NUMERAL: the report prints every figure from the evidence")
    else:
        low = P._dash(text.lower())
        wm = P.MATERIAL_NUMBER.search(P.BENIGN.sub(" ", text))
        band = next((b for b in _BAND_WORDS if b in low or b.replace("-", " ") in low), None)
        sc = _SPELLED_CARDINAL.search(re.sub(r"\b[a-z]+[-\u2010\u2011 ](?:month|year|quarter|day|week)s?\b", " ", low))
        if wm or band or sc:
            r.append(f"NUMERAL: '{(wm.group(0) if wm else band or sc.group(0)).strip()}'")
    if P.RATING_WORD.search(text):
        r.append("RATING_WORD: the report prints each rating itself")
    pl = P.public_language_violation(text, ctx.internal_terms)
    if pl or (ctx.qa_vocab is not None and ctx.qa_vocab.search(text)):
        r.append(f"INTERNAL_VOCAB: {pl or 'QA vocabulary'}")
    low = text.lower()
    for t in getattr(ctx.rules, "engine_forbidden", ()) or ():
        if re.search(r"\b" + re.escape(t.lower()) + r"\b", low) and not P.DENIAL.search(low):
            r.append(f"INTERNAL_VOCAB: '{t}' is not this issuer's valuation vocabulary")
            break
    for topic, phrases in ((P.PRICE_MODEL_TOPIC, getattr(ctx.rules, "quant_forbidden_phrases", ()) or ()),
                           (P.PANEL_TOPIC, getattr(ctx.rules, "panel_forbidden_phrases", ()) or ())):
        if topic.search(text) and any(re.search(r"\b" + re.escape(ph.lower()) + r"\b", low) for ph in phrases):
            r.append("INTERNAL_VOCAB: describes a quantitative model more strongly than its reliability allows")
            break
    n = len([w for w in re.split(r"\s+", text.strip()) if w])
    if n > max_words:
        r.append(f"WORD_LIMIT: {n} words (at most {max_words})")
    if re.search(r"(?:…|\.\.\.)\W*$", text.strip()) or not re.search(r"[.!?][\"'”’)\]]*$", text.strip()):
        r.append("INCOMPLETE: not a complete sentence")
    if form and P.ANAPHORIC_OPENING.search(text):
        r.append("ANAPHORIC_OPENING: a point stands on its own")
    return r


# ── claim lineage: engine / company-guidance figures against consensus (typed provenance, never a word list) ──────
# the source class of a claim, by its typed family; the subject it measures, by its economic concept
_CONSENSUS_IDS = frozenset({"CONSENSUS_EPS_FY1", "CONSENSUS_EPS_FY2", "CONSENSUS_EPS_NTM", "CONSENSUS_REVENUE_FY1",
                            "CONSENSUS_REVENUE_FY2", "CONSENSUS_REVENUE_NTM", "LIFECYCLE_FORWARD_GROWTH"})
_SUBJECT_OF_CONCEPT = {"REVENUE": "revenue", "REVENUE_GROWTH": "revenue", "ORGANIC_REVENUE_GROWTH": "revenue",
                       "EPS": "eps", "EPS_GROWTH": "eps"}
# the ONE relation that may compare each (source, subject) with consensus
_CONNECT = {("ENGINE", "revenue"): "REL_ENGINE_REVENUE_CONSENSUS", ("ENGINE", "eps"): "REL_FORECAST_EPS_CONSENSUS",
            ("GUIDANCE", "eps"): "REL_GUIDANCE_EPS_CONSENSUS", ("GUIDANCE", "revenue"): None}
# how a reader names each source class: the catalog's own labels (a consensus claim may never be labelled with the
# engine's or the company's words, and vice versa: catalog._CONSENSUS_WORDS / _FORECAST_WORDS) and prose.GUIDANCE_WORDS
_CLASS_NAMES = {"CONSENSUS": tuple(w for w in C._FORECAST_WORDS if "consensus" in w) + ("street",),
                "ENGINE": tuple(w for w in C._CONSENSUS_WORDS if w != "guidance") + ("engine's forecast", "our estimate")}


def provenance(cid, cat):
    """ENGINE / GUIDANCE / CONSENSUS for a claim, or None."""
    if getattr(cat.get(cid), "stype", None) == C.GUIDANCE:
        return "GUIDANCE"
    if cid in _CONSENSUS_IDS:
        return "CONSENSUS"
    if str(cid).startswith("FORECAST_"):
        return "ENGINE"
    return None


def _subject(ctx, cid):
    try:
        m = C._claim_metric(ctx.state, cid)
    except Exception:  # noqa: BLE001
        return None
    return _SUBJECT_OF_CONCEPT.get(getattr(m, "economic_concept", None)) if m is not None else None


def _names_class(low, cls):
    if cls == "GUIDANCE":
        return bool(P.GUIDANCE_WORDS.search(low))
    return any(re.search(r"\b" + re.escape(n) + r"\b", low) for n in _CLASS_NAMES.get(cls, ()))


def lineage_reasons(ctx, text, scope, cocite=True, named=True):
    """An engine or company-guidance figure compared with consensus only through the typed relation that establishes
    the comparison (same horizon, perimeter, basis; an engine figure independent of consensus). Two checks, both typed,
    neither reading the predicate (aligned, below, close to ... are never enumerated):
    CO-CITATION — the text's evidence holds an engine/guidance claim and a consensus claim of the same subject;
    NAMED COUNTERPART — the text cites one side and names the other source class by the catalog's own class labels
    (catalog._FORECAST_WORDS / _CONSENSUS_WORDS, prose.GUIDANCE_WORDS) without citing it: two sources of one subject in
    one statement are a comparison. Either way the connecting relation must be cited; where none exists the figures may
    be stated in separate statements.
    ``cocite`` is off for a thesis, whose scope is the union of its points' evidence rather than its own citation;
    ``named`` is off for template sentences (typed renderings, audited by co-citation only)."""
    ids = list(dict.fromkeys(scope or []))
    rels = [e for e in ids if e in ctx.relations]
    own = {}
    for e in ids:
        members = covered(ctx, e) if e in ctx.relations else {e}
        for x in members:
            own.setdefault(x, e)                       # the cited id a claim arrived through (itself, or its relation)
    typed = {x: (provenance(x, ctx.cat), _subject(ctx, x)) for x in own}

    def need(side, subj, a, b=None):
        rel = _CONNECT.get((side, subj))
        # the cited relation must state THIS pair (a guidance relation on the GAAP row does not cover the adjusted row)
        if rel and rel in rels and {a, b} - {None} <= covered(ctx, rel):
            return None
        code = "GUIDANCE_CONSENSUS_COMPATIBILITY" if side == "GUIDANCE" else "FORECAST_RELATION_REQUIRED"
        what = f"{a} and {b}" if b else f"{a} and consensus"
        if rel and rel in ctx.relations:
            return f"{code}: {what} are compared only through {rel}"
        need_ = ("the same fiscal period and horizon, a compatible accounting and security basis" if side == "GUIDANCE"
                 else "the same horizon, perimeter and basis, and an engine figure independent of consensus")
        return f"{code}: no typed relation compares {what} (it needs {need_}); state them separately"
    for a, (pa, sa) in (typed.items() if cocite else ()):
        for b, (pb, sb) in typed.items():
            if pa in ("ENGINE", "GUIDANCE") and pb == "CONSENSUS" and sa and sa == sb:
                r = need(pa, sa, a, b)                 # None when the connecting relation itself is cited
                if r:
                    return [r]
    low = P._dash(str(text or "").lower())
    if named:
        present = {(p, s) for p, s in typed.values() if p and s}
        for side, subj in present:
            if side in ("ENGINE", "GUIDANCE") and ("CONSENSUS", subj) not in present and _names_class(low, "CONSENSUS"):
                r = need(side, subj, next(x for x, v in typed.items() if v == (side, subj)))
                if r:
                    return [r]
            if side == "CONSENSUS":
                for other in ("GUIDANCE", "ENGINE"):
                    if (other, subj) not in present and _names_class(low, other):
                        r = need(other, subj, next(x for x, v in typed.items() if v == (side, subj)))
                        if r:
                            return [r]
    return []


def _probe(text, scope):
    return text + " ; " + " ".join(f"[[{e}]]" for e in scope)


def direction_reasons(ctx, text, scope, own, *, cocite=True):
    """T6–T11: the text against its evidence SCOPE and the author's own return (typed values only)."""
    R = ctx.rules
    r = []
    vrels = R.valuation_relations or {}
    probe = _probe(text, scope)
    vr = P.valuation_relation_violation(probe, vrels) or P.universal_valuation_violation(text, vrels)
    if vr:
        r.append(f"VALUATION_CONTRADICTS: {vr}")
    in_scope = [e for e in scope if e in vrels]
    words = [w for t in vrels.values() for w in _VALUATION_WORDS.get(t, ())]
    low = text.lower()
    hit = next((w for w in _DIR_OF_WORD if re.search(r"\b" + w + r"\b", low)), None)
    if hit and not in_scope and not P.DENIAL.search(low):
        r.append(f"VALUATION_UNCITED: '{hit}' needs a cited valuation relation")
    elif hit and in_scope and not P.DENIAL.search(low):
        want = _DIR_OF_WORD.get(hit)
        if want and all(vrels[e] != want for e in in_scope):
            r.append(f"VALUATION_CONTRADICTS: '{hit}' but every cited anchor holds the other direction")
    av = P.assessment_violation(probe, R.assessments, R.assumption_claims)
    if av:
        r.append(f"ASSESSMENT_CONTRADICTS: {av}")
    if own.get("value") is not None:
        rd = P.return_direction_violation(text, own["value"])
        if rd:
            r.append(f"RETURN_SIGN: {rd}")
    # speech attributed to management needs its own quoted words, and quotations are not published in authored text
    # (T1): so no statement may say what management said, whether or not a transcript exists
    sp = re.search(P._SPEECH, low)
    if sp and (re.search(_SPEAKERS, low[:sp.start()]) or any(w and w in low[:sp.start()] for w in R.company_words)):
        r.append("ATTRIBUTION: a statement about what management said is not publishable without its quoted words")
    for m in P.ROLE_NAME.finditer(text):
        role = {"CHIEF EXECUTIVE OFFICER": "CEO", "CHIEF FINANCIAL OFFICER": "CFO",
                "CHIEF OPERATING OFFICER": "COO"}.get(m.group(1).upper(), m.group(1).upper())
        canon = (R.officers or {}).get(role)
        if not canon or not P._same_person(m.group(2).strip(), canon):
            r.append(f"OFFICER: names {m.group(2).strip()} as {role}")
            break
    if P.GUIDANCE_WORDS.search(text) and not any(str(e).startswith("GUIDANCE_") for e in scope) and not P.DENIAL.search(low):
        r.append("GUIDANCE_UNCITED: a statement about guidance needs a cited guidance row")
    lc = P.lifecycle_violation(text, ctx.cat, R.company_words)
    if lc:
        r.append(f"LIFECYCLE: {lc}")
    r += lineage_reasons(ctx, text, scope, cocite=cocite)
    return r


# who can be said to have said something (the company's people; "guidance" is a document, not a speaker)
_SPEAKERS = r"\b(?:management|the company|company|executives?|ceo|cfo|coo|chief executive|chief financial|leadership)\b"
# evaluative valuation adjectives describe the stock outright; "premium"/"discount" are valuation words only next to an
# anchor, which valuation_relation_violation already binds (a "premium product" is not a valuation claim)
_DIR_OF_WORD = {"cheap": "BELOW", "undervalued": "BELOW", "expensive": "ABOVE", "overvalued": "ABOVE"}
_VALUATION_WORDS = {"ABOVE": ("expensive", "overvalued", "premium"), "BELOW": ("cheap", "undervalued", "discount")}


def covered(ctx, rid):
    """What a cited relation already states: its constituents and, for the expectations frontier, its case relations."""
    out = set(constituents(ctx, rid))
    if rid == "REL_EXPECTATIONS_STORY":
        out |= {c for ids in ctx.cases.values() for c in ids}
    return out


def evidence_reasons(ctx, ids, kind, *, allowed=None, required=True, max_ids=None):
    """Evidence rules (typed): known, offered, allowed, pairs, single case. Any offered id may support any kind: the kind
    labels the point, and the pair and relation rules protect what the evidence can show (the per-kind lists dropped
    valid evidence: a buyback guidance row under MANAGEMENT, an answer outside its target's kind). An id that a cited
    relation already states adds no evidence, so it does not count toward the limit."""
    r = []
    cited = [e for e in ids if e in ctx.relations]
    cov = set().union(*[covered(ctx, x) for x in cited]) if cited else set()
    n = len([e for e in ids if e not in cov])
    if n > (max_ids or LIMITS["evidence"]):
        r.append(f"EVIDENCE_COUNT: at most {max_ids or LIMITS['evidence']} ids")
    for e in ids:
        if ctx.cat.get(e) is None:
            r.append(f"UNKNOWN_ID: {e}")
        elif e not in ctx.offer:
            r.append(f"WITHHELD_EVIDENCE: {e}")
        elif allowed is not None and e not in allowed:
            r.append(f"ID_NOT_ALLOWED: {e} cannot support this point")
    if required and not ids:
        r.append("NO_EVIDENCE: every point cites at least one id")
    if r:
        return r
    rels_c = [e for e in ids if e in ctx.relations]
    facts = [e for e in ids if e not in ctx.relations]
    K = set().union(*[constituents(ctx, x) for x in rels_c]) if rels_c else set()
    pair_fn = getattr(ctx.rules, "pair_fn", None)
    if pair_fn is not None:
        for i, a in enumerate(facts):
            for b in facts[i + 1:] + sorted(K - {a}):
                if a == b or (b in K and a in K):
                    continue
                try:
                    st = pair_fn(a, b)
                except Exception:  # noqa: BLE001
                    st = None
                if st is None:
                    continue
                if st[0] == "RELATION":
                    if st[1] in rels_c:
                        continue
                    return [f"PAIR_RELATION_REQUIRED: {a} and {b} are compared only through {st[1]}"]
                if st[0] == "OK":
                    return [f"PAIR_UNRELATED: {a} and {b} have no typed relation (cite the relation, or put them in "
                            "separate points)"]
                if benign_cocitation(ctx, a, b):
                    continue
                return [f"PAIR_INCOMPATIBLE: {a} and {b} ({'; '.join(st[1])[:90]})"]
        for i, x in enumerate(rels_c):          # across two cited relations only an incompatible pair drops
            for y in rels_c[i + 1:]:
                for a in constituents(ctx, x):
                    for b in constituents(ctx, y):
                        if a == b:
                            continue
                        try:
                            st = pair_fn(a, b)
                        except Exception:  # noqa: BLE001
                            st = None
                        if st and st[0] == "INCOMPATIBLE":
                            return [f"PAIR_INCOMPATIBLE: {x} and {y} ({'; '.join(st[1])[:90]})"]
    # a property of the evidence set, whatever the kind (a RISK point, a PM factor or a summary item citing both solves
    # framed them as one joint requirement in run 8d12481bffa6e2e6)
    if ctx.cases and "REL_EXPECTATIONS_STORY" not in ids:
        hit = [c for c, cids in ctx.cases.items() if set(ids) & set(cids)]
        if len(hit) > 1:
            return ["EXPECTATIONS_JOINT_CASES: the revenue and margin solves are alternatives; cite REL_EXPECTATIONS_STORY"]
    return r


def benign_cocitation(ctx, a, b):
    """Two facts that may not be COMPARED can still share a point when citing them together cannot read as a
    like-for-like comparison of one quantity: different concepts on the same perimeter, basis and quantity type
    (operating and net margin; reported and organic growth), or the same concept for consecutive fiscal years (a
    trajectory, FY1 then FY2). A perimeter, accounting-basis or level/change mismatch, or partly overlapping periods,
    still drop: those are the measured defects (reviewers judged the rest of the pair drops good content)."""
    import datetime as _dt
    try:
        ma, mb = C._claim_metric(ctx.state, a), C._claim_metric(ctx.state, b)
    except Exception:  # noqa: BLE001
        return False
    if ma is None or mb is None or ma.perimeter != mb.perimeter:
        return False
    na = ("NA", None, "")
    if ma.accounting_basis not in na and mb.accounting_basis not in na and ma.accounting_basis != mb.accounting_basis:
        return False
    if ma.quantity_type and mb.quantity_type and ma.quantity_type != mb.quantity_type:
        return False
    if ma.economic_concept != mb.economic_concept:
        return True
    try:
        pairs = [(_dt.date.fromisoformat(str(x.period_start)[:10]), _dt.date.fromisoformat(str(x.period_end)[:10]))
                 for x in (ma, mb)]
    except (TypeError, ValueError):
        return False
    (s1, e1), (s2, e2) = sorted(pairs)
    return 0 <= (s2 - e1).days <= 5


def cross_reasons(ctx, ids, target_ids):
    """An answer's evidence against the evidence of the point it answers: only an INCOMPATIBLE pair drops (different
    period, perimeter or basis); unrelated quantities may answer each other."""
    pair_fn = getattr(ctx.rules, "pair_fn", None)
    if pair_fn is None:
        return []

    def expand(xs):
        out = set()
        for x in xs:
            out |= covered(ctx, x) if x in ctx.relations else {x}
        return out
    for a in sorted(expand(ids)):
        for b in sorted(expand(target_ids)):
            if a == b:
                continue
            try:
                st = pair_fn(a, b)
            except Exception:  # noqa: BLE001
                st = None
            if st and st[0] == "INCOMPATIBLE" and not benign_cocitation(ctx, a, b):
                return [f"PAIR_INCOMPATIBLE_WITH_TARGET: {a} and {b} ({'; '.join(st[1])[:90]})"]
    return []


def _labels(x):
    """[(prefix, number)] from [1, 3] / ["P1", "A2"] / "1, 3": each entry a bare number or ONE letter + number."""
    items = x if isinstance(x, list) else ([x] if isinstance(x, (str, int)) and not isinstance(x, bool) else [])
    out = []
    for it in items:
        if isinstance(it, bool):
            continue
        if isinstance(it, int):
            out.append(("", it))
            continue
        for part in re.split(r"[,;]\s*", str(it)):
            m = re.fullmatch(r"\s*([A-Za-z]?)\s*(\d+)\s*", part)
            if m:
                out.append((m.group(1).upper(), int(m.group(2))))
    return out


def _positions(x):
    return [k for _pre, k in _labels(x)]


def rests_on(raw, objs, own="P", by_target=False):
    """The validated numbers (n) of the objects a thesis rests on. A bare number or the author's own prefix names its
    own object by position (P for a seat's points, A for a rebuttal's answers, F for the PM's factors); in a rebuttal an
    opponent P-label names the answer addressed to that point. Any other label names nothing (never a digit match)."""
    by_src = {o.get("src"): o["n"] for o in objs or []}
    by_to = {o.get("to"): o["n"] for o in objs or []} if by_target else {}
    out = []
    for pre, k in _labels(raw):
        if pre in ("", own):
            n = by_src.get(k)
        elif by_target and pre == "P":
            n = by_to.get(f"P{k}")
        else:
            n = None
        if n is not None and n not in out:
            out.append(n)
    return out


def _norm(t):
    return re.sub(r"[^a-z0-9 ]", "", re.sub(r"\s+", " ", str(t or "").lower())).strip()


def _contains(big, small):
    n = len(small)
    return any(big[i:i + n] == small for i in range(len(big) - n + 1)) if n else False


def same_text(reply, opening):
    """Typed identity on whole tokens: the reply IS the opening, a contiguous fragment of it, or the opening with at
    most a fifth of new words. A longer reply that merely contains a short opening says something new."""
    x, y = _norm(reply).split(), _norm(opening).split()
    if not x or not y:
        return False
    return x == y or _contains(y, x) or (_contains(x, y) and len(y) >= 0.8 * len(x))


def _own(value, label, return_claim=None):
    return {"value": value, "label": label, "return_claim": return_claim}


def validate_points(ctx, raw_points, *, own, limit, source, allowed=None):
    """[validated point dicts], [drops]. Validated points are numbered P1… in order (frozen when forwarded)."""
    out, drops, seen = [], [], set()
    for i, p in enumerate(raw_points if isinstance(raw_points, list) else []):
        if len(out) >= limit:
            drops.append({"source": f"{source}.{i}", "reason": "OVERFLOW", "raw": p})
            continue
        if not isinstance(p, dict):
            drops.append({"source": f"{source}.{i}", "reason": "SCHEMA: not an object", "raw": p})
            continue
        kind, view, ev = _kind(p.get("kind") or p.get("topic")), _view_of(p), _evidence_of(p)
        rs = []
        if kind is None or kind not in ctx.kinds:     # a kind with no permitted evidence (e.g. OWNERSHIP when 13F and
            rs.append(f"KIND_UNKNOWN: {p.get('kind') or p.get('topic')!r}")   # insiders are descriptive-only) is not open
        else:
            rs += evidence_reasons(ctx, ev, kind, allowed=None if allowed is None else set(allowed) & set(ctx.offer))
        rs += text_reasons(ctx, view, max_words=LIMITS["view"])
        if not rs:
            rs += direction_reasons(ctx, view, ev, own)
        key = (kind, tuple(sorted(ev)))
        if not rs and key in seen:
            rs.append("DUPLICATE: same kind and evidence as an earlier point")
        if not rs and any(_norm(view) == _norm(o["view"]) for o in out):
            rs.append("DUPLICATE: the same words as an earlier point")
        if rs:
            drops.append({"source": f"{source}.{i}", "reason": "; ".join(rs)[:400], "raw": p})
            continue
        seen.add(key)
        out.append({"n": len(out) + 1, "src": i + 1, "kind": kind, "evidence": ev, "view": view})
    return out, drops


def validate_thesis(ctx, text, *, own, max_words, source, scope=(), rests=None, named=True, repeat_of=None):
    """A thesis is judged against the evidence of the points it RESTS ON (``rests``: their validated numbers). A thesis
    whose points were all removed has nothing under it (run 8d12481bffa6e2e6 published "earnings quality is overstated
    by adjustments and buyback dependency" after both supporting points were dropped)."""
    t = _text(text)
    if not t:
        return None, []
    rs = text_reasons(ctx, t, max_words=max_words, form=False)
    if rests is not None and not rests:
        rs.append("THESIS_ORPHANED: " + ("name the points it rests on in thesis_rests_on" if not named
                                         else "every point it rests on was removed"))
    if repeat_of and same_text(t, repeat_of):
        rs.append("REPEATS_OPENING: the reply restates the opening thesis; say what the exchange changed")
    if not rs:
        rs = direction_reasons(ctx, t, list(scope), own, cocite=False)
    if rs:
        return None, [{"source": source, "reason": "; ".join(rs)[:400], "raw": t}]
    return t, []


def _scope(points):
    return [e for p in points or [] for e in p.get("evidence") or []]


def validate_seat(ctx, role, reply, *, own):
    """A round-1 seat (neutral / bear / bull) → the argument set and the drops."""
    st = (reply or {}).get("structured") or {}
    pts, d2 = validate_points(ctx, st.get("points"), own=own, limit=LIMITS["points"], source=f"{role}.points")
    rest = rests_on(st.get("thesis_rests_on"), pts, own="P")
    thesis, d1 = validate_thesis(ctx, st.get("thesis"), own=own, max_words=LIMITS["thesis"], source=f"{role}.thesis",
                                 scope=_scope([p for p in pts if p["n"] in rest]), rests=rest,
                                 named=bool(_labels(st.get("thesis_rests_on"))))
    return {"schema": SCHEMA, "role": role, "format": "v2", "thesis": thesis, "thesis_rests_on": rest if thesis else [],
            "points": pts}, d1 + d2


def validate_rebuttal(ctx, role, reply, *, own, opponent, opponent_role, r1_value, r1_label, opening_thesis=None):
    """A rebuttal: answers point at the opponent's VALIDATED points (P-labels); the stance is derived from the numbers."""
    st = (reply or {}).get("structured") or {}
    targets = {f"P{p['n']}": p for p in (opponent or {}).get("points") or []}
    stance = stance_of(r1_value, r1_label, own.get("value"), own.get("label"))
    if not targets:                  # nothing validated to answer: not the author's to fix, so never a repair request
        drops = [{"source": f"{role}.rebuttal", "reason": "NO_TARGETS: the opponent has no validated points", "raw": st}] \
            if (st.get("answers") or st.get("thesis")) else []
        return {"schema": SCHEMA, "role": role, "format": "v2", "thesis": None, "thesis_rests_on": [], "answers": [],
                "stance": stance}, drops
    answers, used, drops = [], set(), []
    for i, a in enumerate(st.get("answers") if isinstance(st.get("answers"), list) else []):
        src = f"{role}.rebuttal.answers.{i}"
        if len(answers) >= LIMITS["answers"]:
            drops.append({"source": src, "reason": "OVERFLOW", "raw": a})
            continue
        if not isinstance(a, dict):
            drops.append({"source": src, "reason": "SCHEMA: not an object", "raw": a})
            continue
        m = re.search(r"(\d+)", str(a.get("to") or ""))
        lab = f"P{int(m.group(1))}" if m else None
        tgt = targets.get(lab)
        verdict = _VERDICT_ALIASES.get(str(a.get("verdict") or "").strip().upper())
        ev, view = _evidence_of(a), _view_of(a)
        rs = []
        if tgt is None:
            rs.append(f"BAD_TARGET: {a.get('to')!r} is not one of the opponent's points")
        elif lab in used:
            rs.append("BAD_TARGET: the point was already answered")
        if verdict is None:
            rs.append(f"VERDICT_UNKNOWN: {a.get('verdict')!r}")
        if tgt is not None:
            # the author's whole offer, checked for incompatibility against the evidence of the point it answers
            rs += evidence_reasons(ctx, ev, None, required=(verdict == "FAILS"), max_ids=LIMITS["answer_evidence"]) or \
                cross_reasons(ctx, ev, tgt.get("evidence") or [])
        rs += text_reasons(ctx, view, max_words=LIMITS["view"])
        if not rs:
            rs += direction_reasons(ctx, view, ev + list((tgt or {}).get("evidence") or []), own)
        if rs:
            drops.append({"source": src, "reason": "; ".join(rs)[:400], "raw": a})
            continue
        used.add(lab)
        answers.append({"n": len(answers) + 1, "src": i + 1, "to": lab, "to_role": opponent_role, "to_kind": tgt["kind"],
                        "to_view": tgt["view"], "to_evidence": list(tgt.get("evidence") or []), "verdict": verdict,
                        "evidence": ev, "view": view})
    rest = rests_on(st.get("thesis_rests_on"), answers, own="A", by_target=True)
    thesis, d1 = validate_thesis(ctx, st.get("thesis"), own=own, max_words=LIMITS["rebuttal_thesis"],
                                 source=f"{role}.rebuttal.thesis", scope=_scope([x for x in answers if x["n"] in rest]),
                                 rests=rest, named=bool(_labels(st.get("thesis_rests_on"))), repeat_of=opening_thesis)
    return {"schema": SCHEMA, "role": role, "format": "v2", "thesis": thesis, "thesis_rests_on": rest if thesis else [],
            "answers": answers, "stance": stance}, d1 + drops


def stance_of(r1_value, r1_label, value, label):
    """HOLD / REVISED_RETURN / REVISED_LABEL from the numbers (the typed stance, never authored)."""
    if r1_label and label and r1_label != label:
        return "REVISED_LABEL"
    if r1_value is not None and value is not None and abs(float(value) - float(r1_value)) >= 0.005:
        return "REVISED_RETURN"
    return "HOLD"


def debate_evidence(sets):
    """The ids the validated seats cited (the PM and the summary may not introduce new evidence)."""
    return {e for a in (sets or {}).values() if a for key in ("points", "answers") for p in a.get(key) or []
            for e in p.get("evidence") or []}


def validate_pm(ctx, reply, *, own, debate_ids=None):
    st = (reply or {}).get("structured") or {}
    if debate_ids is not None and not debate_ids:       # the debate published no evidence: nothing may be cited
        return {"schema": SCHEMA, "role": "pm", "format": "v2", "thesis": None, "thesis_rests_on": [], "points": []}, \
            ([{"source": "pm", "reason": "NO_ALLOWED_EVIDENCE: the validated debate cited no evidence", "raw": st}] if st else [])
    pts, d2 = validate_points(ctx, st.get("factors") or st.get("points"), own=own, limit=LIMITS["factors"],
                              source="pm.factors", allowed=debate_ids)
    rest = rests_on(st.get("thesis_rests_on"), pts, own="F")
    thesis, d1 = validate_thesis(ctx, st.get("thesis"), own=own, max_words=LIMITS["pm_thesis"], source="pm.thesis",
                                 scope=_scope([p for p in pts if p["n"] in rest]), rests=rest,
                                 named=bool(_labels(st.get("thesis_rests_on"))))
    return {"schema": SCHEMA, "role": "pm", "format": "v2", "thesis": thesis, "thesis_rests_on": rest if thesis else [],
            "points": pts}, d1 + d2


def validate_summary(ctx, summ, *, own, debate_ids=None, debate_texts=()):
    """The executive summary: a figure-free text and view-first items citing only debate evidence, each in the
    summary's own words (a verbatim copy of a debate object would be printed twice)."""
    summ = summ or {}
    drops, items = [], {}
    if debate_ids is not None and not debate_ids:
        return {"schema": SCHEMA, "role": "summary", "format": "v2", "thesis": None, "items_thesis": [], "items_risks": []}, \
            ([{"source": "summary", "reason": "NO_ALLOWED_EVIDENCE: the validated debate cited no evidence", "raw": summ}]
             if summ else [])
    for key in ("thesis", "risks"):
        out = []
        for i, it in enumerate(summ.get(key) if isinstance(summ.get(key), list) else []):
            src = f"{key}.{i}"
            if len(out) >= LIMITS["items"]:
                break
            if not isinstance(it, dict):
                drops.append({"source": src, "reason": "SCHEMA: not an object", "raw": it})
                continue
            ev, view = _evidence_of(it), _view_of(it)
            rs = evidence_reasons(ctx, ev, None, allowed=(set(debate_ids) if debate_ids is not None else None)) + \
                text_reasons(ctx, view, max_words=LIMITS["item"])
            if not rs and any(_norm(view) == _norm(d) for d in debate_texts or ()):
                rs.append("COPIES_DEBATE: write the reason in the summary's own words")
            if not rs:
                rs = direction_reasons(ctx, view, ev, own)
            if rs:
                drops.append({"source": src, "reason": "; ".join(rs)[:400], "raw": it})
                continue
            out.append({"n": len(out) + 1, "kind": None, "evidence": ev, "view": view})
        items[key] = out
    alli = items["thesis"] + items["risks"]
    text, d0 = validate_thesis(ctx, summ.get("summary"), own=own, max_words=LIMITS["summary"], source="summary",
                               scope=_scope(alli), rests=[1] if alli else [])
    return {"schema": SCHEMA, "role": "summary", "format": "v2", "thesis": text, "items_thesis": items["thesis"],
            "items_risks": items["risks"]}, d0 + drops


# ── rendering: every figure from a typed claim ────────────────────────────────────────────────────────────────
def _perimeter_note(state):
    ev = ((state.historical.get("perimeter") or {}).get("event") or {})
    return f"before the separation of {ev.get('business')}" if ev.get("business") else None


_READER = {  # claim id -> (text before the value, text after the value); qualifiers are added from the state
    "REVENUE_TTM": ("trailing revenue ", ""), "OPERATING_MARGIN_TTM": ("trailing operating margin ", ""),
    "NET_MARGIN_TTM": ("trailing net margin ", ""), "NET_INCOME_TTM": ("trailing net income ", ""),
    "OPERATING_INCOME_TTM": ("trailing operating income ", ""), "EPS_ADJUSTED_TTM": ("trailing adjusted EPS ", ""),
    "EPS_GAAP_TTM": ("trailing reported EPS ", ""),
    "BEAT_RATE": ("EPS beat estimates in ", " of recent quarters"),
    "ADJ_GAAP_BRIDGE": ("adjusted EPS at ", " times reported EPS (trailing)"),
    "FORECAST_REVENUE": ("the engine's revenue forecast for the next twelve months ", ""),
    "FORECAST_REVENUE_GROWTH": ("the engine's forecast revenue growth for the next twelve months ", ""),
    "FORECAST_OPERATING_MARGIN": ("the engine's operating margin forecast for the next twelve months ", ""),
    "FORECAST_EPS": ("the engine's EPS forecast for the next twelve months ", ""),
    "CONSENSUS_EPS_FY1": ("consensus EPS for the current fiscal year ", ""),
    "CONSENSUS_EPS_FY2": ("consensus EPS for the next fiscal year ", ""),
    "CONSENSUS_REVENUE_FY1": ("consensus revenue for the current fiscal year ", ""),
    "CONSENSUS_REVENUE_NTM": ("consensus revenue for the next twelve months ", ""),
    "ANALYST_TARGET": ("mean analyst price target ", ""), "PRICE": ("share price ", ""),
    "MARKET_CAP": ("market capitalisation ", ""), "DIVIDEND_YIELD": ("dividend yield ", ""),
    "PE_FORWARD": ("forward P/E ", ""), "PEG": ("forward PEG ", ""),
    "INTRINSIC_VALUE": ("story value ", ""), "MECHANICAL_VALUE": ("mechanical DCF value ", ""),
    "MARKET_COMPOSITE": ("market composite ", ""), "LIFECYCLE_STAGE": ("life-cycle stage ", ""),
    "LIFECYCLE_FORWARD_GROWTH": ("consensus revenue growth for the next fiscal year ", ""),
    "LIFECYCLE_TRAILING_GROWTH": ("trailing revenue growth ", ""),
    "BE_REVENUE": ("revenue the price needs at maturity ", ""), "BE_MARGIN": ("operating margin the price needs ", ""),
    "STORY_MARGIN": ("story maturity operating margin ", ""), "STORY_TAM": ("story market size at maturity ", ""),
    "STORY_SHARE": ("story peak market share ", ""), "STORY_REVENUE_MATURITY": ("story revenue at maturity ", ""),
    "STORY_WACC": ("initial cost of capital ", ""), "STORY_WACC_STABLE": ("terminal (stable-period) cost of capital ", ""),
    "STORY_TERMINAL_GROWTH": ("terminal growth ", ""), "STORY_FAILURE": ("failure probability ", ""),
    "STORY_START_MARGIN": ("continuing-company starting operating margin ", ""),
    "STORY_START_REVENUE": ("continuing-company starting revenue ", ""),
    "DMD_BETA": ("bottom-up beta ", ""), "DMD_MARGIN": ("industry operating margin ", " (Damodaran industry data)"),
    "DMD_INDUSTRY": ("industry ", ""), "DMD_ERP": ("equity risk premium ", ""), "DMD_S2C": ("industry sales-to-capital ", ""),
    "SEGMENT_COUNT_CONTINUING": ("", " businesses in the continuing company"), "CEO": ("chief executive ", ""),
    "CFO": ("chief financial officer ", ""), "SMA200": ("200-day average ", ""), "MOMENTUM": ("momentum ", ""),
    "RETURN_12M_PHRASE": ("shares ", ""), "RETURN_3M_PHRASE": ("shares ", ""),
}
_TRAILING = ("REVENUE_TTM", "OPERATING_MARGIN_TTM", "NET_MARGIN_TTM", "NET_INCOME_TTM", "OPERATING_INCOME_TTM",
             "EPS_ADJUSTED_TTM", "EPS_GAAP_TTM", "ADJ_GAAP_BRIDGE", "LIFECYCLE_TRAILING_GROWTH")


def _fact(R, cid, state, cat):
    """Fragments for one fact: reader label + the claim fragment (+ perimeter qualifier)."""
    cl = cat.get(cid)
    if cl is None:
        return []
    if getattr(cl, "stype", None) == C.GUIDANCE:
        v = cl.value if isinstance(cl.value, dict) else {}
        rows = ((state.guidance.get("company") or {}).get("metrics")) or []
        try:
            label = str((rows[int(cid.split("_")[1]) - 1] or {}).get("label") or "").strip()
        except (ValueError, IndexError):
            label = ""
        label = label or str(v.get("concept") or "guidance").lower().replace("_", " ")
        basis = C.guidance_basis_label(cat, cid)
        basis = None if (basis and basis.lower() in label.lower()) else basis     # the row's label may name it already
        tail = f" ({v.get('period_label')}{', ' + basis if basis else ''})" if v.get("period_label") else \
            (f" ({basis})" if basis else "")
        return [P.Fragment(f"company guidance, {label}: "), R.c(cid), P.Fragment(tail)]
    pre, post = _READER.get(cid) or ((re.sub(r"\s*\([^)]*\)", "", cl.label or cid).strip() + " "), "")
    if not post and pre.endswith(" "):
        pre = pre.rstrip() + ": "                  # a noun label reads "label: value" (no verbless run-on)
    note = _perimeter_note(state) if cid in _TRAILING else None
    if note:
        post = post + f" ({note})"
    return [P.Fragment(pre), R.c(cid), P.Fragment(post)]


# the second and later mentions of a relation in one report: its subject (with the subject's own condition and period)
# and the relation's predicate or clause form (the first mention prints the full relation with both values). A relation
# whose condition is a value of its own (the break-even revenue "holding the operating margin at 45.0%") is never
# compacted: its compact forms drop that condition.
_SUBJECT = {"PRICE": ("The price ", "PREDICATE"), "ANALYST_TARGET": ("The mean analyst target ", "PREDICATE"),
            "STORY_START_MARGIN": ("The continuing company's operating margin ", "PREDICATE"),
            "FORECAST_EPS": ("The engine's EPS forecast for the next twelve months is ", "CLAUSE"),
            "FORECAST_REVENUE": ("The engine's revenue forecast for the next twelve months is ", "CLAUSE"),
            "BE_MARGIN": ("The operating margin today's price needs at the story's revenue is ", "CLAUSE")}


def _relation(R, rid, cat, rels, compact=False, cases_cited=False):
    """A relation as one sentence: the price relations in their predicate form after "The price", else the clause (or,
    when the report has already printed it in full, its subject and predicate)."""
    rel = rels.get(rid) or {}
    if rel.get("lhs") == "PRICE" and cat.get(f"{rid}__PREDICATE") is not None:
        return [P.Fragment("The price "), R.c(f"{rid}__PREDICATE"), P.Fragment(".")]
    if rid == "REL_EXPECTATIONS_STORY" and cat.get(f"{rid}__COMPACT") is not None:
        # the pointer to the valuation section only when the unit does not print the cases itself
        return [R.c(f"{rid}__COMPACT", cap=True), P.Fragment("." if cases_cited else
                                                             " (the valuation section sets out the revenue and the margin "
                                                             "the price needs).")]
    subj, form = _SUBJECT.get(rel.get("lhs"), (None, None))
    if compact and subj and cat.get(f"{rid}__{form}") is not None:
        return [P.Fragment(subj), R.c(f"{rid}__{form}"), P.Fragment(".")]
    return [R.c(rid, cap=True), P.Fragment(".")]


def render_evidence(R, ids, state, cat, rels, seen=None):
    """Evidence fragments: relations as sentences; the remaining facts (minus any relation's own operands) as one.
    ``seen`` is the report's evidence registry: a relation printed in full earlier in the report is printed compactly."""
    ids = list(dict.fromkeys(ids))
    cited_rels = [e for e in ids if cat.get(e) is not None and cat[e].stype == C.REL]
    operands = {x for rid in cited_rels for x in ((rels.get(rid) or {}).get("lhs"), (rels.get(rid) or {}).get("rhs")) if x}
    out = []
    case_ids = {"REL_BREAK_EVEN_REVENUE_STORY", "REL_BREAK_EVEN_MARGIN_STORY", "REL_BREAK_EVEN_REVENUE_GROWTH"}
    for rid in cited_rels:
        out += ([P.Fragment(" ")] if out else []) + _relation(R, rid, cat, rels, compact=seen is not None and rid in seen,
                                                              cases_cited=bool(case_ids & set(ids)))
        if seen is not None:
            seen.add(rid)
    facts = [e for e in ids if e not in cited_rels and e not in operands]
    parts = [_fact(R, e, state, cat) for e in facts]
    parts = [p for p in parts if p]
    if parts:
        sent = []
        for i, p in enumerate(parts):
            sent += ([P.Fragment("; ")] if i else []) + p
        first = sent[0]
        if first.text and not first.claim_id:
            sent[0] = P.Fragment(first.text[:1].upper() + first.text[1:])
        out += ([P.Fragment(" ")] if out else []) + sent + [P.Fragment(".")]
    return out


def evidence_plain(ids, cat, rels):
    """The evidence as plain text (for forwarding to the next round and for the semantic gate)."""
    bits = []
    for e in dict.fromkeys(ids):
        cl = cat.get(e)
        if cl is None:
            continue
        if cl.stype == C.REL:
            bits.append(str(cl.rendered))
        else:
            pre, post = _READER.get(e) or ((re.sub(r"\s*\([^)]*\)", "", cl.label or e).strip() + " "), "")
            pre = pre.rstrip() + ": " if not post and pre.endswith(" ") else pre
            bits.append(f"{pre}{cl.rendered}{post}".strip())
    return "; ".join(bits)


def forward_text(argset, cat, rels, *, indexed=False, label=None):
    """What the next round reads: the VALIDATED thesis and points only (never raw prose or risk flags)."""
    if not argset:
        return ""
    lines = []
    if argset.get("thesis"):
        lines.append(f"  Thesis: {P.TOKEN.sub('its own return', argset['thesis'])}")
    for p in argset.get("points") or []:
        tag = f"P{p['n']} " if indexed else "- "
        ev = evidence_plain(p.get("evidence") or [], cat, rels)
        lines.append(f"  {tag}[{KIND_LABEL.get(p.get('kind'), 'Point')}] {p['view']}" + (f" (evidence: {ev})" if ev else ""))
    head = f"{label}:\n" if label else ""
    return head + "\n".join(lines)


def evidence_menu(ctx):
    """The author's evidence menu (replaces the token header): ids by kind, with values and meanings."""
    lines = ["EVIDENCE MENU — cite these IDs in \"evidence\"; the report prints each one's figure and wording itself.",
             "ID | value | meaning"]
    for cid in sorted(ctx.offer):
        cl = ctx.cat[cid]
        meaning = re.sub(r"\s*\([^)]*complete relation[^)]*\)", "", cl.label or "")
        basis = C.guidance_basis_label(ctx.cat, cid) if cl.stype == C.GUIDANCE else None
        meaning += f" [{basis} basis]" if basis else ""
        val = f"\"{cl.rendered}\"" if cl.stype == C.REL else str(cl.rendered)
        lines.append(f"  {cid} | {val} | {meaning}")
    lines.append(f"KINDS (a label for what the point is about): {', '.join(ctx.kinds)}. Every point cites 1-3 IDs from this "
                 "menu that support it; two facts in one point need the relation that connects them; the two break-even "
                 "solves are alternatives, so cite REL_EXPECTATIONS_STORY to discuss them together.")
    return "\n".join(lines)


# ── the semantic gate: does each judgement follow from its evidence? ──────────────────────────────────────────
GATE_VERDICTS = ("OK", "UNSUPPORTED", "CONTRADICTED", "MISATTRIBUTED", "EVIDENCE_MISUSE")

# Calibrated offline (2026-09-28) against 96 objects from four live SPGI runs labelled by three reviewers: this
# rubric (the reviewers' own standard, stated generally) caught 67-71% of defective objects against 52-62% for the
# previous one, at 11-15% of good objects removed (5-8% before); published defect rate after the gate 8.6-9.5%
# (10-13% before). scratchpad calib/ holds the corpus, prompts and scores.
GATE_RUBRIC = """You check an equity report before it is published, to the standard its reviewers apply. Each OBJECT below is
a short judgement written by an analyst persona, printed next to the EVIDENCE it cites (figures rendered by the system
from verified data). An investor reads the words as claims. Decide for EACH object whether an investor reading it would
be misled or the report embarrassed.

verdict:
- CONTRADICTED: the evidence, the FACTS or the REST OF THE REPORT contradict it: a wrong direction, or a universal or
  absolute claim ("no company sustains ...", "impossible", "never", a "premium" or "discount" against peers) that any
  figure printed in the report refutes. Check such claims against every table in the report, not only the object's
  evidence.
- UNSUPPORTED: it asserts as FACT something material that no evidence, fact or call line shows: a cause, motive or
  intention read from a price series (momentum or a moving average do not show what investors think or plan); a claim
  about a quantity none of its evidence measures (a comparison with consensus for a metric that has no consensus
  figure); closeness or distance ("near", "far above") to a figure it does not cite; events, trends or history not in
  the brief.
- MISATTRIBUTED: it presents the model's own assumptions or forecasts as the company's plans or guidance, or says
  management, the company or the call said something the call excerpt does not show that speaker saying.
- EVIDENCE_MISUSE: it draws a conclusion its evidence cannot carry: figures of a different period or business perimeter,
  an industry aggregate as the company's own figure, ownership counts as conviction.
- OK: honest opinion or judgement that is hedged or plainly a view ("looks demanding", "appears attainable", "if margins
  stall ...") and is consistent with the evidence and the report. Do not remove a view only because it is strongly
  worded or because you would weigh the evidence differently.
One short reason per object. Unless the verdict is OK, "quote" gives the exact words of that object's own "text" the
verdict is about."""

# thesis pass recalibrated (2026-09-28) on 32 labelled theses from six runs: the objects-rubric wording removed 28%
# of sound theses (the live gate emptied the opening cases); this wording removed 11% at 75% precision, strict on
# contradictions and invented specifics, lenient on characterisation the report's figures bear out.
GATE_THESIS_RUBRIC = """You check an equity report before it is published. Each OBJECT below is an analyst persona's THESIS: a short
summary of its case. "evidence" lists the figures the system printed for the author's validated points; the author may
also draw on any figure printed elsewhere in the REPORT. Judge ONLY the thesis text. Summarising and characterising is
a thesis's job.

verdict:
- CONTRADICTED: a figure in its evidence, the FACTS or the REPORT contradicts it: a wrong direction (upside against a
  value the price already exceeds; cheap where every anchor shows a premium), or an absolute claim that a printed figure
  refutes.
- UNSUPPORTED: it asserts a specific material fact that nothing printed in the report supports: an event, a motive or
  intention nobody states, a problem the report's figures do not show, or plans for the company that its guidance does
  not contain.
- MISATTRIBUTED: it attributes words or plans to management or the call that the call excerpt does not show.
- OK: a summary judgement or characterisation consistent with the report, including general claims its figures bear
  out (a high-quality franchise where margins are high, capital returns where return guidance is printed, a demanding
  valuation where the price sits above value). Strong wording alone is not a reason to remove.
Be strict about contradictions and invented specifics, and lenient about characterisation. One short reason per object.
Unless the verdict is OK, "quote" gives the exact words of that thesis's own "text"."""


def _rest_objects(role, a):
    """The validated objects a thesis rests on (all items for the summary; all points when no list was recorded)."""
    if role == "summary":
        return (a.get("items_thesis") or []) + (a.get("items_risks") or [])
    pool = (a.get("points") or []) + (a.get("answers") or [])
    if "thesis_rests_on" not in a:
        return pool
    want = set(a.get("thesis_rests_on") or [])
    return [p for p in pool if p.get("n") in want]


def gate_objects(sets, cat, rels, phase="objects"):
    """[{id, author, text, evidence}]: phase "objects" = every point, item and answer; phase "theses" = every thesis,
    with the surviving points it rests on as its evidence (judged after the objects, so a thesis is never approved
    over points the same gate removed)."""
    out = []
    for role, a in (sets or {}).items():
        if not a:
            continue
        if phase == "theses":
            if a.get("thesis"):
                # the figures its points cite, never the points' own wording (the live gate judged a point's words and
                # removed the thesis for them: run 515a67a71150dd12)
                ids = list(dict.fromkeys(e for p in _rest_objects(role, a) for e in p.get("evidence") or []))
                out.append({"id": f"{role}:thesis", "author": role, "text": P.TOKEN.sub("its own return", a["thesis"]),
                            "evidence": evidence_plain(ids, cat, rels) or "(no evidence)"})
            continue
        for key in ("points", "items_thesis", "items_risks"):
            for p in a.get(key) or []:
                out.append({"id": f"{role}:{key}:{p['n']}", "author": role, "text": p["view"],
                            "evidence": evidence_plain(p.get("evidence") or [], cat, rels)})
        for i, x in enumerate(a.get("answers") or []):
            out.append({"id": f"{role}:answers:{i + 1}", "author": role,
                        "text": f"(answering the {x['to_role']}'s point \"{x['to_view']}\", verdict {x['verdict']}) {x['view']}",
                        "evidence": evidence_plain(x.get("evidence") or [], cat, rels)})
    return out


def gate_prompt(objects, facts_text, excerpt, report_text=None, phase="objects"):
    import json as _json
    return ((GATE_THESIS_RUBRIC if phase == "theses" else GATE_RUBRIC)
            + "\n\nFACTS (verified, rendered by the system):\n" + facts_text
            + ("\n\nTHE REST OF THE REPORT (printed by the system: every figure the reader sees beside these objects):\n"
               + report_text[:16000] if report_text else "")
            + "\n\nCALL EXCERPT (the end of the latest earnings call; speaker names precede their words):\n"
            + (excerpt or "(none)")[:6000]
            + "\n\nOBJECTS:\n" + "\n".join(_json.dumps(o, ensure_ascii=False) for o in objects)
            + '\n\nRespond with ONLY this JSON: {"verdicts": [{"id": "<object id>", "verdict": "OK|UNSUPPORTED|CONTRADICTED|'
              'MISATTRIBUTED|EVIDENCE_MISUSE", "quote": "<unless OK: the exact words of this object\'s text>", '
              '"reason": "<one short sentence>"}]} with one entry per object.')


def facts_text(ctx):
    """The verified facts a gate reads: every offered claim, the relations, the assessments and the perimeter."""
    lines = [f"- {cid}: {ctx.cat[cid].rendered} — {ctx.cat[cid].label}" for cid in sorted(ctx.offer)]
    for cid, a in ((ctx.rules.assessments or {}).items()):
        lines.append(f"- assessment of {cid}: {a.get('assessment')} ({a.get('basis')})")
    note = _perimeter_note(ctx.state)
    if note:
        lines.append(f"- trailing (twelve-month) figures are {note}; forecasts and the story describe the continuing company")
    return "\n".join(lines)


def _attributed(quote, text):
    """A non-OK verdict names the words it is about; they must be the object's own (typed lineage of the verdict)."""
    q = _norm(quote)
    return bool(q) and q in _norm(text)


def apply_gate(sets, verdicts, phase="objects", unattributed=None):
    """Remove every object of this phase the gate did not mark OK (a missing verdict is not OK); objects of the other
    phase are untouched. A non-OK verdict whose quote is not in the object's own text is about something else: the
    object is kept and its id is added to ``unattributed`` (the caller asks once more for it alone). Returns
    (sets, drops)."""
    vmap = {}
    for v in verdicts or []:
        if isinstance(v, dict) and isinstance(v.get("id"), str):
            vmap[v["id"]] = (str(v.get("verdict") or "").upper(), str(v.get("reason") or "")[:200], v.get("quote"))
    unattributed = unattributed if unattributed is not None else []

    def verdict(oid, text):
        vd = vmap.get(oid, ("MISSING", "no verdict", None))
        if vd[0] in ("OK", "MISSING") or _attributed(vd[2], text):
            return vd
        unattributed.append(oid)
        return ("OK", "", None)
    drops, out = [], {}
    for role, a in (sets or {}).items():
        if not a:
            out[role] = a
            continue
        a = dict(a)
        if phase == "theses":
            if a.get("thesis"):
                vd = verdict(f"{role}:thesis", a["thesis"])
                if vd[0] != "OK":
                    drops.append({"source": f"{role}.thesis", "reason": f"SEMANTIC_{vd[0]}: {vd[1]}", "raw": a["thesis"]})
                    a["thesis"] = None
                    if "thesis_rests_on" in a:
                        a["thesis_rests_on"] = []
            out[role] = a
            continue
        for key in ("points", "items_thesis", "items_risks"):
            kept = []
            for p in a.get(key) or []:
                vd = verdict(f"{role}:{key}:{p['n']}", p["view"])
                if vd[0] == "OK":
                    kept.append(p)
                else:        # P<n>: the validated label (typed drops name the author's raw position instead)
                    drops.append({"source": f"{role}.{key}.P{p['n']}", "reason": f"SEMANTIC_{vd[0]}: {vd[1]}", "raw": p})
            if key in a:
                a[key] = kept
        if a.get("answers"):
            kept = []
            for i, x in enumerate(a["answers"]):
                vd = verdict(f"{role}:answers:{i + 1}", x["view"])
                if vd[0] == "OK":
                    kept.append(x)
                else:
                    drops.append({"source": f"{role}.answers.A{i + 1}", "reason": f"SEMANTIC_{vd[0]}: {vd[1]}", "raw": x})
            a["answers"] = kept
        out[role] = a
    out, d2 = drop_orphan_answers(out)
    return out, drops + d2


def drop_orphan_answers(sets):
    """An answer whose target point was removed no longer answers anything the reader can see: drop it (recorded)."""
    out, drops = dict(sets or {}), []
    for role, a in list(out.items()):
        if not a or not a.get("answers"):
            continue
        tgt = out.get(a["answers"][0].get("to_role")) or {}
        visible = {f"P{p['n']}" for p in tgt.get("points") or []}
        kept = [x for x in a["answers"] if x["to"] in visible]
        drops += [{"source": f"{role}.answers.{x.get('to')}", "reason": "TARGET_REMOVED: the point it answers is not "
                   "published", "raw": x} for x in a["answers"] if x["to"] not in visible]
        if len(kept) != len(a["answers"]):
            out[role] = dict(a, answers=kept)
    return out, drops


def orphan_theses(sets):
    """Drop every thesis whose points were all removed after it validated (typed revalidation or the gate); keep the
    rest list to the survivors (after dropping answers to removed points). Returns (sets, drops)."""
    sets, drops = drop_orphan_answers(sets)
    out = {}
    for role, a in (sets or {}).items():
        if not a or not a.get("thesis"):
            out[role] = a
            continue
        a = dict(a)
        if role == "summary":
            alive = bool((a.get("items_thesis") or []) + (a.get("items_risks") or []))
        elif "thesis_rests_on" in a:
            pool = {p.get("n") for p in (a.get("points") or []) + (a.get("answers") or [])}
            a["thesis_rests_on"] = [n for n in a.get("thesis_rests_on") or [] if n in pool]
            alive = bool(a["thesis_rests_on"])
        else:
            alive = True
        if not alive:
            drops.append({"source": f"{role}.thesis", "reason": "THESIS_ORPHANED: every point it rests on was removed",
                          "raw": a["thesis"]})
            a["thesis"] = None
        out[role] = a
    return out, drops


# ── one bounded repair turn: the author sees its removed objects with the typed reasons ────────────────────────────
_NOT_REPAIRABLE = ("INTERNAL_ERROR", "OVERFLOW", "SEMANTIC_", "REBUTTAL_NO_NUMBERS", "REVALIDATION", "NO_TARGETS",
                   "NO_ALLOWED_EVIDENCE", "TARGET_REMOVED")


def repairable(drops):
    """The drops an author can fix by re-authoring (typed reasons); never a crash, an overflow or a gate verdict."""
    return [d for d in drops or [] if d.get("reason") and not str(d["reason"]).startswith(_NOT_REPAIRABLE)]


def repair_prompt(original_prompt, structured, drops):
    """The original task, the author's previous structured reply and each removed object with the rule it broke. The
    reply to this is validated exactly like the first one; code never edits an object."""
    import json as _json
    lines = [f"- {d.get('source')}: {str(d.get('reason'))[:300]}\n  was: "
             + _json.dumps(d.get("raw"), ensure_ascii=False, default=str)[:400] for d in drops]
    return (original_prompt + "\n\n=== REPAIR (one attempt) ===\nYOUR PREVIOUS REPLY (its structured part):\n"
            + _json.dumps(structured or {}, ensure_ascii=False, default=str)[:6000]
            + "\n\nTHESE OBJECTS WERE REMOVED, WITH THE RULE EACH BROKE:\n" + "\n".join(lines)
            + "\n\nReturn the COMPLETE JSON again, in the same format as before. Keep every number exactly as before. "
              "Rewrite each removed object so it meets the rule named, or leave it out; keep the objects that were not "
              "removed as they were, and renumber thesis_rests_on to the positions in your new reply.")


def object_count(argset):
    """How many publishable objects an argument set holds."""
    a = argset or {}
    return (1 if a.get("thesis") else 0) + sum(len(a.get(k) or []) for k in ("points", "answers", "items_thesis",
                                                                             "items_risks"))


# ── what the portfolio manager is told about each seat's arguments (the semantic gate runs before the PM round) ──
SEAT_SETS = {"neutral": ("neutral",), "bear": ("bear_open", "bear"), "bull": ("bull_open", "bull")}
SEAT_ROLES = tuple(r for roles in SEAT_SETS.values() for r in roles)


def supporting_count(argset):
    """The supporting arguments an argument set holds: its points and answers (a thesis is a conclusion, not support)."""
    a = argset or {}
    return len(a.get("points") or []) + len(a.get("answers") or [])


def seat_evidence_state(sets, drops=()):
    """{seat: {accepted, rejected}}: the supporting arguments each seat still has after typed validation and the
    semantic gate, and how many were removed (first attempts superseded by a repair are not counted)."""
    out = {}
    for seat, roles in SEAT_SETS.items():
        rej = 0
        for d in drops or ():
            src = str((d or {}).get("source") or "")
            if src.startswith(("attempt1:", "repair")) or src.endswith(".thesis"):
                continue
            if src.split(".")[0].split("_")[0] == seat:
                rej += 1
        out[seat] = {"accepted": sum(supporting_count((sets or {}).get(r)) for r in roles), "rejected": rej}
    return out


def evidence_state_line(seat, st):
    """One line of the PM's brief: the seat's evidence state, stated explicitly when nothing survived."""
    n, m = int((st or {}).get("accepted") or 0), int((st or {}).get("rejected") or 0)
    removed = f"{m} removed by evidence validation" if m else "none removed"
    if n == 0:
        return (f"  EVIDENCE STATE: {seat.capitalize()} seat has no accepted supporting arguments ({removed}). Its scenario "
                "return stands without accepted support.")
    return f"  EVIDENCE STATE: {n} supporting argument{'s' if n != 1 else ''} accepted ({removed})."


def repair_keeps(first, repaired):
    """A repair is kept only when it loses nothing in ANY class: the thesis (and summary text) stays if it was there,
    and no list of points, answers or items gets shorter (a total count could trade a thesis for a point)."""
    a, b = first or {}, repaired or {}
    if a.get("thesis") and not b.get("thesis"):
        return False
    if any(len(b.get(k) or []) < len(a.get(k) or []) for k in ("points", "answers", "items_thesis", "items_risks")):
        return False
    return object_count(b) > object_count(a)


# ── revalidation at compose / audit: the same checks, on the stored validated objects ─────────────────────────
def revalidate(ctx, argset, *, own):
    """Re-run the typed checks on a stored argument set (compose and the report audit). Returns (argset, drops)."""
    if not argset:
        return argset, []
    a, drops = dict(argset), []
    role = a.get("role") or "?"
    for key in ("points", "items_thesis", "items_risks"):
        if key not in a:
            continue
        kept = []
        for p in a.get(key) or []:
            kind = p.get("kind")
            rs = (evidence_reasons(ctx, p.get("evidence") or [], kind) if kind else
                  evidence_reasons(ctx, p.get("evidence") or [], None, allowed=None)) + \
                text_reasons(ctx, p.get("view"), max_words=LIMITS["view"])
            if not rs:
                rs = direction_reasons(ctx, p["view"], p.get("evidence") or [], own)
            (drops.append({"source": f"{role}.{key}.{p.get('n')}", "reason": "REVALIDATION: " + "; ".join(rs)[:380],
                           "raw": p}) if rs else kept.append(p))
        a[key] = kept
    if a.get("answers"):
        kept = []
        for x in a["answers"]:
            rs = (evidence_reasons(ctx, x.get("evidence") or [], None, required=(x.get("verdict") == "FAILS"),
                                   max_ids=LIMITS["answer_evidence"]) or cross_reasons(ctx, x.get("evidence") or [],
                                                                                      x.get("to_evidence") or [])) + \
                text_reasons(ctx, x.get("view"), max_words=LIMITS["view"])
            if not rs:
                rs = direction_reasons(ctx, x["view"], (x.get("evidence") or []) + (x.get("to_evidence") or []), own)
            (drops.append({"source": f"{role}.answers.{x.get('to')}", "reason": "REVALIDATION: " + "; ".join(rs)[:380],
                           "raw": x}) if rs else kept.append(x))
        a["answers"] = kept
    if a.get("thesis"):
        lim = LIMITS["pm_thesis"] if role == "pm" else LIMITS["summary"] if role == "summary" else LIMITS["thesis"]
        rest = _rest_objects(role, a)
        rests = None if (role != "summary" and "thesis_rests_on" not in a) else [p.get("n") or 1 for p in rest]
        t, d = validate_thesis(ctx, a["thesis"], own=own, max_words=lim, source=f"{role}.thesis", scope=_scope(rest),
                               rests=rests)
        a["thesis"], drops = t, drops + d
        if "thesis_rests_on" in a:
            a["thesis_rests_on"] = [p.get("n") for p in rest] if t else []
    return a, drops