summaryrefslogtreecommitdiff
path: root/test/test_fiuv_ordo.ml
blob: 3bfed68565ee3298b91ad4be3220ca83faa06a18 (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
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
(* Witnesses task (2026-08-22-colitur-celebrant-rubrics-phase1): the
   SEVENTH validation layer, and the first UNIVERSAL, SECOND-COMPILER
   witness -- the FIUV (Foederatio Internationalis Una Voce) Ordo for
   2025-2026, compiled by Joseph Shaw, independent of the Latin Mass
   Society's own three editions (test_lms_ordo.ml) except for sharing a
   publisher/host (see test/fixtures/fiuv-ordo-2025-2026.sexp's own
   provenance header, and docs/research/ordo/PROVENANCE-ordo-corpus.md,
   for the full account of what independence this source does and does
   not buy).

   WHY THIS LAYER IS MORE VALUABLE THAN THE LMS ONE, per the task brief's
   own four reasons: (1) UNIVERSAL -- no diocesan variants to exclude at
   all, so this fixture's own [row] carries no [has_diocesan_variant]
   field, unlike the LMS one; (2) IN LATIN, the rubrics' own vocabulary;
   (3) BOTH DIRECTIONS EXPLICIT ("Gloria"/"sine Gloria", "Credo"/"sine
   Credo") -- used here exactly as with the LMS source; (4) RECORDS THE
   TE DEUM, the hinge of RG 431(a)'s own Mass-Gloria rule (deferred to
   Phase 2, Breviary nn. 237-238) -- captured into the fixture, NOT
   compared here (no colitur-side Te Deum predicate exists yet).

   SCOPE: the CREED (RG 475-476, {!Rite_ef.Rubrics_ef.creed}) is the ONE
   axis compared against colitur, per the task brief. [praef] and
   [te_deum] are captured into the fixture (a genuinely useful data set
   for Phase 2/3's still-unbuilt Gloria and preface rules) but
   deliberately NOT validated here -- there is nothing on colitur's own
   side yet to compare either against. [class_] (raw, including the FIUV
   ordo's own explicit "III cl." vs "III cl. (Priv.)" Cum Sanctissima
   convention, stated on its own title page) is likewise captured but not
   compared: colitur's core is strictly the 1962 Missal with Cum
   Sanctissima/Quo Magis modelled only as an OVERLAY, never core
   (CLAUDE.md's own binding decision #2), so colitur's UNOVERLAID output
   has no Cum-Sanctissima-aware notion of "III cl." vs "(Priv.)" to
   compare this field against in the first place -- comparing it would be
   comparing the Ordo against a question colitur's default configuration
   was never asked. *)

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"
let fixture_path = "fixtures/fiuv-ordo-2025-2026.sexp"
let allow_list_path = "../data/ef/expected-divergences-fiuv.sexp"

(* Duplicated, not shared -- 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_fiuv_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 fixture_sha256 = "a744a2dbc7c8cf09590005e956c973f316209f3cf102b8035edadebf764110de"

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_fiuv_ordo.ml's *)
(* own [row] -- see this file's own header on why there is no             *)
(* [has_diocesan_variant] field here, unlike the LMS mirror.               *)
(* ---------------------------------------------------------------------- *)

open Sexplib0.Sexp_conv

type ordo_row = {
  date : string;
  class_ : string option;
  title : string;
  te_deum : bool option;
  gloria : bool option;
  credo : bool option;
  praef : string option;
  alt_te_deum : bool option;
      (** the "-VEL-" Cum-Sanctissima-alternative office's own Te Deum,
          when this day prints one -- see tools/extract_fiuv_ordo.ml's own
          [row.alt_te_deum] citation. Captured, not compared here: closing
          F3 needed [te_deum] itself to stop reading it, not a new
          colitur-side axis to check it against. *)
}
[@@deriving sexp]

let ordo_rows () =
  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

let window_first = "2025-11-27"
let window_last = "2026-12-31"

(* ---------------------------------------------------------------------- *)
(* The colitur side, over the same window -- mirrors test_lms_ordo.ml's   *)
(* own [colitur_rows] exactly (a liturgical year straddles two civil      *)
(* years, so the resolution walk starts a civil year early and ends one   *)
(* late). *)
(* ---------------------------------------------------------------------- *)

type colitur_row = { c_date : string; c_creed : bool; c_gloria : bool; c_preface : Preface.t option }

let colitur_rows () =
  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 = 2024 to 2027 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_creed = day.LD.creed; c_gloria = day.LD.gloria;
            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

(* Te Deum has no colitur-side [Liturgical_day.t] field of its own -- it is
   a Breviary fact {!Rite_ef.Rubrics_ef.gloria} reads internally, not a Mass
   part {!Colitur_kernel.Rite.t} exposes. Resolved separately, straight from
   {!Rite_ef.Rubrics_ef.te_deum}, over the identical window/day set. *)
type colitur_te_deum_row = {
  t_date : string;
  t_te_deum : bool;
  t_rank : V.rank;
  t_slug : string;
  t_has_commemoration : bool;
}

let colitur_te_deum_rows () =
  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 = 2024 to 2027 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 ->
        let te_deum =
          Rite_ef.Rubrics_ef.te_deum ~temporal:day.LD.temporal ~observed:day.LD.observed ~date:day.LD.date
        in
        rows :=
          { t_date = Date.to_iso8601 day.LD.date; t_te_deum = te_deum;
            t_rank = day.LD.observed.Colitur_kernel.Celebration.rank;
            t_slug = Colitur_kernel.Slug.to_string day.LD.observed.Colitur_kernel.Celebration.slug;
            t_has_commemoration = day.LD.commemorations <> [] }
          :: !rows
    | None -> Alcotest.failf "no colitur day resolved for %s" (Date.to_iso8601 !d));
    d := Date.add_days !d 1
  done;
  List.rev !rows

(* ---------------------------------------------------------------------- *)
(* data/ef/expected-divergences-fiuv.sexp -- same shape as the LMS/       *)
(* missalemeum allow-lists' own [allow_entry]. A SEPARATE file: this Ordo *)
(* is a fourth, independent lineage from all three others already in this *)
(* project. *)
(* ---------------------------------------------------------------------- *)

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

let load_allow_list () =
  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

(* ---------------------------------------------------------------------- *)
(* Tests *)
(* ---------------------------------------------------------------------- *)

let test_fixture_checksum () =
  Alcotest.(check string) "fixture SHA-256 matches its provenance note" fixture_sha256 (sha256_of_file fixture_path)

let test_dates_align () =
  let ordo = ordo_rows () in
  let colitur = colitur_rows () in
  Alcotest.(check int) "the Ordo fixture has 400 rows (2025-11-27..2026-12-31)" 400 (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

(* Holy Saturday (2026-04-04) is the ONLY day this fixture shows no Credo
   marker for -- confirmed directly against the raw source: unlike the
   LMS Ordo (whose "no Mass" day is Good Friday, the 1955-restored Holy
   Week having no Mass that day at all), THIS Ordo's Good Friday DOES
   carry a Mass rubric ("Missa pr., ... Gloria, sine Credo, praef.
   comm."), leaving Holy Saturday as the sole day with no Mass mentioned
   at all (its own Vigil Mass is conventionally counted under Easter
   Sunday's own date in this source, matching a real, if narrower,
   instance of the same "a vigil Mass is dated to the day it anticipates"
   convention the LMS characterisation already established). Asserted as
   the ONLY such day, not merely observed once: a parsing regression that
   swallowed a real row would otherwise silently join this bucket instead
   of failing loudly as an unexplained mismatch. *)
let test_creed_coverage () =
  let ordo = ordo_rows () in
  let no_creed = List.filter (fun o -> o.credo = None) ordo in
  Alcotest.(check (list string)) "only Holy Saturday has no Ordo Creed marker" [ "2026-04-04" ]
    (List.map (fun o -> o.date) no_creed)

let describe_creed_mismatch (o : ordo_row) (c : colitur_row) =
  Printf.sprintf "%s %S: colitur creed=%b, Ordo creed=%b" o.date o.title c.c_creed (Option.get o.credo)

(* Which allow-list id, if any, explains a Creed mismatch on this date --
   date-keyed, not "any declared entry excuses any mismatch", the same
   discipline test_lms_ordo.ml's own [allow_list_id_for_date] uses 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. Empty until characterisation found a real divergence to
   name. *)
let fiuv_allow_list_id_for_date = ([] : (string * string) list)

let test_creed_matches_or_is_explained () =
  let ordo = ordo_rows () in
  let colitur = colitur_rows () in
  let allow_list = load_allow_list () in
  let by_id = List.map (fun e -> (e.id, e)) allow_list in
  let unexplained = 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.credo with
      | None -> ()
      | Some ocreed ->
          if Bool.equal ocreed c.c_creed then ()
          else
            match List.assoc_opt o.date fiuv_allow_list_id_for_date with
            | Some id -> Hashtbl.replace explained_counts id (1 + try Hashtbl.find explained_counts id with Not_found -> 0)
            | None -> unexplained := describe_creed_mismatch o c :: !unexplained)
    ordo colitur;
  Alcotest.(check (list string)) "every Creed mismatch is named in the allow-list -- none unexplained" []
    (List.rev !unexplained);
  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;
  List.iter
    (fun e ->
      if not (Hashtbl.mem explained_counts e.id) then
        Alcotest.failf "allow-list entry %s is declared but never matched a real divergence" e.id)
    allow_list

(* ---------------------------------------------------------------------- *)
(* Gloria (RG 431-432, {!Rite_ef.Rubrics_ef.gloria}) -- Phase 2. Same shape *)
(* as the Creed comparison above, over the identical 400-row window.      *)
(* ---------------------------------------------------------------------- *)

let test_gloria_coverage () =
  let ordo = ordo_rows () in
  let no_gloria = List.filter (fun o -> o.gloria = None) ordo in
  Alcotest.(check int) "exactly one day has no Ordo Gloria marker" 1 (List.length no_gloria)

let describe_gloria_mismatch (o : ordo_row) (c : colitur_row) =
  Printf.sprintf "%s %S: colitur gloria=%b, Ordo gloria=%b" o.date o.title c.c_gloria (Option.get o.gloria)

(* F1 -- OPEN, adjudicated FOR colitur. The single Gloria mismatch found:
   Good Friday (2026-04-03). See data/ef/expected-divergences-fiuv.sexp's
   own F1 for the full citation and the raw source text this was checked
   against directly (docs/research/ordo/fiuv-ordo-2025-2026.pdf, page 46). *)
let is_f1_good_friday (o : ordo_row) = String.equal o.date "2026-04-03"

let test_gloria_matches_or_is_explained () =
  let ordo = ordo_rows () in
  let colitur = colitur_rows () in
  let unexplained = ref [] in
  let f1_count = ref 0 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.gloria with
      | None -> ()
      | Some ogloria ->
          if Bool.equal ogloria c.c_gloria then ()
          else if is_f1_good_friday o then incr f1_count
          else unexplained := describe_gloria_mismatch o c :: !unexplained)
    ordo colitur;
  Alcotest.(check (list string)) "every Gloria mismatch is named in the allow-list -- none unexplained" []
    (List.rev !unexplained);
  Alcotest.(check int) "F1 (Good Friday) count" 1 !f1_count

(* ---------------------------------------------------------------------- *)
(* Preface (RG 482-499, {!Rite_ef.Rubrics_ef.preface}) -- Phase 3. Same    *)
(* shape as Creed/Gloria above, over the identical 400-row window, using   *)
(* the [praef] column already captured into the fixture (CAPTURED, NOT    *)
(* VALIDATED at the time -- tools/extract_fiuv_ordo.ml's own [row.praef]  *)
(* citation) but never compared until now.                                *)
(*                                                                          *)
(* CHARACTERISATION FIRST, per the task's own standing instruction (this
   project has been misled by trusting an Ordo field's surface shape
   before, four times this session alone): [praef] is RAW TRAILING TEXT
   after the literal token "praef." to the end of the primary Mass
   clause -- it is not a clean enum, it can carry an arbitrary tail of
   unrelated rubrical prose glued on by the same token-flattening that
   produces it (Good Friday's own [gloria]/F1 finding above is the
   worked example of exactly this shape). CLASSIFIED here by PREFIX
   match against the FIRST WORD(S) only -- every prefix below was found
   by direct inspection of the fixture's own distinct [praef] values
   (test/fixtures/fiuv-ordo-2025-2026.sexp, 400 rows, all read by hand
   while building this comparison) and cross-checked against the primary
   rubric text, not guessed. Two genuine EXCEPTION SHAPES, THREE rows,
   found the same way:

     - Christmas Day (both of them -- the fixture's own window, 27
       November 2025-31 December 2026, spans thirteen months and TWO 25
       Decembers, confirmed by running the coverage test below before
       pinning it at "two", not three, the first time) and Epiphany Day
       (2026-01-06, the window's only one) print "praef. et Communic..."
       -- an ELLIPSIS: the compiler treats "this feast's own [i.e.
       obviously implied] preface" as not needing its own name restated
       on the ONE day that IS that feast, unlike every octave/de-Tempore
       day governed by the SAME feast (which always restates the name
       explicitly, e.g. 26 December's own "de Nativ."). Confirmed by
       direct inspection: no OTHER day in the fixture omits the name this
       way. [classify_praef] returns [None] (unrecognised) for all three
       rather than special-casing them by date -- both feasts are
       independently, directly citable from RG 484(a)/485(a)'s own text
       (the Nativity/Epiphany feast itself is the paradigm case each
       clause names first), so the oracle's corroboration is not needed
       to trust colitur's own answer on these dates, and forcing a match
       here would mean trusting an inference about the source's own
       ellipsis rather than reading it.
     - Good Friday (2026-04-03) prints "comm. Feria VI prima in mense." --
       classifies cleanly as [Common] by the same prefix rule as any
       other day, but this file's own F1 (Gloria, above) already
       adjudicated this EXACT date's raw text as unreliable (a copied,
       not a considered, line -- confirmed against the PDF's own page
       image, not merely the extracted fixture). The same defect
       extends to whatever trails "praef." on the identical corrupted
       line: excluded from this comparison by reusing [is_f1_good_friday]
       directly, not re-argued. *)

let has_prefix ~prefix s =
  String.length s >= String.length prefix && String.sub s 0 (String.length prefix) = prefix

(* Every prefix below maps 1:1 onto exactly one branch of
   {!Rite_ef.Rubrics_ef.preface}'s own priority order -- see that
   function's own header for the full citation of each. Order does not
   matter here (unlike in [preface] itself): the Ordo's own printed text
   never carries two of these prefixes on the same row, so this is a
   partition, not a priority list. *)
let praef_prefixes =
  [ ("comm.", Preface.Common);
    ("Trinit.", Preface.Trinity);
    ("Quadr.", Preface.Lent);
    ("de Sancta Cruce", Preface.Holy_cross);
    ("Pasch.", Preface.Easter);
    ("etc. ut in festo", Preface.Easter);
      (* Easter-octave ferias, "as on the feast [of Easter]" -- e.g. 6
         April 2026, "etc. ut in festo., ad Ite, missa est additur duplex
         Alleluia." *)
    ("Ascensionis", Preface.Ascension);
    ("de Spirito Sancto", Preface.Holy_spirit);
    ("BMV", Preface.Bvm);
    ("App.", Preface.Apostles);
    ("de Nativ.", Preface.Nativity);
    ("de Epiphania", Preface.Epiphany);
    ("de Ss.mi Corde", Preface.Sacred_heart);
    ("de Ss.mo Corde", Preface.Sacred_heart);
    ("de Domino Nostro Jesu Rege", Preface.Christ_the_king);
    ("de S Iosepho", Preface.St_joseph);
    ("de S Ioseph", Preface.St_joseph);
    ("defunctorum", Preface.Requiem) ]

let classify_praef (s : string) : Preface.t option =
  let rec go = function
    | [] -> None
    | (prefix, p) :: rest -> if has_prefix ~prefix s then Some p else go rest
  in
  go praef_prefixes

(* Measured directly, not assumed: every one of the 400 rows' own [praef]
   text, classified, tallied by outcome. Two rows are genuinely
   unclassifiable by this prefix table (the ellipsis dates above); every
   other non-[None] [praef] value classifies. Pinned so a change to
   either the fixture or [classify_praef] that silently drops coverage
   fails loudly here rather than merely narrowing the comparison below. *)
let test_praef_classification_coverage () =
  let ordo = ordo_rows () in
  let with_praef = List.filter_map (fun o -> o.praef) ordo in
  Alcotest.(check int) "399 of 400 rows carry a [praef] value (Holy Saturday is the one exception)" 399
    (List.length with_praef);
  let unclassified = List.filter (fun s -> classify_praef s = None) with_praef in
  (* THREE rows, not two: the fixture's own window (27 November 2025-31
     December 2026, 13 months) spans TWO Christmas Days, both printing the
     identical ellipsis text -- found running this exact assertion, not
     assumed from the date count alone. *)
  Alcotest.(check int) "the three ellipsis rows (two Christmas Days, one Epiphany) are unclassified" 3
    (List.length unclassified);
  Alcotest.(check bool) "every unclassified row starts with the ellipsis's own \"et Communic\"" true
    (List.for_all (has_prefix ~prefix:"et Communic") unclassified)

let describe_preface_mismatch (o : ordo_row) (c : colitur_row) (expected : Preface.t) =
  Printf.sprintf "%s %S: colitur preface=%s, Ordo praef=%S (classified %s)" o.date o.title
    (match c.c_preface with Some p -> Preface.to_string p | None -> "-")
    (Option.get o.praef) (Preface.to_string expected)

let test_preface_matches_or_is_explained () =
  let ordo = ordo_rows () in
  let colitur = colitur_rows () in
  let unexplained = ref [] in
  let unclassified_count = ref 0 in
  let f1_count = ref 0 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;
      if is_f1_good_friday o then incr f1_count
      else
        match o.praef with
        | None -> ()
        | Some raw -> (
            match classify_praef raw with
            | None -> incr unclassified_count
            | Some expected ->
                if c.c_preface = Some expected then ()
                else unexplained := describe_preface_mismatch o c expected :: !unexplained))
    ordo colitur;
  Alcotest.(check (list string)) "every classified preface difference is explained -- none unexplained" []
    (List.rev !unexplained);
  Alcotest.(check int) "the three ellipsis rows are skipped, not silently counted as agreement" 3
    !unclassified_count;
  Alcotest.(check int) "F1 (Good Friday) is skipped here too, the same root cause as the Gloria axis" 1 !f1_count

(* ---------------------------------------------------------------------- *)
(* Te Deum (Breviary 237-238, {!Rite_ef.Rubrics_ef.te_deum}) -- Phase 2,   *)
(* the mitigation the task brief names for this source's own stated       *)
(* weakness (a single, not yet scan-verified, web transcription). A       *)
(* SEPARATE colitur-side resolution ({!colitur_te_deum_rows}), since Te    *)
(* Deum has no {!Colitur_kernel.Liturgical_day.t} field of its own.        *)
(*                                                                          *)
(* The SUNDAY shape this same comparison originally found (237(b)'s own    *)
(* literal Septuagesima/Sexagesima/Quinquagesima exception, contradicted   *)
(* 15/15) is FIXED at the source ({!Rite_ef.Rubrics_ef.te_deum}'s own      *)
(* header has the correction and its full citation) -- not allow-listed,  *)
(* because it no longer diverges. Three OTHER shapes remain OPEN,         *)
(* single-witnessed (never captured by the LMS fixtures, which do not     *)
(* record Te Deum at all) -- see data/ef/expected-divergences-fiuv.sexp's *)
(* own F2/F3/F4 for the full citations. Matched by PREDICATE, the same    *)
(* "varies by shape, not by a fixed date list" reasoning L5/L6 already    *)
(* establish for the LMS suite.                                           *)
(* ---------------------------------------------------------------------- *)

let test_te_deum_coverage () =
  let ordo = ordo_rows () in
  let no_te_deum = List.filter (fun o -> o.te_deum = None) ordo in
  Alcotest.(check int) "the unresolved-Te-Deum population matches this fixture's own measured figure" 133
    (List.length no_te_deum)

let describe_te_deum_mismatch (o : ordo_row) (t : colitur_te_deum_row) =
  Printf.sprintf "%s %S: colitur te_deum=%b, Ordo te_deum=%b (rank=%s commemoration=%b)" o.date o.title t.t_te_deum
    (Option.get o.te_deum) (V.rank_to_string t.t_rank) t.t_has_commemoration

(* F2 (every I-class and omissible vigil), F4 (the three September Ember
   days) and NOW F3 (the privileged-Lenten/Passiontide-feria-plus-
   commemoration shape) were ALL THREE found against a buggy extraction
   (see the fixture's own provenance header, "FIX HISTORY" section, and
   {!Rite_ef.Rubrics_ef.te_deum}'s own 237(a) comment for F2/F4's own
   account) -- re-run against the corrected data, NONE of the three fires
   any more.

   F3 specifically was NEVER a Te Deum defect on either side: the six
   dates print a complete SECOND office after a literal "-VEL-" ("or")
   separator -- the same saint kept outright under the 2020 decree Cum
   Sanctissima, with its OWN "Ad Mat. ... Te Deum" lessons -- and
   tools/extract_fiuv_ordo.ml's own [extract_te_deum] used to scan the
   WHOLE block unbounded, so on these six days (whose PRIMARY, strict-
   1962 office states no "Ad Mat." of its own at all) it silently read
   the SECOND office's Te Deum instead. Fixed at the source
   (tools/extract_fiuv_ordo.ml's own [split_at_alt_office]); the fixture
   re-extracted from the SAME pdftotext dump. colitur's own answer
   ([false], the primary office's own ferial Matins) was never wrong --
   the OTHER side of the comparison was reading the wrong printed office.
   [te_deum] now reads [None] (unresolved, not [false]) on these six
   dates, because the PRIMARY office's own text states no "Ad Mat." at
   all, so there is nothing to positively confirm either direction from
   -- consistent with [te_deum]'s own documented meaning elsewhere in
   this fixture.

   NOTE, so a reader arriving here from data/ef/expected-divergences-lms.sexp's
   own L5 does not draw the wrong conclusion: F3 never "contradicted" L5
   in the first place, and its removal does not corroborate L5 either.
   Both were the SAME underlying fact read two different ways -- L5's own
   16 LMS dates and these 6 FIUV dates are exactly the shape the 2020
   decree Cum Sanctissima changes, and colitur, by CLAUDE.md's own binding
   decision 2, models the 1962 core without it. See L5's own rewritten
   entry for the full account, including the FIUV "-VEL-" dual printing
   as the decisive evidence for BOTH files.

   Removed rather than kept as dead code with an [expected 0] pin, the
   same "an entry that stops firing is a real change, not silently
   absorbed" discipline data/ef/expected-divergences-lms.sexp's own L1/L3
   closures already establish -- see that file's own history for the
   precedent this follows. *)

let test_te_deum_matches_or_is_explained () =
  let ordo = ordo_rows () in
  let colitur = colitur_te_deum_rows () in
  let unexplained = ref [] in
  List.iter2
    (fun (o : ordo_row) (t : colitur_te_deum_row) ->
      if not (String.equal o.date t.t_date) then Alcotest.failf "misaligned: ordo %s vs colitur %s" o.date t.t_date;
      match o.te_deum with
      | None -> ()
      | Some otd -> if Bool.equal otd t.t_te_deum then () else unexplained := describe_te_deum_mismatch o t :: !unexplained)
    ordo colitur;
  Alcotest.(check (list string)) "every Te Deum mismatch is named in the allow-list -- none unexplained" []
    (List.rev !unexplained)

let suite =
  ( "fiuv-ordo",
    [ Alcotest.test_case "fixture SHA-256 matches its provenance note" `Quick test_fixture_checksum;
      Alcotest.test_case "streams are 400 rows each, dates aligned 1:1" `Quick test_dates_align;
      Alcotest.test_case "only Holy Saturday has no Ordo Creed marker" `Quick test_creed_coverage;
      Alcotest.test_case "every Creed difference is named in the cited allow-list -- none unexplained" `Quick
        test_creed_matches_or_is_explained;
      Alcotest.test_case "Ordo Gloria coverage matches the measured figure" `Quick test_gloria_coverage;
      Alcotest.test_case "every Gloria difference is named in the cited allow-list -- none unexplained" `Quick
        test_gloria_matches_or_is_explained;
      Alcotest.test_case "praef classification coverage matches the measured figure" `Quick
        test_praef_classification_coverage;
      Alcotest.test_case "every classified preface difference is explained -- none unexplained" `Quick
        test_preface_matches_or_is_explained;
      Alcotest.test_case "Ordo Te Deum coverage matches the measured figure" `Quick test_te_deum_coverage;
      Alcotest.test_case "every Te Deum difference is named in the cited allow-list -- none unexplained" `Quick
        test_te_deum_matches_or_is_explained
    ] )