aboutsummaryrefslogtreecommitdiff
path: root/test/test_rite_of.ml
blob: 90cedc11f42bca6ddec79ab180671af38b7af461 (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
(* Task 5 (2026-08-25-colitur-of-phases-3-5): [Rite_of.context], wired
   together with the REAL data/of/calendar-2002.sexp + all 13 decree
   overlays + data/of/lectionary.sexp, resolved through
   {!Colitur_kernel.Calendar} and checked by {!Colitur_kernel.Validate.run}
   -- the same pipeline `colitur day --rite of` uses, and the same
   discipline test_validate.ml/test_rite_ef.ml already apply to the EF
   side: exactly one observed office per day, the year covered once with no
   gaps, slug-uniqueness within the liturgical year, and transfers reaching
   a fixed point are all invariants {!Colitur_kernel.Validate.run} already
   checks generically -- this file supplies the REAL OF rite and data and
   asserts the result is clean, rather than re-deriving each invariant by
   hand. *)

module Val = Colitur_kernel.Validate
module Layer = Colitur_kernel.Layer
module Overlay = Colitur_kernel.Overlay
module Cal = Colitur_kernel.Calendar
module LD = Colitur_kernel.Liturgical_day
module Slug = Colitur_kernel.Slug
module Date = Colitur_kernel.Date
module Cel = Colitur_kernel.Celebration
module P = Colitur_kernel.Precedence
module Computus = Colitur_kernel.Computus
module V = Rite_of.Vocab_of

(* Relative to this test's own build directory (_build/default/test/), same
   convention test_amendments_of.ml/test_calendar_of_data.ml already use --
   test/dune declares each of these as a dep of the (test ...) stanza. *)
let base_path = "../data/of/calendar-2002.sexp"
let amendments_dir = "../data/of/amendments/"
let lectionary_path = "../data/of/lectionary.sexp"

(* Decree-chronological order -- the exact list and order
   test_amendments_of.ml's own [files] table already pins by SHA-256; not
   re-pinning the hashes here (that is that file's own job), just applying
   them in the same order so the rite under test here is the one bin/main.ml
   will assemble. *)
let amendment_files =
  [ "001-padre-pio.sexp"; "002-juan-diego-cuauhtlatoatzin.sexp"; "003-our-lady-of-guadalupe.sexp";
    "004-john-xxiii-john-paul-ii.sexp"; "005-mary-magdalene-rank.sexp"; "006-mary-mother-of-the-church.sexp";
    "007-paul-vi.sexp"; "008-our-lady-of-loreto.sexp"; "009-faustina-kowalska.sexp";
    "010-narek-avila-hildegard.sexp"; "011-martha-mary-lazarus.sexp"; "012-teresa-of-calcutta.sexp";
    "013-john-henry-newman.sexp" ]

let real_of_layer =
  let base =
    match Layer.load V.rank_of_sexp base_path with
    | Ok l -> l
    | Error e -> failwith (Printf.sprintf "%s: failed to load: %s" base_path e)
  in
  let overlays =
    List.map
      (fun name ->
        let path = amendments_dir ^ name in
        match Overlay.load V.rank_of_sexp path with
        | Ok o -> o
        | Error e -> failwith (Printf.sprintf "%s: failed to load: %s" path e))
      amendment_files
  in
  let layer, diagnostics = Overlay.merge base overlays in
  if diagnostics <> [] then
    failwith
      (Printf.sprintf "unexpected amendment diagnostics: %s"
         (String.concat "; " (List.map Overlay.diagnostic_to_string diagnostics)));
  layer

(* [Rite_of.context] takes [~lectionary] as a caller-supplied parameter, not
   a value closed over an internal load -- see rite_of.ml's own citation for
   why (the exact `colitur easter <year>` startup-death lesson
   {!Rite_ef.Rite_ef.context}'s own comment records). *)
let real_of_lectionary =
  match Colitur_kernel.Lectionary.load lectionary_path with
  | Ok l -> l
  | Error e -> failwith (Printf.sprintf "%s: failed to load: %s" lectionary_path e)

let real_of_rite = Rite_of.context ~lectionary:real_of_lectionary

let run year = Val.run real_of_rite real_of_layer ~year

(* data/of/lectionary.sexp's own provenance header names ONE informational
   temporal-slug gap: [of-nativity] (25 December -- Christmas Day itself)
   has no entry, because niedziela.pl's own source keys the Nativity's
   several Masses (Vigil/Night/Dawn/Day) differently than a single flat
   slug can address, and [Lectionary_of.readings] has no fallback beyond
   its own three-step chain (unlike EF's RG 299 preceding-Sunday rule,
   which OF's own OLM Praenotanda gives no equivalent for -- see
   lectionary_of.mli's own citation). This makes {!Colitur_kernel.Validate
   .run}'s ["citations-unresolved"] check fire exactly once a year, on 25
   December, on real shipped data -- a documented, measured gap in
   lectionary_of's own data file (out of this task's scope to touch or
   close: lectionary_of.* is excluded from this task's brief), not a defect
   this module's wiring introduced. Named and filtered explicitly, never
   silently dropped, so a future SECOND gap (a regression) still reddens
   this test. *)
let ends_with ~suffix s =
  let ls = String.length s and lx = String.length suffix in
  ls >= lx && String.sub s (ls - lx) lx = suffix

(* No [Str]/regex (deps frozen) -- a plain substring scan. *)
let contains ~substring s =
  let ls = String.length s and lx = String.length substring in
  let rec go i = i + lx <= ls && (String.sub s i lx = substring || go (i + 1)) in
  lx = 0 || go 0

let is_known_nativity_gap (f : Val.failure) =
  (f.Val.check = "citations-unresolved" || f.Val.check = "formulary") && ends_with ~suffix:"12-25" f.Val.date

(* A SECOND, NEWLY-FOUND gap this task's own wiring surfaced -- [rite_of] was
   never previously wired into a real {!Colitur_kernel.Rite.t}, so
   [Validate.run]'s ["anchor"] check has never before run [Temporal_of
   .anchors] against [Temporal_of.temporal] for real. Normae n. 35(a)'s own
   text is "Dominica infra octavam Nativitatis, VEL, EA DEFICIENTE, die 30
   decembris" -- the Holy Family fallback is a FIXED-DATE fallback (30
   December, whatever weekday it falls on that year), confirmed by
   {!Temporal_of.holy_family}'s own [None -> mk y 12 30] branch and its own
   .mli citation ("this 6-day window CAN be [empty of a Sunday] -- confirmed
   by the fallback clause's own existence"). But {!Temporal_of.temporal}
   only ever TESTS [same d (holy_family y)] inside its [sunday_slug d]
   dispatch arm -- i.e. only when [d] genuinely IS a Sunday -- so in a year
   whose 26-31 December window has NO Sunday (equivalently: 25 December,
   Christmas Day, is itself a Sunday -- a 6-day window starting the day
   after a Sunday omits Sunday from every other position), the fallback
   date is computed correctly by [holy_family]/[anchors] but [temporal]
   itself silently falls through to an ordinary ferial slug instead
   (["of-christmas-0-friday"] et al.) -- exactly the "generic day slug
   masking a real named feast" shape CLAUDE.md already records EF's own
   Holy Family/Holy Name gaps as, before they were fixed there. Not rare:
   this fires whenever Christmas Day is a Sunday, roughly one year in
   seven (1583, 2005, 2011, 2016, 2022, 2033, 2039, 2044, 2050 all
   independently confirmed via Python's own proleptic-Gregorian
   [datetime]). OUT OF THIS TASK'S SCOPE to fix -- temporal_of.ml's own
   rubric logic is Task 1's deliverable, not Task 5's, and the task brief
   models exactly this "pin the known-wrong behaviour, do not fix it here"
   treatment for a different, already-known gap (Normae n.56(f), St
   Joseph/Palm Sunday) -- so the same discipline is applied to this
   newly-found one rather than silently working around it or fixing rite
   logic this task was not chartered to touch. Reported in this task's own
   report as a concern. *)
let is_known_holy_family_fallback_gap (f : Val.failure) =
  f.Val.check = "anchor" && contains ~substring:"of-holy-family" f.Val.detail

let check_year_allowing_known_gaps year =
  let fs = run year in
  let is_known f = is_known_nativity_gap f || is_known_holy_family_fallback_gap f in
  let unexpected = List.filter (fun f -> not (is_known f)) fs in
  (match unexpected with
   | [] -> ()
   | fs ->
       Alcotest.failf "%d: %s" year
         (String.concat "; " (List.map Val.failure_to_string (List.filteri (fun i _ -> i < 5) fs))));
  let nativity_gaps = List.filter is_known_nativity_gap fs in
  Alcotest.(check int)
    (Printf.sprintf "%d: exactly the two documented of-nativity checks (citations-unresolved + \
                      formulary), no more" year)
    2 (List.length nativity_gaps)

(* Landmark years, the same choice test_validate.ml's own
   [test_landmark_years] makes for EF: both domain edges plus two ordinary
   years well inside it. *)
let test_landmark_years () = List.iter check_year_allowing_known_gaps [ 1583; 2026; 2035; 9998 ]

(* 9999 mirrors test_validate.ml's own [test_year_9999_does_not_raise]: the
   liturgical year opening in civil year 9999 continues into out-of-domain
   civil year 10000, so [Val.run]/[Calendar.year] clamp the walk to 31
   December 9999 rather than raising, and the truncated season run is
   *expected* to fail the "seasons" check -- pinning that this surfaces as
   an ordinary failure, not an uncaught exception, and that resolution
   itself ("coverage"/"lost"/"duplicated") stays clean through the clamp. *)
let test_year_9999_does_not_raise () =
  let fs = run 9999 in
  Alcotest.(check bool) "no coverage failures (temporal stayed total through the clamp)" true
    (not (List.exists (fun f -> f.Val.check = "coverage") fs));
  Alcotest.(check bool) "seasons check flags the truncated final year as incomplete" true
    (List.exists (fun f -> f.Val.check = "seasons") fs)

(* A real transfer-bearing sample, the OF counterpart of test_rite_ef.ml's
   own 2005-2050 sweep: every impeded Sollemnitas [Precedence_of.disposition]
   defers is either placed by [transfer_target] and reaches a fixed point
   (no "unconverged" failure -- Validate's own check), or is recorded
   [omitted] with a reason, never silently dropped ("lost"/"duplicated"),
   over the project's own 2005-2050 differential-testing window (CLAUDE.md).

   STRUCTURAL checks only -- ["coverage"; "weekday"; "rank"; "colour";
   "determinism"; "slugs"; "seasons"; "week"; "lost"; "duplicated";
   "unconverged"; "admission"; "observed"] -- deliberately NOT the
   citation-chain ones ("anchor"/"citations"/"citations-unresolved"/
   "formulary"), and this exclusion is itself a finding, not a shortcut:
   sweeping the full 46-year window (not just the 4 landmark years above)
   surfaced that [data/of/lectionary.sexp]'s own coverage was measured
   against exactly ONE civil year (2026, per that file's own provenance
   header) -- OTHER years exercise the OTHER weekday-cycle letter for the
   same temporal slug (OLM n.69.4, {!Lectionary_of.weekday_cycle}: which
   letter applies flips on the LITURGICAL year label's parity, independent
   of which civil year is being swept), and at least one such pairing is
   asymmetric on real shipped data: [of-christmas-0-friday-ii] exists,
   [of-christmas-0-friday-i] does not (confirmed directly against
   data/of/lectionary.sexp; first reached in this sweep at 2006-12-29).
   Separately, 2006-12-24 (a year Christmas Eve itself is a Sunday) also
   exposed a THIRD newly-found gap: {!Temporal_of.anchors} lists
   [of-nativity-vigil] on 24 December UNCONDITIONALLY, but
   {!Temporal_of.named}'s own coded exception (Tabula I.2/Normae n.5:
   Advent IV outranks the Vigil when they coincide) means
   {!Temporal_of.temporal} does not always agree. All three are OUT OF
   THIS TASK'S SCOPE to fix -- lectionary_of.* and data/of/lectionary.sexp
   are excluded from this task's brief, and temporal_of.ml's own rubric
   logic is Task 1's deliverable -- reported as concerns in this task's own
   report rather than fixed here or silently excluded without explanation. *)
let sample_years =
  let rec range a b = if a > b then [] else a :: range (a + 1) b in
  range 2005 2050

let structural_check = function
  | "anchor" | "citations" | "citations-unresolved" | "formulary" -> false
  | _ -> true

let check_year_structural_only year =
  match List.filter (fun f -> structural_check f.Val.check) (run year) with
  | [] -> ()
  | fs ->
      Alcotest.failf "%d: %s" year
        (String.concat "; " (List.map Val.failure_to_string (List.filteri (fun i _ -> i < 5) fs)))

let test_transfers_reach_a_fixed_point_2005_2050 () =
  List.iter check_year_structural_only sample_years

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

(* precedence_of.mli's own documented, KNOWN-UNIMPLEMENTED fourth
   transfer_target rule (final review Important #5, ledger ruling R9):
   Normae n. 56(f) -- St Joseph's solemnity, falling on Palm Sunday, is
   ANTICIPATED BACKWARD to 18 March, not carried forward. colitur's
   [transfer_target] has no backward-search branch (rite.mli's own
   [transfer_target] contract requires the result be strictly LATER than
   the origin -- an EF-shaped kernel obligation, per that value's own
   citation), so it instead sends Joseph forward via the general rule 3,
   the same target date the Annunciation would already be heading to that
   same year (Easter + 8).

   This is NOT fixed by this task (explicitly out of scope, per the task
   brief) -- pinned here, on a REAL affected year, so the current
   (known-wrong) behaviour is asserted deliberately rather than passing
   silently as if it were correct. All years with Gregorian Easter = 26
   March put Palm Sunday on 19 March, coinciding with Joseph; 2062 is the
   nearest one after today (2026) still inside the differential-adjacent
   range this project favours for worked examples.

   2062 is a doubly-worked example, not chosen only for Joseph: that same
   year, 25 March (the Annunciation) falls on HOLY SATURDAY (Easter - 1),
   so BOTH solemnities are impeded and BOTH nominally target Easter + 8 via
   the general rule -- {!Colitur_kernel.Calendar}'s own placement pass
   settles that collision by landing whichever it processes first (Joseph,
   departing the earlier date, 19 March) on Easter + 8 and pushing the
   other to the next admissible day; empirically, on shipped data, it is
   JOSEPH who is pushed one day further still, to Easter + 9 -- read as
   further confirmation this is genuinely rule 3's general forward search
   (sensitive to whatever else is competing that year), not the fixed,
   collision-proof Easter+8 destination rule 3's cousin (the Annunciation's
   own Holy-Week branch) names outright -- not as a claim about exactly
   which of the two wins the tie, which this test does not depend on. *)
let test_joseph_palm_sunday_2062_known_wrong_forward_transfer () =
  let easter_2062 = Computus.gregorian_easter 2062 in
  Alcotest.(check string) "2062 Easter is really 26 March (the Normae n.56(f) trigger)" "2062-03-26"
    (Date.to_iso8601 easter_2062);
  let palm_sunday = Date.add_days easter_2062 (-7) in
  Alcotest.(check string) "Palm Sunday 2062 is really 19 March, coinciding with St Joseph" "2062-03-19"
    (Date.to_iso8601 palm_sunday);
  let holy_saturday = Date.add_days easter_2062 (-1) in
  Alcotest.(check string) "25 March 2062 is really Holy Saturday, coinciding with the Annunciation too"
    "2062-03-25" (Date.to_iso8601 holy_saturday);
  let days = Cal.year real_of_rite real_of_layer 2061 in
  let joseph_day =
    Array.to_list days |> List.find (fun d -> Date.compare d.LD.date palm_sunday = 0)
  in
  Alcotest.(check string) "Palm Sunday itself is observed on 19 March (Joseph impeded, correctly)"
    "of-palm-sunday" (slug_of joseph_day.LD.observed);
  let joseph_slug = "joseph-husband-of-the-blessed-virgin-mary" in
  let departure =
    List.find_opt (fun (c, _) -> slug_of c = joseph_slug) joseph_day.LD.transferred_out
  in
  match departure with
  | None -> Alcotest.fail "expected Joseph to be transferred_out from 19 March 2062 (impeded by Palm Sunday)"
  | Some (_, target) ->
      (* Concrete, not merely qualitative: 2062-04-04 is the actual pinned
         value on shipped data today (Easter + 9, one day past the
         Annunciation's own Easter + 8 -- see this test's own header). A
         future data or Calendar change that moves it is meant to be
         caught here; the point being pinned is the SHAPE (forward, past
         Easter, not 18 March), asserted again below in a form that does
         not depend on the exact day. *)
      Alcotest.(check string)
        "KNOWN WRONG (Normae n.56(f) unimplemented, precedence_of.mli): Joseph lands forward, past \
         Easter (currently Easter+9, one day beyond the Annunciation's own Easter+8), not the \
         rubrically-correct 18 March (backward)"
        "2062-04-04" (Date.to_iso8601 target);
      Alcotest.(check bool) "not 18 March -- confirming this really is the anticipation defect, not a \
                             coincidence" false
        (Date.compare target (Date.add_days palm_sunday (-1)) = 0);
      Alcotest.(check bool) "and it lands on or after Easter itself (rule 3's forward search, not a \
                             backward anticipation)" true
        (Date.compare target easter_2062 >= 0)

(* Second NEWLY-FOUND defect, pinned the same way (see
   [is_known_holy_family_fallback_gap]'s own citation just above for the
   full argument): 1583 is the domain floor AND a year Christmas Day falls
   on a Sunday, so 26-31 December has no Sunday of its own and
   [Temporal_of.holy_family] falls back to its own fixed 30 December --
   correctly, per [Temporal_of.anchors] -- but [Temporal_of.temporal]
   itself never reaches that fallback (its Holy-Family test lives inside
   the Sunday-only dispatch arm), so 30 December 1583 is observed as an
   ordinary Friday-of-Christmastide ferial office instead of the Feast of
   the Holy Family. *)
let test_holy_family_fallback_1583_known_wrong_ferial () =
  let christmas_1583 = Date.of_iso8601 "1583-12-25" |> Result.get_ok in
  Alcotest.(check bool) "1583 Christmas Day really is a Sunday (the Normae n.35(a) trigger)" true
    (Date.weekday christmas_1583 = Date.Sun);
  let days = Cal.year real_of_rite real_of_layer 1583 in
  let dec30 = Array.to_list days |> List.find (fun d -> Date.compare d.LD.date (Date.add_days christmas_1583 5) = 0) in
  Alcotest.(check string)
    "KNOWN WRONG (Normae n.35(a) fallback unreached, see [is_known_holy_family_fallback_gap]'s own \
     citation): 30 December 1583 is an ordinary ferial slug, not of-holy-family"
    "of-christmas-0-friday" (slug_of dec30.LD.observed);
  Alcotest.(check bool) "and that really is NOT the Holy Family (confirming this really is the defect)" false
    (slug_of dec30.LD.observed = "of-holy-family")

let suite =
  ( "Rite_of (real data: assembled Rite.t end-to-end via Calendar/Validate)",
    [ Alcotest.test_case "landmark years validate cleanly (modulo two documented gaps)" `Quick
        test_landmark_years;
      Alcotest.test_case "year 9999 does not raise; seasons flags the clamp, coverage stays clean" `Quick
        test_year_9999_does_not_raise;
      Alcotest.test_case "2005-2050: transfers reach a fixed point, nothing lost or duplicated" `Slow
        test_transfers_reach_a_fixed_point_2005_2050;
      Alcotest.test_case
        "KNOWN OPEN DEFECT pinned, not fixed: St Joseph on Palm Sunday 2062 transfers forward, not \
         backward per Normae n.56(f)" `Quick
        test_joseph_palm_sunday_2062_known_wrong_forward_transfer;
      Alcotest.test_case
        "NEWLY-FOUND DEFECT pinned, not fixed: Holy Family's Normae n.35(a) 30-December fallback is \
         unreached when Christmas Day is a Sunday (1583)" `Quick
        test_holy_family_fallback_1583_known_wrong_ferial ] )