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
|
(* The Tabula dierum liturgicorum secundum ordinem praecedentiae disposita
(Missale Romanum, editio typica tertia 2002; "Normae universales de anno
liturgico et de calendario", printed immediately before it) -- 13 numbered
entries in three parts. This module transcribes it, one [band] branch per
entry, the same discipline Rite_ef.Precedence_ef applies to RG 91's 28.
CITATION DISCIPLINE: the 2002 Missal PDF carries BOTH the Normae universales
AND the Institutio Generalis Missalis Romani, each independently numbered
from 1. Every citation below therefore names its document: "Normae n. N" or
"IGMR n. N". A bare "n. N" is ambiguous and must not appear.
ENTRY VALUES ARE THE TABLE'S OWN NUMBER TIMES TEN (entry 1 -> 10, entry 13
-> 130). Identical to Precedence_ef's own convention and adopted for the
identical reason: a future sub-rank (a movable/fixed split of one row, the
shape RG 91 entry 14 forced on the EF side) becomes an ordinary position
BETWEEN neighbours, with no rescale of every other branch. Do NOT express a
sub-rank as a negative sentinel: Precedence_ef's own history records that as
a defect, because a negative value avoids collision but inverts ORDERING. *)
open Colitur_kernel
let unclassified = max_int
(* Not a Normae citation. The Tabula distinguishes universal-calendar entries
(3, 7, 10) from proper-calendar ones (4, 8, 11), and nothing in
Celebration.t marks that distinction except [layer]. Same mechanism, and
same naming, as Precedence_ef.{universal_layer,indult_prefix}.
[universal_layer] itself is exported (hand-authored sanctoral data is
tagged with it) but [band] no longer tests [layer = universal_layer]
directly anywhere in this chain: every temporal-origin candidate
{!Rite_of.Temporal_of.temporal} constructs carries [layer = "temporal"]
unconditionally (temporal_of.ml, [build]'s own hardcoded ~layer), never
[universal_layer] -- so a positive "is this the universal layer" test
would silently exclude Trinity Sunday, Corpus Christi, Christ the King,
Mary Mother of God, Holy Family and the Baptism of the Lord, none of
which are proper-calendar in the sense the Tabula's own 4/8/11 mean.
Entries 3, 5 and 7 (all universal-or-temporal, never proper) therefore
test [not (is_proper layer)] instead -- true for both [universal_layer]
and ["temporal"], false only for a genuine ["proper:..."] candidate. Only
entries 4 and 8 test [is_proper] itself. *)
let universal_layer = "of-universal"
let proper_prefix = "proper:"
let is_proper layer = String.starts_with ~prefix:proper_prefix layer
(* Days measured from Easter, so the Triduum and the Easter Octave are
identified STRUCTURALLY rather than by slug. Temporal_of emits Good Friday
and Holy Saturday as ordinary Lent week-6 ferias
("of-lent-6-friday"/"of-lent-6-saturday"), so there is no slug to key on --
the same situation Precedence_ef.band faces for RG 91 entry 2, resolved the
same way. *)
let easter_offset (ctx : Vocab_of.season Precedence.context) =
let y = Date.year ctx.date in
Date.to_rata ctx.date - Date.to_rata (Computus.gregorian_easter y)
let slug_of (c : Vocab_of.rank Precedence.candidate) = Slug.to_string c.cel.slug
(* Tabula I.1: "Triduum paschale Passionis et Resurrectionis Domini."
HOLY THURSDAY IS DELIBERATELY NOT HERE. The Tabula's own entry 2 reads
"Feriae Hebdomadae sanctae, a feria II ad feriam V inclusive" -- Monday
through THURSDAY inclusive -- and the Paschal Triduum begins with the
evening Mass of the Lord's Supper, an event INSIDE Holy Thursday's civil
day. At colitur's civil-day granularity (one office per date, never a
sub-day boundary) the day itself therefore stays with the Holy Week ferias
at entry 2. This is the SAME granularity decision, on the precedence axis,
that data/of/expected-divergences-litcal.sexp's own L1 records on the season
axis (Normae nn. 28/30) -- one decision with two visible consequences, not
two independent judgements. *)
let is_triduum ctx = List.mem (easter_offset ctx) [ -2; -1; 0 ]
(* Tabula I.2, five clauses in one entry. *)
let entry_2_named =
[ "of-nativity"; "of-epiphany"; "of-ascension"; "of-pentecost"; "of-ash-wednesday" ]
(* Tabula I.2's own words for this clause: "Dominicae Adventus, Quadragesimae
et Paschae" -- the same three seasons Normae n. 5 privileges, and the
predicate transfer_target's rule 2 (below) re-uses. *)
let is_privileged_sunday (ctx : Vocab_of.season Precedence.context) c =
ctx.weekday = Date.Sun
&& (match ctx.season with
| Vocab_of.Advent | Vocab_of.Lent | Vocab_of.Easter -> true
| Vocab_of.Christmas | Vocab_of.Ordinary_time -> false)
&& c.Precedence.origin = Precedence.Temporal
(* "Feriae Hebdomadae sanctae, a feria II ad feriam V inclusive": Easter-6
(Monday) through Easter-3 (Thursday). Palm Sunday (Easter-7) is a Sunday of
Lent, covered by [is_privileged_sunday] in the same entry. *)
let is_holy_week_feria ctx = List.mem (easter_offset ctx) [ -6; -5; -4; -3 ]
(* "Dies infra octavam Paschae": Easter+1 .. Easter+7. Easter Sunday itself is
entry 1. *)
let is_easter_octave_day ctx =
let o = easter_offset ctx in
o >= 1 && o <= 7
(* Tabula II.6: "Dominicae temporis Nativitatis et dominicae 'per annum'".
The privileged Sundays (Advent, Lent, Easter) are entry 2 and are tested
first, so this branch sees only the remaining two seasons. *)
let is_ordinary_sunday (ctx : Vocab_of.season Precedence.context) c =
ctx.weekday = Date.Sun
&& (match ctx.season with
| Vocab_of.Christmas | Vocab_of.Ordinary_time -> true
| Vocab_of.Advent | Vocab_of.Lent | Vocab_of.Easter -> false)
&& c.Precedence.origin = Precedence.Temporal
(* Tabula II.9, three clauses. "Feriae Adventus a die 17 ad 24 decembris
inclusive" -- the late-Advent ferias, which outrank an obligatory memorial
where an ordinary Advent feria (entry 13) does not. *)
let is_late_advent_feria (ctx : Vocab_of.season Precedence.context) =
ctx.season = Vocab_of.Advent
&& Date.month ctx.date = 12
&& Date.day ctx.date >= 17
&& Date.day ctx.date <= 24
(* "Dies infra octavam Nativitatis": 26-31 December. The Nativity itself
(25 December) is entry 2; 1 January is the Octave Day, a solemnity in the
sanctoral, not a feria. *)
let is_nativity_octave_day (ctx : Vocab_of.season Precedence.context) =
Date.month ctx.date = 12 && Date.day ctx.date >= 26 && Date.day ctx.date <= 31
(* "Feriae Quadragesimae". Ash Wednesday and the Holy Week ferias are entry 2
and are tested first. *)
let is_lenten_feria (ctx : Vocab_of.season Precedence.context) =
ctx.season = Vocab_of.Lent
let band (ctx : Vocab_of.season Precedence.context)
(c : Vocab_of.rank Precedence.candidate) : int =
let temporal = c.Precedence.origin = Precedence.Temporal in
let layer = c.cel.layer in
if temporal && is_triduum ctx then 10 (* Tabula I.1 *)
else if
temporal
&& (List.mem (slug_of c) entry_2_named
|| is_privileged_sunday ctx c
|| is_holy_week_feria ctx
|| is_easter_octave_day ctx)
then 20 (* Tabula I.2 *)
else if c.cel.rank = Vocab_of.Sollemnitas && not (is_proper layer) then
30 (* Tabula I.3 -- universal or temporal-origin, never proper; see the
[is_proper] comment above for why this is not [is_universal].
ENTRY 3 HAS A SECOND CLAUSE THIS BRANCH DOES NOT IMPLEMENT (transcription
line 840, identical in the 1969 Calendarium at OCR line 936):
"Commemoratio omnium fidelium defunctorum" -- All Souls, 2 November.
Nothing constructs it: there is no sanctoral data yet (Phase 3), and
Vocab_of.rank has no constructor for it -- the 2002 Calendarium prints
2 November UNGRADED ("Omnium Fidelium defunctorum" / "Commemoratio
omnium Fidelium defunctorum"), so the natural rank tag is ambiguous:
Memoria_obligatoria (band 100, loses to a per annum Sunday, WRONG),
Memoria_ad_libitum (band 120, worse), or Sollemnitas (band 30, the
right precedence but a rank word the source never applies to this
day). The 1969 Calendarium's own printing of this clause additionally
carries a qualifier the 2002 text drops -- "quae tamen locum cedit
dominicae occurrenti" -- an edition difference Phase 3 will also need.
PHASE 3 HAZARD: transcribing 2 November with the natural ungraded
rank will silently make it an ordinary weekday whenever it falls on a
Sunday, with every test in this branch still green, because no test
here knows entry 3 has a second clause. Phase 3 must settle how this
day is tagged (or add a dedicated slug test here, mirroring
[entry_2_named]) before shipping OF sanctoral data. *)
else if c.cel.rank = Vocab_of.Sollemnitas && is_proper layer then
40 (* Tabula I.4 *)
else if c.cel.rank = Vocab_of.Festum && c.cel.subject = Subject.Lord
&& not (is_proper layer) then 50 (* Tabula II.5 *)
else if is_ordinary_sunday ctx c then 60 (* Tabula II.6 *)
else if c.cel.rank = Vocab_of.Festum && not (is_proper layer) then
70 (* Tabula II.7 -- BVM and saints; entry 5 already took subject = Lord *)
else if c.cel.rank = Vocab_of.Festum && is_proper layer then
80 (* Tabula II.8 *)
else if
temporal
&& (is_late_advent_feria ctx || is_nativity_octave_day ctx || is_lenten_feria ctx)
then 90 (* Tabula II.9 *)
else if c.cel.rank = Vocab_of.Memoria_obligatoria && not (is_proper layer) then
100 (* Tabula III.10 -- universal or proper; the [layer] test is what
separates entry 10 from entry 11. Unlike entries 3, 5 and 7, "or
temporal-origin" does not apply here: Temporal_of constructs no
Memoria_obligatoria candidate at all (only Sollemnitas, Festum and
Feria), so no temporal candidate ever reaches this branch. *)
else if c.cel.rank = Vocab_of.Memoria_obligatoria && is_proper layer then
110 (* Tabula III.11 *)
else if c.cel.rank = Vocab_of.Memoria_ad_libitum then
(* Tabula III.12. ONE undivided row: unlike entries 10/11 (and 3/4, 7/8),
the Tabula draws no universal/proper split for optional memorials, so
[layer] is deliberately not read here. The asymmetry is the source's. *)
120
else if temporal && c.cel.rank = Vocab_of.Feria then
(* Tabula III.13, four clauses -- ordinary Advent ferias to 16 December,
Christmas-time ferias from 2 January, paschal ferias after the Octave,
and ferias "per annum". Every privileged feria (entries 2 and 9) is
tested earlier in this chain, so what reaches here is exactly the
residue those four clauses name, and no clause needs its own date
arithmetic. *)
130
else unclassified
(* Normae n. 60: "Attamen sollemnitas, quae impeditur a die liturgico, qui
praecedentia gaudeat, ad proximiorem diem transferatur... Reliquae
celebrationes eo anno omittuntur." An impeded SOLEMNITY is transferred;
every other losing celebration is simply omitted for that year.
[Commemorate] IS NEVER RETURNED, AND THAT IS THE RULE, NOT A GAP. The OF
has no commemoration of a losing celebration in the EF's sense at all --
contrast RG 108-111, which the EF module implements at length. An optional
memorial (Tabula III.12) is an ELECTION the celebrant may make, not a
commemoration attached to another day's office, and must not be modelled as
one: doing so would put a celebration in Liturgical_day.commemorations that
no rubric ever puts there. Design 2026-08-24 §3 ("What will not survive
contact") predicted exactly this; this function is where the prediction is
kept.
[Repose] is likewise never returned. It is EF vocabulary that the EF module
itself emits from nothing.
[winner] is deliberately unread. n. 60 keys the loser's fate on the LOSER's
own grade alone -- unlike the EF's RG 92-95, where the winner's class
decides whether a commemoration survives. Kept in the signature because
Precedence.rules.disposition requires it, and a future proper-calendar rule
might read it. *)
let disposition ~(winner : Vocab_of.rank Precedence.candidate)
~(loser : Vocab_of.rank Precedence.candidate) : Precedence.disposition =
ignore winner;
match loser.cel.rank with
| Vocab_of.Sollemnitas -> Precedence.Transfer
| Vocab_of.Festum | Vocab_of.Memoria_obligatoria | Vocab_of.Memoria_ad_libitum
| Vocab_of.Feria ->
Precedence.Omit
(* Normae n. 60's "Reliquae celebrationes eo anno omittuntur", read together
with the whole Tabula: nothing in the OF is admitted as a commemoration of
another day's office, so this returns the empty list unconditionally.
NOT A STUB. This is [disposition]'s own rule expressed at the second of the
two places the kernel asks about it: [disposition] never returns
[Commemorate], so Precedence.resolve never offers this function a candidate
it could admit even if it wanted to. Returning [] is therefore the only
answer consistent with the rubric, and every losing candidate is reported in
[resolution.omitted] with its reason -- nothing is dropped silently.
All three parameters are unread, for that reason. The EF's own admit reads
all of them (RG 108-113); the OF's has nothing to decide. *)
let admit ~(observed : Vocab_of.rank Precedence.candidate)
~(temporal : Vocab_of.rank Precedence.candidate)
(_ : (Vocab_of.rank Precedence.candidate * Precedence.privilege * int) list) :
(Vocab_of.rank Precedence.candidate * Precedence.privilege) list =
ignore observed;
ignore temporal;
[]
(* RG 33's third omission trigger is EF-only. The OF has no vigil OFFICE that
can be orphaned by its feast moving: Normae n. 11 gives solemnities First
Vespers and n. 13 denies them to feasts, but neither creates a separate
celebration that could survive or fail independently of the day it precedes.
This is why the hook exists at all and why it returns [None] for every
OTHER candidate -- there is no OF office this rule could ever apply to.
"of-nativity-vigil" itself needs a narrower, structural argument, not that
general one: Temporal_of.named (temporal_of.ml:230) DOES construct it as
its own Sollemnitas-rank candidate on 24 December, independent of
"of-nativity" on the 25th -- it is a genuine contestant in [band]/[resolve],
not merely a Mass formulary with no life of its own on the precedence axis.
What makes [None] safe here is that the feast it precedes can never be
orphaned. The Nativity is Tabula entry 2 (band 20); the only entry that
bands lower is entry 1, the Paschal Triduum (band 10, [is_triduum] above).
Gregorian Easter falls in [22 March, 25 April] for EVERY year 1583-9999 --
measured exhaustively, not assumed (test_computus.ml's
[test_gregorian_invariants_exhaustive]) -- so the Triduum's own band-10
window (Easter-2 .. Easter) is confined to 20 March-25 April and can never
reach 25 December. Nothing else in the Tabula bands below entry 2. So
"of-nativity" is unconditionally the observed office on its own day, its
vigil can never be orphaned by a transfer or reduction, and [None] is the
correct answer for it too -- not because it lacks standing as a candidate,
but because the day it precedes can never lose. *)
let vigil_feast (_ : Vocab_of.rank Precedence.candidate) : Slug.t option = None
(* The assembled record Precedence.resolve consumes. Kept here rather than in
rite_of.ml so a reader sees the four functions and their citations
together. *)
let rules : (Vocab_of.season, Vocab_of.rank) Precedence.rules =
{ band; disposition; admit; vigil_feast }
(* Normae n. 60 and Normae n. 5 together. THREE rules, in this priority order
-- n. 60's own text defers to n. 5 explicitly ("servatis iis quae n. 5
statuuntur"), so the two are one rule set, not two competing ones.
1. THE ANNUNCIATION IN HOLY WEEK (Normae n. 60): "Sollemnitas vero
Annuntiationis Domini, quotiescumque occurrit aliquo die Hebdomadae
sanctae, semper ad feriam II post dominicam II Paschae erit
transferenda." A fixed destination, Easter + 8, not a search. The EF has
the identical rule for the identical feast (RG 96's own exception, "the
Monday after Low Sunday" -- Low Sunday IS the Second Sunday of Easter),
which is why Rite.t.transfer_target is a rite-supplied function at all;
see its own doc comment.
2. A SOLEMNITY ON A PRIVILEGED SUNDAY (Normae n. 5): "Sollemnitates autem in
his dominicis occurrentes ad feriam secundam sequentem transferuntur" --
the FOLLOWING MONDAY, not a searched free day. "his dominicis" are n. 5's
own subject: the Sundays of Advent, Lent and Easter. The clause carries
its own exception -- "nisi agatur de occurrentia in Dominica in Palmis
aut in Dominica Resurrectionis Domini" -- and those two cases fall
through to rule 3, whose forward search handles them correctly (the
Monday after Palm Sunday is Tabula entry 2 and is therefore not free).
3. THE GENERAL RULE (Normae n. 60): "ad proximiorem diem transferatur qui
sit liber a diebus sub nn. 1-8 in tabula praecedentiae recensitis" --
the nearest later day free of Tabula entries 1-8, i.e. the first date
whose occupant bands ABOVE 80 (entry 8 x 10).
TERMINATION, which Rite.t.transfer_target requires the rite to guarantee:
the search is bounded at [max_search_days] steps AND at {!domain_max_date},
and returns the last date visited -- never raises -- if either bound is
reached before a free day is found. That bound is STRUCTURAL -- it does
not appeal to the real calendar's own shape -- because Calendar's round
guard bounds ROUNDS, not the internal search of a single call, and an
unbounded walk would hang the caller outright before that guard was ever
consulted; the domain bound additionally prevents the walk from ever
calling [occupant] on a date {!Computus.gregorian_easter} cannot construct
(see {!domain_max_date}'s own comment). *)
let max_search_days = 400
(* The domain's own ceiling ({!Date.make}'s documented 1583..9999 bound, also
duplicated by calendar.ml's own [domain_max_date] for the same reason:
neither module exposes it to the other, and this is a three-line constant,
not worth a new signature just to share it). [search] below must never
call [occupant]/[free] on a date past this: [occupant] chains through the
rite's own [temporal] ([season_of], via [Temporal_of.temporal]), which
calls [Computus.gregorian_easter], NOT total outside 1583..9999 -- it
builds a [Date.t] via [Date.make] and [failwith]s on [Error]. [Date.add_days]
itself has no such limit (documented "unbounded total arithmetic"), so a
walk CAN advance [d] past 31 December 9999 without raising by itself -- the
raise would only happen on the NEXT [occupant]/[free] call, exactly the bug
this guards against: a proper solemnity (band 40) or proper feast (band 80,
NOT > [entry_8_band]) [Add]-ed by an overlay across 26-31 December leaves
only Class2-equivalent Nativity-octave days for the rest of civil year
9999, so an unguarded walk would reach 1 January 10000 and crash there --
reachable through the project's own overlay mechanism, the identical shape
Rite_ef.Precedence_ef's own [domain_max_date] documents and fixes
(precedence_ef.ml:1856-1875). *)
let domain_max_date =
match Date.make ~year:9999 ~month:12 ~day:31 with Ok d -> d | Error e -> failwith e
let annunciation_slug = "of-annunciation"
(* Tabula entries 1-8, scaled: a day is FREE to receive a transfer when its
occupant bands strictly above entry 8. *)
let entry_8_band = 80
let transfer_target (c : Vocab_of.rank Precedence.candidate) (origin : Date.t)
(occupant : Date.t -> Vocab_of.rank Celebration.t) : Date.t =
let year = Date.year origin in
let easter = Computus.gregorian_easter year in
let offset d = Date.to_rata d - Date.to_rata easter in
let is_holy_week d = offset d >= -7 && offset d <= -1 in
(* Rule 1. *)
if Slug.to_string c.cel.slug = annunciation_slug && is_holy_week origin then
Date.add_days easter 8
else
let season_of d = (Temporal_of.temporal d).Temporal.season in
let privileged_sunday d =
Date.weekday d = Date.Sun
&& match season_of d with
| Vocab_of.Advent | Vocab_of.Lent | Vocab_of.Easter -> true
| Vocab_of.Christmas | Vocab_of.Ordinary_time -> false
in
let is_palm_sunday d = offset d = -7 in
let is_easter_sunday d = offset d = 0 in
(* Rule 2. *)
if
c.cel.rank = Vocab_of.Sollemnitas
&& privileged_sunday origin
&& (not (is_palm_sunday origin))
&& not (is_easter_sunday origin)
then Date.add_days origin 1
else
(* Rule 3. *)
let free d =
let cel = occupant d in
let cand = { Precedence.cel; origin = Precedence.Temporal } in
let ctx =
{ Precedence.date = d; season = season_of d; weekday = Date.weekday d }
in
band ctx cand > entry_8_band
in
(* Stops, without calling [free] (and so without calling [occupant])
again, once [d] passes {!domain_max_date} -- see that constant's own
comment for why probing past it can raise. Either way the last date
visited is returned WITHOUT a further [free] probe -- one more finite
(not necessarily free) date, not a further search -- because the
value the caller is still owed is "a date", never an exception;
{!Colitur_kernel.Calendar}'s own [~start ~stop] bound is what turns an
implausible non-terminating real search into a recorded [omitted],
not this function pretending to have found something free. Same
shape as Rite_ef.Precedence_ef.search_from. *)
let rec search d steps =
if steps > max_search_days || Date.compare d domain_max_date > 0 then d
else if free d then d
else search (Date.add_days d 1) (steps + 1)
in
search (Date.add_days origin 1) 1
|