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
|
(* Table-driven, one Alcotest case per Tabula entry, so a transcription error
names the entry it broke. Mirrors test/test_precedence_ef.ml. *)
open Colitur_kernel
module Vocab_of = Rite_of.Vocab_of
let mk_cel ?(layer = Rite_of.Precedence_of.universal_layer)
?(status = Celebration.Feast) ?(colour = Colour.White)
?(subject = Subject.Saint) ~slug ~rank () =
{ Celebration.slug = Slug.of_string_exn slug;
names = Names.empty; rank; status; colour; subject;
citations = []; layer }
let mk ?layer ?status ?colour ?subject ~slug ~rank ~origin () =
{ Precedence.cel = mk_cel ?layer ?status ?colour ?subject ~slug ~rank ();
origin }
let ctx ~iso ~season =
let date = Date.of_iso8601 iso |> Result.get_ok in
{ Precedence.date; season; weekday = Date.weekday date }
(* Each row: label, context, candidate, expected band value. *)
let part1_cases =
[ ( "entry 1: Good Friday",
ctx ~iso:"2026-04-03" ~season:Vocab_of.Lent,
mk ~slug:"of-lent-6-friday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (),
10 );
( "entry 1: Holy Saturday",
ctx ~iso:"2026-04-04" ~season:Vocab_of.Lent,
mk ~slug:"of-lent-6-saturday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (),
10 );
( "entry 1: Easter Sunday",
ctx ~iso:"2026-04-05" ~season:Vocab_of.Easter,
mk ~slug:"of-easter-sunday" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (),
10 );
( "entry 2: Holy Thursday is a Holy Week feria, not the Triduum",
ctx ~iso:"2026-04-02" ~season:Vocab_of.Lent,
mk ~slug:"of-lent-6-thursday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (),
20 );
( "entry 2: the Nativity",
ctx ~iso:"2026-12-25" ~season:Vocab_of.Christmas,
mk ~slug:"of-nativity" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (),
20 );
( "entry 2: a Sunday of Advent",
ctx ~iso:"2026-11-29" ~season:Vocab_of.Advent,
mk ~slug:"of-advent-sunday-1" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (),
20 );
( "entry 2: Ash Wednesday",
ctx ~iso:"2026-02-18" ~season:Vocab_of.Lent,
mk ~slug:"of-ash-wednesday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (),
20 );
( "entry 2: a day within the Octave of Easter",
ctx ~iso:"2026-04-07" ~season:Vocab_of.Easter,
mk ~slug:"of-easter-octave-day-3" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (),
20 );
( "entry 3: a universal solemnity of a saint",
ctx ~iso:"2026-06-29" ~season:Vocab_of.Ordinary_time,
mk ~slug:"of-peter-and-paul" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Sanctoral (),
30 );
( "entry 4: a proper solemnity",
ctx ~iso:"2026-06-29" ~season:Vocab_of.Ordinary_time,
mk ~layer:"proper:diocese-of-poznan" ~slug:"of-local-patron"
~rank:Vocab_of.Sollemnitas ~origin:Precedence.Sanctoral (),
40 ) ]
let test_part1 () =
List.iter
(fun (label, c, cand, expected) ->
Alcotest.(check int) label expected (Rite_of.Precedence_of.band c cand))
part1_cases
let part2_cases =
[ ( "entry 5: a universal feast of the Lord",
ctx ~iso:"2026-08-06" ~season:Vocab_of.Ordinary_time,
mk ~subject:Subject.Lord ~slug:"of-transfiguration" ~rank:Vocab_of.Festum
~origin:Precedence.Sanctoral (),
50 );
( "entry 6: a Sunday per annum",
ctx ~iso:"2026-07-12" ~season:Vocab_of.Ordinary_time,
(* rank is Festum, not the brief's original Sollemnitas: a real Sunday
per annum is never Sollemnitas (temporal_of.ml only grades Advent,
Lent and Easter Sundays that way, and those are entry 2, tested
before this one), and a Sollemnitas candidate here would hit entry 3
first regardless of layer -- the brief's own adjacent "Sunday of
Christmas time" row already uses Festum for the identical reason. *)
mk ~slug:"of-ordinary-time-sunday-15" ~rank:Vocab_of.Festum
~origin:Precedence.Temporal (),
60 );
( "entry 6: a Sunday of Christmas time",
ctx ~iso:"2027-01-03" ~season:Vocab_of.Christmas,
mk ~slug:"of-christmas-sunday-2" ~rank:Vocab_of.Festum
~origin:Precedence.Temporal (),
60 );
( "entry 7: a universal feast of a saint",
ctx ~iso:"2026-04-25" ~season:Vocab_of.Easter,
mk ~subject:Subject.Saint ~slug:"of-mark" ~rank:Vocab_of.Festum
~origin:Precedence.Sanctoral (),
70 );
( "entry 7: a universal feast of the BVM",
ctx ~iso:"2026-05-31" ~season:Vocab_of.Ordinary_time,
mk ~subject:Subject.Bvm ~slug:"of-visitation" ~rank:Vocab_of.Festum
~origin:Precedence.Sanctoral (),
70 );
( "entry 8: a proper feast",
ctx ~iso:"2026-04-25" ~season:Vocab_of.Easter,
mk ~layer:"proper:diocese-of-poznan" ~slug:"of-cathedral-dedication"
~rank:Vocab_of.Festum ~origin:Precedence.Sanctoral (),
80 );
( "entry 9: an Advent feria on 17 December",
ctx ~iso:"2026-12-17" ~season:Vocab_of.Advent,
mk ~slug:"of-advent-3-thursday" ~rank:Vocab_of.Feria
~origin:Precedence.Temporal (),
90 );
( "entry 9: a day within the Octave of the Nativity",
ctx ~iso:"2026-12-29" ~season:Vocab_of.Christmas,
mk ~slug:"of-christmas-0-tuesday" ~rank:Vocab_of.Feria
~origin:Precedence.Temporal (),
90 );
( "entry 9: a Lenten feria",
ctx ~iso:"2026-03-03" ~season:Vocab_of.Lent,
mk ~slug:"of-lent-2-tuesday" ~rank:Vocab_of.Feria
~origin:Precedence.Temporal (),
90 );
(* MY RULING (2026-08-25, task-2-brief correction): every temporal-origin
celebration Temporal_of.temporal constructs carries [layer = "temporal"],
never [Precedence_of.universal_layer] -- so entries 3, 5 and 7 must test
[not (is_proper layer)], not [is_universal layer]. These four rows pin
exactly what that correction fixes: without it, Corpus Christi comes out
unclassified (a hole) and Trinity/Christ the King land at entry 6 instead
of entry 3. *)
( "entry 3: Trinity Sunday is a solemnity of the general calendar",
ctx ~iso:"2026-05-31" ~season:Vocab_of.Ordinary_time,
mk ~slug:"of-trinity" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (),
30 );
( "entry 3: Corpus Christi, a Thursday, is not unclassified",
ctx ~iso:"2026-06-04" ~season:Vocab_of.Ordinary_time,
mk ~slug:"of-corpus-christi" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (),
30 );
(* Fix round 1 (coordinator review, 2026-08-25). (a) THE TENSION: the
Tabula's own entry 9 reads "Feriae Adventus a die 17 ad 24 decembris
INCLUSIVE" -- 24 December is textually a late-Advent feria -- yet
Phase 1's temporal_of.ml gives that civil day the identity
[of-nativity-vigil] at rank Sollemnitas, because the Vigil Mass is
celebrated that evening; at colitur's one-office-per-civil-day
granularity only one identity can hold the day. (b) THE RESOLUTION:
Phase 2 classifies whatever identity Phase 1 already chose to emit, it
does not reach back and override that choice -- the same shape, and
the same settlement, as Holy Thursday (entry 2, not entry 1) above.
So this candidate's rank (Sollemnitas) and layer (temporal, hence
[not (is_proper layer)]) correctly route it to entry 3, band 30, not
entry 9's 90. (c) OBSERVABILITY: on the universal calendar nothing
else competes for 24 December, so this value can never be OBSERVED
today -- it is unobservable, not untested. It becomes live the moment
a proper/diocesan overlay places a proper solemnity (entry 4, band 40)
on 24 December: at 30 the Vigil beats it, but at the textually-arguable
90 the proper solemnity would beat the Vigil instead. That reversal is
exactly what this row guards against a silent branch-reorder.
(d) CORRECTED (fix wave, 2026-08-25): band 30 is NOT the value for
every 24 December -- this row's own [ctx] is 2026-12-24, a THURSDAY.
When 24 December falls on a SUNDAY (e.g. 2028-12-24, 2034-12-24),
[is_privileged_sunday] fires instead (Sunday + Advent + temporal
origin) and the SAME slug/rank/origin bands 20 via entry 2, not 30 via
entry 3. The pinned assertion above is still correct for the Thursday
it names; only the earlier "band 30" framing read as unconditional,
which it is not.
RESOLVED (task R8, 2026-08-25): the Phase 1 defect this paragraph used
to describe -- [named] (temporal_of.ml) claiming 24 December
unconditionally, matched BEFORE [sunday_slug] in [temporal]'s
dispatch, so a 24-December Sunday silently lost the Fourth Sunday of
Advent to [of-nativity-vigil] -- is fixed. [named] now returns [None]
for 24 December when that date is itself a Sunday, citing Tabula
I.2/Normae n. 5 and the identical Holy Thursday granularity argument
(b) above already makes; [temporal]'s dispatch then falls through to
[sunday_slug] exactly as it does for every other Sunday [named] does
not claim, landing on [of-advent-sunday-4]. Paragraph (d) above
already anticipated the RIGHT band (20, via [is_privileged_sunday])
for that case; this fix is what makes that band actually reachable
from a real [temporal] call rather than only from this row's own
hand-built candidate. Confirmed on both 2028-12-24 and 2034-12-24
(test_temporal_of.ml, "nativity vigil yields to Advent Sunday IV"):
[named] claims nothing, [temporal] resolves to [of-advent-sunday-4],
and the non-Sunday case (2026-12-24 here) is unchanged. The litcal
layer was re-run: no allow-list count moved (L1's 36, the 1876
witnessed Ordinary Time weeks, and the 859 unwitnessed count are all
unchanged), because that layer compares season and Ordinary-Time week
only and both readings of 24 December are "advent" either way -- the
exact blind spot this same paragraph named above. This row's own
pinned assertion (a hand-built candidate, Thursday 2026-12-24, band
30) needed no change: it was never wrong, only the claim that no test
anywhere exercised the Sunday case. *)
( "entry 3, not 9: the Nativity Vigil is a solemnity, though 24 December \
is also a late-Advent feria",
ctx ~iso:"2026-12-24" ~season:Vocab_of.Advent,
mk ~slug:"of-nativity-vigil" ~rank:Vocab_of.Sollemnitas
~origin:Precedence.Temporal (),
30 );
( "entry 5: the Baptism of the Lord is a feast OF THE LORD",
ctx ~iso:"2026-01-11" ~season:Vocab_of.Christmas,
mk ~subject:Subject.Lord ~slug:"of-baptism-of-the-lord" ~rank:Vocab_of.Festum
~origin:Precedence.Temporal (),
50 );
( "entry 6, not 5: an ordinary Christmas Sunday has no Lord subject",
ctx ~iso:"2027-01-03" ~season:Vocab_of.Christmas,
mk ~subject:Subject.Temporal ~slug:"of-christmas-sunday-2" ~rank:Vocab_of.Festum
~origin:Precedence.Temporal (),
60 ) ]
let test_part2 () =
List.iter
(fun (label, c, cand, expected) ->
Alcotest.(check int) label expected (Rite_of.Precedence_of.band c cand))
part2_cases
let part3_cases =
[ ( "entry 10: a universal obligatory memorial",
ctx ~iso:"2026-01-21" ~season:Vocab_of.Ordinary_time,
mk ~slug:"of-agnes" ~rank:Vocab_of.Memoria_obligatoria
~origin:Precedence.Sanctoral (),
100 );
( "entry 11: a proper obligatory memorial",
ctx ~iso:"2026-01-21" ~season:Vocab_of.Ordinary_time,
mk ~layer:"proper:diocese-of-poznan" ~slug:"of-secondary-patron"
~rank:Vocab_of.Memoria_obligatoria ~origin:Precedence.Sanctoral (),
110 );
( "entry 12: an optional memorial",
ctx ~iso:"2026-01-13" ~season:Vocab_of.Ordinary_time,
mk ~slug:"of-hilary" ~rank:Vocab_of.Memoria_ad_libitum
~origin:Precedence.Sanctoral (),
120 );
( "entry 12: an optional memorial is entry 12 on a proper layer too",
ctx ~iso:"2026-01-13" ~season:Vocab_of.Ordinary_time,
mk ~layer:"proper:diocese-of-poznan" ~slug:"of-local-optional"
~rank:Vocab_of.Memoria_ad_libitum ~origin:Precedence.Sanctoral (),
120 );
( "entry 13: an ordinary feria per annum",
ctx ~iso:"2026-07-14" ~season:Vocab_of.Ordinary_time,
mk ~slug:"of-ordinary-time-15-tuesday" ~rank:Vocab_of.Feria
~origin:Precedence.Temporal (),
130 );
( "entry 13: a Christmas-time feria after 1 January",
ctx ~iso:"2027-01-05" ~season:Vocab_of.Christmas,
mk ~slug:"of-christmas-1-tuesday" ~rank:Vocab_of.Feria
~origin:Precedence.Temporal (),
130 );
( "entry 13, not 9: an Advent feria on 16 December",
ctx ~iso:"2026-12-16" ~season:Vocab_of.Advent,
mk ~slug:"of-advent-3-wednesday" ~rank:Vocab_of.Feria
~origin:Precedence.Temporal (),
130 );
( "entry 13: a paschal feria after the Octave",
ctx ~iso:"2026-04-14" ~season:Vocab_of.Easter,
mk ~slug:"of-easter-2-tuesday" ~rank:Vocab_of.Feria
~origin:Precedence.Temporal (),
130 ) ]
let test_part3 () =
List.iter
(fun (label, c, cand, expected) ->
Alcotest.(check int) label expected (Rite_of.Precedence_of.band c cand))
part3_cases
(* The temporal candidate Temporal_of produces for [d], as Precedence.resolve
would build it. *)
let temporal_candidate d =
let t = Rite_of.Temporal_of.temporal d in
{ Precedence.cel = t.Temporal.office; origin = Precedence.Temporal }
let ctx_of_date d =
let t = Rite_of.Temporal_of.temporal d in
{ Precedence.date = d; season = t.Temporal.season; weekday = Date.weekday d }
let band_of_date d = Rite_of.Precedence_of.band (ctx_of_date d) (temporal_candidate d)
(* Every day of a year: the temporal candidate must land on a real table
entry, never [unclassified]. *)
let check_year y =
let mk_date y m dd =
Date.make ~year:y ~month:m ~day:dd |> Result.get_ok in
let d = ref (mk_date y 1 1) in
let last = mk_date y 12 31 in
while Date.compare !d last <= 0 do
let b = band_of_date !d in
if b = Rite_of.Precedence_of.unclassified then
Alcotest.failf "%s: temporal candidate is unclassified"
(Date.to_iso8601 !d);
d := Date.add_days !d 1
done
let prop_band_total =
QCheck.Test.make ~count:200 ~name:"band classifies every temporal candidate"
(QCheck.int_range 1583 9999)
(fun y -> check_year y; true)
let test_exhaustive_band_total () =
if Sys.getenv_opt "COLITUR_EXHAUSTIVE_SWEEP" = None then
Alcotest.skip ()
else for y = 1583 to 9999 do check_year y done
let disposition_cases =
[ ( "a losing solemnity transfers (Normae n. 60)",
mk ~slug:"of-advent-sunday-2" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (),
mk ~slug:"of-immaculate-conception" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Sanctoral (),
Precedence.Transfer );
( "a losing feast is omitted, never commemorated",
mk ~slug:"of-lent-sunday-3" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (),
mk ~slug:"of-mark" ~rank:Vocab_of.Festum ~origin:Precedence.Sanctoral (),
Precedence.Omit );
( "a losing obligatory memorial is omitted",
mk ~slug:"of-lent-2-monday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (),
mk ~slug:"of-agnes" ~rank:Vocab_of.Memoria_obligatoria ~origin:Precedence.Sanctoral (),
Precedence.Omit );
( "a losing optional memorial is omitted",
mk ~slug:"of-ordinary-time-15-tuesday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (),
mk ~slug:"of-hilary" ~rank:Vocab_of.Memoria_ad_libitum ~origin:Precedence.Sanctoral (),
Precedence.Omit );
( "a losing feria is omitted",
mk ~slug:"of-peter-and-paul" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Sanctoral (),
mk ~slug:"of-ordinary-time-13-monday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (),
Precedence.Omit ) ]
let test_disposition () =
List.iter
(fun (label, winner, loser, expected) ->
Alcotest.(check string) label
(Precedence.sexp_of_disposition expected |> Sexplib0.Sexp.to_string)
(Rite_of.Precedence_of.disposition ~winner ~loser
|> Precedence.sexp_of_disposition |> Sexplib0.Sexp.to_string))
disposition_cases
(* The rule stated as a property rather than as five rows: across every
rank/rank pairing, disposition returns Transfer exactly when the loser is a
solemnity, and Omit otherwise -- never Commemorate, never Repose. *)
let test_disposition_never_commemorates () =
List.iter
(fun lr ->
List.iter
(fun wr ->
let winner = mk ~slug:"of-winner" ~rank:wr ~origin:Precedence.Temporal () in
let loser = mk ~slug:"of-loser" ~rank:lr ~origin:Precedence.Sanctoral () in
match Rite_of.Precedence_of.disposition ~winner ~loser with
| Precedence.Transfer ->
Alcotest.(check bool) "only a solemnity transfers" true
(lr = Vocab_of.Sollemnitas)
| Precedence.Omit ->
Alcotest.(check bool) "a solemnity never merely omits" false
(lr = Vocab_of.Sollemnitas)
| Precedence.Commemorate _ ->
Alcotest.fail "OF has no commemorations (Normae n. 60)"
| Precedence.Repose -> Alcotest.fail "OF never reposes a celebration")
Vocab_of.ranks)
Vocab_of.ranks
let test_admit_is_always_empty () =
let cand slug rank =
mk ~slug ~rank ~origin:Precedence.Sanctoral () in
let observed = cand "of-winner" Vocab_of.Sollemnitas in
let temporal = cand "of-feria" Vocab_of.Feria in
let losers =
[ (cand "of-a" Vocab_of.Festum, Precedence.Ordinary, 70);
(cand "of-b" Vocab_of.Memoria_obligatoria, Precedence.Privileged, 100) ] in
Alcotest.(check int) "nothing is ever admitted" 0
(List.length (Rite_of.Precedence_of.admit ~observed ~temporal losers))
let test_vigil_feast_is_always_none () =
List.iter
(fun slug ->
let c = mk ~slug ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Sanctoral () in
Alcotest.(check bool)
(Printf.sprintf "%s has no vigil-feast link" slug)
true
(Rite_of.Precedence_of.vigil_feast c = None))
[ "of-nativity-vigil"; "of-nativity"; "of-pentecost"; "of-peter-and-paul" ]
(* An [occupant] callback of the shape Rite.t.transfer_target expects, backed
by the real temporal cycle -- so the search meets genuine Tabula entries
rather than a hand-made fiction. *)
let occupant d = (Rite_of.Temporal_of.temporal d).Temporal.office
(* W6 (of-known-wrongs, 2026-08-26): this used to carry the placeholder slug
"of-annunciation" -- the very bug precedence_of.ml's own [annunciation_
slug] carried, so this synthetic candidate coincidentally matched the
bug rather than testing against it. Now the REAL shipped slug
(data/of/calendar-2002.sexp: "annunciation-of-the-lord"), so the tests
below that use it (Rule 1/n. 60) actually exercise Rule 1 against the
slug production code now checks -- see
[test_annunciation_rule1_actually_fires] below for the test that tells
"Rule 1 fired" apart from "Rule 3 happened to agree", which none of
these did before it. *)
let annunciation =
mk ~subject:Subject.Lord ~slug:"annunciation-of-the-lord" ~rank:Vocab_of.Sollemnitas
~origin:Precedence.Sanctoral ()
let iso s = Date.of_iso8601 s |> Result.get_ok
let test_annunciation_in_holy_week () =
(* 2027: Easter is 28 March, so 25 March is Holy Thursday -- inside Holy
Week. Normae n. 60 sends it to the Monday after the Second Sunday of
Easter, Easter + 8 = 5 April 2027. *)
let target =
Rite_of.Precedence_of.transfer_target annunciation (iso "2027-03-25") occupant in
Alcotest.(check string) "Annunciation 2027 goes to Easter+8"
"2027-04-05" (Date.to_iso8601 target)
let test_annunciation_in_easter_octave () =
(* 2035: Easter is 25 March, so the Annunciation collides with Easter Sunday
itself -- NOT Holy Week, so n. 60's Annunciation clause does not fire and
the general search runs. Every Octave day is Tabula entry 2, so the first
day free of entries 1-8 is the Monday after the Second Sunday of Easter:
the same 2 April the explicit rule would have named. Asserted rather than
assumed. *)
let target =
Rite_of.Precedence_of.transfer_target annunciation (iso "2035-03-25") occupant in
Alcotest.(check string) "Annunciation 2035 also lands on Easter+8"
"2035-04-02" (Date.to_iso8601 target)
let test_solemnity_on_a_lenten_sunday () =
(* Normae n. 5: a solemnity occurring on a Sunday of Advent, Lent or Easter
goes to the FOLLOWING MONDAY, not to a searched free day. 19 March 2028
(St Joseph) is the Third Sunday of Lent, not Palm Sunday -- Rule 0
(n. 56(f)) does not fire here even with the REAL slug (see the test
right below this one for that), because its own guard is [is_palm_sunday
origin], not merely "this candidate is St Joseph". A synthetic slug is
used deliberately, to keep this test about rule 2's own generic
"solemnity on a privileged Sunday" behaviour, not about St Joseph
specifically. *)
let joseph =
mk ~slug:"of-joseph" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Sanctoral () in
let target =
Rite_of.Precedence_of.transfer_target joseph (iso "2028-03-19") occupant in
Alcotest.(check string) "St Joseph 2028 goes to the following Monday"
"2028-03-20" (Date.to_iso8601 target)
(* Normae n. 56(f), Rule 0 -- the one EARLIER-target shape
{!Colitur_kernel.Rite.t.transfer_target}'s relaxed obligation now permits
(rite.mli's own comment carries the argument; W1,
[of-normae-56f], 2026-08-26). Unit-level counterpart to
test_rite_of.ml's own full-Calendar [test_joseph_palm_sunday_2062_
anticipated_backward] -- this one calls [transfer_target] directly, the
same shape every other test in this file uses, and checks the REAL
shipped slug (unlike [test_solemnity_on_a_lenten_sunday] above, which
uses a placeholder deliberately). Two independent years, not one: 2062
and 1978 both have Gregorian Easter = 26 March (Palm Sunday = 19 March),
confirmed directly rather than assumed. *)
let test_st_joseph_on_palm_sunday_anticipated_backward () =
let joseph =
mk ~slug:"joseph-husband-of-the-blessed-virgin-mary" ~rank:Vocab_of.Sollemnitas
~origin:Precedence.Sanctoral () in
List.iter
(fun (palm_sunday, expected_target) ->
let origin = iso palm_sunday in
Alcotest.(check bool)
(Printf.sprintf "%s really is a Sunday (Easter = 26 March that year)" palm_sunday)
true (Date.weekday origin = Date.Sun);
let target = Rite_of.Precedence_of.transfer_target joseph origin occupant in
Alcotest.(check string)
(Printf.sprintf "St Joseph on Palm Sunday %s is anticipated BACKWARD to the preceding Saturday"
palm_sunday)
expected_target (Date.to_iso8601 target);
Alcotest.(check bool) (Printf.sprintf "%s -> earlier than origin, not later" palm_sunday) true
(Date.compare target origin < 0))
[ ("2062-03-19", "2062-03-18"); ("1978-03-19", "1978-03-18") ]
let test_target_is_strictly_later () =
(* This is the Annunciation's OWN obligation (rules 1-3), not a claim
about {!Rite_of.Precedence_of.transfer_target} as a whole any more --
{!Colitur_kernel.Rite.t.transfer_target}'s kernel-level obligation was
relaxed from "strictly later" to "different from origin" (W1,
[of-normae-56f], 2026-08-26; see rite.mli's own comment), precisely so
Rule 0 above could return an EARLIER target for St Joseph. The
Annunciation's own three rules are untouched by that relaxation --
none of them was rewritten, and Calendar's placement pass still treats
[target = origin] as a legitimate (bad) placement for whichever
candidate returns it, so a rule that can stand still would still loop
in place rather than fail loudly. *)
List.iter
(fun s ->
let d = iso s in
let t = Rite_of.Precedence_of.transfer_target annunciation d occupant in
Alcotest.(check bool)
(Printf.sprintf "%s -> strictly later" s)
true (Date.compare t d > 0))
[ "2027-03-25"; "2035-03-25"; "2026-03-25"; "2029-03-25"; "2032-03-25" ]
let test_target_is_free_of_entries_1_to_8 () =
List.iter
(fun s ->
let t = Rite_of.Precedence_of.transfer_target annunciation (iso s) occupant in
let b = band_of_date t in
Alcotest.(check bool)
(Printf.sprintf "%s -> a day free of Tabula entries 1-8 (band %d)" s b)
true (b > 80))
[ "2027-03-25"; "2035-03-25"; "2026-03-25"; "2029-03-25" ]
(* Fix round 2 (Task 1, 2026-08-25): the shipped All Souls entry (2
November, data/of/calendar-2002.sexp) actually beats an ordinary
per-annum Sunday when they coincide -- the exact hazard the [band]
comment above named ("PHASE 3 HAZARD ... before shipping OF sanctoral
data"). Loads the REAL shipped entry (not a synthetic reconstruction)
and resolves it through {!Colitur_kernel.Precedence.resolve} with
[Rite_of.Precedence_of.rules] -- not [band] in isolation -- against a
real per-annum Sunday context, 2 November 2025 (independently a real
Sunday: date -d confirms it, and it is the same date CLAUDE.md's own
EF golden pin "All Souls falling on a Sunday" already uses). *)
let test_all_souls_beats_an_ordinary_sunday () =
let path = "../data/of/calendar-2002.sexp" in
let l =
match Colitur_kernel.Layer.load Vocab_of.rank_of_sexp path with
| Ok l -> l
| Error e -> Alcotest.failf "%s: failed to load: %s" path e
in
let all_souls =
match Colitur_kernel.Layer.find l (Slug.of_string_exn "all-souls") with
| Some e -> e.Colitur_kernel.Layer.cel
| None -> Alcotest.fail "\"all-souls\" not found in the shipped calendar"
in
Alcotest.(check bool) "shipped All Souls is tagged Sollemnitas" true
(all_souls.Celebration.rank = Vocab_of.Sollemnitas);
let all_souls_candidate = { Precedence.cel = all_souls; origin = Precedence.Sanctoral } in
let d = Date.of_iso8601 "2025-11-02" |> Result.get_ok in
Alcotest.(check bool) "2025-11-02 is really a Sunday" true (Date.weekday d = Date.Sun);
let ctx = { Precedence.date = d; season = Vocab_of.Ordinary_time; weekday = Date.Sun } in
(* An ordinary per-annum Sunday's own candidate, shaped exactly as
Temporal_of.temporal builds one (rank Festum, Tabula II.6) --
independent of temporal_candidate/ctx_of_date above, which read the
office off Temporal_of.temporal directly; this test instead builds it
by hand so it does not depend on 2025-11-02 having no competing named
temporal office of its own. *)
let sunday_candidate =
mk ~slug:"of-ordinary-time-sunday" ~rank:Vocab_of.Festum ~origin:Precedence.Temporal ()
in
let resolution =
Precedence.resolve Rite_of.Precedence_of.rules ctx ~temporal:sunday_candidate
~sanctoral:[ all_souls_candidate ]
in
Alcotest.(check bool) "All Souls, not the Sunday, is observed" true
(resolution.Precedence.observed = all_souls_candidate)
(* W6 (of-known-wrongs, 2026-08-26). [annunciation_slug] used to read
"of-annunciation", a placeholder that never matched the real shipped
entry -- Rule 1 (Normae n. 60's fixed Easter+8 destination) was DEAD
CODE, only ever exercised by this file's own synthetic candidate, which
happened to carry the identical placeholder. Every test above this one
proves nothing about whether Rule 1 fires against real data: they all
land on Easter+8, but Rule 3's general forward search converges on the
IDENTICAL date whenever the 3-9 April window is empty of anything at
Tabula entries 1-8 (true on the shipped universal calendar), so the
output is the same whichever rule actually answers.
This test tells the two apart. It loads the REAL shipped
"annunciation-of-the-lord" entry (not a synthetic reconstruction, the
same discipline [test_all_souls_beats_an_ordinary_sunday] above uses)
and RIGS the [occupant] callback to occupy Easter+8 itself with a
synthetic Tabula-entry-3 candidate (band 30, well inside entries 1-8).
Rule 3's search, if it were what answered, would have to step past that
occupied day onto Easter+9 -- confirmed by a CONTROL case, a different
solemnity on the same origin and the same rig, which can only reach
Rule 3 (it is neither the Annunciation nor St Joseph on Palm Sunday) and
does indeed land on Easter+9. Rule 1's own fixed computation never
consults [occupant] at all, so the rig has no effect on it: the real
Annunciation still landing on Easter+8 DESPITE the block is something
only Rule 1 firing can explain. *)
let test_annunciation_rule1_actually_fires () =
let path = "../data/of/calendar-2002.sexp" in
let l =
match Colitur_kernel.Layer.load Vocab_of.rank_of_sexp path with
| Ok l -> l
| Error e -> Alcotest.failf "%s: failed to load: %s" path e
in
let annunciation_real =
match Colitur_kernel.Layer.find l (Slug.of_string_exn "annunciation-of-the-lord") with
| Some e -> { Precedence.cel = e.Colitur_kernel.Layer.cel; origin = Precedence.Sanctoral }
| None -> Alcotest.fail "\"annunciation-of-the-lord\" not found in the shipped calendar"
in
(* 2027: Easter 28 March, so 25 March is Holy Thursday -- inside Holy
Week -- and Easter+8 is 5 April, the date this rig occupies. *)
let origin = iso "2027-03-25" in
let blocked = iso "2027-04-05" in
let rigged d =
if Date.compare d blocked = 0 then
mk_cel ~slug:"of-w6-synthetic-blocker" ~rank:Vocab_of.Sollemnitas ()
else occupant d
in
let control =
mk ~slug:"of-w6-control" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Sanctoral () in
let control_target = Rite_of.Precedence_of.transfer_target control origin rigged in
Alcotest.(check string)
"control (not the Annunciation, not St Joseph): the rig really blocks -- pushed past 5 April to 6 April"
"2027-04-06" (Date.to_iso8601 control_target);
let target = Rite_of.Precedence_of.transfer_target annunciation_real origin rigged in
Alcotest.(check string)
"the REAL shipped Annunciation still lands on Easter+8 despite the block -- Rule 1, not Rule 3, fired"
"2027-04-05" (Date.to_iso8601 target)
(* W3 (of-known-wrongs, 2026-08-26). Mary, Mother of the Church (Easter+50)
and a fixed obligatory memorial (e.g. Anthony of Padua, 13 June) both
used to band at Tabula III.10 (100) whenever they coincide -- a tie
{!Rite_of.Precedence_of.band} could not break, silently resolved instead
by {!Colitur_kernel.Precedence.resolve}'s own generic alphabetical
tie-break. See {!Rite_of.Precedence_of.movable_bvm_memorials}'s own
comment for the primary source (the CDWDS's own 24 March 2018
Notification) this fix cites: the Marian memorial now bands at 95 and
always prevails. This is the direct unit-level counterpart to
test_rite_of.ml's own end-to-end
[test_mater_ecclesiae_anthony_tie_2011_resolved] -- this one calls
{!Colitur_kernel.Precedence.resolve} directly, the same shape every
other test in this file uses, and checks BOTH real years the sweep in
this task's own report found (2011 for Mater Ecclesiae, 2015 for the
Immaculate Heart -- three years apart via the other offset, colliding
with the SAME rival, Anthony of Padua, both confirmed via
{!Colitur_kernel.Computus.gregorian_easter} rather than trusted). *)
let test_movable_bvm_memorial_beats_a_fixed_one () =
let anthony =
mk ~slug:"anthony-of-padua-priest-and-doctor" ~rank:Vocab_of.Memoria_obligatoria
~origin:Precedence.Sanctoral ()
in
List.iter
(fun (label, slug, easter_offset, year, expected_date) ->
let movable =
mk ~slug ~rank:Vocab_of.Memoria_obligatoria ~subject:Subject.Bvm
~origin:Precedence.Sanctoral ()
in
Alcotest.(check bool)
(Printf.sprintf "%s: alphabetically \"anthony-of-padua...\" sorts BEFORE %S -- confirms this \
test exercises the tie-break the fix overrides, not a vacuous check"
label slug)
true (String.compare "anthony-of-padua-priest-and-doctor" slug < 0);
let easter = Computus.gregorian_easter year in
let d = Date.add_days easter easter_offset in
Alcotest.(check string)
(Printf.sprintf "%s: Easter+%d %d really is %s" label easter_offset year expected_date)
expected_date (Date.to_iso8601 d);
let temporal_c =
mk ~slug:"of-ordinary-time-feria" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal ()
in
let ctx = { Precedence.date = d; season = Vocab_of.Ordinary_time; weekday = Date.weekday d } in
let resolution =
Precedence.resolve Rite_of.Precedence_of.rules ctx ~temporal:temporal_c
~sanctoral:[ anthony; movable ]
in
Alcotest.(check bool)
(Printf.sprintf "%s prevails over Anthony of Padua, not the alphabetical order" label)
true (resolution.Precedence.observed = movable))
[ ("Mary, Mother of the Church vs Anthony of Padua", "mary-mother-of-the-church", 50, 2011,
"2011-06-13");
("Immaculate Heart of Mary vs Anthony of Padua", "immaculate-heart-of-mary", 69, 2015,
"2015-06-13") ]
let suite =
( "precedence-of",
[ Alcotest.test_case "Tabula part I (entries 1-4)" `Quick test_part1;
Alcotest.test_case "Tabula part II (entries 5-9)" `Quick test_part2;
Alcotest.test_case "Tabula part III (entries 10-13)" `Quick test_part3;
Alcotest.test_case "band is total over the domain" `Slow test_exhaustive_band_total;
Alcotest.test_case "disposition (Normae n. 60)" `Quick test_disposition;
Alcotest.test_case "disposition never commemorates" `Quick test_disposition_never_commemorates;
Alcotest.test_case "admit is always empty" `Quick test_admit_is_always_empty;
Alcotest.test_case "vigil_feast is always none" `Quick test_vigil_feast_is_always_none;
Alcotest.test_case "Annunciation in Holy Week -> Easter+8" `Quick
test_annunciation_in_holy_week;
Alcotest.test_case "Annunciation in the Easter Octave -> Easter+8 too" `Quick
test_annunciation_in_easter_octave;
Alcotest.test_case "solemnity on a Lenten Sunday -> following Monday" `Quick
test_solemnity_on_a_lenten_sunday;
Alcotest.test_case
"Normae n.56(f), W1: St Joseph on Palm Sunday is anticipated backward to 18 March" `Quick
test_st_joseph_on_palm_sunday_anticipated_backward;
Alcotest.test_case "the Annunciation's own rules 1-3 are always strictly later" `Quick
test_target_is_strictly_later;
Alcotest.test_case "transfer_target lands free of Tabula entries 1-8" `Quick
test_target_is_free_of_entries_1_to_8;
Alcotest.test_case
"fix round 2: shipped All Souls beats an ordinary per-annum Sunday (2025-11-02)" `Quick
test_all_souls_beats_an_ordinary_sunday;
Alcotest.test_case
"W6: the REAL shipped Annunciation slug makes Rule 1 fire (not merely agree with Rule 3)" `Quick
test_annunciation_rule1_actually_fires;
Alcotest.test_case
"W3: a movable BVM obligatory memorial beats a fixed one, per the CDWDS's 24 March 2018 \
Notification" `Quick
test_movable_bvm_memorial_beats_a_fixed_one ]
@ List.map QCheck_alcotest.to_alcotest [ prop_band_total ] )
|