aboutsummaryrefslogtreecommitdiff
path: root/test/test_precedence_ef.ml
blob: 3b2d5f62fd03aa376292cc63725acaac937019a2 (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
(* RG 91's Table of Precedence, transcribed by Rite_ef.Precedence_ef.band.
   Table-driven, one row (hence one Alcotest.test_case) per RG 91 entry, so a
   misplaced or missing entry names itself in the failure output instead of
   failing anonymously (docs/research/rules-register.md §4). Each row's date
   is checked against the register to make sure it is not ALSO an instance of
   some other entry at the same band (the vacuous-test trap this project has
   caught before -- see the Advent-Ember-day note on entry 18 below). *)

module P = Colitur_kernel.Precedence
module Cel = Colitur_kernel.Celebration
module S = Colitur_kernel.Slug
module Col = Colitur_kernel.Colour
module D = Colitur_kernel.Date
module Sub = Colitur_kernel.Subject
module Comp = Colitur_kernel.Computus
module T = Rite_ef.Temporal_ef
module V = Rite_ef.Vocab_ef
module PE = Rite_ef.Precedence_ef

let mk y m dd = match D.make ~year:y ~month:m ~day:dd with Ok t -> t | Error e -> failwith e

(* [T.season] is the same function Calendar itself would use to build a
   context, so a row's [season]/[weekday] are exactly what the real engine
   would compute for that date, not a hand-picked value that might not
   actually occur together with it. *)
let ctx date = { P.date; season = T.season date; weekday = D.weekday date }

let cand ?(origin = P.Temporal) ?(rank = V.Class1) ?(status = Cel.Feast) ?(subject = Sub.Temporal)
    ?(layer = "temporal") slug =
  { P.cel = Cel.make ~slug:(S.of_string_exn slug) ~rank ~status ~colour:Col.White ~subject ~layer ();
    origin }

(* A candidate built from [Temporal_ef.temporal]'s own real output, not a
   hand-typed slug -- review finding 3: [band]'s Ember/vigil detection reads
   temporal_ef.ml's slug conventions, and a row that also hand-types the same
   literal proves nothing if that convention ever drifts (both sides would
   drift together, silently). Rows built with this instead fail loudly on
   such a drift, because they source the slug from the same place [band]
   itself is implicitly trusting. *)
let of_temporal date =
  let day = T.temporal date in
  { P.cel = day.Colitur_kernel.Temporal.office; origin = P.Temporal }

(* Every Easter-relative date below is anchored to this single computed
   Easter rather than a hand-typed calendar date, so an arithmetic slip in a
   test date cannot silently pass by accident. *)
let easter = Comp.gregorian_easter 2026
let off n = D.add_days easter n

(* (description, date, candidate, expected RG 91 entry). *)
let cases =
  [ (* Entry 1 -- register line 327: Nativity, Easter Sunday, Pentecost Sunday. *)
    ("1 Nativity", mk 2026 12 25, cand "ef-nativity", 1);
    ("1 Easter Sunday", off 0, cand "ef-easter-sunday", 1);
    ("1 Pentecost Sunday", off 49, cand "ef-pentecost", 1);
    (* Entry 2 -- register line 328: Sacred Triduum. Thu-Sat of Holy Week,
       NOT entry 7 (which stops at Wednesday -- see entry 7 below). *)
    ("2 Holy Thursday", off (-3), cand "ef-holy-thursday", 2);
    ("2 Good Friday", off (-2), cand "ef-good-friday", 2);
    ("2 Holy Saturday", off (-1), cand "ef-holy-saturday", 2);
    (* Entry 3 -- register line 329. *)
    ("3 Epiphany", mk 2026 1 6, cand "ef-epiphany", 3);
    ("3 Ascension", off 39, cand "ef-ascension", 3);
    ("3 Trinity", off 56, cand "ef-trinity", 3);
    ("3 Corpus Christi", off 60, cand "ef-corpus-christi", 3);
    ("3 Sacred Heart", off 68, cand "ef-sacred-heart", 3);
    ("3 Christ the King", T.christ_the_king 2026, cand "ef-christ-the-king", 3);
    (* Entry 4 -- register line 330. Sanctoral-origin: neither feast is part
       of temporal_ef's movable cycle. *)
    ( "4 Immaculate Conception", mk 2026 12 8,
      cand ~origin:P.Sanctoral ~subject:Sub.Bvm ~layer:PE.universal_layer
        "ef-immaculate-conception",
      4 );
    ("4 Assumption", mk 2026 8 15, cand ~origin:P.Sanctoral ~subject:Sub.Bvm ~layer:PE.universal_layer "ef-assumption", 4);
    (* Entry 5 -- register line 331. *)
    ("5 Nativity Vigil", mk 2026 12 24, cand "ef-nativity-vigil", 5);
    ("5 Octave day (Circumcision)", mk 2026 1 1, cand "ef-circumcision", 5);
    (* Entry 6 -- register line 332. *)
    ("6 Advent Sunday", T.advent_start 2026, cand "ef-advent-sunday-1", 6);
    ("6 Lent Sunday", off (-42), cand "ef-lent-sunday-1", 6);
    ("6 Passion Sunday (I Passiontide)", off (-14), cand "ef-passion-sunday", 6);
    ("6 Palm Sunday (II Passiontide)", off (-7), cand "ef-palm-sunday", 6);
    ("6 Low Sunday", off 7, cand "ef-low-sunday", 6);
    (* Entry 7 -- register line 333: Ash Wednesday and Mon/Tue/Wed of Holy
       Week ONLY -- Thu-Sat are entry 2 above, not this entry. *)
    ("7 Ash Wednesday", off (-46), cand "ef-ash-wednesday", 7);
    ("7 Monday of Holy Week", off (-6), cand "ef-holy-monday", 7);
    ("7 Tuesday of Holy Week", off (-5), cand "ef-holy-tuesday", 7);
    ("7 Wednesday of Holy Week", off (-4), cand "ef-holy-wednesday", 7);
    (* Entry 8 -- register line 334. 2 Nov 2026 is a Monday (verified
       independently below the table), so this row is the plain case. The
       register's own qualifying case -- "yields to an occurring Sunday" --
       gets its own row and its own end-to-end test after this table (2 Nov
       2025 is a real Sunday). *)
    ("8 All Souls (non-Sunday)", mk 2026 11 2, cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls", 8);
    (* Entry 8's qualifier: "(yields to an occurring Sunday)". 2 Nov 2025 is
       a Sunday, so this must NOT be 8 -- it must lose to entry 15 (16 =
       entry 15's own value + 1, the exact value precedence_ef.ml documents
       and justifies at entry 8's branch). The end-to-end resolve-level
       proof that the Sunday actually wins the day is
       [test_all_souls_yields_to_sunday] below; this row pins the specific
       integer [band] returns. *)
    ("8 All Souls (yields to a Sunday, 2 Nov 2025)", mk 2025 11 2, cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls", 16);
    (* Entry 9 -- register line 335. *)
    ("9 Pentecost Vigil", off 48, cand "ef-pentecost-vigil", 9);
    (* Entry 10 -- register line 336: both range boundaries, to guard the
       off-by-one an inclusive Easter-offset window invites. *)
    ("10 Easter octave, day+1", off 1, cand "ef-easter-1-mon", 10);
    ("10 Easter octave, day+6", off 6, cand "ef-easter-1-sat", 10);
    ("10 Pentecost octave, day+50", off 50, cand "ef-pentecost-1-mon", 10);
    ("10 Pentecost octave, day+55", off 55, cand "ef-pentecost-1-sat", 10);
    (* Entry 11 -- register line 337. *)
    ( "11 Universal I-class feast", mk 2026 6 29,
      cand ~origin:P.Sanctoral ~subject:Sub.Saint ~layer:PE.universal_layer "ef-ss-peter-paul",
      11 );
    (* Entry 12 -- register line 338. The one non-base-layer case the brief
       asks for explicitly: same date/rank/subject as 11, only the layer
       differs, so this row isolates the layer test as the deciding factor. *)
    ( "12 Proper I-class feast (non-base layer)", mk 2026 6 29,
      cand ~origin:P.Sanctoral ~subject:Sub.Saint ~layer:"diocese-warsaw" "ef-local-patron",
      12 );
    (* Entry 13 -- register line 339. *)
    ( "13 Indult I-class feast", mk 2026 6 29,
      cand ~origin:P.Sanctoral ~subject:Sub.Saint ~layer:(PE.indult_prefix ^ "local-grant")
        "ef-indult-feast-1",
      13 );
    (* Entry 14 -- register line 341, deliberately UNQUALIFIED (contrast
       entry 16, line 342, which explicitly says "not of the Lord"). *)
    ( "14 Feast of the Lord, II class", mk 2026 7 1,
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Lord ~layer:PE.universal_layer
        "ef-precious-blood",
      14 );
    (* Entry 14, non-base layer: unlike 11-13/16/19/20/23/24, entry 14 draws
       no universal/proper/indult line at all, so this must STILL be 14, not
       19 -- the exact restriction review finding 2 flagged and this row
       exists to keep from silently coming back. *)
    ( "14 Feast of the Lord, II class (non-base layer)", mk 2026 7 2,
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Lord ~layer:"diocese-warsaw"
        "ef-local-feast-of-the-lord",
      14 );
    (* Entry 15 -- register line 342: an ordinary Sunday not named at entry 6
       -- Septuagesima is II class (RG 11-12 names only Advent/Lent/
       Passiontide/Easter/Low/Pentecost as I class). *)
    ("15 II-class Sunday (Septuagesima)", off (-63), cand ~rank:V.Class2 "ef-septuagesima-sunday", 15);
    (* Entry 16 -- register line 342. *)
    ( "16 Universal II-class feast, not of the Lord", mk 2026 1 20,
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Saint ~layer:PE.universal_layer
        "ef-some-saint",
      16 );
    (* Entry 17 -- register line 343: days WITHIN the Nativity octave (26-28
       Dec are Stephen/John/Innocents -- sanctoral, not this entry; 1 Jan is
       entry 5's Octave DAY, not this entry either). *)
    ("17 Nativity octave, 29 Dec", mk 2026 12 29, cand ~rank:V.Class2 "ef-nativity-octave-day-5", 17);
    ("17 Nativity octave, 31 Dec", mk 2026 12 31, cand ~rank:V.Class2 "ef-nativity-octave-day-7", 17);
    (* Entry 18 -- register line 343-344: Advent 17-23 Dec ferias AND the
       Ember days of Advent/Lent/September share this one entry. The second
       row is deliberately a Lent date (season Lent, NOT Advent) to prove the
       Ember-slug path fires on its own, not merely because it also happens
       to fall in the Dec 17-23 window -- the exact trap the brief warns
       about, worked the other way round: this Ember day must NOT be
       mistaken for an ordinary entry-22 Lent feria either. *)
    ("18 Advent 17-23 Dec feria", mk 2026 12 21, cand ~rank:V.Class2 "ef-advent-4-mon", 18);
    (* Sourced from Temporal_ef.temporal's own output (see [of_temporal])
       rather than a hand-typed "ef-lent-ember-wed" -- closes review finding
       3's coupling concern for the Ember prefixes specifically. *)
    ("18 Lent Ember Wednesday (from Temporal_ef.temporal)", off (-39), of_temporal (off (-39)), 18);
    (* Entry 19 -- register line 344. *)
    ( "19 Proper II-class feast", mk 2026 1 20,
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Saint ~layer:"diocese-warsaw"
        "ef-local-saint-2",
      19 );
    (* Entry 20 -- register line 345. *)
    ( "20 Indult II-class feast", mk 2026 1 20,
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Saint
        ~layer:(PE.indult_prefix ^ "local-grant-2") "ef-indult-feast-2",
      20 );
    (* Entry 21 -- register line 345 (RG 28-34). Two rows: the Ascension
       Vigil is the one II-class vigil temporal_ef already produces today
       (temporal-origin); the Assumption Vigil stands in for the
       sanctoral-origin case no task has loaded data for yet -- proving
       [band] does not gate this entry on [origin] (see precedence_ef.ml's
       file comment). *)
    (* Sourced from Temporal_ef.temporal's own output (see [of_temporal])
       rather than a hand-typed "ef-ascension-vigil" -- closes review finding
       3's coupling concern for [vigil_suffix]. *)
    ("21 Ascension Vigil (from Temporal_ef.temporal)", off 38, of_temporal (off 38), 21);
    ( "21 Assumption Vigil (sanctoral-origin)", mk 2026 8 14,
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "ef-assumption-vigil",
      21 );
    (* Also review finding 3 / "worth doing": a UNIVERSAL-layer Class2 vigil
       whose subject is the Lord must still be 21, not 14 -- pins entry 14's
       [not is_vigil] guard even after finding 2 dropped its layer test. *)
    ( "21 Universal II-class vigil of the Lord", mk 2026 6 23,
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~subject:Sub.Lord ~layer:PE.universal_layer
        "ef-precious-blood-vigil",
      21 );
    (* Entry 22 -- register line 347-348 (corrected: ends at Palm Sunday, not
       Passion Sunday). Both a Lent and a Passiontide feria, clear of Ash
       Wednesday, Holy Week and the Ember days. *)
    ("22 Lent feria", off (-41), cand ~rank:V.Class3 "ef-lent-1-mon", 22);
    ("22 Passiontide feria", off (-12), cand ~rank:V.Class3 "ef-passiontide-1-tue", 22);
    (* Entry 23 -- register line 349. NOTE the table's own order here is the
       REVERSE of 11/12 and 14/16/19/20 above: entry 23 (particular
       calendars) is numbered BELOW entry 24 (universal), so a proper
       III-class feast outranks a universal one -- transcribed as the
       register states it, not "corrected" to match the other classes. *)
    ( "23 Proper III-class feast (non-base layer)", mk 2026 6 30,
      cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:"diocese-warsaw" "ef-local-saint-3",
      23 );
    (* Entry 24 -- register line 349. *)
    ( "24 Universal III-class feast", mk 2026 6 30,
      cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-some-saint-3",
      24 );
    (* Entry 25 -- register line 350. *)
    ("25 Advent feria to 16 Dec", mk 2026 12 1, cand ~rank:V.Class3 "ef-advent-1-tue", 25);
    (* Entry 26 -- register line 350. *)
    ( "26 III-class vigil", mk 2026 8 9,
      cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-lawrence-vigil",
      26 );
    (* Also worth doing: a NON-universal-layer Class3 vigil must still be 26,
       not 23 -- pins entry 23's [not is_vigil] guard. *)
    ( "26 III-class vigil (non-base layer)", mk 2026 8 10,
      cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:"diocese-warsaw" "ef-local-patron-vigil",
      26 );
    (* Entry 27 -- register line 352: an otherwise-unoccupied IV-class
       Saturday. *)
    ( "27 Office of the BVM on Saturday", off 62,
      cand ~rank:V.Class4 "ef-time-after-pentecost-1-sat",
      27 );
    (* Entry 28 -- register line 352: the unqualified IV-class catch-all. *)
    ("28 IV-class feria", off 65, cand ~rank:V.Class4 "ef-time-after-pentecost-1-tue", 28);
    (* Not an RG 91 row at all: a I-class candidate marked as a vigil, which
       is not the Nativity or Pentecost (entries 5/9, the only I-class
       vigils the table names) and so has no entry to fall into. Proves the
       documented fallback -- not entry 11/12/13, which the [not is_vigil]
       guard exists specifically to keep this out of. *)
    ( "unclassified: I-class vigil outside Nativity/Pentecost", mk 2026 3 10,
      cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-mystery-vigil",
      PE.unclassified );
    (* Also worth doing: a temporal-origin Class1 candidate on a date none of
       entries 1/2/3/5/6/7/9/10 name. 15 Jul 2026 is a Wednesday, off=101
       from Easter -- clear of every Easter-relative window this module
       checks, and not one of the fixed dates either. Without the
       [not is_temporal] guard on entries 11-13, this would wrongly reach 12
       (its default layer, "temporal", is not [universal_layer] and does not
       carry [indult_prefix], so it reads as "proper" by the layer test
       alone -- precisely the bug the guard exists to prevent; see the
       [not is_temporal] guard's role in the entry-25 mutation test recorded
       in the task report). *)
    ("unclassified: I-class temporal candidate on an unnamed date", mk 2026 7 15, cand "ef-unnamed-day", PE.unclassified);
    (* RG 91's own vigil list (register lines 381-384) stops at III class --
       there is no IV-class vigil for entry 28's ferial catch-all to absorb. *)
    ( "unclassified: IV-class candidate marked as a vigil", mk 2026 6 20,
      cand ~rank:V.Class4 "ef-second-mystery-vigil", PE.unclassified )
  ]

(* Review finding 1's end-to-end proof: on a real Sunday landing on 2
   November, [Precedence.resolve] -- not just [band] in isolation -- observes
   the Sunday, not All Souls. This exercises the exact mechanism the finding
   named ("resolve observes the lowest band, so whenever 2 November falls on
   a Sunday, All Souls wins and the Sunday loses"), rather than only the
   integer [band] returns for the standalone row above. [disposition] and
   [admit] are stubs -- only [observed] is under test here. *)
let test_all_souls_yields_to_sunday () =
  let date = mk 2025 11 2 in
  let day_ctx = ctx date in
  let sunday =
    { P.cel =
        Cel.make ~slug:(S.of_string_exn "ef-time-after-pentecost-sunday-x") ~rank:V.Class2
          ~colour:Col.Green ~subject:Sub.Temporal ~layer:"temporal" ();
      origin = P.Temporal }
  in
  let all_souls = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls" in
  let rules =
    { P.band = (fun c cd -> PE.band c cd);
      disposition = (fun ~winner:_ ~loser:_ -> P.Omit);
      admit = (fun ~observed:_ cs -> cs) }
  in
  let resolution = P.resolve rules day_ctx ~temporal:sunday ~sanctoral:[ all_souls ] in
  Alcotest.(check string) "the Sunday is observed, not All Souls"
    "ef-time-after-pentecost-sunday-x"
    (S.to_string resolution.P.observed.P.cel.Cel.slug)

(* Task 8: [disposition] -- what happens to the day's LOSING candidate (RG
   92-95, 33, 94; register lines 316-325, 381-384). Table-driven like [band]'s
   own [cases] above, one row per rule, each checked against a description of
   which register clause it pins. [disposition] takes no context (see
   precedence.mli's [rules.disposition]), so "is the winner a Sunday" is read
   off the winner's own slug the same way [band] itself reads "is this a
   vigil" off the loser's -- see precedence_ef.ml's [sunday_marker]. *)

let string_of_disposition = function
  | P.Omit -> "Omit"
  | P.Commemorate P.Privileged -> "Commemorate(Privileged)"
  | P.Commemorate P.Ordinary -> "Commemorate(Ordinary)"
  | P.Transfer -> "Transfer"
  | P.Repose -> "Repose"

(* A II-class ordinary Sunday, built the same way [test_all_souls_yields_to_sunday]
   builds its Sunday -- a hand-typed slug matching temporal_ef.ml's own
   "ef-<season>-sunday-<n>" convention, since [disposition] only ever reads
   this string, never the real computed date. *)
let an_ordinary_sunday =
  cand ~rank:V.Class2 "ef-time-after-pentecost-sunday-11"

let disposition_cases =
  [ (* RG 95 -- register line 323-325: only I-class feasts transfer; a
       II-class feast loses to a I-class day and is COMMEMORATED, not
       transferred. Paired with the next row (a I-class loser, same shape of
       winner) so the discriminating factor is provably the LOSER's own
       rank, not the winner's -- the brief's explicit "one without the other
       proves nothing" pairing. *)
    ( "RG95 II-class feast loses to I-class day -> Commemorate",
      cand "ef-nativity",
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "ef-some-saint",
      "Commemorate(Ordinary)" );
    ( "RG95 I-class feast loses to a higher I-class day -> Transfer",
      cand "ef-nativity",
      cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-local-i-class-feast",
      "Transfer" );
    (* RG 33 -- register line 383-384: a I/II-class vigil impeded by any
       Sunday or a I-class feast is entirely OMITTED, not commemorated. The
       vigil is sourced from [Temporal_ef.temporal]'s own real output (as
       [of_temporal]'s existing callers above do), not a hand-typed
       "ef-ascension-vigil", so a drift in temporal_ef's vigil-slug
       convention cannot silently defeat this row the way a duplicated
       literal could. This is the row the brief singles out as most likely
       to pass vacuously if the fallback below happened to already be
       [Omit] -- it is not: the fallback is [Commemorate] (see the next two
       rows), so this genuinely exercises RG 33's own branch. *)
    ( "RG33 II-class vigil loses to an ordinary Sunday -> Omit",
      an_ordinary_sunday,
      of_temporal (off 38) (* Ascension Vigil *),
      "Omit" );
    ( "RG33 II-class vigil loses to a I-class feast (non-Sunday) -> Omit",
      cand "ef-immaculate-conception",
      of_temporal (off 38),
      "Omit" );
    (* RG 33's own boundary, proved from both sides so the rule is shown to
       gate on the WINNER too, not "any vigil is always omitted": *)
    ( "RG33 boundary: vigil loses to an ordinary (non-Sunday, non-I-class) \
       II-class day -> Commemorate, NOT Omit",
      cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "ef-some-other-feast",
      of_temporal (off 38),
      "Commemorate(Ordinary)" );
    ( "RG33 boundary: a III-class vigil (outside RG33's I/II-class scope) \
       loses to a Sunday -> Commemorate, NOT Omit",
      an_ordinary_sunday,
      cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-lawrence-vigil",
      "Commemorate(Ordinary)" );
    (* Brief: a Commemoration_only loser is ALWAYS Commemorate -- checked
       here with a loser that ALSO carries a Class1 rank and a vigil-suffixed
       slug losing to a Sunday, so this row only passes if the
       Commemoration_only check is checked BEFORE both RG 33's omission and
       RG 95's transfer, not after. *)
    ( "Commemoration_only loser is always Commemorate, even if I-class and \
       vigil-shaped, even losing to a Sunday",
      an_ordinary_sunday,
      cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~layer:PE.universal_layer
        "ef-suppressed-vigil",
      "Commemorate(Ordinary)" );
    (* Totality: the lower ranks the RG 33/RG 95 branches never touch still
       reach the RG 95 "commemorated or omitted" branch, not an
       unhandled/exceptional case. *)
    ( "III-class feast loses to a I-class day -> Commemorate",
      cand "ef-nativity",
      cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-some-saint-3",
      "Commemorate(Ordinary)" );
    ( "IV-class feria loses to a II-class Sunday -> Commemorate",
      an_ordinary_sunday,
      cand ~rank:V.Class4 "ef-time-after-pentecost-1-sat",
      "Commemorate(Ordinary)" )
  ]

(* Completes Task 7's carried fix (register line 334): on a real Sunday
   landing on 2 November, All Souls does not merely lose (that was Task 7's
   [band] fix, proved by [test_all_souls_yields_to_sunday] above) -- it must
   be TRANSFERRED, not commemorated and not omitted. All Souls is I class
   (RG 91 entry 8's own [rank] field, untouched by the Sunday-exception band
   bump -- see precedence_ef.ml's comment on entry 8), so RG 95's rank
   condition alone should route it to [Transfer]. *)
let test_all_souls_disposition_is_transfer () =
  let sunday = an_ordinary_sunday in
  let all_souls = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls" in
  Alcotest.(check string) "All Souls loses to a Sunday and transfers"
    "Transfer"
    (string_of_disposition (PE.disposition ~winner:sunday ~loser:all_souls))

(* The same fact, proved end-to-end through [Precedence.resolve] with the
   REAL [PE.band] and REAL [PE.disposition] wired together (Task 7's own
   integration test above still stubs [disposition] to a constant [Omit],
   which is exactly what this task must not leave true) -- All Souls must
   land in [deferred], not [commemorations] or [omitted]. WHERE it is placed
   (3 November, RG 96) is [Rite.transfer_target]'s job, out of this task's
   scope; this only proves [resolve] hands it to the transfer path at all. *)
let test_all_souls_transfers_end_to_end () =
  let date = mk 2025 11 2 in
  let day_ctx = ctx date in
  let sunday =
    { P.cel =
        Cel.make ~slug:(S.of_string_exn "ef-time-after-pentecost-sunday-x") ~rank:V.Class2
          ~colour:Col.Green ~subject:Sub.Temporal ~layer:"temporal" ();
      origin = P.Temporal }
  in
  let all_souls = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls" in
  let rules = { P.band = PE.band; disposition = PE.disposition; admit = (fun ~observed:_ cs -> cs) } in
  let resolution = P.resolve rules day_ctx ~temporal:sunday ~sanctoral:[ all_souls ] in
  Alcotest.(check (list string)) "All Souls is deferred (transferred), not omitted or commemorated"
    [ "ef-all-souls" ]
    (List.map (fun c -> S.to_string c.P.cel.Cel.slug) resolution.P.deferred);
  Alcotest.(check int) "nothing commemorated" 0 (List.length resolution.P.commemorations);
  Alcotest.(check int) "nothing omitted" 0 (List.length resolution.P.omitted)

let suite =
  ( "Precedence_ef",
    List.map
      (fun (desc, date, c, expect) ->
        Alcotest.test_case desc `Quick (fun () ->
            Alcotest.(check int) desc expect (PE.band (ctx date) c)))
      cases
    @ [ Alcotest.test_case "8 All Souls yields to a Sunday (resolve-level)" `Quick
          test_all_souls_yields_to_sunday ]
    @ List.map
        (fun (desc, winner, loser, expect) ->
          Alcotest.test_case desc `Quick (fun () ->
              Alcotest.(check string) desc expect
                (string_of_disposition (PE.disposition ~winner ~loser))))
        disposition_cases
    @ [ Alcotest.test_case "All Souls disposition is Transfer" `Quick
          test_all_souls_disposition_is_transfer;
        Alcotest.test_case "All Souls transfers end-to-end (resolve, real rules)" `Quick
          test_all_souls_transfers_end_to_end ] )