summaryrefslogtreecommitdiff
path: root/test/test_rite_ef.ml
blob: 45aa9188b8f0ca04735a7a2628a0aa5ad36d7cb0 (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
(* Coordinator review (Task 11 fix round): integration tests wiring
   [Rite_ef.context] together with the REAL data/ef/sanctoral.sexp +
   data/ef/adjustments.sexp through [Colitur_kernel.Calendar] -- the same
   pipeline `colitur day` uses, proven here at the OCaml level so these
   properties are pinned by the test suite, not merely observable in CLI
   output (which is exactly what finding 3 flagged: with the overlay's one
   directive replaced by [()], `colitur day 2026`'s stdout is byte-identical
   for all 365 days, since [ef-nativity-vigil] always outranks
   [vigil-of-christmas] regardless -- the suppression's only observable
   effect is on [Liturgical_day.omitted], which no cram assertion reads). *)

module Cal = Colitur_kernel.Calendar
module Layer = Colitur_kernel.Layer
module Overlay = Colitur_kernel.Overlay
module LD = Colitur_kernel.Liturgical_day
module Slug = Colitur_kernel.Slug
module Date = Colitur_kernel.Date
module Date_spec = Colitur_kernel.Date_spec
module Cel = Colitur_kernel.Celebration
module Colour = Colitur_kernel.Colour
module Subject = Colitur_kernel.Subject
module P = Colitur_kernel.Precedence
module Comp = Colitur_kernel.Computus
module V = Rite_ef.Vocab_ef
module T = Rite_ef.Temporal_ef
module PE = Rite_ef.Precedence_ef

(* Relative to this test's own build directory (_build/default/test/), same
   convention test_sanctoral_ef.ml uses -- test/dune declares both as deps
   of the (test ...) stanza. *)
let sanctoral_path = "../data/ef/sanctoral.sexp"
let adjustments_path = "../data/ef/adjustments.sexp"

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

let real_layer () =
  let layer =
    match Layer.load V.rank_of_sexp sanctoral_path with
    | Ok l -> l
    | Error e -> Alcotest.failf "%s: failed to load: %s" sanctoral_path e
  in
  let overlay =
    match Overlay.load V.rank_of_sexp adjustments_path with
    | Ok o -> o
    | Error e -> Alcotest.failf "%s: failed to load: %s" adjustments_path e
  in
  let layer, diagnostics = Overlay.apply layer overlay in
  Alcotest.(check (list string)) "the committed overlay applies cleanly, no diagnostics" []
    (List.map Overlay.diagnostic_to_string diagnostics);
  layer

let slug_of (c : V.rank Cel.t) = Slug.to_string c.Cel.slug

(* Finding 3: the suppression's ONLY observable effect is on 24 December's
   [omitted] (and, belt-and-braces, on every OTHER field of every OTHER day
   too -- vigil-of-christmas must not surface anywhere at all, since the
   overlay removes it from the layer before resolution ever runs, unlike an
   ordinary occurrence loss). Mutation-verified (see the task report):
   substituting `(directives ())` for the real overlay makes this test's
   first assertion fail (24 December's [omitted] gains
   "vigil-of-christmas"), and confirmed the whole cram suite (test/cli.t)
   still passes under that same mutation -- so this closes the "removing
   the deliverable breaks no test" gap the coordinator flagged, which cram
   alone structurally cannot. *)
let test_vigil_of_christmas_suppressed () =
  let layer = real_layer () in
  let days = Cal.year Rite_ef.context layer 2026 in
  let christmas_eve =
    Array.to_list days |> List.find (fun d -> Date.compare d.LD.date (mk 2026 12 24) = 0)
  in
  Alcotest.(check string) "ef-nativity-vigil is still the observed day" "ef-nativity-vigil"
    (slug_of christmas_eve.LD.observed);
  Alcotest.(check (list string))
    "24 December has nothing omitted -- vigil-of-christmas never enters the RG91 contest at all"
    []
    (List.map (fun (c, _) -> slug_of c) christmas_eve.LD.omitted);
  let appears_anywhere =
    Array.to_list days
    |> List.exists (fun d ->
           let is_it c = slug_of c = "vigil-of-christmas" in
           is_it d.LD.observed
           || List.exists (fun (c, _) -> is_it c) d.LD.commemorations
           || List.exists (fun (c, _) -> is_it c) d.LD.omitted
           || (match d.LD.transferred_in with Some c -> is_it c | None -> false)
           || List.exists (fun (c, _) -> is_it c) d.LD.transferred_out)
  in
  Alcotest.(check bool) "vigil-of-christmas appears NOWHERE in the resolved year" false
    appears_anywhere

(* RG16(a) task (register §6.0 + the sanctoral subject audit, this task's
   own report). data/ef/sanctoral.sexp is a faithful mirror of lectio, not
   hand-patched (see test_sanctoral_ef.ml's own "explicit class = lord"
   spot-check, which loads that file WITHOUT applying this overlay and
   still passes) -- the one live correction, `most-holy-name-of-mary` ->
   `Bvm`, lives entirely in data/ef/adjustments.sexp's [Edit] directive.

   CORRECTED, ef-rebootstrap (2026-08-12): this comment previously said the
   base file itself carries `subject Lord` for `most-holy-name-of-mary`,
   which this overlay then corrects. That stopped being true the moment
   lectio's own generator was fixed (source SHA-256 1b303ef2...): the
   regenerated INI dropped `class = lord` for that slug outright, so the
   base bootstrap now already produces `Saint`, and this overlay's [Edit]
   forces `Saint -> Bvm` instead of `Lord -> Bvm` -- same final answer,
   different starting point. See adjustments.sexp's own "ef-rebootstrap"
   note for why the directive is kept anyway (data precision + a
   regression guard against `class = lord` being reintroduced upstream).

   CORRECTED, fix round 1 (CRITICAL finding, item 1): this test used to
   also assert `purification-of-the-blessed-virgin-mary` was retagged
   `Bvm`. REVERTED -- the user has ruled: follow the oracle (register §6.0
   has the full account, both the calendarium argument for `Bvm` and the
   oracle evidence against it). The Purification is `Lord` again, one of
   FIVE untouched entries this test now re-verifies, not four.

   This is the ONE test in the suite that observes the one live retag
   actually takes effect through the real pipeline -- without it, a typo in
   the directive's slug (silently a no-op diagnostic, not a hard failure --
   Overlay.apply's own contract) or a reverted adjustments.sexp would leave
   every other test green ([test_sanctoral_ef.ml] deliberately never
   applies the overlay). Also re-verifies the five entries the audit
   confirmed correct and left untouched, so a future accidental retag of
   one of THEM would be caught here too, not just the one that changed. *)
let test_rg16a_subject_retag_in_effect () =
  let layer = real_layer () in
  let subject_of slug =
    match Layer.find layer (Slug.of_string_exn slug) with
    | Some e -> e.Layer.cel.Cel.subject
    | None -> Alcotest.failf "slug %s not found in the overlaid layer" slug
  in
  Alcotest.(check bool) "Most Holy Name of Mary retagged Bvm (calendarium: \"Nominis Mariae\")" true
    (subject_of "most-holy-name-of-mary" = Subject.Bvm);
  Alcotest.(check bool)
    "Purification stays Lord (reverted, fix round 1 -- the user's ruling: follow the oracle)" true
    (subject_of "purification-of-the-blessed-virgin-mary" = Subject.Lord);
  Alcotest.(check bool) "Precious Blood stays Lord (calendarium: \"D. N. I. C.\") -- audit, not touched" true
    (subject_of "precious-blood-of-our-lord-jesus-christ" = Subject.Lord);
  Alcotest.(check bool) "Transfiguration stays Lord (calendarium: \"D. N. I. C.\") -- audit, not touched" true
    (subject_of "transfiguration-of-our-lord" = Subject.Lord);
  Alcotest.(check bool) "Exaltation of the Holy Cross stays Lord -- audit, not touched" true
    (subject_of "exaltation-of-the-holy-cross" = Subject.Lord);
  Alcotest.(check bool)
    "Dedication of the Archbasilica of the Most Holy Saviour stays Lord -- audit, not touched" true
    (subject_of "dedication-of-the-archbasilica-of-our-holy-savior" = Subject.Lord)

(* Coordinator review, finding 2, reproduced through the project's OWN
   extension path (an overlay), the same way the reviewer found it: adding
   an I-class feast on 25 December (competing against, and losing to, the
   real Nativity) forces an RG 96 search starting 26 December -- which, with
   the real sanctoral data (Stephen/John/the Innocents, all II class) plus
   the temporal cycle's own Nativity-octave-day entries (29-31 Dec, also II
   class), is blocking every single day through 31 December 9999. Before the
   domain-ceiling fix this raised (Computus: year 10000 out of range);
   confirmed by mutation-testing at the precedence_ef.ml unit level (see the
   task report) -- this is the same defect reproduced end to end, through
   Calendar, with real data, exactly as the review found it. *)
