aboutsummaryrefslogtreecommitdiff
path: root/lib/rites/rite_ef/precedence_ef.ml
blob: ff6f991e5f8f212ae43ced74065825d9359e3c73 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
(* RG 91's Table of Precedence (docs/research/rules-register.md §4). Each
   branch below is one of the table's 28 entries, checked in the table's own
   numeric order -- lower wins, and because occasional entries are true
   exceptions to a later, broader one (RG 91 entry 18's Ember days are an
   exception carved out of entry 22's Lent ferias; entry 21/26's vigils are
   an exception carved out of the generic Class2/Class3 sanctoral-feast
   entries that would otherwise also match), checking in table order and
   returning on the first match is what makes the exception actually win
   without a separate exclusion for every later entry it pre-empts.

   Two kinds of evidence decide an entry:
   - The temporal cycle's own office (Nativity, a Sunday, a feria, All Souls)
     is identified structurally, from the context's date/season/weekday and
     the day's Easter offset -- never from its slug, which is just a label.
     [origin = Temporal] gates every such entry so a sanctoral candidate that
     happens to share a date (Immaculate Conception can never coincide with
     the movable cycle, but nothing stops a future rite bug from producing
     one) cannot be mistaken for the office itself. All Souls (entry 8, the
     one non-temporal-origin member of this group) additionally reads the
     context's weekday for its own register-stated exception -- see entry 8
     below.
   - A sanctoral feast's entry is decided by its [rank], and -- except at
     entry 14 (see its own comment below, where the register draws no such
     line) -- per the brief's structural insight, also by its
     {!Celebration.t}.layer: a celebration whose layer is not the universal
     base is an overlay, hence "proper" or "indult" rather than the
     universal entry (11-13 I class; 16/19/20 II class; 23/24 III class; see
     precedence_ef.mli). [origin = Sanctoral] gates these for the same
     reason: temporal-origin celebrations carry the literal layer id
     "temporal" (rite_ef/temporal_ef.ml's [build]), which is not
     [universal_layer] either, and would otherwise be misread as "proper" by
     the layer test alone.

   Vigils (21, 26) are the one shape neither of those two kinds fully
   describes on their own: a II/III-class vigil can be temporal-origin (the
   Ascension Vigil, produced by temporal_ef today) or sanctoral-origin (a
   saint's vigil, not yet loaded by any task), so its entry cannot be gated
   on [origin] at all. Nothing in the day's other fields marks "this is a
   vigil, not an ordinary office of the same rank" either, so this reads it
   off the temporal cycle's own slug convention (a "-vigil" suffix -- see
   [named] in temporal_ef.ml) rather than guessing a new one. *)

open Colitur_kernel

(* Not an RG citation -- RG 91 ranks proper and indult feasts, it does not
   encode how a computer tells them apart. See precedence_ef.mli. *)
let universal_layer = "ef-universal"
let indult_prefix = "indult:"
let unclassified = max_int

let is_indult layer = String.starts_with ~prefix:indult_prefix layer
let is_universal layer = String.equal layer universal_layer

(* Not an RG citation either -- see [universal_layer] above. Nothing in
   {!Celebration.t} otherwise marks "this is a vigil, not an ordinary office
   of the same rank" (see the file's top comment), so entries 21/26 read it
   off the temporal cycle's own slug suffix (rite_ef/temporal_ef.ml's
   [named], e.g. "ef-ascension-vigil"). Exposed so a future task naming a
   sanctoral vigil (Task 10: Assumption, John Baptist, Peter & Paul,
   Lawrence -- only Ascension exists today) uses the same suffix; a
   differently-named vigil would band 16/24 instead of 21/26, silently. *)
let vigil_suffix = "-vigil"

let is_vigil slug = String.ends_with ~suffix:vigil_suffix slug

