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
|
(* 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 -- register line 327: 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 -- register line 328: 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 -- register line 329. *)
("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 -- register line 330. 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 -- register line 331. *)
("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 -- register line 332. *)
("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 -- register line 333: 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 -- register line 334. 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 -- register line 335. *)
("9 Pentecost Vigil", off 48, cand "ef-pentecost-vigil", 9);
(* Entry 10 -- register line 336: 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 -- register line 337. *)
( "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 -- register line 338. 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 -- register line 339. *)
( "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 -- register line 341, deliberately UNQUALIFIED (contrast
entry 16, line 342, 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 -- register line 342: 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 -- register line 342. *)
( "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 -- register line 343: 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 -- register line 343-344: 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 -- register line 344. *)
( "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 -- register line 345. *)
( "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 -- register line 345 (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 -- register line 347-348 (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 -- register line 349. 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 -- register line 349. *)
( "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 -- register line 350. *)
("25 Advent feria to 16 Dec", mk 2026 12 1, cand ~rank:V.Class3 "ef-advent-1-tue", 25);
(* Entry 26 -- register line 350. *)
( "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 );
(* Entry 27 -- register line 352: 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 -- register line 352: 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 (register lines 381-384) 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:_ 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; register lines 316-325, 381-384). 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 -- register line 323-325: 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 (register lines 323, 363)
restricts the right of translation to I-class FEASTS -- RG 91's own
table lists Sundays as a separate row (entry 6, register line 332)
from feasts (entries 11-13, lines 337-339) -- 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) (register
line 374) 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 -- register line 383-384: a I/II-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": *)
( "RG33 boundary: vigil loses to an ordinary (non-Sunday, non-I-class) \
II-class day -> Commemorate, NOT Omit",
cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "ef-some-other-feast",
of_temporal (off 38),
"Commemorate(Ordinary)" );
( "RG33 boundary: a III-class vigil (outside RG33's I/II-class scope) \
loses to a Sunday -> Commemorate, NOT Omit",
an_ordinary_sunday,
cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-lawrence-vigil",
"Commemorate(Ordinary)" );
(* 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) (register line 374-375, "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: the lower ranks the RG 33/RG 95 branches never touch still
reach the RG 95 "commemorated or omitted" branch, not an
unhandled/exceptional case. *)
( "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)" );
( "IV-class feria loses to a II-class Sunday -> Commemorate",
an_ordinary_sunday,
cand ~rank:V.Class4 "ef-time-after-pentecost-1-sat",
"Commemorate(Ordinary)" )
]
(* Task 9: [privilege_of]'s RG 109 categories (register lines 374-377),
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) register line 374: "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) register line 375: "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) register line 375-376: "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 an Advent/Lent Ember day (a DIFFERENT
Ember set, deliberately excluded by (d) -- see the negative row
below), not a plain Advent/Lent/Passiontide feria slug either. *)
( "(d) a September Ember day is privileged",
cand "ef-nativity",
of_temporal (mk 2026 9 23),
"Commemorate(Privileged)" );
(* (e) register line 376: "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)" );
(* Negative, RG 109(d) vs (e)'s own boundary: the Advent and Lent Ember
sets are ALSO II-class ferias of Advent/Lent by RG 91 (entry 18), and
their slugs ("ef-advent-ember-*", "ef-lent-ember-*") share (e)'s own
season prefixes -- but RG 109 privileges ONLY the September set (d),
leaving these two ordinary. 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]. If [privilege_of] relied on the season prefix
alone without excluding Ember slugs, both would wrongly come back
[Privileged] -- the exact trap this pair of rows guards against. *)
( "boundary: an Advent Ember day is NOT privileged (only September is, \
RG109(d))",
cand "ef-nativity",
of_temporal (mk 2026 12 16),
"Commemorate(Ordinary)" );
( "boundary: a Lent Ember day is NOT privileged (only September is, \
RG109(d))",
cand "ef-nativity",
of_temporal (off (-39)),
"Commemorate(Ordinary)" );
(* 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. *)
( "boundary: a Minor Rogation day is NOT privileged (RG109(f) names \
the Major Litanies, not these)",
cand "ef-nativity",
of_temporal (off 36),
"Commemorate(Ordinary)" )
]
(* Task 9: [PE.admit] -- RG 111's admission counts (register line 378),
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"
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
let admit_cases =
[ (* RG 111 (register line 378): "I class: none save one privileged." *)
( "I-class day, only an ordinary commemoration due -> none admitted",
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,
[ (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,
[ (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,
[ (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,
[ (ordinary_hi, P.Ordinary); (privileged_lo, P.Privileged) ],
[ "ef-privileged-lo" ] );
(* 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,
[ (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,
[ (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,
[ (ordinary_hi, P.Ordinary); (privileged_lo, P.Privileged);
(ordinary_lowest, P.Ordinary) ],
[ "ef-ordinary-hi"; "ef-privileged-lo" ] )
]
(* 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 comms) in
let reversed = slugs_of (PE.admit ~observed: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)
(* Completes Task 7's carried fix (register line 334): 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:_ 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)
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, comms, expect) ->
Alcotest.test_case desc `Quick (fun () ->
Alcotest.(check (list string)) desc expect
(slugs_of (PE.admit ~observed 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 ] )
|