summaryrefslogtreecommitdiff
path: root/test/test_efdotorg_ordo.ml
blob: 6200cd9e402e732ee3d3a8175d6b544240b25219 (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
(* efdotorg-preface task (2026-08-24-colitur-celebrant-rubrics-phase1): the
   THIRD independent preface witness, extraordinaryform.org's own three
   annual Ordo editions (2024-2025, 2025-2026, 2026-2027). A prior task
   (2026-08-21-colitur-celebrant-rubrics-phase1, see
   .superpowers/sdd/2026-08-21-colitur-celebrant-rubrics-phase1/preface-
   witnesses-report.md) DECLINED to wire this source in, for two reasons:
   an unexplained "Advent" label in the preface column, and this corpus's
   own known Vigil-of-St-Lawrence gap. Both are now characterisation work,
   not blockers -- see tools/extract_efdotorg_ordo.py's own header for the
   full account this file's own comparator relies on.

   WHY THIS LAYER IS DIFFERENT FROM test_fiuv_ordo.ml/test_lms_ordo.ml:
   this source prints ONE preface value per day (unlike the LMS Ordo's own
   option lists), already reduced to the day's own real OFFICE by the
   menu-trap filter baked into the generator (see that script's own
   header, finding 1) -- so the comparison here is a single equality
   check per day, not a membership check. It is also the only one of the
   three witnesses whose own JSON, not a fresh pdftotext dump, is the
   input -- the JSON is already fidelity-audited
   (.superpowers/ordo-fidelity-report.md, 1091/1091 against an
   independently-written parser) and is treated as the source; this file
   does not re-parse the PDFs.

   SCOPE: preface only (RG 482-499, {!Rite_ef.Rubrics_ef.preface}) -- this
   source carries no Creed/Gloria/Te Deum data at all
   (docs/research/ordo/PROVENANCE-ordo-corpus.md: "The extraordinaryform.org
   editions carry NO Gloria or Creed"), so there is nothing else to
   compare it against. *)

module Cal = Colitur_kernel.Calendar
module Layer = Colitur_kernel.Layer
module Overlay = Colitur_kernel.Overlay
module LD = Colitur_kernel.Liturgical_day
module Date = Colitur_kernel.Date
module V = Rite_ef.Vocab_ef
module Preface = Colitur_kernel.Preface

let sanctoral_path = "../data/ef/sanctoral.sexp"
let adjustments_path = "../data/ef/adjustments.sexp"

(* Duplicated, not shared -- test_fiuv_ordo.ml/test_lms_ordo.ml's own
   identical function carries the same "no shared .mli" reasoning. *)
let sha256_of_file path =
  let tmp = Filename.temp_file "colitur_efdotorg_ordo_sha256" ".txt" in
  Fun.protect
    ~finally:(fun () -> try Sys.remove tmp with Sys_error _ -> ())
    (fun () ->
      let cmd = Printf.sprintf "sha256sum %s > %s" (Filename.quote path) (Filename.quote tmp) in
      let rc = Sys.command cmd in
      if rc <> 0 then Alcotest.failf "sha256sum exited %d for %s (is it on PATH?)" rc path;
      let ic = open_in tmp in
      let line =
        try input_line ic
        with End_of_file ->
          close_in ic;
          Alcotest.failf "sha256sum produced no output for %s" path
      in
      close_in ic;
      match String.index_opt line ' ' with
      | Some i -> String.sub line 0 i
      | None -> Alcotest.failf "unexpected sha256sum output for %s: %S" path line)

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 real_lectionary () =
  match Colitur_kernel.Lectionary.load "../data/ef/lectionary.sexp" with
  | Ok l -> l
  | Error e -> Alcotest.failf "../data/ef/lectionary.sexp: failed to load: %s" e

let real_commons () =
  match Rite_ef.Lectionary_ef.Commons.load "../data/ef/commons.sexp" with
  | Ok c -> c
  | Error e -> Alcotest.failf "../data/ef/commons.sexp: failed to load: %s" e

(* ---------------------------------------------------------------------- *)
(* The Ordo side: the sexp row, mirrored from tools/extract_efdotorg_ordo.py's *)
(* own row shape. *)
(* ---------------------------------------------------------------------- *)

open Sexplib0.Sexp_conv

type ordo_row = { date : string; mass : string option; cls : int option; colour : string option; praef : string option }
[@@deriving sexp]

let ordo_rows fixture_path =
  let sexp =
    try Sexplib.Sexp.load_sexp fixture_path
    with e -> Alcotest.failf "%s: failed to load: %s" fixture_path (Printexc.to_string e)
  in
  list_of_sexp ordo_row_of_sexp sexp

(* ---------------------------------------------------------------------- *)
(* The colitur side, over the same window -- mirrors test_fiuv_ordo.ml's  *)
(* own [colitur_rows]. *)
(* ---------------------------------------------------------------------- *)

type colitur_row = { c_date : string; c_weekday : Date.weekday; c_preface : Preface.t option }

let colitur_rows ~year_lo ~year_hi ~window_first ~window_last =
  let layer = real_layer () in
  let rite = Rite_ef.context ~lectionary:(real_lectionary ()) ~commons:(real_commons ()) in
  let by_rata : (int, (V.season, V.rank) LD.t) Hashtbl.t = Hashtbl.create 800 in
  for y = year_lo to year_hi do
    let days = Cal.year rite layer y in
    Array.iter (fun (d : (V.season, V.rank) LD.t) -> Hashtbl.replace by_rata (Date.to_rata d.LD.date) d) days
  done;
  let mk s = match Date.of_iso8601 s with Ok d -> d | Error e -> Alcotest.failf "%s: %s" s e in
  let rows = ref [] in
  let d = ref (mk window_first) in
  let stop = mk window_last in
  while Date.compare !d stop <= 0 do
    (match Hashtbl.find_opt by_rata (Date.to_rata !d) with
    | Some day ->
        rows :=
          { c_date = Date.to_iso8601 day.LD.date; c_weekday = Date.weekday day.LD.date; c_preface = day.LD.preface }
          :: !rows
    | None -> Alcotest.failf "no colitur day resolved for %s" (Date.to_iso8601 !d));
    d := Date.add_days !d 1
  done;
  List.rev !rows

(* ---------------------------------------------------------------------- *)
(* Classification -- see tools/extract_efdotorg_ordo.py's own header,     *)
(* finding 3, for why "Advent" needs the day's own weekday rather than a  *)
(* static table entry: RG 494(b) grants the TRINITY preface on every      *)
(* Advent SUNDAY, RG 498 the residual COMMON on every Advent FERIA, and   *)
(* this source's own "Advent" label does not distinguish the two. Every   *)
(* other raw value maps 1:1 onto exactly one {!Preface.t} constructor --  *)
(* verified exhaustive over the whole 1091-day office-selected population *)
(* while building this comparison (16 distinct raw values: these 14 fixed *)
(* mappings, "Advent" itself, and [None]) -- unlike the FIUV/LMS sources, *)
(* nothing here is genuinely unclassifiable. *)
(* ---------------------------------------------------------------------- *)

let classify_praef ~(weekday : Date.weekday) (raw : string) : Preface.t option =
  match raw with
  | "Common" -> Some Preface.Common
  | "For the Dead" -> Some Preface.Requiem
  | "Easter" -> Some Preface.Easter
  | "Lent" -> Some Preface.Lent
  | "BVM" -> Some Preface.Bvm
  | "Holy Trinity" -> Some Preface.Trinity
  | "Holy Cross" -> Some Preface.Holy_cross
  | "Nativity" -> Some Preface.Nativity
  | "Apostles" -> Some Preface.Apostles
  | "Ascension" -> Some Preface.Ascension
  | "Holy Spirit" -> Some Preface.Holy_spirit
  | "Epiphany" -> Some Preface.Epiphany
  | "St. Joseph" -> Some Preface.St_joseph
  | "Sacred Heart" -> Some Preface.Sacred_heart
  | "Christ the King" -> Some Preface.Christ_the_king
  | "Advent" -> Some (if weekday = Date.Sun then Preface.Trinity else Preface.Common)
  | _ -> None

(* ---------------------------------------------------------------------- *)
(* data/ef/expected-divergences-efdotorg.sexp -- same shape as the other  *)
(* Ordo allow-lists' own [allow_entry]. A SEPARATE file: this Ordo is a   *)
(* FIFTH lineage (lectio/missalemeum, LMS, FIUV, and now this one), a     *)
(* different publisher and a different compiler from all three others.   *)
(* ---------------------------------------------------------------------- *)

type allow_entry = { id : string; citation : string; verdict : string; note : string; expected_rows : int }
[@@deriving sexp]

let load_allow_list allow_list_path =
  let sexps =
    try Sexplib.Sexp.load_sexps allow_list_path
    with e -> Alcotest.failf "%s: failed to load: %s" allow_list_path (Printexc.to_string e)
  in
  List.map allow_entry_of_sexp sexps

let allow_list_path = "../data/ef/expected-divergences-efdotorg.sexp"

(* ---------------------------------------------------------------------- *)
(* [make_suite]: one edition's worth of tests, parameterised -- mirrors   *)
(* test_lms_ordo.ml's own shape. *)
(* ---------------------------------------------------------------------- *)

let make_suite ~label ~fixture_path ~fixture_sha256 ~window_first ~window_last ~year_lo ~year_hi ~expected_rows
    ~expected_no_data ~allow_list_id_for_date =
  let test_fixture_checksum () =
    Alcotest.(check string) "fixture SHA-256 matches its provenance note" fixture_sha256
      (sha256_of_file fixture_path)
  in
  let test_dates_align () =
    let ordo = ordo_rows fixture_path in
    let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in
    Alcotest.(check int) (Printf.sprintf "the Ordo fixture has %d rows (%s..%s)" expected_rows window_first window_last)
      expected_rows (List.length ordo);
    Alcotest.(check int) "colitur resolved the same number of days" (List.length ordo) (List.length colitur);
    (match ordo with o :: _ -> Alcotest.(check string) "first date" window_first o.date | [] -> Alcotest.fail "empty");
    (match List.rev ordo with
    | o :: _ -> Alcotest.(check string) "last date" window_last o.date
    | [] -> Alcotest.fail "empty");
    let mismatched =
      List.filter_map
        (fun (o, c) -> if String.equal o.date c.c_date then None else Some (o.date, c.c_date))
        (List.combine ordo colitur)
    in
    Alcotest.(check (list (pair string string))) "no misaligned dates" [] mismatched
  in
  (* This window's own Good Friday(s), RE-DERIVED from
     {!Colitur_kernel.Computus.gregorian_easter} for every civil year the
     window touches (never hardcoded), plus this edition's own share of
     the single known SOURCE GAP (2027-03-03) -- the only two shapes a row
     can have no [praef] at all for (see tools/extract_efdotorg_ordo.py's
     own header, findings 2 and 5). *)
  let window_no_praef () =
    let first_year = int_of_string (String.sub window_first 0 4) in
    let last_year = int_of_string (String.sub window_last 0 4) in
    let good_fridays =
      List.map
        (fun y -> Date.to_iso8601 (Date.add_days (Colitur_kernel.Computus.gregorian_easter y) (-2)))
        (List.init (last_year - first_year + 1) (fun i -> first_year + i))
    in
    let candidates = good_fridays @ [ "2027-03-03" ] in
    List.filter (fun d -> String.compare d window_first >= 0 && String.compare d window_last <= 0) candidates
    |> List.sort_uniq String.compare
  in
  let test_praef_coverage () =
    let ordo = ordo_rows fixture_path in
    let no_praef = List.filter (fun o -> o.praef = None) ordo in
    let expected = window_no_praef () in
    Alcotest.(check int) "the no-praef population matches this window's own Good-Friday-plus-source-gap count"
      expected_no_data (List.length no_praef);
    Alcotest.(check (list string)) "every no-praef row is either this window's own Good Friday or the known source gap"
      expected (List.map (fun o -> o.date) no_praef)
  in
  let describe_praef_mismatch (o : ordo_row) (c : colitur_row) (expected : Preface.t) =
    Printf.sprintf "%s %S: colitur preface=%s, %s praef=%S (classified %s, weekday %s)" o.date
      (Option.value o.mass ~default:"?")
      (match c.c_preface with Some p -> Preface.to_string p | None -> "NONE")
      label (Option.get o.praef) (Preface.to_string expected) (Date.weekday_to_string c.c_weekday)
  in
  let test_praef_matches_or_is_explained () =
    let ordo = ordo_rows fixture_path in
    let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in
    let allow_list = load_allow_list allow_list_path in
    let by_id = List.map (fun e -> (e.id, e)) allow_list in
    let unexplained = ref [] in
    let unclassifiable = ref [] in
    let explained_counts = Hashtbl.create 8 in
    List.iter2
      (fun (o : ordo_row) (c : colitur_row) ->
        if not (String.equal o.date c.c_date) then Alcotest.failf "misaligned: ordo %s vs colitur %s" o.date c.c_date;
        match o.praef with
        | None -> ()
        | Some raw -> (
            match classify_praef ~weekday:c.c_weekday raw with
            | None -> unclassifiable := o.date :: !unclassifiable
            | Some expected ->
                if c.c_preface = Some expected then ()
                else
                  match allow_list_id_for_date o.date with
                  | Some id ->
                      Hashtbl.replace explained_counts id (1 + try Hashtbl.find explained_counts id with Not_found -> 0)
                  | None -> unexplained := describe_praef_mismatch o c expected :: !unexplained))
      ordo colitur;
    Alcotest.(check (list string))
      (Printf.sprintf "[%s] every preface mismatch is named in the allow-list -- none unexplained" label)
      [] (List.rev !unexplained);
    Alcotest.(check (list string))
      (Printf.sprintf "[%s] every raw praef value classifies -- none dropped" label)
      [] (List.rev !unclassifiable);
    Hashtbl.iter
      (fun id n ->
        match List.assoc_opt id by_id with
        | None -> Alcotest.failf "allow-list entry %s used by the comparator but not declared in %s" id allow_list_path
        | Some e -> Alcotest.(check int) (Printf.sprintf "%s: expected_rows matches the actual count" id) e.expected_rows n)
      explained_counts
  in
  (* The known Vigil-of-St-Lawrence corpus gap (tools/extract_efdotorg_ordo.py's
     own header, finding 4; docs/research/ordo/PROVENANCE-ordo-corpus.md;
     .superpowers/ordo-class-report.md) is RECONFIRMED present, but its
     effect on THIS axis now differs by year -- checked per date, not
     assumed uniform, because colitur's own CURRENT behaviour (RG 33's
     third trigger, which landed after the reports above were written --
     CLAUDE.md's own warning that `day`/`readings` output is not stable
     across commits is exactly this) already omits the vigil in SOME of
     these years for its own, entirely independent reason:
       - 2025-08-09 (Saturday): colitur's own RG33-third-trigger ALSO
         omits the vigil this year (RG 78's BVM Saturday Office wins
         outright), and Rule A (this generator's own finding 6) picks the
         SAME office from the corpus's own listing -- a full, direct
         match (office identity AND preface), not a lucky coincidence
         limited to preface alone.
       - 2027-08-09 (Monday): colitur's own vigil IS genuinely observed
         this year (RG33's trigger does not fire); the corpus still shows
         no trace of it at all -- STILL the known gap, reconfirmed, naming
         St Romanus instead -- but both the true Vigil (Class3, no
         title-preface trigger) and the corpus's own substitute fall
         through RG 482's chain to the identical Common preface, so the
         identity error stays invisible to this one axis. Asserted
         POSITIVELY (not merely "no failure occurred") so a future change
         giving either office a real trigger would surface here.
       - 2026-08-09 (Sunday): the vigil is impeded on every source (a
         Sunday outranks a Class3 vigil outright); no special case is
         needed and none is asserted here. *)
  let test_known_vigil_of_st_lawrence_shape () =
    let ordo = ordo_rows fixture_path in
    let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in
    let by_date = List.combine ordo colitur in
    let find d = List.find_opt (fun ((o : ordo_row), _) -> String.equal o.date d) by_date in
    let in_window d = String.compare d window_first >= 0 && String.compare d window_last <= 0 in
    (match find "2025-08-09" with
    | Some (o, c) when in_window "2025-08-09" ->
        Alcotest.(check (option string)) "2025-08-09: Rule A selects the BVM Saturday Office directly" (Some "BVM")
          o.praef;
        Alcotest.(check (option string))
          "2025-08-09: colitur's own answer is the same office (RG33 3rd trigger + RG78)" (Some "bvm")
          (Option.map Preface.to_string c.c_preface)
    | _ -> ());
    match find "2027-08-09" with
    | Some (o, c) when in_window "2027-08-09" ->
        Alcotest.(check (option string))
          "2027-08-09: the known gap is reconfirmed -- the corpus still shows no Vigil of St Lawrence"
          (Some "Common") o.praef;
        Alcotest.(check (option string))
          "2027-08-09: colitur's own real Vigil office also reads Common -- the identity error stays invisible here"
          (Some "common") (Option.map Preface.to_string c.c_preface)
    | _ -> ()
  in
  ( label,
    [ Alcotest.test_case "fixture SHA-256 matches its provenance note" `Quick test_fixture_checksum;
      Alcotest.test_case "streams are N rows each, dates aligned 1:1" `Quick test_dates_align;
      Alcotest.test_case "no-praef population matches Good Friday plus the known source gap" `Quick
        test_praef_coverage;
      Alcotest.test_case "every preface mismatch is named in the allow-list -- none unexplained" `Quick
        test_praef_matches_or_is_explained;
      Alcotest.test_case "the known Vigil-of-St-Lawrence gap: reconfirmed in 2027, resolved by Rule A in 2025" `Quick
        test_known_vigil_of_st_lawrence_shape
    ] )

(* The seven adjudicated root causes behind this comparison's own 11 row-
   level divergences (three editions combined) -- see
   data/ef/expected-divergences-efdotorg.sexp for the full citation of
   each. Date-keyed, the same discipline test_lms_ordo.ml's own
   [allow_list_id_for_date] and test_fiuv_ordo.ml's own
   [fiuv_allow_list_id_for_date] use, and for the identical reason: an
   entry that starts firing on an unexpected new date must be visible as
   a real change, not silently absorbed. Shared across all three editions
   (unlike the LMS file's per-window function) because every id here is
   adjudicated against colitur's own DATA or RULES, not against a single
   edition's own printing quirk -- E1/E3/E4 each recur, unchanged, in
   more than one edition. *)
let allow_list_id_for_date = function
  | "2025-07-16" | "2026-07-16" | "2027-07-16" -> Some "E1"
  | "2026-09-24" -> Some "E2"
  | "2025-11-27" | "2026-11-27" -> Some "E3"
  | "2025-05-06" | "2026-05-06" -> Some "E4"
  | "2025-09-29" -> Some "E5"
  | "2026-11-01" -> Some "E6"
  | "2025-11-29" -> Some "E7"
  | _ -> None

let suite_2024_2025 =
  make_suite ~label:"efdotorg-ordo-2024-2025" ~fixture_path:"fixtures/efdotorg-ordo-2024-2025.sexp"
    ~fixture_sha256:"7de633e292fdb85415dd3ad87fcf9414fbb8f34c42772663b78e107e68c6e571" ~window_first:"2024-12-01"
    ~window_last:"2025-11-29" ~year_lo:2023 ~year_hi:2026 ~expected_rows:364 ~expected_no_data:1
    ~allow_list_id_for_date

let suite_2025_2026 =
  make_suite ~label:"efdotorg-ordo-2025-2026" ~fixture_path:"fixtures/efdotorg-ordo-2025-2026.sexp"
    ~fixture_sha256:"c810ec437cae9fde785f6b3453f77e667644fb82f465b3652040bb2a3b888b9b" ~window_first:"2025-11-30"
    ~window_last:"2026-11-28" ~year_lo:2024 ~year_hi:2027 ~expected_rows:364 ~expected_no_data:1
    ~allow_list_id_for_date

let suite_2026_2027 =
  make_suite ~label:"efdotorg-ordo-2026-2027" ~fixture_path:"fixtures/efdotorg-ordo-2026-2027.sexp"
    ~fixture_sha256:"19c35b45d7964dd357f92a871e81423af8217dff6c40178f3a95d3ec9c4091ea" ~window_first:"2026-11-29"
    ~window_last:"2027-11-27" ~year_lo:2025 ~year_hi:2028 ~expected_rows:364 ~expected_no_data:2
    ~allow_list_id_for_date

(* Guards against a stale allow-list entry, the same discipline
   test_lms_ordo.ml's own [test_allow_list_ids_are_exactly_l4] uses: a
   STATIC check that the sexp file's own declared ids are exactly this
   set -- not a cross-edition "did every id actually fire" proof (neither
   test_lms_ordo.ml nor test_fiuv_ordo.ml's own analogous guard attempts
   that either; each edition's own [test_praef_matches_or_is_explained]
   already fails loudly if an id fires the WRONG number of times, or if a
   real divergence names an id that does not exist at all). *)
let test_allow_list_ids_are_exactly_e1_to_e7 () =
  let ids = List.sort String.compare (List.map (fun e -> e.id) (load_allow_list allow_list_path)) in
  Alcotest.(check (list string)) "the efdotorg allow-list declares exactly E1..E7"
    [ "E1"; "E2"; "E3"; "E4"; "E5"; "E6"; "E7" ] ids

let suite_allow_list =
  ( "efdotorg-ordo-allow-list",
    [ Alcotest.test_case "declares exactly E1..E7" `Quick test_allow_list_ids_are_exactly_e1_to_e7 ] )