let test_transfer_search_does_not_raise_at_domain_ceiling () =
  let layer = real_layer () in
  let impeding_entry : V.rank Layer.entry =
    { Layer.date = (match Date_spec.fixed ~month:12 ~day:25 with Ok d -> d | Error e -> failwith e);
      cel =
        Cel.make ~slug:(Slug.of_string_exn "test-domain-ceiling-impeder") ~rank:V.Class1
          ~colour:Colour.White ~layer:Rite_ef.Precedence_ef.universal_layer () }
  in
  let overlay : V.rank Overlay.t =
    { Overlay.id = "test-domain-ceiling"; directives = [ Overlay.Add impeding_entry ] }
  in
  let layer, _diagnostics = Overlay.apply layer overlay in
  (* Must not raise -- the whole point of the fix. *)
  let days = Cal.year Rite_ef.context layer 9999 in
  Alcotest.(check bool) "year 9999 resolves without raising, even with an impeded Christmas Day"
    true (Array.length days > 0);
  let impeder_placed_or_recorded =
    Array.to_list days
    |> List.exists (fun d ->
           slug_of d.LD.observed = "test-domain-ceiling-impeder"
           || List.exists (fun (c, _) -> slug_of c = "test-domain-ceiling-impeder") d.LD.omitted)
  in
  Alcotest.(check bool) "the impeding candidate is accounted for (observed somewhere, or omitted \
                         with a recorded reason) -- never silently dropped"
    true impeder_placed_or_recorded

