aboutsummaryrefslogtreecommitdiff
path: root/test/test_precedence_ef.ml
blob: 52de989fd63b22d7efdde63b6f0467246a0ae21a (plain) (blame)
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
(* RG 91's Table of Precedence, transcribed by Rite_ef.Precedence_ef.band.
   Table-driven, one row (hence one Alcotest.test_case) per RG 91 entry, so a
   misplaced or missing entry names itself in the failure output instead of
   failing anonymously (docs/research/rules-register.md §4). Each row's date
   is checked against the register to make sure it is not ALSO an instance of
   some other entry at the same band (the vacuous-test trap this project has
   caught before -- see the Advent-Ember-day note on entry 18 below). *)

module P = Colitur_kernel.Precedence
module Cel = Colitur_kernel.Celebration
module S = Colitur_kernel.Slug
module Col = Colitur_kernel.Colour
module D = Colitur_kernel.Date
module Sub = Colitur_kernel.Subject
module Comp = Colitur_kernel.Computus
module T = Rite_ef.Temporal_ef
module V = Rite_ef.Vocab_ef
module PE = Rite_ef.Precedence_ef

let mk y m dd = match D.make ~year:y ~month:m ~day:dd with Ok t -> t | Error e -> failwith e

(* [T.season] is the same function Calendar itself would use to build a
   context, so a row's [season]/[weekday] are exactly what the real engine
   would compute for that date, not a hand-picked value that might not
   actually occur together with it. *)
let ctx date = { P.date; season = T.season date; weekday = D.weekday date }

let cand ?(origin = P.Temporal) ?(rank = V.Class1) ?(status = Cel.Feast) ?(subject = Sub.Temporal)
    ?(layer = "temporal") slug =
  { P.cel = Cel.make ~slug:(S.of_string_exn slug) ~rank ~status ~colour:Col.White ~subject ~layer ();
    origin }

(* A candidate built from [Temporal_ef.temporal]'s own real output, not a
   hand-typed slug -- review finding 3: [band]'s Ember/vigil detection reads
   temporal_ef.ml's slug conventions, and a row that also hand-types the same
   literal proves nothing if that convention ever drifts (both sides would
   drift together, silently). Rows built with this instead fail loudly on
   such a drift, because they source the slug from the same place [band]
   itself is implicitly trusting. *)
let of_temporal date =
  let day = T.temporal date in
  { P.cel = day.Colitur_kernel.Temporal.office; origin = P.Temporal }

(* Every Easter-relative date below is anchored to this single computed
   Easter rather than a hand-typed calendar date, so an arithmetic slip in a
   test date cannot silently pass by accident. *)
let easter = Comp.gregorian_easter 2026
let off n = D.add_days easter n