(* Not an RG citation -- see [universal_layer]. Entry 18's Ember days are
   identified by the temporal cycle's own slug convention (rite_ef/
   temporal_ef.ml's [ember]: "ef-<set>-ember-<day>"), not re-derived here:
   the September anchor in particular is one of the more contested dates in
   the 1962 calendar (temporal_ef.ml's own comment on
   [third_sunday_of_september]), and re-deriving it a second time would only
   create a second place for that same uncertainty to drift. Only the
   Advent, Lent and September sets are listed: RG 91 entry 18 names exactly
   those three; the Whitsun (Pentecost) set is I class and falls inside the
   Pentecost octave, entry 10, matched below before this is ever reached.
   Exposed for the same reason as [vigil_suffix]: a rename of temporal_ef's
   format has somewhere to be caught other than a silently-wrong entry 18.

   [september_ember_prefix] is broken out as its own name (rather than an
   anonymous list literal) because Task 9's [privilege_of] needs to test the
   September set alone, RG 109 privileging it while leaving the Advent and
   Lent sets ordinary (register lines 375-376) -- building [ember_prefixes]
   from it rather than duplicating the literal keeps the two from silently
   drifting apart. *)
let advent_ember_prefix = "ef-advent-ember-"
let lent_ember_prefix = "ef-lent-ember-"
let september_ember_prefix = "ef-september-ember-"
let ember_prefixes = [ advent_ember_prefix; lent_ember_prefix; september_ember_prefix ]

let is_ember_18 slug = List.exists (fun prefix -> String.starts_with ~prefix slug) ember_prefixes

