aboutsummaryrefslogtreecommitdiff
path: root/test/test_rite_ef.ml
blob: ad9057baaa0c7b282e7053879107fb8e408f70d8 (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
(* 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 left untouched (still a faithful
   mirror of lectio, subject `Lord` for both entries below -- see
   test_sanctoral_ef.ml's own "explicit class = lord" spot-check, which
   loads that file WITHOUT applying this overlay and still passes); the
   correction lives entirely in data/ef/adjustments.sexp's two [Edit]
   directives, the same mechanism [test_vigil_of_christmas_suppressed]
   above already proves for a sibling bootstrap defect. This is the ONE
   test in the suite that observes the two retags actually take effect
   through the real pipeline -- without it, a typo in either 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 (2 February isn't a Sunday in any golden-pinned year, and
   [test_sanctoral_ef.ml] deliberately never applies the overlay). Also
   re-verifies the four 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 two 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) "Purification retagged Bvm (calendarium: \"B. MARIAE VIRG.\")" true
    (subject_of "purification-of-the-blessed-virgin-mary" = Subject.Bvm);
  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) "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)

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 ] )