aboutsummaryrefslogtreecommitdiff
path: root/lib/rites/rite_of/precedence_of.ml
blob: 53443740c7e86d3e386cb1cf649fd377dfa17b19 (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
(* 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'S SECOND CLAUSE -- RESOLVED, fix round 2, Task 1
          (2026-08-25; transcription line 840, identical in the 1969
          Calendarium at OCR line 936): "Commemoratio omnium fidelium
          defunctorum" -- All Souls, 2 November. The 2002 Calendarium
          prints 2 November UNGRADED ("Commemoratio omnium Fidelium
          defunctorum"), so no grade word could be transcribed literally;
          data/of/calendar-2002.sexp (tools/extract_of_calendar.py's own
          ALL_SOULS_OVERRIDE) tags it [Sollemnitas] anyway, on this table's
          own authority -- entry 3 itself names "Commemoratio omnium
          fidelium defunctorum" in the SAME line as "Sollemnitates ...
          Sanctorum in Calendario generali inscriptae", i.e. this table
          places it at THIS precedence tier regardless of what word the
          calendar page prints next to it. (For the record, the two
          rejected alternatives were Memoria_obligatoria, band 100, loses
          to a per annum Sunday, WRONG; and Memoria_ad_libitum, band 120,
          worse.) Proved, not merely asserted:
          test/test_precedence_of.ml's [test_all_souls_beats_an_ordinary_
          sunday] loads the SHIPPED "all-souls" entry and resolves it
          against a real per-annum Sunday (2 November 2025, itself a
          Sunday) through {!Colitur_kernel.Precedence.resolve} and
          [rules] below -- not [band] in isolation -- confirming All Souls
          is [observed], not the Sunday.

          STILL OPEN, deliberately out of this task's scope: the 1969
          Calendarium's own printing of this clause carries a qualifier
          the 2002 text drops -- "quae tamen locum cedit dominicae
          occurrenti" -- an edition difference not reconciled here.

          A LOSING All Souls -- unreachable on universal data (nothing
          shipped outranks a Tabula I.3 Sollemnitas except the Triduum's
          own Easter-bound window, which can never reach 2 November) but
          real the moment an overlay puts something higher there --
          would be TRANSFERRED, not omitted: [disposition] below sends
          any losing Sollemnitas forward via Normae n. 60, and nothing
          distinguishes "Commemoratio" from an ordinary Sollemnitas for
          that purpose. The same shape as [Rite_ef.Precedence_ef]'s own
          [entry_14_movable_band] (CLAUDE.md, "Carried into Plan 4") --
          recorded here rather than built defensively against. *)
  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. 56(f), n. 60 and n. 5 together. FOUR rules, in this priority
   order -- n. 60's own text defers to n. 5 explicitly ("servatis iis quae
   n. 5 statuuntur"), and n. 56(f) names the single collision it overrides
   outright, so all three are one rule set, not three competing ones.

   0. ST JOSEPH ON PALM SUNDAY (Normae n. 56(f)): "Sollemnitas S. Ioseph,
      ubi est de praecepto servanda, si cum Dominica in palmis de Passione
      Domini occurrit, anticipatur sabbato praecedenti, die 18 martii" --
      ANTICIPATED BACKWARD to the preceding Saturday, 18 March, the one
      named exception to every other rule here searching or landing
      forward. RESOLVED (of-normae-56f/W1, 2026-08-26; previously KNOWN
      UNIMPLEMENTED -- see this value's own .mli for the full history,
      including why it was blocked on a kernel contract change and what
      that change cost). Checked first, ahead of rule 1, because it names
      a more specific collision (one candidate, one date shape) than any
      rule below.

   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:
   rules 1-3's 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). Rule 0 needs no bound of its own: it runs no search at
   all, computing [Date.add_days origin (-1)] directly, so it terminates
   trivially -- the one shape {!Colitur_kernel.Rite.t.transfer_target}'s own
   comment names as always safe regardless of direction. *)

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

(* NOTE, found while adding [st_joseph_slug] below, NOT fixed here --
   out of scope for the W1 task this touches: [annunciation_slug] does not
   match the real shipped entry's own slug (data/of/calendar-2002.sexp has
   "annunciation-of-the-lord", not "of-annunciation"), so Rule 1 below
   never actually fires against real sanctoral data -- only against the
   synthetic candidate test_precedence_of.ml's own [annunciation] builds
   with the same placeholder slug. Whether this is output-visible on real
   data was not established either way here; Rule 3's general forward
   search is a plausible fallback that could reach the identical Easter+8
   target by coincidence in the one case tested
   ([test_annunciation_in_easter_octave]'s own comment says as much for a
   collision NOT in Holy Week), but that is not the same claim as "the two
   rules always agree", and this was not checked for Holy Week itself. *)
let annunciation_slug = "of-annunciation"

(* The real shipped slug (data/of/calendar-2002.sexp) -- St Joseph's own
   Rule 0 below is matched against the actual sanctoral entry, not a
   placeholder, precisely because the note above exists. *)
let st_joseph_slug = "joseph-husband-of-the-blessed-virgin-mary"

(* 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
  let is_palm_sunday d = offset d = -7 in
  (* Rule 0 (Normae n. 56(f)): "Sollemnitas S. Ioseph, ubi est de praecepto
     servanda, si cum Dominica in palmis de Passione Domini occurrit,
     anticipatur sabbato praecedenti, die 18 martii" -- St Joseph, impeded
     by Palm Sunday, is ANTICIPATED BACKWARD to the preceding Saturday, not
     carried forward like every other rule here. A fixed one-day
     displacement, not a search: [origin] is Palm Sunday itself whenever
     this fires (a Sunday, by construction), so "the preceding Saturday"
     and "the day before [origin]" are the same date, and computing it this
     way -- rather than via [offset]/[easter] -- makes that identity exact
     rather than incidental. This is the rite.mli-documented EARLIER-target
     shape (rite.mli's own [transfer_target] comment): a single named
     collision, no internal search, hence trivially terminating on its own.
     Checked ahead of Rules 1-3, most-specific first, the same order this
     whole function already keeps. *)
  if Slug.to_string c.cel.slug = st_joseph_slug && is_palm_sunday origin then
    Date.add_days origin (-1)
  else if
    (* Rule 1. *)
    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_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