let band (ctx : Vocab_ef.season Precedence.context) (c : Vocab_ef.rank Precedence.candidate) :
    int =
  let cel = c.Precedence.cel in
  let rank = cel.Celebration.rank in
  let subject = cel.Celebration.subject in
  let layer = cel.Celebration.layer in
  let slug = Slug.to_string cel.Celebration.slug in
  let is_temporal = c.Precedence.origin = Precedence.Temporal in
  let is_vigil = is_vigil slug in
  let date = ctx.Precedence.date in
  let season = ctx.Precedence.season in
  let weekday = ctx.Precedence.weekday in
  let is_sunday = weekday = Date.Sun in
  let m = Date.month date and d = Date.day date in
  (* Easter offset, the same convention as temporal_ef.ml's [days_between
     easter d]: 0 is Easter itself, negative before, positive after. *)
  let off = Date.to_rata date - Date.to_rata (Computus.gregorian_easter (Date.year date)) in
  (* Named so entry 8's Sunday exception below can read "one worse than the
     Sunday it must yield to" rather than a bare integer that happens to
     equal entry 15's own value; entry 15's own branch returns this same
     binding, not a second literal, so the two can never drift apart. *)
  let entry_15_band = 15 in
  let open Vocab_ef in
  (* 1: Nativity, Easter Sunday, Pentecost Sunday (I class w/ octave). *)
  if is_temporal && rank = Class1 && ((m = 12 && d = 25) || off = 0 || off = 49) then 1
    (* 2: Sacred Triduum (Thu-Sat of Holy Week). *)
  else if is_temporal && rank = Class1 && off >= -3 && off <= -1 then 2
    (* 3: Epiphany, Ascension, Holy Trinity, Corpus Christi, Sacred Heart,
       Christ the King. *)
  else if is_temporal && rank = Class1
          && ((m = 1 && d = 6) (* Epiphany *)
             || off = 39 (* Ascension *) || off = 56 (* Trinity *)
             || off = 60 (* Corpus Christi *) || off = 68 (* Sacred Heart *)
             || Date.compare date (Temporal_ef.christ_the_king (Date.year date)) = 0)
  then 3
    (* 4: Immaculate Conception, Assumption BVM. *)
  else if (not is_temporal) && rank = Class1 && ((m = 12 && d = 8) || (m = 8 && d = 15)) then 4
    (* 5: Vigil & Octave day of the Nativity. *)
  else if is_temporal && rank = Class1 && ((m = 12 && d = 24) || (m = 1 && d = 1)) then 5
    (* 6: Sundays of Advent, Lent, Passiontide, and Low Sunday. *)
  else if is_temporal && rank = Class1 && is_sunday
          && (season = Advent || season = Lent || season = Passiontide || off = 7)
  then 6
    (* 7: I-class ferias not above -- Ash Wednesday; Mon/Tue/Wed of Holy Week.
       Thu-Sat of Holy Week are the Triduum, entry 2 above, not this entry. *)
  else if is_temporal && rank = Class1 && (off = -46 || (off >= -6 && off <= -4)) then 7
    (* 8: All Souls -- register line 334's own text carries a qualifier this
       transcription must honour: "yields to an occurring Sunday". 2 November
       is always Time_after_pentecost (well clear of Advent/Lent/Passiontide
       and of every other entry's own Easter-relative or fixed date), so a
       Sunday landing on it is always an ordinary entry-15 II-class Sunday --
       the one and only rival this exception ever has to lose to. On such a
       Sunday this returns [entry_15_band + 1]: strictly worse than 15 (an
       exact tie would fall to Precedence.resolve's slug tie-break, which
       for "ef-all-souls" against a "ef-time-after-pentecost-sunday-*" slug
       would make All Souls WIN -- the precise bug this guards against), but
       otherwise not a citation to any other RG 91 row -- nothing else can
       ever occur on 2 November to be confused with it. Entry 8's own [rank]
       is untouched by this, so Task 8's disposition (RG 95: only I-class
       feasts transfer) still sees the true I-class candidate it needs to
       move to 3 November. *)
  else if (not is_temporal) && rank = Class1 && m = 11 && d = 2 then
    if is_sunday then entry_15_band + 1 else 8
    (* 9: Vigil of Pentecost. *)
  else if is_temporal && rank = Class1 && off = 48 then 9
    (* 10: Days within the Octaves of Easter and Pentecost. *)
  else if is_temporal && rank = Class1 && ((off >= 1 && off <= 6) || (off >= 50 && off <= 55))
  then 10
    (* 11: I-class feasts of the universal Church not above. *)
  else if (not is_temporal) && (not is_vigil) && rank = Class1 && is_universal layer then 11
    (* 12: Proper I-class feasts. *)
  else if (not is_temporal) && (not is_vigil) && rank = Class1 && not (is_indult layer) then 12
    (* 13: Indult I-class feasts. By elimination once 11 and 12 have failed:
       not the universal layer (11), and marked as an indult overlay (12's
       "not indult" test having just failed). *)
  else if (not is_temporal) && (not is_vigil) && rank = Class1 then 13
    (* 14: Feasts of the Lord, II class -- register line 341, deliberately
       UNQUALIFIED (contrast entry 16 at line 342, which explicitly says "not
       of the Lord"; RG 37c, register line 393, speaks of "II-class feasts of
       the Lord" replacing an occurring II-class Sunday with no universal
       qualifier either). No layer test here, unlike 11/12/13 and 16/19/20:
       the register does not split this entry into universal/proper/indult,
       so a proper or indult feast of the Lord still bands 14, not 19/20. *)
  else if (not is_temporal) && (not is_vigil) && rank = Class2 && subject = Subject.Lord then 14
    (* 15: Sundays, II class (every Sunday not already named at 6). *)
  else if is_temporal && rank = Class2 && is_sunday then entry_15_band
    (* 16: II-class feasts of the universal Church, not of the Lord. *)
  else if (not is_temporal) && (not is_vigil) && rank = Class2 && is_universal layer then 16
    (* 17: Days within the Octave of the Nativity (26-28 Dec are Stephen,
       John, the Innocents -- sanctoral, not this entry). *)
  else if is_temporal && rank = Class2 && m = 12 && (d = 29 || d = 30 || d = 31) then 17
    (* 18: II-class ferias -- Advent 17-23 Dec; Ember days of Advent, Lent,
       September. *)
  else if is_temporal && rank = Class2
          && ((season = Advent && m = 12 && d >= 17 && d <= 23) || is_ember_18 slug)
  then 18
    (* 19: Proper II-class feasts. *)
  else if (not is_temporal) && (not is_vigil) && rank = Class2 && not (is_indult layer) then 19
    (* 20: Indult II-class feasts. By elimination, as at 13. *)
  else if (not is_temporal) && (not is_vigil) && rank = Class2 then 20
    (* 21: II-class vigils (Ascension, Assumption, John Baptist, Peter & Paul
       -- can be temporal- or sanctoral-origin, see the file comment above). *)
  else if rank = Class2 && is_vigil then 21
    (* 22: Ferias of Lent and Passiontide (Thursday after Ash Wednesday to the
       Saturday before Palm Sunday), except the Ember days (18 above). *)
  else if is_temporal && rank = Class3 && (season = Lent || season = Passiontide) then 22
    (* 23: III-class feasts in particular calendars. Unlike 11/12 and 14/16
       above, the universal entry (24) is the HIGHER number here -- RG 91's
       own table ranks a particular-calendar III-class feast ahead of a
       universal one, the reverse of the I/II-class ordering. Transcribed as
       the register states it, not "corrected" into the other classes'
       pattern. RG 91 has no indult sub-rank at III class, so every non-base
       layer lands here, not split further. *)
  else if (not is_temporal) && (not is_vigil) && rank = Class3 && not (is_universal layer) then 23
    (* 24: III-class feasts in the universal calendar. *)
  else if (not is_temporal) && (not is_vigil) && rank = Class3 then 24
    (* 25: Ferias of Advent to 16 Dec, except the Ember days (18 above). *)
  else if is_temporal && rank = Class3 && season = Advent then 25
    (* 26: III-class vigils (St Lawrence). *)
  else if rank = Class3 && is_vigil then 26
    (* 27: Office of the BVM on Saturday -- every otherwise-unoccupied IV-class
       Saturday, per the historical default that fills it; ordinary Mass
       propers still make Rogation Mon/Tue/Wed proper without changing the
       Office (RG 88, see temporal_ef.ml's [temporal]), so those never carry
       this entry unless they happen to fall on the Saturday itself. Excludes
       vigils for the same reason 11-13/14/16/19/20/23/24 do: RG 91 has no
       IV-class vigil at all (its own vigil list, register lines 381-384,
       stops at III class), so one would be an anomaly, not this entry. *)
  else if is_temporal && (not is_vigil) && rank = Class4 && weekday = Date.Sat then 27
    (* 28: IV-class ferias -- the unqualified catch-all (temporal_ef.ml's own
       comment on [ferial_rank] cites the same primary text, "Feriae IV
       classis"). Excludes vigils for the same reason as 27 above: a IV-class
       "feria" that is also a vigil is not a feria RG 91 describes. *)
  else if (not is_vigil) && rank = Class4 then 28
  else unclassified

(* Task 8: what happens to the day's LOSING candidate (docs/research/
   rules-register.md §4, "Occurrence" RG 92-95 and "Vigils" RG 33, plus RG
   94). [band] above decides who wins; this decides the loser's fate, which
   turns on the LOSER's own rank and status (RG 95), except RG 33's vigil
   omission, which also has to read the winner. Nothing here ever returns
   [Precedence.Repose]: that disposition denotes RG 100-102's *repositio*
   (perpetual impediment from a proper/diocesan calendar), out of this
   plan's scope -- see calendar.mli's own note that nothing in the EF
   ruleset currently emits it. *)

(* RG 33 (register line 383-384): a I- or II-class vigil falling on any
   Sunday or a I-class feast is entirely omitted. Every Sunday slug this
   rite's temporal cycle produces -- named (temporal_ef.ml's [named], e.g.
   "ef-easter-sunday") or the generic "ef-<season>-sunday-<n>" fallback
   ([sunday_slug]) -- contains this marker; nothing else [band] classifies
   does. Not an RG citation itself -- see [universal_layer]'s note on this
   file's own naming conventions -- exposed for the same reason as
   {!vigil_suffix}: a future rename of temporal_ef's Sunday-slug format has
   somewhere to be caught other than a silently-wrong RG 33 disposition. *)