(* Task 11's re-review finding, closed here: [Precedence_ef.transfer_target]'s
   Annunciation/Easter condition (and, more generally, RG 96's whole "not I or
   II class" test) is correct only because [Temporal_ef] happens to make
   every day from Easter Sunday through Low Sunday (Easter+0..+7) blocking --
   Easter itself and Low Sunday via [named], every day between via
   [privileged_feria]. Nothing in the type system enforces that; a future
   edit narrowing [privileged_feria]'s Easter-octave range would silently let
   the RG 96 walk land a translated feast inside the octave. This tests the
   CONSEQUENCE (no day in that window ever receives one), not the mechanism
   ([privileged_feria] itself), so it stays sensitive to any way that
   consequence could break, not only the one code path that currently
   protects it. *)

let easter_offset (d : Date.t) =
  let easter = Comp.gregorian_easter (Date.year d) in
  Date.to_rata d - Date.to_rata easter

let in_easter_octave d = let off = easter_offset d in off >= 0 && off <= 7

(* 2005-2050: the project's own differential-testing window (CLAUDE.md),
   reused here as a deterministic, non-trivial sample -- 46 liturgical years,
   each with several genuine transfers (All Souls onto a Sunday, impeded
   universal feasts, and so on; test_precedence_ef.ml's own 21k-day manual
   review already confirmed "all 7 slugs that ever transfer have a verified
   rubrical cause" over a similar span), so this is not a vacuous sweep over
   years where nothing ever transfers. *)
let sample_years =
  let rec range a b = if a > b then [] else a :: range (a + 1) b in
  range 2005 2050

(* Property 1: no day in the resolved output, across the whole sample, is
   EVER a transfer's landing point inside [Easter, Easter+7] -- checked two
   ways. [transferred_out]'s own recorded target is what [transfer_target]
   itself returned (calendar.ml's [assignment], stored verbatim), so this is
   the more direct signal; [transferred_in] is also checked, in case some
   future Calendar change ever let the two disagree. *)
let test_no_transfer_lands_in_easter_octave () =
  let layer = real_layer () in
  let violations = ref [] in
  List.iter
    (fun y ->
      let days = Cal.year Rite_ef.context layer y in
      Array.iter
        (fun (d : (V.season, V.rank) LD.t) ->
          (match d.LD.transferred_in with
           | Some c when in_easter_octave d.LD.date ->
               violations :=
                 (Printf.sprintf "%s transferred_in on %s (Easter+%d)" (slug_of c)
                    (Date.to_iso8601 d.LD.date) (easter_offset d.LD.date))
                 :: !violations
           | _ -> ());
          List.iter
            (fun (c, target) ->
              if in_easter_octave target then
                violations :=
                  Printf.sprintf "%s transferred_out from %s to %s (Easter+%d)" (slug_of c)
                    (Date.to_iso8601 d.LD.date) (Date.to_iso8601 target) (easter_offset target)
                  :: !violations)
            d.LD.transferred_out)
        days)
    sample_years;
  Alcotest.(check (list string))
    "no day in [Easter, Easter+7] is ever a transfer's target, 2005-2050" [] (List.rev !violations)

(* Property 2, and the LIVE case: [PE.transfer_target] called directly, with
   an origin that genuinely starts the search INSIDE Holy Week -- Holy
   Thursday 2026 (Easter - 3), a date no real sanctoral entry in
   data/ef/sanctoral.sexp occupies (Holy Week carries none), so this is
   deliberately constructed, not found. [occupant] is the REAL
   [Temporal_ef.temporal] (not a synthetic stand-in), so the search is driven
   by the actual blocking shape [privileged_feria] produces, not a
   hand-picked one -- this is genuinely live: search_from walks origin+1
   (Good Friday, Easter-2) forward through every remaining day of Holy Week,
   all of Easter through Low Sunday (Easter+0..+7, all Class1), and only
   stops at Easter+8 (the Monday after Low Sunday), which [named] and
   [ferial_rank]/[privileged_feria] agree is Class4 -- confirmed below by
   checking the OCCUPANT's own rank there, not asserted blind. Without an
   origin inside the window itself, [search_from] could stop before ever
   reaching it and this test would prove nothing (the vacuity trap the task
   brief names explicitly) -- [test_search_genuinely_enters_the_window] pins
   that it does not stop early. *)
let holy_week_origin_2026 =
  let easter_2026 = Comp.gregorian_easter 2026 in
  Date.add_days easter_2026 (-3)

let real_occupant d = (T.temporal d).Colitur_kernel.Temporal.office

let test_transfer_target_skips_the_whole_easter_octave () =
  let easter_2026 = Comp.gregorian_easter 2026 in
  let c =
    { P.cel =
        Cel.make ~slug:(Slug.of_string_exn "test-impeded-in-holy-week") ~rank:V.Class1
          ~colour:Colour.White ~subject:Subject.Temporal ~layer:PE.universal_layer ();
      origin = P.Sanctoral }
  in
  let target = PE.transfer_target c holy_week_origin_2026 real_occupant in
  Alcotest.(check string) "lands on Easter + 8 (Monday after Low Sunday), past the entire octave"
    (Date.to_iso8601 (Date.add_days easter_2026 8)) (Date.to_iso8601 target);
  Alcotest.(check bool) "strictly after origin (rite.mli's own obligation)" true
    (Date.compare target holy_week_origin_2026 > 0);
  Alcotest.(check bool) "not inside [Easter, Easter+7]" false
    (let off = Date.to_rata target - Date.to_rata easter_2026 in
     off >= 0 && off <= 7)

(* The vacuity check itself: proves the search genuinely walked THROUGH the
   window rather than [search_from] having some other reason to stop before
   it (e.g. an off-by-one that happened to also land past the octave). Reads
   the real occupant's own rank at every day from the origin through
   Easter+7 and requires every one of them to be blocking (Class1 or
   Class2) -- if any single one were not, [search_from] would have stopped
   there instead of at Easter+8, and the test above would be passing for the
   wrong reason. *)
let test_search_genuinely_enters_the_window () =
  let easter_2026 = Comp.gregorian_easter 2026 in
  let rec days_from a b = if Date.compare a b > 0 then [] else a :: days_from (Date.add_days a 1) b in
  let walked = days_from (Date.add_days holy_week_origin_2026 1) (Date.add_days easter_2026 7) in
  Alcotest.(check bool)
    "every day from origin+1 through Easter+7 (the whole span search_from must cross) is blocking"
    true
    (List.for_all
       (fun d ->
         match (real_occupant d).Cel.rank with V.Class1 | V.Class2 -> true | V.Class3 | V.Class4 -> false)
       walked);
  Alcotest.(check bool)
    "the walked span is at least 8 days -- the octave alone (Easter..Easter+7), not a one-day hop"
    true (List.length walked >= 8)

(* Task B fix round 1 (coordinator finding 1), end to end with REAL data:
   22 September 2027 is a September Ember Wednesday -- the OBSERVED day is
   the Ember office itself ([band] entry 18, RG 91, beats both sanctoral
   candidates), and RG 111(c) ("other II class: one") admits exactly ONE
   of the two competing losers, "maurice-and-companions-martyrs"
   ([Commemoration_only], Class3) and "thomas-of-villanova" ([Feast],
   Class3). Before the fix, [Precedence_ef.band] gave Maurice the SAME
   table entry (24) as Thomas, so the residual slug tie-break picked
   Maurice ('m' < 't') and OMITTED Thomas -- contrary to the calendarium's
   own 22 September row ("S. Thomae de Villanova Ep. et Conf., III
   classis. / Commemoratio Ss. Mauritii et Soc. Mm.": Thomas is the one
   WITH a table entry) and to missalemeum (test/test_oracle.ml's own
   former M17 allow-list entry -- oracle "commemorations": Thomas,
   "displaced": Maurice -- deleted once this fix made the divergence
   disappear). This is the SAME date the oracle harness itself checks,
   pinned here too so a regression shows up at the OCaml level, not only
   via the fixture comparison. *)
let test_maurice_thomas_band_fidelity_end_to_end () =
  let layer = real_layer () in
  (* [Calendar.year]'s own liturgical-year convention (Advent-anchored, see
     calendar.ml's [year_bounds]): the label [y] spans December of civil
     year [y] through November of civil year [y+1], so 22 September 2027
     is reached via [Cal.year ... 2026], not 2027 -- the same
     [y-1]/straddling indexing test_oracle.ml's own header comment already
     documents for exactly this reason. *)
  let days = Cal.year Rite_ef.context layer 2026 in
  let day = Array.to_list days |> List.find (fun d -> Date.compare d.LD.date (mk 2027 9 22) = 0) in
  Alcotest.(check string) "the September Ember Wednesday office itself is observed (band entry 18)"
    "ef-september-ember-wed" (slug_of day.LD.observed);
  Alcotest.(check (list string))
    "St Thomas of Villanova (band entry 24, a real table row) is the sole commemoration"
    [ "thomas-of-villanova" ]
    (List.map (fun (c, _) -> slug_of c) day.LD.commemorations);
  Alcotest.(check (list string))
    "St Maurice and Companions (Commemoration_only, unclassified -- no table row) is omitted, not \
     commemorated"
    [ "maurice-and-companions-martyrs" ]
    (List.map (fun (c, _) -> slug_of c) day.LD.omitted)

let suite =
  ( "Rite_ef (real data: overlay-in-effect, domain-ceiling)",
    [ Alcotest.test_case "the overlay suppression is observably in effect" `Quick
        test_vigil_of_christmas_suppressed;
      Alcotest.test_case "RG16(a) task: the two subject retags are in effect, the other four untouched"
        `Quick test_rg16a_subject_retag_in_effect;
      Alcotest.test_case "RG96 search does not raise at the domain ceiling (real data)" `Quick
        test_transfer_search_does_not_raise_at_domain_ceiling;
      Alcotest.test_case "no transfer ever lands inside [Easter, Easter+7], 2005-2050" `Quick
        test_no_transfer_lands_in_easter_octave;
      Alcotest.test_case "transfer_target skips the whole Easter octave from inside Holy Week" `Quick
        test_transfer_target_skips_the_whole_easter_octave;
      Alcotest.test_case "the search genuinely enters the window (not vacuous)" `Quick
        test_search_genuinely_enters_the_window;
      Alcotest.test_case
        "band fidelity end-to-end: Ember Wednesday observed, Thomas of Villanova the sole \
         commemoration, Maurice omitted (2027-09-22)" `Quick
        test_maurice_thomas_band_fidelity_end_to_end ] )