(* (description, date, candidate, expected RG 91 entry). *)
let cases =
  [ (* Entry 1 -- RG 91 entry 1 (§4): Nativity, Easter Sunday, Pentecost Sunday. *)
    ("1 Nativity", mk 2026 12 25, cand "ef-nativity", 1);
    ("1 Easter Sunday", off 0, cand "ef-easter-sunday", 1);
    ("1 Pentecost Sunday", off 49, cand "ef-pentecost", 1);
    (* Entry 2 -- RG 91 entry 2 (§4): Sacred Triduum. Thu-Sat of Holy Week,
       NOT entry 7 (which stops at Wednesday -- see entry 7 below). *)
    ("2 Holy Thursday", off (-3), cand "ef-holy-thursday", 2);
    ("2 Good Friday", off (-2), cand "ef-good-friday", 2);
    ("2 Holy Saturday", off (-1), cand "ef-holy-saturday", 2);
    (* Entry 3 -- RG 91 entry 3 (§4). *)
    ("3 Epiphany", mk 2026 1 6, cand "ef-epiphany", 3);
    ("3 Ascension", off 39, cand "ef-ascension", 3);
    ("3 Trinity", off 56, cand "ef-trinity", 3);
    ("3 Corpus Christi", off 60, cand "ef-corpus-christi", 3);
    ("3 Sacred Heart", off 68, cand "ef-sacred-heart", 3);
    ("3 Christ the King", T.christ_the_king 2026, cand "ef-christ-the-king", 3);
    (* Entry 4 -- RG 91 entry 4 (§4). Sanctoral-origin: neither feast is part
       of temporal_ef's movable cycle. *)
    ( "4 Immaculate Conception", mk 2026 12 8,
      cand ~origin:P.Sanctoral ~subject:Sub.Bvm ~layer:PE.universal_layer
        "ef-immaculate-conception",
      4 );
    ("4 Assumption", mk 2026 8 15, cand ~origin:P.Sanctoral ~subject:Sub.Bvm ~layer:PE.universal_layer "ef-assumption", 4);
    (* Entry 5 -- RG 91 entry 5 (§4). *)
    ("5 Nativity Vigil", mk 2026 12 24, cand "ef-nativity-vigil", 5);
    ("5 Octave day (Circumcision)", mk 2026 1 1, cand "ef-circumcision", 5);
    (* Entry 6 -- RG 91 entry 6 (§4). *)
    ("6 Advent Sunday", T.advent_start 2026, cand "ef-advent-sunday-1", 6);
    ("6 Lent Sunday", off (-42), cand "ef-lent-sunday-1", 6);
    ("6 Passion Sunday (I Passiontide)", off (-14), cand "ef-passion-sunday", 6);
    ("6 Palm Sunday (II Passiontide)", off (-7), cand "ef-palm-sunday", 6);
    ("6 Low Sunday", off 7, cand "ef-low-sunday", 6);
    (* Entry 7 -- RG 91 entry 7 (§4): Ash Wednesday and Mon/Tue/Wed of Holy
       Week ONLY -- Thu-Sat are entry 2 above, not this entry. *)
    ("7 Ash Wednesday", off (-46), cand "ef-ash-wednesday", 7);
    ("7 Monday of Holy Week", off (-6), cand "ef-holy-monday", 7);
    ("7 Tuesday of Holy Week", off (-5), cand "ef-holy-tuesday", 7);
    ("7 Wednesday of Holy Week", off (-4), cand "ef-holy-wednesday", 7);
    (* Entry 8 -- RG 91 entry 8 (§4). 2 Nov 2026 is a Monday (verified
       independently below the table), so this row is the plain case. The
       register's own qualifying case -- "yields to an occurring Sunday" --
       gets its own row and its own end-to-end test after this table (2 Nov
       2025 is a real Sunday). *)
    ("8 All Souls (non-Sunday)", mk 2026 11 2, cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls", 8);
    (* Entry 8's qualifier: "(yields to an occurring Sunday)". 2 Nov 2025 is
       a Sunday, so this must NOT be 8 -- it must lose to entry 15 (16 =
       entry 15's own value + 1, the exact value precedence_ef.ml documents
       and justifies at entry 8's branch). The end-to-end resolve-level
       proof that the Sunday actually wins the day is
       [test_all_souls_yields_to_sunday] below; this row pins the specific
       integer [band] returns. *)
    ("8 All Souls (yields to a Sunday, 2 Nov 2025)", mk 2025 11 2, cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls", 16);
    (* Entry 9 -- RG 91 entry 9 (§4). *)
    ("9 Pentecost Vigil", off 48, cand "ef-pentecost-vigil", 9);
    (* Entry 10 -- RG 91 entry 10 (§4): both range boundaries, to guard the
       off-by-one an inclusive Easter-offset window invites. *)
    ("10 Easter octave, day+1", off 1, cand "ef-easter-1-mon", 10);
    ("10 Easter octave, day+6", off 6, cand "ef-easter-1-sat", 10);
    ("10 Pentecost octave, day+50", off 50, cand "ef-pentecost-1-mon", 10);
    ("10 Pentecost octave, day+55", off 55, cand "ef-pentecost-1-sat", 10);
    (* Entry 11 -- RG 91 entry 11 (§4). *)
    ( "11 Universal I-class feast", mk 2026 6 29,
      cand ~origin:P.Sanctoral ~subject:Sub.Saint ~layer:PE.universal_layer "ef-ss-peter-paul",
      11 );
    (* Entry 12 -- RG 91 entry 12 (§4). The one non-base-layer case the brief
       asks for explicitly: same date/rank/subject as 11, only the layer
       differs, so this row isolates the layer test as the deciding factor. *)
    ( "12 Proper I-class feast (non-base layer)", mk 2026 6 29,
      cand ~origin:P.Sanctoral ~subject:Sub.Saint ~layer:"diocese-warsaw" "ef-local-patron",
      12 );
    (* Entry 13 -- RG 91 entry 13 (§4). *)
    ( "13 Indult I-class feast", mk 2026 6 29,
      cand ~origin:P.Sanctoral ~subject:Sub.Saint ~layer:(PE.indult_prefix ^ "local-grant")
        "ef-indult-feast-1",
      13 );
    (* Entry 14 -- RG 91 entry 14 (§4), deliberately UNQUALIFIED (contrast
       entry 16, which explicitly says "not of the Lord"). *)
    ( "14 Feast of the Lord, II class", mk 2026 7 1,
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Lord ~layer:PE.universal_layer
        "ef-precious-blood",
      14 );
    (* Entry 14, non-base layer: unlike 11-13/16/19/20/23/24, entry 14 draws
       no universal/proper/indult line at all, so this must STILL be 14, not
       19 -- the exact restriction review finding 2 flagged and this row
       exists to keep from silently coming back. *)
    ( "14 Feast of the Lord, II class (non-base layer)", mk 2026 7 2,
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Lord ~layer:"diocese-warsaw"
        "ef-local-feast-of-the-lord",
      14 );
    (* Entry 15 -- RG 91 entry 15 (§4): an ordinary Sunday not named at entry 6
       -- Septuagesima is II class (RG 11-12 names only Advent/Lent/
       Passiontide/Easter/Low/Pentecost as I class). *)
    ("15 II-class Sunday (Septuagesima)", off (-63), cand ~rank:V.Class2 "ef-septuagesima-sunday", 15);
    (* Entry 16 -- RG 91 entry 16 (§4). *)
    ( "16 Universal II-class feast, not of the Lord", mk 2026 1 20,
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Saint ~layer:PE.universal_layer
        "ef-some-saint",
      16 );
    (* Entry 17 -- RG 91 entry 17 (§4): days WITHIN the Nativity octave (26-28
       Dec are Stephen/John/Innocents -- sanctoral, not this entry; 1 Jan is
       entry 5's Octave DAY, not this entry either). *)
    ("17 Nativity octave, 29 Dec", mk 2026 12 29, cand ~rank:V.Class2 "ef-nativity-octave-day-5", 17);
    ("17 Nativity octave, 31 Dec", mk 2026 12 31, cand ~rank:V.Class2 "ef-nativity-octave-day-7", 17);
    (* Entry 18 -- RG 91 entry 18 (§4): Advent 17-23 Dec ferias AND the
       Ember days of Advent/Lent/September share this one entry. The second
       row is deliberately a Lent date (season Lent, NOT Advent) to prove the
       Ember-slug path fires on its own, not merely because it also happens
       to fall in the Dec 17-23 window -- the exact trap the brief warns
       about, worked the other way round: this Ember day must NOT be
       mistaken for an ordinary entry-22 Lent feria either. *)
    ("18 Advent 17-23 Dec feria", mk 2026 12 21, cand ~rank:V.Class2 "ef-advent-4-mon", 18);
    (* Sourced from Temporal_ef.temporal's own output (see [of_temporal])
       rather than a hand-typed "ef-lent-ember-wed" -- closes review finding
       3's coupling concern for the Ember prefixes specifically. *)
    ("18 Lent Ember Wednesday (from Temporal_ef.temporal)", off (-39), of_temporal (off (-39)), 18);
    (* Entry 19 -- RG 91 entry 19 (§4). *)
    ( "19 Proper II-class feast", mk 2026 1 20,
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Saint ~layer:"diocese-warsaw"
        "ef-local-saint-2",
      19 );
    (* Entry 20 -- RG 91 entry 20 (§4). *)
    ( "20 Indult II-class feast", mk 2026 1 20,
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Saint
        ~layer:(PE.indult_prefix ^ "local-grant-2") "ef-indult-feast-2",
      20 );
    (* Entry 21 -- RG 91 entry 21 (§4, RG 28-34). Two rows: the Ascension
       Vigil is the one II-class vigil temporal_ef already produces today
       (temporal-origin); the Assumption Vigil stands in for the
       sanctoral-origin case no task has loaded data for yet -- proving
       [band] does not gate this entry on [origin] (see precedence_ef.ml's
       file comment). *)
    (* Sourced from Temporal_ef.temporal's own output (see [of_temporal])
       rather than a hand-typed "ef-ascension-vigil" -- closes review finding
       3's coupling concern for [vigil_suffix]. *)
    ("21 Ascension Vigil (from Temporal_ef.temporal)", off 38, of_temporal (off 38), 21);
    ( "21 Assumption Vigil (sanctoral-origin)", mk 2026 8 14,
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "ef-assumption-vigil",
      21 );
    (* Also review finding 3 / "worth doing": a UNIVERSAL-layer Class2 vigil
       whose subject is the Lord must still be 21, not 14 -- pins entry 14's
       [not is_vigil] guard even after finding 2 dropped its layer test. *)
    ( "21 Universal II-class vigil of the Lord", mk 2026 6 23,
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Lord ~layer:PE.universal_layer
        "ef-precious-blood-vigil",
      21 );
    (* Entry 22 -- RG 91 entry 22 (§4) (corrected: ends at Palm Sunday, not
       Passion Sunday). Both a Lent and a Passiontide feria, clear of Ash
       Wednesday, Holy Week and the Ember days. *)
    ("22 Lent feria", off (-41), cand ~rank:V.Class3 "ef-lent-1-mon", 22);
    ("22 Passiontide feria", off (-12), cand ~rank:V.Class3 "ef-passiontide-1-tue", 22);
    (* Entry 23 -- RG 91 entry 23 (§4). NOTE the table's own order here is the
       REVERSE of 11/12 and 14/16/19/20 above: entry 23 (particular
       calendars) is numbered BELOW entry 24 (universal), so a proper
       III-class feast outranks a universal one -- transcribed as the
       register states it, not "corrected" to match the other classes. *)
    ( "23 Proper III-class feast (non-base layer)", mk 2026 6 30,
      cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:"diocese-warsaw" "ef-local-saint-3",
      23 );
    (* Entry 24 -- RG 91 entry 24 (§4). *)
    ( "24 Universal III-class feast", mk 2026 6 30,
      cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-some-saint-3",
      24 );
    (* Entry 25 -- RG 91 entry 25 (§4). *)
    ("25 Advent feria to 16 Dec", mk 2026 12 1, cand ~rank:V.Class3 "ef-advent-1-tue", 25);
    (* Entry 26 -- RG 91 entry 26 (§4). *)
    ( "26 III-class vigil", mk 2026 8 9,
      cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-lawrence-vigil",
      26 );
    (* Also worth doing: a NON-universal-layer Class3 vigil must still be 26,
       not 23 -- pins entry 23's [not is_vigil] guard. *)
    ( "26 III-class vigil (non-base layer)", mk 2026 8 10,
      cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:"diocese-warsaw" "ef-local-patron-vigil",
      26 );
    (* Task 11, issue (a): the sanctoral bootstrap (data/ef/sanctoral.sexp)
       names its vigils with lectio's OWN "vigil-of-X" PREFIX convention, not
       [PE.vigil_suffix]'s "-vigil" SUFFIX every row above uses -- exactly
       the mismatch Task 7's review predicted. These two rows use the real
       bootstrapped slugs verbatim (data/ef/sanctoral.sexp: 28 Jun, 9 Aug),
       proving [band] recognises the prefix convention too: without it, both
       would misfile at 16/24 (an ordinary feast of the same rank) instead
       of 21/26. *)
    ( "21 II-class vigil via the sanctoral data's own \"vigil-of-X\" prefix",
      mk 2026 6 28,
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "vigil-of-sts-peter-paul",
      21 );
    ( "26 III-class vigil via the sanctoral data's own \"vigil-of-X\" prefix",
      mk 2026 8 9,
      cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "vigil-of-st-lawrence",
      26 );
    (* Entry 27 -- RG 91 entry 27 (§4): an otherwise-unoccupied IV-class
       Saturday. *)
    ( "27 Office of the BVM on Saturday", off 62,
      cand ~rank:V.Class4 "ef-time-after-pentecost-1-sat",
      27 );
    (* Entry 28 -- RG 91 entry 28 (§4): the unqualified IV-class catch-all. *)
    ("28 IV-class feria", off 65, cand ~rank:V.Class4 "ef-time-after-pentecost-1-tue", 28);
    (* Not an RG 91 row at all: a I-class candidate marked as a vigil, which
       is not the Nativity or Pentecost (entries 5/9, the only I-class
       vigils the table names) and so has no entry to fall into. Proves the
       documented fallback -- not entry 11/12/13, which the [not is_vigil]
       guard exists specifically to keep this out of. *)
    ( "unclassified: I-class vigil outside Nativity/Pentecost", mk 2026 3 10,
      cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-mystery-vigil",
      PE.unclassified );
    (* Also worth doing: a temporal-origin Class1 candidate on a date none of
       entries 1/2/3/5/6/7/9/10 name. 15 Jul 2026 is a Wednesday, off=101
       from Easter -- clear of every Easter-relative window this module
       checks, and not one of the fixed dates either. Without the
       [not is_temporal] guard on entries 11-13, this would wrongly reach 12
       (its default layer, "temporal", is not [universal_layer] and does not
       carry [indult_prefix], so it reads as "proper" by the layer test
       alone -- precisely the bug the guard exists to prevent; see the
       [not is_temporal] guard's role in the entry-25 mutation test recorded
       in the task report). *)
    ("unclassified: I-class temporal candidate on an unnamed date", mk 2026 7 15, cand "ef-unnamed-day", PE.unclassified);
    (* RG 91's own vigil list (§4, "Vigils / octaves / Rogations / Sunday classes") stops at III class --
       there is no IV-class vigil for entry 28's ferial catch-all to absorb. *)
    ( "unclassified: IV-class candidate marked as a vigil", mk 2026 6 20,
      cand ~rank:V.Class4 "ef-second-mystery-vigil", PE.unclassified )
  ]

(* Review finding 1's end-to-end proof: on a real Sunday landing on 2
   November, [Precedence.resolve] -- not just [band] in isolation -- observes
   the Sunday, not All Souls. This exercises the exact mechanism the finding
   named ("resolve observes the lowest band, so whenever 2 November falls on
   a Sunday, All Souls wins and the Sunday loses"), rather than only the
   integer [band] returns for the standalone row above. [disposition] and
   [admit] are stubs -- only [observed] is under test here. *)
let test_all_souls_yields_to_sunday () =
  let date = mk 2025 11 2 in
  let day_ctx = ctx date in
  let sunday =
    { P.cel =
        Cel.make ~slug:(S.of_string_exn "ef-time-after-pentecost-sunday-x") ~rank:V.Class2
          ~colour:Col.Green ~subject:Sub.Temporal ~layer:"temporal" ();
      origin = P.Temporal }
  in
  let all_souls = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls" in
  let rules =
    { P.band = (fun c cd -> PE.band c cd);
      disposition = (fun ~winner:_ ~loser:_ -> P.Omit);
      admit = (fun ~observed:_ ~temporal:_ cs -> cs) }
  in
  let resolution = P.resolve rules day_ctx ~temporal:sunday ~sanctoral:[ all_souls ] in
  Alcotest.(check string) "the Sunday is observed, not All Souls"
    "ef-time-after-pentecost-sunday-x"
    (S.to_string resolution.P.observed.P.cel.Cel.slug)

(* Task 8: [disposition] -- what happens to the day's LOSING candidate (RG
   92-95, 33, 94; §4). Table-driven like [band]'s
   own [cases] above, one row per rule, each checked against a description of
   which register clause it pins. [disposition] takes no context (see
   precedence.mli's [rules.disposition]), so "is the winner a Sunday" is read
   off the winner's own slug the same way [band] itself reads "is this a
   vigil" off the loser's -- see precedence_ef.ml's [sunday_marker]. *)

let string_of_disposition = function
  | P.Omit -> "Omit"
  | P.Commemorate P.Privileged -> "Commemorate(Privileged)"
  | P.Commemorate P.Ordinary -> "Commemorate(Ordinary)"
  | P.Transfer -> "Transfer"
  | P.Repose -> "Repose"

(* A II-class ordinary Sunday, built the same way [test_all_souls_yields_to_sunday]
   builds its Sunday -- a hand-typed slug matching temporal_ef.ml's own
   "ef-<season>-sunday-<n>" convention, since [disposition] only ever reads
   this string, never the real computed date. *)
let an_ordinary_sunday =
  cand ~rank:V.Class2 "ef-time-after-pentecost-sunday-11"

let disposition_cases =
  [ (* RG 95 -- §4, "Occurrence": only I-class feasts transfer; a
       II-class feast loses to a I-class day and is COMMEMORATED, not
       transferred. Paired with the next row (a I-class loser, same shape of
       winner) so the discriminating factor is provably the LOSER's own
       rank, not the winner's -- the brief's explicit "one without the other
       proves nothing" pairing. *)
    ( "RG95 II-class feast loses to I-class day -> Commemorate",
      cand "ef-nativity",
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "ef-some-saint",
      "Commemorate(Ordinary)" );
    ( "RG95 I-class feast loses to a higher I-class day -> Transfer",
      cand "ef-nativity",
      cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-local-i-class-feast",
      "Transfer" );
    (* Fix round 1 (post-Task-9 review): RG 95 (§4, "Occurrence" and "Transfer/translation")
       restricts the right of translation to I-class FEASTS -- RG 91's own
       table lists Sundays as a separate row (entry 6)
       from feasts (entries 11-13) -- so an impeded I-class
       Sunday must NOT transfer, unlike the plain I-class feast row above:
       same [Class1] rank, same kind of winner, the ONLY difference is that
       this loser's slug carries [PE.sunday_marker]. RG 109(a) (§4)
       confirms this from the other direction: "of a Sunday" is a
       privileged commemoration category, which presupposes an impeded
       Sunday stays put rather than moving to another day the way a feast
       does. Sourced from [Temporal_ef.temporal]'s own real output (Advent I
       Sunday 2026, Class1, "ef-advent-sunday-1"), the same coupling-safety
       reason [of_temporal]'s other callers use it -- this is also a
       realistic shape: 8 December falls on an Advent Sunday in 2024, 2030
       and 2041 (Immaculate Conception, RG 91 entry 4, outranking entry 6),
       and 24 December falls on Advent IV in 2023, 2028, 2034 and 2045 (the
       Nativity Vigil, also entry 5 outranking entry 6). *)
    ( "RG95/RG109(a): an impeded I-class SUNDAY does NOT transfer -- it is \
       Commemorated and Privileged",
      cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-immaculate-conception",
      of_temporal (T.advent_start 2026),
      "Commemorate(Privileged)" );
    (* RG 33 -- §4, "Vigils / octaves / Rogations / Sunday classes": a II- or
       III-class vigil impeded by any Sunday or a I-class feast is entirely
       OMITTED, not commemorated. The
       vigil is sourced from [Temporal_ef.temporal]'s own real output (as
       [of_temporal]'s existing callers above do), not a hand-typed
       "ef-ascension-vigil", so a drift in temporal_ef's vigil-slug
       convention cannot silently defeat this row the way a duplicated
       literal could. This is the row the brief singles out as most likely
       to pass vacuously if the fallback below happened to already be
       [Omit] -- it is not: the fallback is [Commemorate] (see the next two
       rows), so this genuinely exercises RG 33's own branch. *)
    ( "RG33 II-class vigil loses to an ordinary Sunday -> Omit",
      an_ordinary_sunday,
      of_temporal (off 38) (* Ascension Vigil *),
      "Omit" );
    ( "RG33 II-class vigil loses to a I-class feast (non-Sunday) -> Omit",
      cand "ef-immaculate-conception",
      of_temporal (off 38),
      "Omit" );
    (* RG 33's own boundary, proved from both sides so the rule is shown to
       gate on the WINNER too, not "any vigil is always omitted": winner is
       neither a Sunday nor I class, so RG 33's omission does not fire; a
       vigil, per RG 31's own text ("si impediuntur, commemorantur"), is
       ALWAYS commemorated once RG 33 does not omit it outright, regardless
       of RG 109's closed list.

       CORRECTED comment (final fix wave, item 2b): this row's own comment
       used to claim it "proves [the RG26 Class4-Omit branch's] own [not
       (is_vigil ...)] guard" -- that claim does NOT survive under the
       current [rank = Class4] gate (it may have been true under an earlier
       predicate). The vigil sourced here ({!of_temporal}, the real
       Ascension Vigil) is [Class2], not [Class4], so the RG26 branch's own
       [rank = Class4] test already excludes it on rank alone, with or
       without the [not (is_vigil ...)] guard -- verified: deleting that
       guard entirely leaves 257/257 green. This row still genuinely proves
       RG 31's own "if impeded, commemorated" vigil mandate (a vigil that
       RG 33 does not omit outright is commemorated, not omitted by some
       OTHER mechanism), which is real and worth keeping; it just does not
       discriminate the [not (is_vigil ...)] guard specifically. *)
    ( "RG33 boundary: vigil loses to an ordinary (non-Sunday, non-I-class) \
       II-class day -> Commemorate, NOT Omit (RG31's own vigil mandate)",
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "ef-some-other-feast",
      of_temporal (off 38),
      "Commemorate(Ordinary)" );
    (* The GENUINE witness for the RG26 branch's [not (is_vigil ...)] guard
       (final fix wave, item 2b): no REAL vigil this engine constructs can
       exercise it, since RG 91 has no IV-class vigil at all (this file's own
       entry-27/28 comments) -- every real vigil is Class2 or Class3, already
       excluded by [rank = Class4] alone, the exact shape the row above is.
       This row is therefore deliberately SYNTHETIC: a temporal-origin loser,
       [Class4], whose slug ALSO carries {!PE.vigil_suffix} -- a shape RG 91
       itself does not describe, but one {!Precedence.resolve} can still be
       asked to construct (see precedence_ef.ml's own comment on this guard).
       With the guard present, [is_vigil] excludes it from the RG26 branch,
       so it falls through to the ordinary [Commemorate] catch-all (RG 31's
       "if impeded, commemorated" mandate, the same rule the row above
       exercises for a realistic Class2 vigil). Deleting [not (is_vigil ...)]
       from that branch turns this row -- and only this row -- red: the
       branch would then fire on [rank = Class4] alone and return [Omit]. *)
    ( "SYNTHETIC (final fix wave, item 2b): a temporal-origin Class4 loser \
       ALSO vigil-shaped is Commemorate (RG31's vigil mandate via the \
       not-is_vigil guard), not Omit",
      an_ordinary_sunday,
      cand ~rank:V.Class4 "ef-synthetic-class4-vigil",
      "Commemorate(Ordinary)" );
    (* CORRECTED 2026-08-12 (Task 16, primary-source-verified): the register
       previously (mis-)transcribed RG 33 as covering only I/II-class
       vigils, so this row's own title used to read "outside RG33's
       I/II-class scope" and expect Commemorate. The primary text ("Vigilia
       II AUT III classis penitus omittitur...") covers II OR III class --
       St Lawrence's vigil (III class, RG 32) falling on ANY Sunday ("in
       dominica quavis") is entirely omitted, exactly like a II-class vigil.
       See {!PE.is_omissible_vigil}'s own comment for the full primary text
       and the register correction. *)
    ( "RG33 (corrected): a III-class vigil loses to a Sunday -> Omit, not \
       Commemorate",
      an_ordinary_sunday,
      cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-lawrence-vigil",
      "Omit" );
    (* Task 11, issue (a): [disposition]'s own [is_vigil] check (the RG 33
       omission test) is a SEPARATE call site from [band]'s -- both read the
       same private [is_vigil], but each needed its own witness, since a fix
       to one call site could in principle miss the other. Real bootstrapped
       slug (data/ef/sanctoral.sexp's "vigil-of-the-assumption", 14 Aug),
       not a hand-typed one, for the same coupling-safety reason [of_temporal]
       rows use real data elsewhere in this file. Before the fix this vigil
       was invisible to [is_vigil] entirely, so it would have fallen through
       to the ordinary Commemorate branch below instead of Omit -- the exact
       failure the task brief describes. *)
    ( "RG33 (prefix convention): a \"vigil-of-X\"-named II-class vigil loses \
       to an ordinary Sunday -> Omit",
      an_ordinary_sunday,
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "vigil-of-the-assumption",
      "Omit" );
    (* Brief: a Commemoration_only loser is ALWAYS Commemorate -- checked
       here with a loser that ALSO carries a Class1 rank and a vigil-suffixed
       slug losing to a Sunday, so this row only passes if the
       Commemoration_only check is checked BEFORE both RG 33's omission and
       RG 95's transfer, not after. Its expected privilege is [Privileged],
       not [Ordinary]: this loser's [rank] is [Class1] (the default [cand]
       leaves unless overridden, deliberately kept here for the
       branch-order proof above), and RG 109(b) (§4, "of
       a I-class day") makes any [Class1] commemoration privileged
       regardless of how it reached [Commemorate] -- Task 8's placeholder
       [interim_privilege] used to hide this (always [Ordinary]); Task 9's
       real [privilege_of] does not. This row is also this suite's ONLY
       witness for RG 109(b): a plain [Feast]-status [Class1] loser never
       reaches [Commemorate] at all (RG 95 sends it to [Transfer] instead,
       see the row above), so [Commemoration_only] is the only shape that
       can exercise it here (see the task report). *)
    ( "Commemoration_only loser is always Commemorate, even if I-class and \
       vigil-shaped, even losing to a Sunday -- and RG109(b) makes it \
       privileged",
      an_ordinary_sunday,
      cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~layer:PE.universal_layer
        "ef-suppressed-vigil",
      "Commemorate(Privileged)" );
    (* Totality (SANCTORAL side): the lower ranks the RG 33/RG 95/Task-16
       branches never touch still reach the RG 95 "commemorated or omitted"
       branch's [Commemorate] side, not an unhandled/exceptional case -- RG
       111(c)/(d) admit an "ordinary" SAINT commemoration freely, with none
       of RG 109's closed-list restriction the temporal branch below has. *)
    ( "III-class feast loses to a I-class day -> Commemorate",
      cand "ef-nativity",
      cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-some-saint-3",
      "Commemorate(Ordinary)" );
    (* Task 16 (primary-source-verified: RG 95 + RG 109's closed list + RG
       113 -- see this branch's own comment in precedence_ef.ml for the full
       three-text argument): an ORDINARY, non-privileged TEMPORAL-cycle
       loser -- a bog-standard green-season feria of Time after Pentecost,
       none of RG 109(a)-(f) -- has NO standing to be commemorated at all
       when impeded; it is entirely omitted, not the "ordinary"
       commemoration a losing SAINT would get (contrast the SANCTORAL row
       immediately above, same rank, same kind of winner, opposite
       [Commemorate]/[Omit] outcome -- the discriminating factor is
       [origin], nothing else). Before this fix the engine wrongly
       commemorated the losing feria itself here; confirmed wrong against
       the missalemeum oracle (Task 16 report): every one of ~190
       structurally identical days (an ordinary sanctoral feast impeding an
       ordinary temporal feria, 2026-2027) shows zero commemorations in an
       independent published EF calendar. *)
    ( "TASK16: an ORDINARY temporal feria loses to a II-class Sunday -> \
       Omit, not Commemorate (RG109's closed list; contrast the sanctoral \
       row above)",
      an_ordinary_sunday,
      cand ~rank:V.Class4 "ef-time-after-pentecost-1-sat",
      "Omit" );
    (* Totality's other half: a SANCTORAL loser of the exact same rank as
       the row above still reaches [Commemorate], proving the branch above
       is gated on [origin] and not merely on rank -- without this row nothing
       here would distinguish "temporal losers are omitted" from "IV-class
       losers are omitted", which would be a much bigger (and wrong) claim. *)
    ( "TASK16 contrast: a SANCTORAL IV-class loser still reaches \
       Commemorate(Ordinary)",
      an_ordinary_sunday,
      cand ~origin:P.Sanctoral ~rank:V.Class4 ~layer:PE.universal_layer "ef-some-minor-saint",
      "Commemorate(Ordinary)" );
    (* Final fix wave, item 2(a): the RG 26 gate above is [rank = Class4],
       and until this row nothing in the suite discriminated that from the
       REFUTED predicate it replaced, [privilege_of loser = Ordinary]
       (precedence_ef.ml's own comment on this branch names that exact
       predicate as the wrong reading it corrects). The two predicates are
       extensionally identical over every candidate this engine actually
       constructs -- confirmed by enumerating the whole candidate space over
       1583-9999: substituting the old predicate back leaves 257/257 green
       and produces byte-identical `colitur day` output for every sampled
       year. No REALISTIC row can tell them apart, so this one is
       deliberately SYNTHETIC: a temporal-origin loser, [Class3] (not
       [Class4]), whose slug matches none of RG 109(a)-(f) -- [privilege_of]
       therefore returns [Ordinary] for it, the SAME privilege value as the
       Class4 row above. The two gates now diverge: RG 26 ([rank = Class4])
       says this loser is NOT a IV-class feria, so it falls through to the
       ordinary [Commemorate] branch (RG 24/25's mandate for a II/III-class
       feria); the refuted gate ([privilege_of loser = Ordinary]) tests only
       the privilege value, which is [Ordinary] here exactly as it is for
       the Class4 row, so it would wrongly return [Omit]. Reverting the
       branch's condition to [privilege_of loser = Ordinary] turns this row
       (and only this row, of the two) red -- see the final-fix-report for
       the exact Alcotest failure message this produces and its reversion. *)
    ( "SYNTHETIC (final fix wave, item 2a): a temporal-origin ORDINARY \
       Class3 loser is Commemorate under RG26 (rank=Class4 gate), would be \
       Omit under the refuted privilege_of=Ordinary gate",
      an_ordinary_sunday,
      cand ~rank:V.Class3 "ef-synthetic-ordinary-class3-feria",
      "Commemorate(Ordinary)" );
    (* RG 16(a) (register §6.0, Caput III "De Dominicis"): "festum Domini I
       aut II classis, in dominica II classis occurrens, ... de dominica,
       proinde, nulla fit commemoratio" -- a Feast of the Lord, I or II
       class, impeding a II-class Sunday, leaves the Sunday with NO
       commemoration at all, unlike every other impeded II-class Sunday
       (contrast the very first row of this table, and [privilege_cases]'s
       own (a): both plain [Class1]/other-sanctoral winners still admit the
       Sunday, [Commemorate(Privileged)]). Two positive rows, I and II
       class, both real slugs/ranks/subjects this project's own audit
       confirmed against the calendarium (this task's own report): the
       Transfiguration (6 Aug, II class, "IN TRANSFIGURATIONE D. N. I. C.")
       and the Precious Blood (1 Jul, I class, "PRETIOSISSIMI SANGUINIS
       D. N. I. C."). *)
    ( "RG16(a): a Feast of the Lord, II class, impedes a II-class Sunday -> \
       Omit, no commemoration at all",
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Lord ~layer:PE.universal_layer
        "transfiguration-of-our-lord",
      an_ordinary_sunday,
      "Omit" );
    ( "RG16(a): a Feast of the Lord, I class, impedes a II-class Sunday -> \
       Omit, no commemoration at all",
      cand ~origin:P.Sanctoral ~rank:V.Class1 ~subject:Sub.Lord ~layer:PE.universal_layer
        "precious-blood-of-our-lord-jesus-christ",
      an_ordinary_sunday,
      "Omit" );
    (* Conjunct 1/4, WINNER subject: RG 16(a) names a feast "Domini", not any
       I-class feast. {!band} entries 11-13 admit any I-class feast ahead of
       a II-class Sunday with no subject test (unlike entry 14, see that
       entry's own comment) -- so a real I-class SAINT feast can win the day
       exactly as a Lord feast would, and must NOT trigger RG 16(a). Real
       slug/rank/subject from data/ef/sanctoral.sexp (24 June, I class,
       "NATIVITAS S. IOANNIS BAPTISTAE", subject Saint): 24 June falls on a
       Time-after-Pentecost Sunday in real years (e.g. 2029), so this is a
       live, not merely synthetic, shape -- {!band} entries 11-13 rank it
       ahead of the ordinary Sunday's own entry 15 regardless of subject.
       Dropping the [subject = Lord] conjunct would wrongly Omit the Sunday
       here too. *)
    ( "RG16(a) does NOT fire for a I-class feast of a SAINT (not the Lord) \
       impeding a II-class Sunday -- stays Commemorate(Privileged)",
      cand ~origin:P.Sanctoral ~rank:V.Class1 ~subject:Sub.Saint ~layer:PE.universal_layer
        "nativity-of-st-john-the-baptist",
      an_ordinary_sunday,
      "Commemorate(Privileged)" );
    (* Conjunct 2/4, LOSER rank: RG 16(a) names "Dominica II classis", not
       any Sunday. A I-class Sunday (Advent/Lent/Passiontide, Low Sunday,
       {!band} entry 6) can never actually reach here as a loser against a
       Class1-or-2 Lord winner in real banding (entry 6's own band value, 6,
       beats every entry such a winner could occupy, 3/11-14), which is
       exactly why the primary text restricts itself to II class -- but
       [disposition] takes no [context] and cannot itself re-derive that
       band fact, so the rank check is what actually enforces it here.
       Sourced from [Temporal_ef.temporal]'s own real output (Advent I
       Sunday 2026, Class1, "ef-advent-sunday-1"), the same row
       [disposition_cases]'s own RG95/RG109(a) entry above already uses.
       Dropping the [rank = Class2] conjunct on the loser would wrongly Omit
       this Sunday's commemoration too. *)
    ( "RG16(a) does NOT fire for a I-class Sunday, even losing to a Feast of \
       the Lord -- stays Commemorate(Privileged)",
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Lord ~layer:PE.universal_layer
        "transfiguration-of-our-lord",
      of_temporal (T.advent_start 2026),
      "Commemorate(Privileged)" );
    (* Conjunct 3/4, LOSER is a Sunday at all: RG 16(a) protects "de
       dominica" specifically, not every ordinary office a Feast of the Lord
       happens to impede. An ordinary sanctoral SAINT losing to the same
       Lord-subject Class2 winner as the row above must still reach the
       ordinary RG 95/111(c) commemorate-or-omit flow, not RG 16(a)'s
       special no-commemoration rule. Dropping [is_sunday_slug] (or its
       [is_temporal] companion) here would wrongly Omit this ordinary
       saint's commemoration too. *)
    ( "RG16(a) does NOT fire for an ordinary (non-Sunday) SAINT losing to a \
       Feast of the Lord -- stays Commemorate(Ordinary)",
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Lord ~layer:PE.universal_layer
        "transfiguration-of-our-lord",
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "ef-some-other-saint",
      "Commemorate(Ordinary)" );
    (* Conjunct 4/4, WINNER rank: RG 16(a) says "I aut II classis", not any
       class. Every real [subject = Lord] entry this project's own sanctoral
       audit found is Class1 or Class2 (this task's own report), so this
       conjunct is not independently reachable on real data -- SYNTHETIC,
       the same defensive-but-not-load-bearing status this file already
       gives the RG26 branch's own [not is_vigil] guard above. Without the
       rank conjunct, [subject = Lord] alone would wrongly Omit this
       Sunday's commemoration under a hypothetical Class3 "feast of the
       Lord" too, which RG 16(a)'s own "I aut II classis" text does not
       cover. *)
    ( "SYNTHETIC: RG16(a) does NOT fire for a Class3 feast of the Lord \
       (outside \"I aut II classis\") impeding a II-class Sunday -- stays \
       Commemorate(Privileged)",
      cand ~origin:P.Sanctoral ~rank:V.Class3 ~subject:Sub.Lord ~layer:PE.universal_layer
        "ef-synthetic-class3-lord-feast",
      an_ordinary_sunday,
      "Commemorate(Privileged)" )
  ]

(* Task 9: [privilege_of]'s RG 109 categories (§4, "Commemorations"),
   exercised through [PE.disposition]'s [Commemorate] payload -- [privilege_of]
   itself is private, so this is the only vantage point a test outside
   precedence_ef.ml has on it. Each row below is built to match ONLY the one
   category it names (see each row's own comment for why), closing the
   hazard flagged in the task brief ("a test day that is both a Sunday and a
   I-class day proves nothing about either"). Category (b), "of a I-class
   day", already has its sole witness above (the Commemoration_only row):
   a plain [Feast]-status [Class1] loser can never reach [Commemorate] at
   all in this ruleset (RG 95 routes it to [Transfer] instead), so no
   further row for (b) is added here -- see the task report. Category (f),
   "of the Major Rogations, in Mass", has no row at all: no candidate this
   codebase can currently construct represents one (see [privilege_of]'s own
   comment on (f)) -- the negative row below proves the one slug this engine
   DOES compute that could be mistaken for it (the Minor Rogations) is
   correctly NOT conflated with it, which is the strongest claim available
   without inventing an unfounded slug convention. *)
let privilege_cases =
  [ (* (a) RG 109(a) (§4): "of a Sunday". [an_ordinary_sunday] is Class2,
       not Class1, not within the Nativity octave, not an Ember day, not a
       feria of Advent/Lent/Passiontide -- matches (a) alone. *)
    ( "(a) an ordinary Sunday commemoration is privileged",
      cand "ef-nativity",
      an_ordinary_sunday,
      "Commemorate(Privileged)" );
    (* (c) RG 109(c) (§4): "of days within the Octave of the Nativity" --
       sourced from [Temporal_ef.temporal]'s own output (29 Dec 2026, Class2,
       "ef-nativity-octave-day-5"), not a hand-typed slug, for the same
       coupling-safety reason the file's own [of_temporal] rows use it
       elsewhere. Not a Sunday, not Class1, not an Ember day, not an
       Advent/Lent/Passiontide feria slug. *)
    ( "(c) a day within the Nativity octave is privileged",
      cand "ef-nativity",
      of_temporal (mk 2026 12 29),
      "Commemorate(Privileged)" );
    (* (d) RG 109(d) (§4): "of September Ember days" -- 23 Sep 2026 is
       the September Ember Wednesday (independently derived from
       [Temporal_ef]'s own third-Sunday-of-September rule: first Sunday of
       September 2026 is the 6th, +14 days = 20th, +3 = 23rd), sourced from
       [Temporal_ef.temporal] itself, Class2. Not a Sunday, not Class1, not
       within the Nativity octave, not a plain Advent/Lent/Passiontide feria
       slug either -- and, unlike the Advent/Lent Ember rows below, its own
       slug ("ef-september-ember-wed") never starts with any of (e)'s own
       [alp_feria_prefixes] ("ef-advent-"/"ef-lent-"/"ef-passiontide-"), so
       (d) is this candidate's ONLY route to [Privileged] -- a genuine,
       still-necessary distinction from (e), unlike the Advent/Lent Ember
       case below (fix round 1). *)
    ( "(d) a September Ember day is privileged",
      cand "ef-nativity",
      of_temporal (mk 2026 9 23),
      "Commemorate(Privileged)" );
    (* (e) RG 109(e) (§4): "of ferias of Advent, Lent and Passiontide" --
       two rows, one per season named, both from [Temporal_ef.temporal]'s
       own generic ferial fallback, neither a Sunday, Ember day, or within
       the Nativity octave. *)
    ( "(e) an Advent feria is privileged",
      cand "ef-nativity",
      of_temporal (mk 2026 12 1),
      "Commemorate(Privileged)" );
    ( "(e) a Lent feria is privileged",
      cand "ef-nativity",
      of_temporal (off (-41)),
      "Commemorate(Privileged)" );
    (* CHANGED, fix round 1 (F1/F2/F6): these two rows used to be titled
       "boundary: ... is NOT privileged (only September is, RG109(d))" and
       expected [Omit] (a Task-16-pass reading that treated RG 109(e)'s bare
       "feriis Adventus, Quadragesimae" as tacitly excluding the Advent and
       Lent Ember sub-days, by analogy with (d)'s own separate, explicit
       September carve-out). Review round 1 (F1) reproduced the real
       consequence directly -- 1900-12-21, an Advent Ember Friday, lost its
       own commemoration entirely, while an ordinary (lower-solemnity,
       non-Ember) Advent feria the same week kept its commemoration --
       backwards on any reading, and traced it to this exact
       misclassification (F2).

       Corrected reading (precedence_ef.ml's own [privilege_of], (e)
       branch, carries the full argument): RG 91's TABLE needs an explicit
       "exceptis feriis Quatuor Temporum" at its own entries 22 and 25 to
       keep Ember days from being double-listed against their own entry 18
       -- an exception that would be unnecessary drafting if "feriae
       Adventus"/"feriae Quadragesimae" did not ALREADY include their Ember
       sub-days by default. RG 109(e) carries no such "exceptis" clause, so
       its bare text is read at that same default, INCLUSIVE scope: the
       Advent and Lent Ember ferias ARE privileged under (e), not merely
       commemorable-but-ordinary. (d)'s own separate existence survives
       this reading intact -- September Ember days sit outside Advent/Lent/
       Passiontide under ANY reading, so (d) remains the ONLY way they
       reach [Privileged], the point the row immediately above this one
       makes explicit.

       16 Dec 2026 is the Advent Ember Wednesday (independently derived:
       Advent I 2026 is 29 Nov, +14 days = 13 Dec, +3 = 16 Dec); the Lent
       Ember Wednesday is the same date [off (-39)] already used by the
       entry-18 [band] row above. Both sourced from [Temporal_ef.temporal],
       not hand-typed, for the same coupling-safety reason every
       [of_temporal] row in this file uses it. *)
    ( "(e), corrected fix round 1: an Advent Ember day is ALSO privileged, \
       not excluded from (e)",
      cand "ef-nativity",
      of_temporal (mk 2026 12 16),
      "Commemorate(Privileged)" );
    ( "(e), corrected fix round 1: a Lent Ember day is ALSO privileged, \
       not excluded from (e)",
      cand "ef-nativity",
      of_temporal (off (-39)),
      "Commemorate(Privileged)" );
    (* Negative, RG 109(f)'s own boundary: the Minor Litanies/Rogations
       (Monday/Tuesday before Ascension, RG 87 -- [Temporal_ef.temporal]
       DOES compute these, unlike the Major Litanies RG 109(f) actually
       names, see [privilege_of]'s own comment) must NOT be mistaken for the
       Major Rogations RG 109(f) privileges: RG 88 says the Minor Rogations
       change nothing in the Office at all, so nothing about them is
       privileged either -- and (Task 16) being temporal+ordinary, a Minor
       Rogation day impeded by a saint is now omitted outright, matching the
       missalemeum oracle exactly (Task 16 report: 11 May 2026 and 12 May
       2026, both Minor Rogation days impeded by a saint, show zero
       commemoration of the Rogation in the independent oracle). *)
    ( "boundary: a Minor Rogation day is NOT privileged (RG109(f) names \
       the Major Litanies, not these) -- TASK16 omits it entirely",
      cand "ef-nativity",
      of_temporal (off 36),
      "Omit" )
  ]

(* Task 9: [PE.admit] -- RG 111's admission counts (§4, "Commemorations"),
   given commemorations ALREADY tagged with their real privilege (as
   [PE.disposition] now tags them -- see [privilege_cases] above). Every
   candidate/privilege pair here is built directly, not routed through
   [PE.disposition], so these rows isolate [admit]'s own selection logic
   from [privilege_of]'s classification -- the two are proved separately by
   design (unlike a test that only proves [admit] admits SOME correct-looking
   set without knowing whether it or [privilege_of] supplied the "correct"
   part). Checked on slug IDENTITY, not count (the brief: "'two admitted'
   proves nothing about *which* two"). *)

(* Class2 dignity, tagged [Ordinary] explicitly (not via [privilege_of]) --
   used as the higher-dignity, non-privileged half of every asymmetry pair
   below. *)
let ordinary_hi = cand ~rank:V.Class2 "ef-ordinary-hi"

(* Class3 dignity (LOWER than [ordinary_hi]), tagged [Privileged] explicitly
   -- pairing a lower-dignity privileged candidate against a higher-dignity
   ordinary one is what makes the II-class-Sunday-vs-other-II-class
   asymmetry observable: pure dignity and "privilege wins the slot" pick
   DIFFERENT winners from this exact pair. *)
let privileged_lo = cand ~rank:V.Class3 "ef-privileged-lo"

(* Class2 dignity (tied with [ordinary_hi], distinguishing rank from
   privilege alone), tagged [Privileged] -- the higher-dignity privileged
   candidate for the "two privileged due" row. *)
let privileged_hi = cand ~rank:V.Class2 "ef-privileged-hi"

(* Class4, the lowest dignity in play -- the third candidate for the
   III/IV-class "at most two" row, so which TWO of three survive is the
   thing under test, not merely how many. *)
let ordinary_lowest = cand ~rank:V.Class4 "ef-ordinary-lowest"

(* Class3, tagged [Ordinary] -- fix round 1, F7: the RG 111(b) rank-floor
   witness [admit_cases] was missing. [ordinary_hi] above is already Class2,
   so every existing II-class-Sunday row here passes whether or not
   [admit]'s "de festo II classis" filter is even present -- reverting that
   filter would only redden [test_oracle.ml], not this file, which is
   exactly the coverage gap the review round found. This candidate is the
   ONLY thing due on the Sunday row below, so a version of [admit] without
   the rank floor would (wrongly) admit it on pure "best available"
   dignity, same as it would have admitted [ordinary_hi]. *)
let ordinary_class3 = cand ~rank:V.Class3 "ef-ordinary-class3"

let observed_class1 = cand "ef-nativity" (* Class1 by [cand]'s own default. *)
let observed_class2_sunday = an_ordinary_sunday (* Class2, slug carries "-sunday". *)
let observed_class2_other = cand ~rank:V.Class2 "ef-other-class2-day" (* Class2, no "-sunday". *)
let observed_class3 = cand ~rank:V.Class3 "ef-some-class3-day"

let slugs_of admitted =
  List.map (fun (c, _) -> S.to_string c.P.cel.Cel.slug) admitted

(* Real-data shapes for the fix round's own RG16(a)/[admit] interaction
   (below): a Feast of the Lord (subject Lord, Class2, sanctoral) as
   [observed], oracle-confirmed real slugs/ranks rather than hand-typed
   ones, the same coupling-safety reason [of_temporal] rows elsewhere in
   this file use real data. *)
let lord_winner =
  cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Lord ~layer:PE.universal_layer
    "transfiguration-of-our-lord"

(* Pope Sixtus II et al. (6 August, real slug/rank/status from
   data/ef/sanctoral.sexp): Class3, Commemoration_only, tagged [Ordinary]
   here directly (as every other [admit_cases] row does, isolating
   [admit]'s own selection logic from [privilege_of], per this table's own
   header comment). *)
let sixtus =
  cand ~origin:P.Sanctoral ~rank:V.Class3 ~status:Cel.Commemoration_only
    ~layer:PE.universal_layer "pope-sixtus-ii-felicissimus-and-agapitus-martyrs"

(* (description, observed, temporal, comms, expected). [temporal] --
   {!Precedence.rules.admit}'s own new parameter, fix round 1 (RG16(a)
   task): the day's temporal-cycle candidate, independent of who is
   [observed]. Every row below except the last two repeats [observed] as
   [temporal] -- no RG16(a) displacement occurs in those rows (the pattern
   [PE.admit]'s pre-fix code implicitly, and wrongly, assumed held
   UNIVERSALLY), so the two values coinciding is the faithful shape, not a
   simplification that dodges the new parameter. The last two rows are
   where they genuinely differ -- see their own comment. *)
let admit_cases =
  [ (* RG 111 (§4): "I class: none save one privileged." *)
    ( "I-class day, only an ordinary commemoration due -> none admitted",
      observed_class1, observed_class1,
      [ (ordinary_hi, P.Ordinary) ],
      [] );
    ( "I-class day, ordinary + privileged both due -> only the privileged \
       one, regardless of the ordinary one's higher dignity",
      observed_class1, observed_class1,
      [ (ordinary_hi, P.Ordinary); (privileged_lo, P.Privileged) ],
      [ "ef-privileged-lo" ] );
    ( "I-class day, two privileged due -> only the higher-dignity one (still \
       just \"one\")",
      observed_class1, observed_class1,
      [ (privileged_lo, P.Privileged); (privileged_hi, P.Privileged) ],
      [ "ef-privileged-hi" ] );
    (* RG 111: "II-class Sundays: one (dropped if a privileged one is due)." *)
    ( "II-class Sunday, only an ordinary commemoration due -> it is admitted",
      observed_class2_sunday, observed_class2_sunday,
      [ (ordinary_hi, P.Ordinary) ],
      [ "ef-ordinary-hi" ] );
    ( "II-class Sunday, ordinary (higher dignity) + privileged (lower \
       dignity) both due -> the PRIVILEGED one is admitted, the ordinary \
       one dropped despite outranking it",
      observed_class2_sunday, observed_class2_sunday,
      [ (ordinary_hi, P.Ordinary); (privileged_lo, P.Privileged) ],
      [ "ef-privileged-lo" ] );
    (* RG 111(b)'s own rank floor ("scilicet DE FESTO II CLASSIS"), fix
       round 1 F7: a Class3 ORDINARY candidate -- no privileged rival due,
       so the pre-fix-round code's "no privileged? take the best of what's
       left" fallback would (wrongly) admit it -- is admitted NOTHING. The
       slot is reserved for a II-class candidate specifically; a III-class
       ordinary one has no standing for it at all, unlike "other II class"
       below, which has no such restriction. *)
    ( "II-class Sunday, sole candidate is an ORDINARY Class3 (not \
       \"de festo II classis\") -> admitted nothing, not the best available",
      observed_class2_sunday, observed_class2_sunday,
      [ (ordinary_class3, P.Ordinary) ],
      [] );
    (* RG 111: "other II class: one" -- no privilege override, the exact
       asymmetry the brief and precedence_ef.ml's own [admit] comment flag:
       same candidate pair as the II-class-Sunday row above, OPPOSITE
       observed day, OPPOSITE winner. *)
    ( "other II-class day, only an ordinary commemoration due -> it is \
       admitted",
      observed_class2_other, observed_class2_other,
      [ (ordinary_hi, P.Ordinary) ],
      [ "ef-ordinary-hi" ] );
    ( "other II-class day, same ordinary+privileged pair as the Sunday row \
       above -> the ORDINARY one wins on pure dignity this time, the \
       privileged one dropped",
      observed_class2_other, observed_class2_other,
      [ (ordinary_hi, P.Ordinary); (privileged_lo, P.Privileged) ],
      [ "ef-ordinary-hi" ] );
    (* RG 111: "III-IV class: at most two" -- three candidates due, top two
       by dignity admitted, the third (lowest dignity) dropped. *)
    ( "III-class day, three commemorations due -> the top two by dignity, \
       not merely \"two of them\"",
      observed_class3, observed_class3,
      [ (ordinary_hi, P.Ordinary); (privileged_lo, P.Privileged);
        (ordinary_lowest, P.Ordinary) ],
      [ "ef-ordinary-hi"; "ef-privileged-lo" ] );
    (* Fix round 1, item 2 (RG16(a) task review): the CRITICAL witness for
       [~temporal]. RG 16(a)'s own text -- the winning Feast of the Lord
       holds the Sunday's place "cum omnibus iuribus et privilegiis" -- means
       the day is STILL a "dominica II classis" for RG 111(b)'s own rank
       floor, even though [observed] is now the FEAST (Transfiguration, real
       slug/rank/subject), not the Sunday. [temporal] (a real Sunday
       candidate, {!an_ordinary_sunday}) is what reveals that; nothing in
       [observed] does, since its own slug carries no Sunday marker at all.
       Oracle-confirmed (missalemeum, 2023-08-06, a Sunday):
       "commemorations": [], Sixtus "displaced". Before this fix
       [observed_is_sunday] read [observed]'s own slug and got [false] here,
       wrongly taking the "other II class: one" branch below and admitting
       Sixtus (Class3) despite RG 111(b)'s rank floor. *)
    ( "RG16(a) interaction: a Feast of the Lord observed on a day whose \
       TEMPORAL candidate is a II-class Sunday -> the RG111(b) rank floor \
       still applies, admits nothing (Sixtus, Class3, has no standing)",
      lord_winner, an_ordinary_sunday,
      [ (sixtus, P.Ordinary) ],
      [] );
    (* The control, same pair as the row above with [observed] = [temporal]
       (no displacement -- an ordinary WEEKDAY, not a Sunday): "other II
       class: one", no rank floor, Sixtus IS admitted. Oracle-confirmed
       (missalemeum, 2026-08-06, a Thursday): Sixtus in "commemorations".
       Proves the DISCRIMINATING factor is [temporal]'s own Sunday-ness, not
       merely "was the winner a Feast of the Lord" -- that alone is true of
       BOTH rows here, and only one of them excludes Sixtus. *)
    ( "control: the same Feast of the Lord on an ORDINARY weekday (temporal \
       = observed, not a Sunday) -> \"other II class: one\", Sixtus IS \
       admitted",
      lord_winner, lord_winner,
      [ (sixtus, P.Ordinary) ],
      [ "pope-sixtus-ii-felicissimus-and-agapitus-martyrs" ] )
  ]

(* Order independence (brief: "the admitted set must not depend on input
   order"): the SAME three candidates as the III-class row above, passed in
   the reverse order, must still admit the same top two -- exercised on this
   row specifically because it is the one where the sort actually has work
   to do (three distinct dignities, a real top-2 cut), unlike a
   two-candidate row where either order already happens to be sorted. *)
let test_admit_order_independent () =
  let comms =
    [ (ordinary_hi, P.Ordinary); (privileged_lo, P.Privileged); (ordinary_lowest, P.Ordinary) ]
  in
  let forward = slugs_of (PE.admit ~observed:observed_class3 ~temporal:observed_class3 comms) in
  let reversed =
    slugs_of (PE.admit ~observed:observed_class3 ~temporal:observed_class3 (List.rev comms))
  in
  Alcotest.(check (list string)) "reversed input admits the same candidates"
    forward reversed

(* The brief: "a case proving that what the limit drops is reported in
   omitted rather than vanishing" -- three end-to-end proofs, wired with the
   REAL [PE.band], [PE.disposition] and [PE.admit] together (not a stub, so
   [privilege_of]'s real classification is exercised too, not just [admit]'s
   selection logic in isolation as above).

   [rules] deliberately reused, not rebuilt per test, since it is always the
   same three real functions. *)
let real_rules = { P.band = PE.band; disposition = PE.disposition; admit = PE.admit }

(* I-class day, zero admitted: the strongest form of "does not vanish" --
   EVERY commemoration due is dropped (RG 111: "none save one privileged",
   and the one loser here is ordinary), yet it must still appear in
   [omitted], not merely be absent from [commemorations]. *)
let test_i_class_day_drops_into_omitted () =
  let date = mk 2026 12 25 in
  let day_ctx = ctx date in
  let nativity = of_temporal date in
  let saint = cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-some-saint-3" in
  let resolution = P.resolve real_rules day_ctx ~temporal:nativity ~sanctoral:[ saint ] in
  Alcotest.(check (list string)) "nothing admitted on a I-class day with only an ordinary loser due"
    [] (List.map (fun (c, _) -> S.to_string c.P.cel.Cel.slug) resolution.P.commemorations);
  Alcotest.(check (list (pair string string))) "the ordinary loser is reported omitted, not vanished"
    [ ("ef-some-saint-3", "omitted: admission limit reached") ]
    (List.map (fun (c, reason) -> (S.to_string c.P.cel.Cel.slug, reason)) resolution.P.omitted)

(* II-class Sunday, two ordinary losers due, RG 111's "one" admits the
   higher-dignity one and drops the other into [omitted]. *)
let test_ii_class_sunday_drops_second_loser_into_omitted () =
  let date = mk 2025 11 9 (* an ordinary Time-after-Pentecost Sunday, not All Souls-adjacent. *) in
  let day_ctx = ctx date in
  let sunday =
    { P.cel =
        Cel.make ~slug:(S.of_string_exn "ef-time-after-pentecost-sunday-x") ~rank:V.Class2
          ~colour:Col.Green ~subject:Sub.Temporal ~layer:"temporal" ();
      origin = P.Temporal }
  in
  let saint_a = cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "ef-some-saint" in
  let saint_b = cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-some-saint-3" in
  let resolution = P.resolve real_rules day_ctx ~temporal:sunday ~sanctoral:[ saint_a; saint_b ] in
  Alcotest.(check (list string)) "only the higher-dignity (Class2) loser is admitted"
    [ "ef-some-saint" ]
    (List.map (fun (c, _) -> S.to_string c.P.cel.Cel.slug) resolution.P.commemorations);
  Alcotest.(check (list (pair string string))) "the lower-dignity loser is reported omitted, not vanished"
    [ ("ef-some-saint-3", "omitted: admission limit reached") ]
    (List.map (fun (c, reason) -> (S.to_string c.P.cel.Cel.slug, reason)) resolution.P.omitted)

(* A genuinely privileged commemoration reaching [admit] through the REAL
   pipeline (register RG 109(e)): a Lent feria (Class3, temporal-origin)
   loses to a universal Class2 sanctoral feast on the same date, and
   [PE.disposition] tags it [Privileged] via [privilege_of] -- proving
   [privilege_of] and [admit] cooperate correctly end-to-end, not merely in
   the hand-tagged unit tests above. *)
let test_privileged_lent_feria_admitted_end_to_end () =
  let date = off (-41) (* Lent I Monday, the same date the entry-22 [band] row uses. *) in
  let day_ctx = ctx date in
  let lent_feria = of_temporal date in
  let saint = cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "ef-some-saint" in
  let resolution = P.resolve real_rules day_ctx ~temporal:lent_feria ~sanctoral:[ saint ] in
  Alcotest.(check string) "the Lent feast wins the day, not the sanctoral feast's own commemoration"
    "ef-some-saint" (S.to_string resolution.P.observed.P.cel.Cel.slug);
  Alcotest.(check (list (pair string string))) "the Lent feria is admitted, tagged Privileged"
    [ ("ef-lent-1-monday", "Privileged") ]
    (List.map
       (fun (c, p) -> (S.to_string c.P.cel.Cel.slug, match p with P.Privileged -> "Privileged" | P.Ordinary -> "Ordinary"))
       resolution.P.commemorations);
  Alcotest.(check int) "nothing omitted" 0 (List.length resolution.P.omitted)

(* Fix round 1 (post-Task-9 review): the II-class-Sunday override (RG 111
   "one, dropped if a privileged one is due") reached through the REAL
   pipeline, with both a privileged AND an ordinary commemoration due on the
   same day -- previously only proven at the isolated [admit] level
   ([admit_cases]'s hand-tagged rows above). RG 109(a)-(f)'s five reachable
   categories are all properties of a TEMPORAL-origin office, and only one
   temporal candidate exists per date, so a privileged AND an ordinary
   commemoration cannot both be due from the temporal side alone -- but
   category (b) is the exception: it is sanctoral and rank-based (a
   [Commemoration_only] entry carrying [Class1], the exact shape
   [disposition_cases]'s own (b) witness row uses), and a [Commemoration_only]
   entry is held out of the band contest entirely ({!Precedence.resolve}),
   so it is a loser regardless of what [observed] turns out to be. Paired
   with an ordinary sanctoral saint, both lose to an ordinary Class2 Sunday,
   giving [observed_is_sunday = true] with one [Privileged] and one
   [Ordinary] loser due at once -- no synthetic fixture needed.

   NOTE on what this test does and does not prove: the only reachable
   witness for RG 109(b) is [Class1] (the highest dignity), and any
   sanctoral loser that could ALSO beat this same Sunday by pure dignity
   would win the day outright instead of losing to it (any [Feast]-status
   [Class1] sanctoral candidate bands at entry 11-13, ahead of an ordinary
   Sunday's entry 15) -- so within this specific pipeline shape the
   privileged loser is unavoidably also the higher-dignity one, and this
   test cannot by itself distinguish "privilege overrides dignity" from
   "dignity alone happened to pick the same winner". That distinction is
   what [admit_cases]'s hand-tagged rows above prove (a LOWER-dignity
   privileged candidate still beats a HIGHER-dignity ordinary one on a
   II-class Sunday, the opposite of "other II class"'s own row). This test's
   job is narrower and complementary: proving the real pipeline
   ([PE.band], [PE.disposition], [PE.privilege_of] via [disposition],
   [PE.admit] together) actually reaches and exercises the override branch
   end-to-end, not merely in isolation. *)
let test_ii_class_sunday_privileged_witness_admitted_end_to_end () =
  let date = mk 2026 7 5 (* an ordinary Time-after-Pentecost Sunday. *) in
  let day_ctx = ctx date in
  let sunday = of_temporal date in
  let privileged_witness =
    cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~layer:PE.universal_layer
      "ef-commemoration-only-b-witness"
    (* Class1 by [cand]'s own default -- RG 109(b). *)
  in
  let ordinary_saint = cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "ef-some-saint" in
  let resolution =
    P.resolve real_rules day_ctx ~temporal:sunday ~sanctoral:[ privileged_witness; ordinary_saint ]
  in
  (* Checked against [sunday]'s own slug, not a hand-typed/guessed literal
     (its exact week number is not worth independently re-deriving here):
     this asserts identity with the real [Temporal_ef.temporal] candidate,
     proving the SUNDAY -- not either sanctoral loser -- is what wins the
     day, which the privilege assertions below presuppose. *)
  Alcotest.(check string) "the Sunday wins the day, not either sanctoral loser"
    (S.to_string sunday.P.cel.Cel.slug) (S.to_string resolution.P.observed.P.cel.Cel.slug);
  Alcotest.(check (list (pair string string)))
    "only the privileged (Commemoration_only, Class1) witness is admitted"
    [ ("ef-commemoration-only-b-witness", "Privileged") ]
    (List.map
       (fun (c, p) -> (S.to_string c.P.cel.Cel.slug, match p with P.Privileged -> "Privileged" | P.Ordinary -> "Ordinary"))
       resolution.P.commemorations);
  Alcotest.(check (list (pair string string))) "the ordinary saint is dropped into omitted, not vanished"
    [ ("ef-some-saint", "omitted: admission limit reached") ]
    (List.map (fun (c, reason) -> (S.to_string c.P.cel.Cel.slug, reason)) resolution.P.omitted)

(* RG 16(a) (register §6.0) reached end-to-end through the REAL pipeline
   ([PE.band], [PE.disposition] AND [PE.admit] together, not any one of
   them in isolation as this file's own hand-tagged rows test them
   separately): a real civil date this task's own blast-radius measurement
   names as a live instance of the bug this branch fixes -- 6 August 2028
   is a Sunday (independently checked with `date -d 2028-08-06 +%A`, the
   same cross-check discipline [test_golden.ml]'s own header requires),
   Time after Pentecost week 9, and the Transfiguration (6 Aug, II class,
   "IN TRANSFIGURATIONE D. N. I. C.") falls on it. Before this fix
   `colitur day` emitted this Sunday as an admitted
   [+ef-time-after-pentecost-sunday-9] commemoration on that date (register
   §6.0's own reproduction).

   Fix round 1 (item 2): a THIRD candidate, Pope Sixtus II et al. (real
   slug/rank/status, 6 August, unrelated to the Sunday), is now also
   offered, so this test exercises BOTH fixes at once, the same real shape
   [test_golden.ml]'s own 2028-08-06 pin does: [observed] is the
   Transfiguration ({!band} entry 14 already did this correctly before this
   task); the Sunday is [disposition]-level [Omit] (RG 16(a)); Sixtus is
   [disposition]-level [Commemorate], but [admit] then excludes him too --
   RG 111(b)'s rank floor, reached only because [admit] reads the day's
   Sunday-ness off [~temporal] (the [sunday] candidate passed to
   [P.resolve]), not off [observed] (the Transfiguration, whose own slug
   carries no Sunday marker). [commemorations] is EMPTY, and BOTH losers
   land in [omitted] with their own distinct reasons -- the Sunday via
   [disposition]'s own Omit ("omitted: yielded to a higher day"), Sixtus via
   [admit]'s cut ("omitted: admission limit reached") -- proving the two
   fixes operate through genuinely different mechanisms, not the same one
   coincidentally producing the same string. *)
let test_rg16a_lord_feast_suppresses_sunday_end_to_end () =
  let date = mk 2028 8 6 in
  let day_ctx = ctx date in
  let sunday = of_temporal date in
  let transfiguration =
    cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Lord ~layer:PE.universal_layer
      "transfiguration-of-our-lord"
  in
  let sixtus =
    cand ~origin:P.Sanctoral ~rank:V.Class3 ~status:Cel.Commemoration_only ~layer:PE.universal_layer
      "pope-sixtus-ii-felicissimus-and-agapitus-martyrs"
  in
  let resolution =
    P.resolve real_rules day_ctx ~temporal:sunday ~sanctoral:[ transfiguration; sixtus ]
  in
  Alcotest.(check string) "the Transfiguration is observed, not the Sunday"
    "transfiguration-of-our-lord" (S.to_string resolution.P.observed.P.cel.Cel.slug);
  Alcotest.(check int)
    "no commemorations at all -- RG16(a)'s own \"nulla fit commemoratio\" AND RG111(b)'s rank floor, both live"
    0 (List.length resolution.P.commemorations);
  Alcotest.(check (list (pair string string)))
    "both losers omitted, via two DIFFERENT mechanisms: the Sunday at disposition-level (RG16a), Sixtus at \
     admit's own cut (RG111b)"
    [ ("ef-time-after-pentecost-sunday-9", "omitted: yielded to a higher day");
      ("pope-sixtus-ii-felicissimus-and-agapitus-martyrs", "omitted: admission limit reached") ]
    (List.map (fun (c, reason) -> (S.to_string c.P.cel.Cel.slug, reason)) resolution.P.omitted
    |> List.sort compare)

(* Completes Task 7's carried fix (RG 91 entry 8, §4): on a real Sunday
   landing on 2 November, All Souls does not merely lose (that was Task 7's
   [band] fix, proved by [test_all_souls_yields_to_sunday] above) -- it must
   be TRANSFERRED, not commemorated and not omitted. All Souls is I class
   (RG 91 entry 8's own [rank] field, untouched by the Sunday-exception band
   bump -- see precedence_ef.ml's comment on entry 8), so RG 95's rank
   condition alone should route it to [Transfer]. *)
let test_all_souls_disposition_is_transfer () =
  let sunday = an_ordinary_sunday in
  let all_souls = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls" in
  Alcotest.(check string) "All Souls loses to a Sunday and transfers"
    "Transfer"
    (string_of_disposition (PE.disposition ~winner:sunday ~loser:all_souls))

(* The same fact, proved end-to-end through [Precedence.resolve] with the
   REAL [PE.band] and REAL [PE.disposition] wired together (Task 7's own
   integration test above still stubs [disposition] to a constant [Omit],
   which is exactly what this task must not leave true) -- All Souls must
   land in [deferred], not [commemorations] or [omitted]. WHERE it is placed
   (3 November, RG 96) is [Rite.transfer_target]'s job, out of this task's
   scope; this only proves [resolve] hands it to the transfer path at all. *)
let test_all_souls_transfers_end_to_end () =
  let date = mk 2025 11 2 in
  let day_ctx = ctx date in
  let sunday =
    { P.cel =
        Cel.make ~slug:(S.of_string_exn "ef-time-after-pentecost-sunday-x") ~rank:V.Class2
          ~colour:Col.Green ~subject:Sub.Temporal ~layer:"temporal" ();
      origin = P.Temporal }
  in
  let all_souls = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls" in
  let rules = { P.band = PE.band; disposition = PE.disposition; admit = (fun ~observed:_ ~temporal:_ cs -> cs) } in
  let resolution = P.resolve rules day_ctx ~temporal:sunday ~sanctoral:[ all_souls ] in
  Alcotest.(check (list string)) "All Souls is deferred (transferred), not omitted or commemorated"
    [ "ef-all-souls" ]
    (List.map (fun c -> S.to_string c.P.cel.Cel.slug) resolution.P.deferred);
  Alcotest.(check int) "nothing commemorated" 0 (List.length resolution.P.commemorations);
  Alcotest.(check int) "nothing omitted" 0 (List.length resolution.P.omitted)

(* Task 11: [PE.transfer_target] -- RG 96 ("the next following day that is
   not I or II class") plus its Annunciation exception. [occupant] is a
   synthetic callback ({!Colitur_kernel.Rite.t.transfer_target}'s own
   [occupant] parameter), not a real [Calendar]-driven one -- the CLI's own
   end-to-end proof (colitur day, All Souls landing on 3 Nov 2025 and the
   Annunciation landing on 5 Apr 2027, see test/cli.t and the task report)
   is what wires this against real data; these rows isolate the search
   function itself. *)

(* [blocked] returns Class1 (blocking) for exactly the listed dates, Class4
   (not blocking) everywhere else -- enough to exercise [is_blocking]'s own
   two-way test (RG 96 speaks of I OR II class; Class1 alone is enough to
   prove the blocking side, [test_transfer_target_terminates...] below adds
   nothing by varying it further). *)
let occupant_blocking_on blocked_dates (d : D.t) : V.rank Cel.t =
  let blocking = List.exists (fun bd -> D.compare bd d = 0) blocked_dates in
  Cel.make ~slug:(S.of_string_exn "occupant") ~rank:(if blocking then V.Class1 else V.Class4)
    ~colour:Col.Green ~layer:"synthetic" ()

let occupant_always_blocking (_ : D.t) : V.rank Cel.t =
  Cel.make ~slug:(S.of_string_exn "occupant") ~rank:V.Class1 ~colour:Col.Green ~layer:"synthetic" ()

(* General RG 96 search: two consecutive blocked days past [origin], proving
   the search walks past MORE than one ineligible day rather than only
   trying [origin + 1] and stopping (the same shape Calendar's own
   synthetic fixture pins for the abstraction -- this pins it for the real
   EF search function). *)
let test_transfer_target_general_multi_step_search () =
  let origin = mk 2026 1 10 in
  let occupant = occupant_blocking_on [ mk 2026 1 11; mk 2026 1 12 ] in
  let c = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-some-i-class-feast" in
  let target = PE.transfer_target c origin occupant in
  Alcotest.(check string) "lands on the first day past the blocked run"
    "2026-01-13" (D.to_iso8601 target)

(* Coordinator review (fix round 1): RG 96 Attamen (a) (register-transcribed,
   primary-source-verified) makes the Annunciation exception CONDITIONAL on
   the general RG 96 walk carrying the feast past Easter Sunday -- NOT
   unconditional as the first transcription had it. The occupant here blocks
   every day from [origin + 1] through the day after Easter (26 March - 6
   April 2026 inclusive), so the GENERAL target itself would land at 7
   April -- after Easter (5 April) -- which is exactly the trigger
   condition, not merely "the Annunciation is impeded at all". *)
let test_transfer_target_annunciation_starts_at_monday_after_low_sunday () =
  let origin = mk 2026 3 25 in
  let easter_2026 = Comp.gregorian_easter 2026 in
  let blocked_through_day_after_easter =
    let rec range a b = if D.compare a b > 0 then [] else a :: range (D.add_days a 1) b in
    range (D.add_days origin 1) (D.add_days easter_2026 1)
  in
  let occupant = occupant_blocking_on blocked_through_day_after_easter in
  let c = cand ~origin:P.Sanctoral ~layer:PE.universal_layer PE.annunciation_slug in
  let target = PE.transfer_target c origin occupant in
  let monday_after_low_sunday = D.add_days easter_2026 8 in
  Alcotest.(check string) "lands on the Monday after Low Sunday (Easter + 8), the general \
                           walk having crossed Easter itself"
    (D.to_iso8601 monday_after_low_sunday) (D.to_iso8601 target);
  Alcotest.(check bool) "NOT the general target (2 days after Easter, discriminates the branch)"
    true
    (D.compare target (D.add_days easter_2026 2) <> 0)

(* RG 96's own qualifier on the exception -- "searching onward from there
   only if that day is itself blocked" (rite.mli) -- is [search_from]'s
   ORDINARY behaviour, not a second mechanism: same blocked run as above
   (forcing the general target past Easter, so the exception fires), PLUS
   the Monday after Low Sunday itself blocked, confirming the search
   continues exactly one more day from there. *)
let test_transfer_target_annunciation_searches_onward_if_blocked () =
  let origin = mk 2026 3 25 in
  let easter_2026 = Comp.gregorian_easter 2026 in
  let monday_after_low_sunday = D.add_days easter_2026 8 in
  let blocked =
    let rec range a b = if D.compare a b > 0 then [] else a :: range (D.add_days a 1) b in
    range (D.add_days origin 1) (D.add_days easter_2026 1) @ [ monday_after_low_sunday ]
  in
  let occupant = occupant_blocking_on blocked in
  let c = cand ~origin:P.Sanctoral ~layer:PE.universal_layer PE.annunciation_slug in
  let target = PE.transfer_target c origin occupant in
  Alcotest.(check string) "searches onward one more day when that Monday is itself blocked"
    (D.to_iso8601 (D.add_days monday_after_low_sunday 1)) (D.to_iso8601 target)

(* THE REGRESSION PIN (coordinator review): the bug an unconditional
   exception produced. 25 March 2057 is Lent III Sunday (I class, RG 91
   entry 6), impeding the Annunciation; 26 March 2057 is an ordinary Lent
   feria (III class, well before Easter, 22 April 2057) -- the general RG
   96 target. The general target does NOT fall after Easter, so the
   exception must NOT fire: the Annunciation lands on 26 March, not 13
   April (Easter + 8), which is what the unconditional reading produced
   (verified by reverting the fix and re-running this exact test -- see the
   task report's mutation record). Uses the REAL [Temporal_ef.temporal] as
   [occupant] (not a synthetic stand-in), the same coupling-safety
   convention [of_temporal]'s callers use elsewhere in this file, so this
   is also effectively an end-to-end check of the real 2057 calendar
   shape, not just the search's own logic in isolation. *)
let test_transfer_target_annunciation_not_overridden_when_general_target_precedes_easter () =
  let origin = mk 2057 3 25 in
  Alcotest.(check string) "25 March 2057 is a Sunday (Lent III)" "sunday"
    (D.weekday_to_string (D.weekday origin));
  let occupant d = (T.temporal d).Colitur_kernel.Temporal.office in
  let c = cand ~origin:P.Sanctoral ~layer:PE.universal_layer PE.annunciation_slug in
  let target = PE.transfer_target c origin occupant in
  Alcotest.(check string) "lands on 26 March 2057 (the general RG96 target), NOT the \
                           Annunciation exception's Monday after Low Sunday"
    "2057-03-26" (D.to_iso8601 target)

(* rite.mli's own obligations on [transfer_target] (Task 11 brief): the call
   must TERMINATE and its result must be STRICTLY AFTER [origin], even for a
   rite/data shape this function cannot have anticipated -- an occupant that
   reports every single day as blocking, forever. Calendar's own round guard
   (max_transfer_rounds) does not cover this: it bounds ROUNDS across a
   whole year, not the internal walk one call to this function makes (see
   precedence_ef.ml's own comment on [search_from] and [max_search_days]).
   Deliberately NOT pinning the exact returned date against the private
   [max_search_days] constant -- that would coalesce a behavioural contract
   (terminates, makes forward progress) with an internal tuning value this
   function is free to change; a generous, test-owned ceiling (1000 days,
   comfortably past any realistic bound) is enough to prove termination is
   genuine and not merely "didn't hang during this particular run". *)
let test_transfer_target_terminates_under_pathological_occupant () =
  let origin = mk 2026 1 1 in
  let c = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-pathological-case" in
  let target = PE.transfer_target c origin occupant_always_blocking in
  Alcotest.(check bool) "strictly after origin" true (D.compare target origin > 0);
  Alcotest.(check bool)
    "terminates within a generous bound (proves the internal search is bounded, not merely lucky)"
    true
    (D.compare target (D.add_days origin 1000) <= 0)

(* Coordinator review: [search_from] must not probe [occupant] past
   {!Date}'s own domain ceiling (31 December 9999). A SYNTHETIC occupant
   (like [occupant_always_blocking] above) can never actually discriminate
   this: it never calls [Computus.gregorian_easter] itself, so it cannot
   raise regardless of whether the domain guard exists -- a test built on
   one would only prove [search_from]'s unrelated step bound, not this fix.
   [occupant] here is instead the REAL [Temporal_ef.temporal] (no sanctoral
   layer needed: 29-31 Dec are ALREADY II class via [named]'s own Nativity-
   octave-day entries, so three real, unbroken blocking days already sit at
   the very end of the domain) -- exactly the shape that raises without the
   fix: 1 January of civil year 10000 is next, and [Computus.gregorian_easter
   10000] does [Date.make ~year:10000 ...] and [failwith]s (the .ml's own
   [domain_max_date] comment; also how the reviewer reproduced the bug
   through the project's own overlay mechanism -- see the task report for
   that end-to-end reproduction). Mutation-verified: reverting the domain
   guard makes this test error with exactly that uncaught [Failure], not
   merely fail an assertion (see the task report). *)
let test_transfer_target_does_not_raise_at_domain_ceiling () =
  let origin = mk 9999 12 28 in
  let occupant d = (T.temporal d).Colitur_kernel.Temporal.office in
  let c = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-domain-ceiling-case" in
  let target = PE.transfer_target c origin occupant in
  Alcotest.(check bool) "past 31 December 9999 (the guard engaged; nothing admissible remained \
                         in-domain, so the search gave up at the ceiling rather than crashing)"
    true
    (D.compare target (mk 9999 12 31) > 0)


let suite =
  ( "Precedence_ef",
    List.map
      (fun (desc, date, c, expect) ->
        Alcotest.test_case desc `Quick (fun () ->
            Alcotest.(check int) desc expect (PE.band (ctx date) c)))
      cases
    @ [ Alcotest.test_case "8 All Souls yields to a Sunday (resolve-level)" `Quick
          test_all_souls_yields_to_sunday ]
    @ List.map
        (fun (desc, winner, loser, expect) ->
          Alcotest.test_case desc `Quick (fun () ->
              Alcotest.(check string) desc expect
                (string_of_disposition (PE.disposition ~winner ~loser))))
        disposition_cases
    @ List.map
        (fun (desc, winner, loser, expect) ->
          Alcotest.test_case desc `Quick (fun () ->
              Alcotest.(check string) desc expect
                (string_of_disposition (PE.disposition ~winner ~loser))))
        privilege_cases
    @ [ Alcotest.test_case "All Souls disposition is Transfer" `Quick
          test_all_souls_disposition_is_transfer;
        Alcotest.test_case "All Souls transfers end-to-end (resolve, real rules)" `Quick
          test_all_souls_transfers_end_to_end ]
    @ List.map
        (fun (desc, observed, temporal, comms, expect) ->
          Alcotest.test_case desc `Quick (fun () ->
              Alcotest.(check (list string)) desc expect
                (slugs_of (PE.admit ~observed ~temporal comms))))
        admit_cases
    @ [ Alcotest.test_case "admit is order-independent (III-class, 3 candidates)" `Quick
          test_admit_order_independent;
        Alcotest.test_case "I-class day: full drop reported in omitted, not vanished" `Quick
          test_i_class_day_drops_into_omitted;
        Alcotest.test_case "II-class Sunday: second loser dropped into omitted" `Quick
          test_ii_class_sunday_drops_second_loser_into_omitted;
        Alcotest.test_case "RG109(e) Lent feria privileged end-to-end" `Quick
          test_privileged_lent_feria_admitted_end_to_end;
        Alcotest.test_case
          "II-class Sunday override: RG109(b) witness admitted over an ordinary saint, end-to-end"
          `Quick test_ii_class_sunday_privileged_witness_admitted_end_to_end;
        Alcotest.test_case
          "RG16(a): a Feast of the Lord suppresses the Sunday's own commemoration entirely, \
           end-to-end (2028-08-06)"
          `Quick test_rg16a_lord_feast_suppresses_sunday_end_to_end;
        Alcotest.test_case "transfer_target: general RG96 search walks past more than one blocked day"
          `Quick test_transfer_target_general_multi_step_search;
        Alcotest.test_case "transfer_target: Annunciation exception starts at Monday after Low Sunday"
          `Quick test_transfer_target_annunciation_starts_at_monday_after_low_sunday;
        Alcotest.test_case
          "transfer_target: Annunciation exception searches onward if that Monday is blocked" `Quick
          test_transfer_target_annunciation_searches_onward_if_blocked;
        Alcotest.test_case
          "transfer_target: Annunciation NOT overridden when the general target precedes Easter \
           (2057 regression)"
          `Quick test_transfer_target_annunciation_not_overridden_when_general_target_precedes_easter;
        Alcotest.test_case "transfer_target: terminates and stays forward under a pathological occupant"
          `Quick test_transfer_target_terminates_under_pathological_occupant;
        Alcotest.test_case "transfer_target: does not raise probing past the domain ceiling" `Quick
          test_transfer_target_does_not_raise_at_domain_ceiling ] )