let sunday_marker = "-sunday"

let contains_substring s ~needle =
  let ls = String.length s and ln = String.length needle in
  let rec at i = i + ln <= ls && (String.sub s i ln = needle || at (i + 1)) in
  ln = 0 || at 0

let is_sunday_slug slug = contains_substring slug ~needle:sunday_marker

(* RG 33's own two conditions, taken directly from its text ("any Sunday or
   a I-class feast") -- NOT derived from anything about which RG 91 entries
   can numerically outrank a vigil. [rank = Class1] is the "I-class feast"
   half. [is_sunday_slug] is the "any Sunday" half, and it is not redundant
   with the rank check: RG 91 entries 14 and 16-20 (Feasts of the Lord II
   class, universal/proper/indult II-class feasts, days within the Nativity
   octave) are all [Class2], all outrank a II-class vigil (entry 21), and
   none of them is a Sunday -- a winner of that shape satisfies neither
   condition here, so [impedes_vigil] correctly returns [false] and such a
   vigil falls through to RG 95's ordinary commemorate-or-omit branch
   instead of RG 33's omission, exactly as the rubric requires. *)
let impedes_vigil (winner : Vocab_ef.rank Precedence.candidate) =
  let cel = winner.Precedence.cel in
  cel.Celebration.rank = Vocab_ef.Class1
  || is_sunday_slug (Slug.to_string cel.Celebration.slug)

(* RG 91 entry 17's own slug convention (rite_ef/temporal_ef.ml's [named]:
   "ef-nativity-octave-day-%d" for 29-31 Dec -- 26-28 Dec are Stephen, John,
   the Innocents, sanctoral, and never carry this prefix, see [band]'s entry
   17 comment). Not an RG citation itself -- see [universal_layer] -- reused
   below by [privilege_of] for RG 109(c). *)
let nativity_octave_prefix = "ef-nativity-octave-day-"

(* RG 109's own three named seasons for (e), "of ferias of Advent, Lent and
   Passiontide" (register line 376) -- temporal_ef.ml's generic
   <season>-<week>-<weekday> ferial fallback slugs, whose season word is
   [season_slug_word]'s output for exactly these three (vocab_ef.ml: Advent
   and Passiontide are unmodified [season_to_string]; Lent likewise). Also
   matches the Lent "after Ashes" sub-case ("ef-lent-after-ashes-<weekday>",
   temporal_ef.ml's own [christmastide_feria_slug]-adjacent branch), which
   is still a Lent feria under this same prefix. Not an RG citation -- see
   [universal_layer] -- private: nothing outside [privilege_of] needs it. *)
let alp_feria_prefixes = [ "ef-advent-"; "ef-lent-"; "ef-passiontide-" ]

(* RG 109 (register lines 374-377, docs/research/rules-register.md §4): the
   closed list of privileged commemorations, checked in the register's own
   lettered order. A candidate matching none of (a)-(f) is ordinary, per the
   register's own closing sentence, "All others are ordinary." Read entirely
   off the candidate's own fields (rank, slug, origin) -- no [context]
   (date/season/weekday) is available or needed: every category names a
   property of the commemorated OFFICE ITSELF ("a commemoration OF a
   Sunday", "OF a I-class day", ...), not of the day it happens to fall on,
   and each of (a)-(e) already has a candidate-only marker this file's own
   conventions establish ([sunday_marker], rank, [nativity_octave_prefix],
   [september_ember_prefix]/[alp_feria_prefixes]) -- see the task report for
   the full reasoning.

   [disposition] below is this function's only caller, at both of its
   [Commemorate] sites -- replacing Task 8's [interim_privilege] placeholder,
   which always returned [Ordinary] regardless of the loser's real shape.
   [admit] (RG 108-111's admission counts, below) trusts the privilege value
   [disposition] has already attached rather than recomputing it here a
   second time. *)
let privilege_of (c : Vocab_ef.rank Precedence.candidate) : Precedence.privilege =
  let cel = c.Precedence.cel in
  let rank = cel.Celebration.rank in
  let slug = Slug.to_string cel.Celebration.slug in
  let is_temporal = c.Precedence.origin = Precedence.Temporal in
  let open Vocab_ef in
  (* (a) register line 374: "of a Sunday" -- the same slug marker RG 33's
     [impedes_vigil] already reads to answer "is this candidate a Sunday". *)
  if is_sunday_slug slug then Precedence.Privileged
  (* (b) register line 374-375: "of a I-class day" -- the candidate's own
     rank. In this codebase's current disposition rules the ONLY way a
     [Class1] candidate ever reaches [Commemorate] at all is via
     [Celebration.status = Commemoration_only] (a plain [Feast]-status
     [Class1] loser always [Transfer]s instead, RG 95, below) -- so this
     branch is real but its only reachable witness today is that shape; see
     the task report. *)
  else if rank = Class1 then Precedence.Privileged
  (* (c) register line 375: "of days within the Octave of the Nativity". *)
  else if is_temporal && String.starts_with ~prefix:nativity_octave_prefix slug then
    Precedence.Privileged
  (* (d) register line 375-376: "of September Ember days" -- deliberately
     ONLY the September set: RG 109 does not list the Advent or Lent Ember
     sets (also II class, RG 91 entry 18), so those must fall through to
     "ordinary", not be caught here or at (e) below. *)
  else if is_temporal && String.starts_with ~prefix:september_ember_prefix slug then
    Precedence.Privileged
  (* (e) register line 376: "of ferias of Advent, Lent and Passiontide" --
     [not (is_ember_18 slug)] is required, not redundant with (d): the
     Advent and Lent Ember prefixes ("ef-advent-ember-", "ef-lent-ember-")
     also start with this branch's own [alp_feria_prefixes] entries
     ("ef-advent-", "ef-lent-"), and RG 109 does not privilege them (see (d)
     above) -- without this exclusion they would wrongly match here. *)
  else if is_temporal
          && (not (is_ember_18 slug))
          && List.exists (fun p -> String.starts_with ~prefix:p slug) alp_feria_prefixes
  then Precedence.Privileged
  (* (f) register line 376-377: "of the Major Rogations, in Mass" -- the
     Major Litanies (25 April, RG 80) are not yet computed anywhere in this
     codebase (temporal_ef.ml's own comment on [temporal]'s Rogation branch:
     "The Major Litanies... are a fixed date and are not yet computed; they
     arrive with Plan 3's sanctoral"), so no candidate this engine can
     currently construct represents one. There is no existing slug
     convention to anchor a check to, and guessing one risks silently
     misclassifying whatever a future task does name it -- a wrong citation
     is worse than a missing one, so this is left unimplemented and flagged
     in the task report rather than guessed. Deliberately NOT matched by
     anything above: the Minor Litanies/Rogations ("ef-rogation-monday"/
     "-tuesday", RG 87) temporal_ef.ml DOES compute are a different
     observance RG 109(f) does not name (RG 88: the Minor Rogations change
     nothing in the Office at all), so they correctly fall through to
     "ordinary" below, not this category. *)
  else Precedence.Ordinary

let disposition ~(winner : Vocab_ef.rank Precedence.candidate)
    ~(loser : Vocab_ef.rank Precedence.candidate) : Precedence.disposition =
  let open Vocab_ef in
  let cel = loser.Precedence.cel in
  if cel.Celebration.status = Celebration.Commemoration_only then
    (* Always -- checked before RG 33's omission and RG 95's transfer so
       neither can override it: a Commemoration_only entry can never win
       (Precedence.resolve holds it out of the band contest entirely, see
       that module's [resolve]) and, per the brief, can never transfer
       either. Its privilege is [privilege_of loser] like every other
       [Commemorate] below -- Commemoration_only carries a real [rank] for
       exactly this purpose (Celebration.mli: "RG 111 orders admitted
       commemorations by dignity"), so RG 109(b) applies to it precisely as
       it would to any other candidate. *)
    Precedence.Commemorate (privilege_of loser)
  else if
    (cel.Celebration.rank = Class1 || cel.Celebration.rank = Class2)
    && is_vigil (Slug.to_string cel.Celebration.slug)
    && impedes_vigil winner
  then
    (* RG 33. Checked before the generic Class1 -> Transfer rule below, or a
       I-class vigil (Nativity, Pentecost) impeded on its own Sunday/
       I-class-feast terms would wrongly transfer instead of vanishing. *)
    Precedence.Omit
  else if
    cel.Celebration.rank = Class1
    && not (is_sunday_slug (Slug.to_string cel.Celebration.slug))
  then
    (* RG 95 (register lines 323, 363): only I-class FEASTS have the right
       of translation -- RG 91's own table lists Sundays as a separate row
       (entry 6, register line 332) from feasts (entries 11-13, register
       lines 337-339), so a Sunday is never a "feast" in RG 95's sense, and
       [is_sunday_slug] (the same marker RG 33's [impedes_vigil] and RG
       109(a)'s [privilege_of] already use) excludes it here. This is the
       branch that completes Task 7's All Souls fix (register line 334, RG
       91 entry 8): All Souls is I class, not a vigil, and not a Sunday
       slug, so once it loses to an occurring Sunday it still reaches here
       and transfers -- to 3 November per the register, but WHERE it lands
       is Rite.transfer_target's job (RG 96), not this function's;
       disposition only says THAT it moves. *)
    Precedence.Transfer
  else
    (* RG 95's other branch: "aut commemorantur aut penitus omittuntur" --
       commemorated or wholly omitted. Reached by everything below I class,
       AND by an impeded I-class Sunday (excluded from the [Transfer] branch
       above): RG 109(a) (register line 374) lists "of a Sunday" as a
       privileged commemoration category, which presupposes an impeded
       Sunday stays put rather than moving to another day the way a feast
       does -- [privilege_of] tags it [Privileged] via the same
       [is_sunday_slug] marker, with no further code needed here. Which of
       commemorate/omit survives is RG 108-111's admission count ([admit],
       below), not this function's decision; this only opens the
       commemoration, tagged with its real RG 109 privilege via
       [privilege_of].

       RG 94 (a fixed-day commemoration is not carried along with a
       transferred feast) needs no code here: [Precedence.resolve] calls
       this function once per loser, always against the day's actual
       [observed] winner -- never against a fellow loser that itself
       transferred away -- so no mechanism exists by which a commemoration
       could ride along with a departing feast in the first place; there is
       nothing to suppress. *)
    Precedence.Commemorate (privilege_of loser)

(* Task 9: how many of the day's commemorations RG 111 admits, and which
   (docs/research/rules-register.md §4, register line 378, "Commemorations"
   RG 111). [band] decides who wins the day; [disposition] decides who is
   even eligible to be commemorated, and tags each with its RG 109 privilege
   via [privilege_of]; this decides how many of THOSE survive.

   RG 111 keys its four admission rules off the CLASS OF THE DAY ("diebus I
   classis", "dominicis II classis", "aliis diebus II classis", "diebus III
   et IV classis") -- read here off [observed]'s own [rank] and, for the
   Sunday/non-Sunday II-class split, the same slug marker [privilege_of] and
   RG 33's [impedes_vigil] already use ([is_sunday_slug]). No [context]
   (date/season/weekday) is available to [admit] (see precedence.mli's
   [rules.admit]) or needed: [observed] IS the day's own celebration, so its
   rank and slug already carry everything RG 111's own four categories test. *)

(* RG 8's four-class dignity order, Class1 highest. Deliberately NOT [band]
   (RG 91's much finer 28-entry table): [band] needs a [context] [admit]
   does not have (see above), and Celebration.mli's own comment on [status]
   -- "RG 111 orders admitted commemorations by dignity" -- names [rank]
   itself as that dignity, not the finer occurrence-table entry. *)
let dignity = function
  | Vocab_ef.Class1 -> 1
  | Vocab_ef.Class2 -> 2
  | Vocab_ef.Class3 -> 3
  | Vocab_ef.Class4 -> 4

(* Deterministic selection order for RG 111: dignity first, then slug --
   the same tie-break {!Precedence.compare_by} uses for [band] itself (the
   brief: "break ties on slug"), so which candidate wins a shared rank never
   depends on the order [comms] arrives in. *)
let compare_dignity (a, _) (b, _) =
  let da = dignity a.Precedence.cel.Celebration.rank
  and db = dignity b.Precedence.cel.Celebration.rank in
  if da <> db then Int.compare da db
  else Slug.compare a.Precedence.cel.Celebration.slug b.Precedence.cel.Celebration.slug

let rec take n = function
  | [] -> []
  | x :: xs -> if n <= 0 then [] else x :: take (n - 1) xs

let admit ~(observed : Vocab_ef.rank Precedence.candidate)
    (comms : (Vocab_ef.rank Precedence.candidate * Precedence.privilege) list) :
    (Vocab_ef.rank Precedence.candidate * Precedence.privilege) list =
  (* Sorted once, by dignity then slug (see [compare_dignity]); every branch
     below either takes a prefix of this list or filters it, so the RESULT
     is always a sub-list of [comms] with its elements untouched -- never
     rebuilt -- which matters beyond determinism: {!Precedence.resolve}'s
     own [dropped] computation tells an admitted candidate from a dropped
     one by physical equality (==) on the candidate value (Task 2's own
     deferred note: "assuming admit returns the same candidate values rather
     than rebuilt ones; undocumented for rite authors" -- documented here,
     now that this is the function that note was about). Building a fresh
     [{ c with ... }] record anywhere below would silently defeat that
     accounting: the dropped candidate would then match nothing in
     [admitted], and {!Precedence.resolve} would count it as dropped a
     SECOND time (once for real, once because its identity no longer
     matches its own admitted copy) without ever raising -- a silent
     double-count, not a crash, which is exactly why this comment exists. *)
  let sorted = List.stable_sort compare_dignity comms in
  let is_privileged (_, p) = p = Precedence.Privileged in
  let observed_rank = observed.Precedence.cel.Celebration.rank in
  let observed_is_sunday =
    is_sunday_slug (Slug.to_string observed.Precedence.cel.Celebration.slug)
  in
  let open Vocab_ef in
  match (observed_rank, observed_is_sunday) with
  | Class1, _ ->
      (* RG 111: "I class: none save one privileged." Ordinary commemorations
         never get a slot at all on a I-class day, no matter how many are
         due; at most one privileged one does, the highest-dignity one if
         several are. *)
      (match List.filter is_privileged sorted with [] -> [] | best :: _ -> [ best ])
  | Class2, true ->
      (* RG 111: "II-class Sundays: one (dropped if a privileged one is
         due)." Read as: the day's one slot goes to a privileged
         commemoration whenever one is due, categorically -- not by
         comparing its dignity against the ordinary contender's -- so an
         ordinary commemoration that would otherwise win the slot on raw
         dignity is still dropped once any privileged commemoration is also
         due. This is the asymmetric clause the brief and task report flag
         as deliberate, not present at "other II class" below; see the task
         report for the reasoning and its residual uncertainty (the register
         does not spell out the mechanism beyond this one sentence). *)
      (match List.filter is_privileged sorted with
       | best :: _ -> [ best ]
       | [] -> ( match sorted with [] -> [] | best :: _ -> [ best ] ))
  | Class2, false ->
      (* RG 111: "other II class: one" -- no privilege-override clause here,
         unlike the Sunday case immediately above, so the day's one slot
         goes to whichever candidate outranks the rest by dignity alone,
         privileged or not. *)
      (match sorted with [] -> [] | best :: _ -> [ best ])
  | (Class3 | Class4), _ ->
      (* RG 111: "III-IV class: at most two" -- by dignity, same as the
         non-Sunday II-class case, just with room for two. *)
      take 2 sorted