aboutsummaryrefslogtreecommitdiff
path: root/test/test_lms_ordo.ml
blob: 32ecfbc49712ea238e0e0ffb48fa40cea7befd0a (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
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
(* Task 6 (2026-08-21-colitur-celebrant-rubrics-phase1) built this layer
   against ONE LMS Ordo edition (2024-2025). The Witnesses task
   (2026-08-22, same phase) extends it to THREE editions -- 2023-2024,
   2024-2025, 2025-2026 -- each compared in its OWN window, counted and
   reported SEPARATELY, never merged: a divergence appearing in one year
   and not another is informative on its own, and merging would hide it.
   [make_suite] below is the shared machinery Task 6 built as a single
   flat file; every per-year fact that Task 6 hardcoded (window dates,
   fixture SHA-256, the "only Good Friday has no Creed marker" pin, the
   formulary-bucket totals, the BVM-Saturday population) is now a
   parameter or a value RE-DERIVED from the rubric independently per
   window, not copied forward -- see each call site below for the
   per-edition numbers, which do differ (397/399/400 rows; not every
   edition even has the same II-class "sharp pairing" saint available,
   see [fixture_probes] in tools/extract_lms_ordo.ml's own header for
   2025-2026's own Joachim-is-impeded finding).

   WHY THIS LAYER EXISTS, AND WHAT IT IS INDEPENDENT OF: layers 3-4
   (test_differential.ml, test_oracle.ml) are ONE LINEAGE running Divinum
   Officium -> missalemeum -> lectio -> colitur (CLAUDE.md's "know what
   each layer cannot see" section); layer 5 (test_golden.ml) pins colitur's
   own output against itself. This Ordo is compiled independently by the
   Latin Mass Society (a printed book, England & Wales diocesan propers)
   from the 1962 Missal/1960 rubrics directly -- it never passed through
   Divinum Officium, missalemeum or lectio at any point, so an error
   inherited by that whole lineage (colitur's own bootstrap included) is
   visible here for the first time. Three editions of the SAME publisher's
   book are still only ONE compiler's judgement (see docs/research/ordo/
   PROVENANCE-ordo-corpus.md's own "what this corpus still cannot show" --
   genuine cross-compiler independence needs the FIUV layer, test_fiuv_
   ordo.ml, built separately in this same task).

   SCOPE, decided after running the comparison against real data (not
   guessed in advance -- see each fixture's own CHARACTERISATION section):

     - The CREED (RG 475-476, {!Rite_ef.Rubrics_ef.creed}): compared on
       every day, full 1:1, in every edition's own window. Exactly one day
       per window is EXCLUDED, not silently skipped: that window's own
       Good Friday (RE-DERIVED per window from {!Colitur_kernel.Computus.
       gregorian_easter}, never hardcoded to a specific date) has no Mass
       in the 1955-restored Holy Week, so the Ordo prints no Gl/Cr line at
       all there -- [test_creed_coverage] asserts this is the ONLY such
       day per window.

     - The BVM-SATURDAY SEASONAL SELECTION (RG 309(a), "iuxta temporum
       diversitatem", {!Rite_ef.Lectionary_ef.bvm_saturday_citations}):
       the Ordo's own roman numeral (I-V) names WHICH of the five seasonal
       "Missae de sancta Maria in sabbato" is said, so it validates
       colitur's SEASONAL SELECTION, not merely that some BVM Mass was
       chosen. Checked against every {!Colitur_kernel.Mass_formulary.
       Votive} day inside each edition's own window.

     - The FORMULARY OVERRIDE (does the day say its own Mass, or a
       borrowed one) -- compared for THREE of {!Colitur_kernel.
       Mass_formulary.source}'s five constructors ([Proper]/
       [Preceding_sunday]/[Common]), on the same [Own_slug]-excluded,
       [Votive]-covered-separately basis Task 6 established (see that
       task's own report for the full reasoning: 11 of [Own_slug]'s
       real days show an Ordo override anyway, a data-representation
       ambiguity in colitur's own lectionary, not a rubric question the
       Ordo could adjudicate).

   GLORIA is extracted into every fixture (a genuine, independently useful
   field for the still-unbuilt Phase 2 Gloria rule) but NOT compared here:
   colitur has no {!Rite_ef.Rubrics_ef}-level Gloria predicate yet.

   DIOCESAN SCOPE: colitur computes the UNIVERSAL General Roman Calendar
   only (no diocesan overlay loaded by the differential/oracle harnesses
   anywhere in this suite) -- every LMS edition is an England & Wales
   diocesan Ordo, and each fixture's own provenance header quantifies how
   many of its days carry at least one diocesan variant. Every comparison
   in this file reads ONLY each fixture's own universal-block fields. *)

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 Computus = Colitur_kernel.Computus
module MF = Colitur_kernel.Mass_formulary
module V = Rite_ef.Vocab_ef
module TE = Rite_ef.Temporal_ef

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

(* Duplicated, not shared, from test_oracle.ml/test_differential.ml's own
   identical function -- neither file exposes an .mli the other could
   depend on, and this is three lines (test_oracle.ml's own comment). *)
let sha256_of_file path =
  let tmp = Filename.temp_file "colitur_lms_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 (not shared -- see this file's    *)
(* own header) from tools/extract_lms_ordo.ml's own [row].                 *)
(* ---------------------------------------------------------------------- *)

open Sexplib0.Sexp_conv

type ordo_row = {
  date : string;
  weekday : string;
  title : string;
  formulary_override : string option;
  bvm_numeral : string option;
  gloria : bool option;
  creed : bool option;
  has_diocesan_variant : bool;
}
[@@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 a given fixture covers. Mirrors  *)
(* test_oracle.ml's own [colitur_rows]: the resolution walk starts a civil *)
(* year EARLY (and ends one late) because a liturgical year is             *)
(* Advent-anchored and straddles two civil years -- generalised from Task  *)
(* 6's single hardcoded [2023..2025] to a [~year_lo]/[~year_hi] parameter  *)
(* per window, since the three editions' own civil-year spans differ.      *)
(* ---------------------------------------------------------------------- *)

type colitur_row = {
  c_date : string;
  c_season : V.season;
  c_formulary : MF.t option;
  c_creed : bool;
  c_gloria : bool;
  c_rank : V.rank;
  c_slug : string;
  c_has_commemoration : bool;
      (** whether {!Colitur_kernel.Liturgical_day.t.commemorations} is
          non-empty -- needed by the Gloria comparison's own L5 shape
          (RG 431(b)/n.302(b)), not by anything Task 6 built. *)
}

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: not a valid ISO-8601 date: %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_season = day.LD.temporal.Colitur_kernel.Temporal.season;
            c_formulary = day.LD.formulary; c_creed = day.LD.creed; c_gloria = day.LD.gloria;
            c_rank = day.LD.observed.Colitur_kernel.Celebration.rank;
            c_slug = Colitur_kernel.Slug.to_string day.LD.observed.Colitur_kernel.Celebration.slug;
            c_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

(* ---------------------------------------------------------------------- *)
(* RG 309(a), "iuxta temporum diversitatem" -- INDEPENDENTLY re-derived    *)
(* from the rubric, not called from Lectionary_ef.bvm_saturday_citations.  *)
(* ---------------------------------------------------------------------- *)

let expected_bvm_numeral season ~month ~day =
  match (season : V.season) with
  | V.Advent -> "I"
  | V.Christmastide -> "II"
  | V.Time_after_epiphany when month = 1 || (month = 2 && day <= 2) -> "II"
  | V.Time_after_epiphany | V.Septuagesima | V.Lent | V.Passiontide -> "III"
  | V.Paschaltide -> "IV"
  | V.Time_after_pentecost -> "V"

(* ---------------------------------------------------------------------- *)
(* data/ef/expected-divergences-lms.sexp -- same shape as                  *)
(* test_oracle.ml's own [allow_entry]/[load_allow_list]. ONE allow-list    *)
(* file for all three editions (they are one lineage, the same publisher   *)
(* and compiler) -- an entry's own [note] names which edition(s) it fired  *)
(* on when that matters. *)
(* ---------------------------------------------------------------------- *)

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

(* ---------------------------------------------------------------------- *)
(* Formulary override, restricted to Proper/Common/Preceding_sunday --     *)
(* see this file's own header for why Own_slug is excluded, and Votive is *)
(* covered by the BVM checks instead. Unchanged from Task 6 except for     *)
(* being read per-window rather than off a single hardcoded fixture.       *)
(* ---------------------------------------------------------------------- *)

let ordinal n =
  let suffix =
    if n mod 100 >= 11 && n mod 100 <= 13 then "th"
    else match n mod 10 with 1 -> "st" | 2 -> "nd" | 3 -> "rd" | _ -> "th"
  in
  Printf.sprintf "%d%s" n suffix

let parse_sunday_slug slug =
  let prefix = "ef-" in
  let plen = String.length prefix in
  if String.length slug <= plen || String.sub slug 0 plen <> prefix then None
  else
    let body = String.sub slug plen (String.length slug - plen) in
    let marker = "-sunday-" in
    let mlen = String.length marker and blen = String.length body in
    let rec find_last i best = if i + mlen > blen then best
      else find_last (i + 1) (if String.sub body i mlen = marker then Some i else best)
    in
    match find_last 0 None with
    | None -> None
    | Some i -> (
        let word = String.sub body 0 i in
        let num_str = String.sub body (i + mlen) (blen - i - mlen) in
        match int_of_string_opt num_str with Some n -> Some (word, n) | None -> None)

(* [~season] disambiguates ONE genuinely ambiguous case, found extending
   this suite to the 2023-2024 window (2024-11-05..08): colitur's own
   "resumed-Sunday tail" (CLAUDE.md's own term) REUSES an Epiphany Sunday's
   own slug ([ef-time-after-epiphany-sunday-4]) for a feria whose REAL
   calendar date falls in November, inside Time after Pentecost -- Easter
   was late enough in 2024 that not every Epiphany Sunday got "used" before
   Septuagesima cut the season short, so the leftover ones resume near the
   END of the year instead. The Ordo's own prose distinguishes the two
   occurrences of the SAME slug by wording alone: "4th Sunday after the
   Epiphany" in January, "4th Sunday REMAINING after the Epiphany" in
   November (verified directly against the raw dump). [c_season] is the
   feria's own actual calendar season -- [Time_after_epiphany] for the
   January occurrence, [Time_after_pentecost] for the November one -- so
   comparing it against the WORD parsed out of the resumed slug ("time-
   after-epiphany" either way) is exactly the signal needed, with no new
   colitur-side field required. *)
let expected_preceding_sunday_override ~(season : V.season) (word : string) (n : int) =
  match (word, n) with
  | "advent", 1 -> Some "Advent Sunday"
  | "advent", 2 -> Some "2nd Sunday of Advent"
  | "advent", 3 -> Some "3rd Sunday of Advent (Gaudete Sunday)"
  | "advent", 4 -> Some "4th Sunday of Advent"
  | "septuagesima", 1 -> Some "Septuagesima Sunday"
  | "septuagesima", 2 -> Some "Sexagesima Sunday"
  | "septuagesima", 3 -> Some "Quinquagesima Sunday"
  | "time-after-epiphany", n when season = V.Time_after_epiphany ->
      Some (Printf.sprintf "%s Sunday after the Epiphany" (ordinal n))
  | "time-after-epiphany", n -> Some (Printf.sprintf "%s Sunday remaining after the Epiphany" (ordinal n))
  | "time-after-pentecost", 24 -> Some "24th & Last Sunday After Pentecost"
  | "time-after-pentecost", n -> Some (Printf.sprintf "%s Sunday after Pentecost" (ordinal n))
  (* [n >= 3], no upper bound: Task 6's original [n <= 5] excluded n=6/7 on
     the (overfit, 2024-2025-window-only) assumption that EVERY such day is
     Ascension-adjacent. Extending to 2023-2024/2025-2026 found a REAL
     counter-example -- Rogation Monday (RG 87-89), which also resumes
     week 6's own Sunday but falls BEFORE Ascension, not after, and the
     Ordo prints the ordinary "5th Sunday after Easter" there, not "Mass
     of the Ascension". The caller now decides Ascension-adjacency by the
     day's own DATE against {!Computus.ascension}, not by [n] -- so this
     mapping can be trusted for n=6/7 whenever the caller does not first
     divert to [ascension_week_override]. *)
  | "easter", n when n >= 3 -> Some (Printf.sprintf "%s Sunday after Easter" (ordinal (n - 1)))
  | _ -> None

let expected_named_sunday_override slug ~year =
  if String.equal slug "ef-christ-the-king" then
    match TE.week (TE.christ_the_king year) with
    | Some n -> Some (Printf.sprintf "%s Sunday after Pentecost" (ordinal n))
    | None -> None
  (* [ef-low-sunday]: a SECOND named-Sunday population reaching
     [Preceding_sunday], found the same way [ef-christ-the-king] was --
     absent from the 2024-2025 window Task 6 built this mapping against
     (every Low-Sunday-week feria that year had its own proper), present
     in both 2023-2024 (9-12 April 2024) and 2025-2026. The Ordo's own
     text is the plain, unnumbered "Mass of Low Sunday". *)
  else if String.equal slug "ef-low-sunday" then Some "Low Sunday"
  else None

(* See Task 6's own [ascension_week_override] citation (test_lms_ordo.ml
   history, data/ef/expected-divergences-lms.sexp's own now-CLOSED L2,
   celebrant-rubrics-phase1 Bug 2, 2026-08-22): STRUCTURAL, not a fixture
   artefact -- the Ascension is a fixed Easter+39, so every feria whose own
   [Preceding_sunday] fallback would otherwise resolve to a week-6/7 Sunday
   falls, in SOME years, between Ascension Thursday and the following
   Sunday. Extending this suite past the single 2024-2025 window sharpened
   the finding: it is specifically the days AFTER Ascension (not merely
   "any week-6/7 Preceding_sunday day") that get this treatment -- see
   [expected_preceding_sunday_override]'s own citation for the Rogation
   Monday counter-example that exposed the difference. [is_after_ascension]
   below is the corrected predicate: the day's own DATE compared against
   {!Computus.ascension} for its own year, not [n].

   FIXED (celebrant-rubrics-phase1, Bug 2): data/ef/lectionary.sexp's new
   [ascension_week_entries] now answers these six ferial slugs directly at
   step 2 (Own_slug), so [check_formulary_overrides]'s own [Preceding_sunday]
   branch below never reaches them any more -- moved to a dedicated
   [Own_slug] branch in that same function, which still asserts the Ordo's
   override reads exactly [ascension_week_override], now as a genuine
   confirmation that colitur's OWN citation matches (previously this
   predicate only served to EXCLUDE the population from the general
   Preceding_sunday mapping, because colitur had no correct answer to
   compare). This constant and [is_after_ascension] itself are still load-
   bearing for that confirmation -- kept, not deleted, the same "the
   regression net survives its own fix" discipline
   data/ef/expected-divergences-lms.sexp's own L6 note states for
   [is_l6_rogation_colour]. *)
let ascension_week_override = "Mass of the Ascension"

(* The exact six slugs data/ef/lectionary.sexp's own [ascension_week_entries]
   (tools/bootstrap_lectionary.ml) now answers at step 2 -- gated on SLUG
   IDENTITY, not merely [is_after_ascension date], because that date range
   also covers "Dominica post Ascensionem" and the Vigil of Pentecost, both
   genuinely [Own_slug] and both carrying their OWN, different citation, not
   the Ascension's (caught live: an earlier, date-only version of this gate
   wrongly flagged both as missing an Ordo override before this list was
   added). *)
let ascension_week_slugs =
  [ "ef-easter-6-friday"; "ef-easter-7-monday"; "ef-easter-7-tuesday"; "ef-easter-7-wednesday";
    "ef-easter-7-thursday"; "ef-easter-7-friday"
  ]

(* Bounded ABOVE by Pentecost, not merely "later than Ascension" -- a bare
   lower bound is wrong for any date past Ascension in the SAME civil year
   (every date from June to December satisfies "> Ascension", which a
   first version of this predicate did and it manufactured 8 false
   failures across the 2024-2025/2025-2026 windows, in July/November/
   December, caught immediately by re-running against those two
   previously-clean windows). Task 6's own original population (Friday
   after Ascension, then the following Monday/Tuesday) already showed the
   window extends PAST the immediately-following Sunday, not merely up to
   it, so Pentecost -- the next fixed liturgical anchor, Easter+49 -- is
   the natural, provable upper bound: nothing in this stretch has its own
   proper once Ascension's octave-like privilege takes over, and Pentecost
   itself always does (RG 91 entry 3). *)
let is_after_ascension date =
  let year = Date.year date in
  Date.compare date (Computus.ascension year) > 0 && Date.compare date (Computus.pentecost year) < 0

type formulary_counts = {
  fc_bad : string list;
  fc_proper : int;
  fc_common : int;
  fc_preceding_sunday : int;
  fc_ascension_week : int;
}

let check_formulary_overrides ordo colitur =
  let pairs = List.combine ordo colitur in
  let proper = ref 0 and common = ref 0 and preceding_sunday = ref 0 and ascension_week = ref 0 in
  let bad = ref [] in
  List.iter
    (fun ((o : ordo_row), (c : colitur_row)) ->
      match c.c_formulary with
      | Some { MF.via = MF.Proper; _ } ->
          incr proper;
          if o.formulary_override <> None then
            bad := Printf.sprintf "%s: Proper day but Ordo shows override %s" o.date (Option.get o.formulary_override)
                    :: !bad
      | Some { MF.via = MF.Common; _ } ->
          incr common;
          if o.formulary_override <> None then
            bad := Printf.sprintf "%s: Common day but Ordo shows override %s" o.date (Option.get o.formulary_override)
                    :: !bad
      | Some { MF.via = MF.Preceding_sunday; said } -> (
          incr preceding_sunday;
          let slug =
            match said with
            | Some s -> Colitur_kernel.Slug.to_string s
            | None -> Alcotest.failf "%s: Preceding_sunday day with said = None (should be impossible)" o.date
          in
          let ordo_says = Printf.sprintf "Mass of %s" in
          let year = int_of_string (String.sub o.date 0 4) in
          let date = match Date.of_iso8601 o.date with Ok d -> d | Error e -> Alcotest.failf "%s: %s" o.date e in
          if is_after_ascension date then (
            match o.formulary_override with
            | Some got when String.equal got ascension_week_override -> incr ascension_week
            | Some got ->
                bad :=
                  Printf.sprintf "%s: colitur resumed %S (after Ascension), Ordo shows %S, not the adjudicated %S"
                    o.date slug got ascension_week_override
                  :: !bad
            | None ->
                bad := Printf.sprintf "%s: colitur resumed %S (after Ascension), Ordo shows no override" o.date slug :: !bad)
          else
          let expected =
            match parse_sunday_slug slug with
            | Some (word, n) -> expected_preceding_sunday_override ~season:c.c_season word n
            | None -> expected_named_sunday_override slug ~year
          in
          match expected with
          | Some expected -> (
              let expected_line = ordo_says expected in
              match o.formulary_override with
              | Some got when String.equal got expected_line -> ()
              | Some got -> bad := Printf.sprintf "%s: colitur resumed %S, Ordo says %S" o.date expected_line got :: !bad
              | None -> bad := Printf.sprintf "%s: colitur resumed %S, Ordo shows no override" o.date expected_line :: !bad)
          | None -> (
              match o.formulary_override with
              | Some got when String.equal got ascension_week_override -> incr ascension_week
              | Some got ->
                  bad :=
                    Printf.sprintf "%s: unmapped (colitur resumed %S) and Ordo shows %S, not the adjudicated %S" o.date
                      slug got ascension_week_override
                    :: !bad
              | None ->
                  bad := Printf.sprintf "%s: unmapped (colitur resumed %S) and Ordo shows no override" o.date slug :: !bad))
      | Some { MF.via = MF.Own_slug; said } -> (
          (* celebrant-rubrics-phase1, Bug 2 fix: the six Ascension-week
             ferias moved from [Preceding_sunday] (the branch above, which
             used to special-case them via [is_after_ascension]) to
             [Own_slug] -- data/ef/lectionary.sexp's own
             [ascension_week_entries] now answers at step 2, before step 3
             is ever reached. Checked HERE, not merely left to fall through
             silently the way every OTHER [Own_slug] day already does
             (RG 299's ordinary case, a real proper Mass, needs no Ordo
             cross-check because nothing about it is adjudicated): this is
             the one [Own_slug] population this suite has an OUTSIDE
             witness for, and losing that check when the fix landed would
             have been a silent regression net, not merely dead code -- the
             Ordo's own override text must still read "Mass of the
             Ascension" on exactly these days. *)
          let slug = match said with Some s -> Colitur_kernel.Slug.to_string s | None -> "" in
          if List.mem slug ascension_week_slugs then
            match o.formulary_override with
            | Some got when String.equal got ascension_week_override -> incr ascension_week
            | Some got ->
                bad :=
                  Printf.sprintf "%s: colitur resolved %S via Own_slug (after Ascension), Ordo shows %S, not %S"
                    o.date slug got ascension_week_override
                  :: !bad
            | None ->
                bad := Printf.sprintf "%s: colitur resolved %S via Own_slug (after Ascension), Ordo shows no override"
                         o.date slug :: !bad)
      | Some { MF.via = MF.Votive; _ } | None -> ())
    pairs;
  { fc_bad = List.rev !bad; fc_proper = !proper; fc_common = !common; fc_preceding_sunday = !preceding_sunday;
    fc_ascension_week = !ascension_week }

(* ---------------------------------------------------------------------- *)
(* [make_suite]: one edition's worth of tests, parameterised. Every count  *)
(* below is a genuine per-edition PIN (measured once against real data,    *)
(* not guessed), the same discipline every other golden/oracle fixture in  *)
(* this suite already uses -- a change to any of them is a real finding,   *)
(* not noise to silence. *)
(* ---------------------------------------------------------------------- *)

(* No Str/regex (frozen deps) -- the same hand-rolled substring test every
   other file in this codebase reaches for (rubrics_ef.ml's own
   [contains_substring], precedence_ef.ml's [contains_substring]). Local to
   this file, not shared, on the same "no common .mli to hang it from"
   footing those other copies already document. *)
let contains_substring s ~needle =
  let ls = String.length s and ln = String.length needle in
  let rec at i = i + ln <= ls && (String.sub s i ln = needle || at (i + 1)) in
  ln = 0 || at 0

let make_suite ~label ~fixture_path ~fixture_sha256 ~window_first ~window_last ~year_lo ~year_hi ~allow_list_path
    ~expected_rows ~expected_bvm_votive ~expected_proper ~expected_common ~expected_preceding_sunday
    ~expected_ascension_week ~expected_gloria_l5 ~expected_gloria_l6 =
  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) (Easter-2, RE-DERIVED here from
     {!Computus.gregorian_easter} for every civil year the window touches,
     never hardcoded) is the only day expected to carry no Ordo Creed
     marker at all -- Good Friday has no Mass in the 1955-restored Holy
     Week. Exactly one such date falls inside any one edition's ~13-month
     window (its OTHER Easter, a year apart, falls outside it), but this
     is computed, not assumed: a window that unexpectedly contained zero or
     two would be a real finding this test would catch. *)
  let window_good_fridays () =
    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 candidates =
      List.concat_map
        (fun y ->
          let gf = Date.add_days (Computus.gregorian_easter y) (-2) in
          [ Date.to_iso8601 gf ])
        (List.init (last_year - first_year + 1) (fun i -> first_year + i))
    in
    List.filter (fun d -> String.compare d window_first >= 0 && String.compare d window_last <= 0) candidates
  in
  let test_creed_coverage () =
    let ordo = ordo_rows fixture_path in
    let no_creed = List.filter (fun o -> o.creed = None) ordo in
    let expected = window_good_fridays () in
    Alcotest.(check (list string)) "only this window's own Good Friday has no Ordo Creed marker" expected
      (List.map (fun o -> o.date) no_creed)
  in
  let describe_creed_mismatch (o : ordo_row) (c : colitur_row) =
    Printf.sprintf "%s %S: colitur creed=%b, Ordo creed=%b (formulary=%s)" o.date o.title c.c_creed
      (Option.get o.creed)
      (match c.c_formulary with
      | Some { MF.said = Some s; _ } -> Colitur_kernel.Slug.to_string s
      | Some { MF.said = None; via = MF.Votive } -> "votive (said unnamed in the data)"
      | Some { MF.said = None; _ } -> "NONE (said, unexpectedly outside Votive)"
      | None -> "NONE")
  in
  (* Which allow-list id, if any, explains a Creed mismatch on this date.
     Date-keyed (not merely "any mismatch is fine") deliberately: an id
     that stops firing, or fires on a NEW date it was never adjudicated
     for, must be visible as a real change, not silently absorbed by a
     loose "some entry explains something" check. Task 6's own version of
     this function declared [by_id]/[explained_counts] but never actually
     populated the latter (the mechanism this comment sits in) -- L1 was
     already CLOSED by the time that task shipped, so it had nothing real
     to explain and the gap went unexercised. L4 (Witnesses task,
     2026-08-22, 2023-2024-only) is the first live test of it. L3's own
     "2024-02-24" branch (bissextile-shift task, 2026-08-22) is REMOVED,
     not left dead: the fix makes that date's Creed match outright, so the
     branch would never fire again, and a stale date->id mapping for a
     now-closed entry is exactly the kind of thing this file's own
     "must be visible as a real change" discipline (this comment's own
     opening sentence) argues against leaving in place. *)
  let allow_list_id_for_date d = if String.equal d "2023-12-24" then Some "L4" else None in
  let test_creed_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 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.creed with
        | None -> ()
        | Some ocreed ->
            if Bool.equal ocreed c.c_creed 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_creed_mismatch o c :: !unexplained))
      ordo colitur;
    Alcotest.(check (list string)) (Printf.sprintf "[%s] every Creed mismatch is named in the allow-list -- none unexplained" label)
      [] (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
  in
  (* ---- Gloria (RG 431-432, {!Rite_ef.Rubrics_ef.gloria}) -- Phase 2 of
     this task. Same shape as the Creed comparison immediately above:
     coverage (which dates carry no Ordo "Gl"/"No Gl" marker at all) is
     checked separately from the value comparison, and both are
     date-keyed against their own allow-list ids, never a loose "some
     divergence is fine" check. *)
  let test_gloria_coverage () =
    let ordo = ordo_rows fixture_path in
    let no_gloria = List.filter (fun o -> o.gloria = None) ordo in
    let expected = window_good_fridays () in
    Alcotest.(check (list string)) "only this window's own Good Friday has no Ordo Gloria marker" expected
      (List.map (fun o -> o.date) no_gloria)
  in
  let describe_gloria_mismatch (o : ordo_row) (c : colitur_row) =
    Printf.sprintf "%s %S: colitur gloria=%b, Ordo gloria=%b (rank=%s commemoration=%b formulary=%s)" o.date o.title
      c.c_gloria (Option.get o.gloria) (V.rank_to_string c.c_rank) c.c_has_commemoration
      (match c.c_formulary with
      | Some { MF.said = Some s; _ } -> Colitur_kernel.Slug.to_string s
      | Some { MF.said = None; via = MF.Votive } -> "votive (said unnamed in the data)"
      | Some { MF.said = None; _ } -> "NONE (said, unexpectedly outside Votive)"
      | None -> "NONE")
  in
  (* L5 is the one REAL, STRUCTURAL shape still open (see
     data/ef/expected-divergences-lms.sexp's own L5 for the full
     citation) -- not a fixed date list, a property of the day itself,
     because it recurs every year the underlying condition holds, not on a
     fixed calendar date the way L4 above does. Matched by PREDICATE, not
     by date, for the same reason L2's own note gives for why it is
     prose-only rather than wired through the shared [expected_rows]
     mechanism: the count varies window to window (L5: 6/5/6), so a single
     static count cannot check it, and [make_suite] threads the per-window
     expected total directly (~expected_gloria_l5 below) instead.

     L6 is CLOSED (celebrant-rubrics-phase1, Bug 1 fix, this task):
     {!Rite_ef.Temporal_ef.temporal}'s Rogation Monday/Tuesday branch no
     longer hardcodes [Colour.Violet] -- see temporal_ef.ml's own citation
     on that branch and data/ef/expected-divergences-lms.sexp's L6 entry
     for the fix and its measured blast radius. [is_l6_rogation_colour] and
     ~expected_gloria_l6 are kept, at 0 in every window, as the regression
     net the same way L3's own bissextile fix keeps a predicate whose count
     is now provably zero rather than deleting it. *)
  let is_l5_lenten_commemoration (o : ordo_row) (c : colitur_row) =
    (* NOT a Gloria defect (CORRECTED, l5-f3-readjudication task,
       2026-08-22 -- data/ef/expected-divergences-lms.sexp's own L5 has
       the full account, including the RG 431(b)/n.302(b) hypothesis this
       comment used to state as settled and RG 303(b)'s own "dici potest
       tantum si occurrit dies liturgicus IV classis", which prohibits
       302(b) on exactly these THIRD-class privileged ferias). Every
       instance is a privileged Lenten/Passiontide feria (Class3, violet
       -- {!TE}'s own [ferial_rank], RG25) carrying exactly one
       commemoration of an impeded Class3 saint, colitur reading
       [gloria]=false (the ferial Mass, correctly, per the 1962 core)
       where the Ordo reads [true] (the LMS Ordo follows the 2020 decree
       Cum Sanctissima, colitur excludes it by binding design, CLAUDE.md
       decision 2) -- an OCCURRENCE difference between two different,
       both genuine, calendars, not a rule dispute. Predicate kept AS A
       MATCHER (the 16-date population is unchanged); only the citation
       explaining WHY it is allow-listed was wrong. *)
    (not c.c_gloria) && Option.value o.gloria ~default:false && c.c_has_commemoration && c.c_rank = V.Class3
  in
  let is_l6_rogation_colour (o : ordo_row) (c : colitur_row) =
    (* CLOSED (celebrant-rubrics-phase1, Bug 1 fix). Was root-caused to a
       DIFFERENT, pre-existing bug this comparison merely surfaced --
       {!Rite_ef.Temporal_ef.temporal}'s own Rogation Monday/Tuesday branch
       used to hardcode [Colour.Violet] with no RG citation at all, where
       the Ordo shows this exact date "FERIA IV Cl W" (white, matching
       Paschaltide's own [season_colour] and RG88's "nihil fit in Officio"
       -- the Office, hence its colour, is unchanged by the Rogation, only
       the Mass TEXT is proper) -- so [gloria]'s own 432(b) violet guard
       wrongly fired. Now fixed at the source (temporal_ef.ml); this
       predicate and its own ~expected_gloria_l6 count (0 in every window)
       are kept as the regression net, not deleted. *)
    (not c.c_gloria) && Option.value o.gloria ~default:false
    && (contains_substring c.c_slug ~needle:"rogation-monday" || contains_substring c.c_slug ~needle:"rogation-tuesday")
  in
  let test_gloria_matches_or_is_explained ~expected_gloria_l5 ~expected_gloria_l6 () =
    let ordo = ordo_rows fixture_path in
    let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in
    let unexplained = ref [] in
    let l5_count = ref 0 and l6_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_l5_lenten_commemoration o c then incr l5_count
            else if is_l6_rogation_colour o c then incr l6_count
            else unexplained := describe_gloria_mismatch o c :: !unexplained)
      ordo colitur;
    Alcotest.(check (list string)) (Printf.sprintf "[%s] every Gloria mismatch is named in the allow-list -- none unexplained" label)
      [] (List.rev !unexplained);
    Alcotest.(check int) (Printf.sprintf "[%s] L5 (Lenten privileged feria + commemoration) count" label)
      expected_gloria_l5 !l5_count;
    Alcotest.(check int) (Printf.sprintf "[%s] L6 (Rogation Monday/Tuesday colour bug) count" label) expected_gloria_l6
      !l6_count
  in
  let test_bvm_seasonal_selection () =
    let ordo = ordo_rows fixture_path in
    let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in
    let votive_pairs =
      List.filter_map
        (fun (o, c) -> match c.c_formulary with Some { MF.via = MF.Votive; _ } -> Some (o, c) | _ -> None)
        (List.combine ordo colitur)
    in
    Alcotest.(check int) (Printf.sprintf "%d BVM-Saturday (Votive) days fall inside this window" expected_bvm_votive)
      expected_bvm_votive (List.length votive_pairs);
    let mismatches =
      List.filter_map
        (fun (o, c) ->
          let expected = expected_bvm_numeral c.c_season ~month:(int_of_string (String.sub c.c_date 5 2))
                            ~day:(int_of_string (String.sub c.c_date 8 2)) in
          match o.bvm_numeral with
          | Some got when String.equal got expected -> None
          | Some got -> Some (Printf.sprintf "%s: Ordo says %s, expected %s (season-derived)" o.date got expected)
          | None ->
              (* One SOURCE-TEXT anomaly, found extending this suite to
                 2023-2024: 2024-02-03 is the ONLY BVM-Saturday day in the
                 2023-2024 fixture (of 12) whose Ordo entry carries no
                 roman-numeral "<N> Mass of BVM" line at all. Verified
                 directly against the raw pdftotext dump (not inferred):
                 every OTHER BVM-Saturday day in this edition prints "Sat
                 <n> CELEBRATION of the BVM IV Cl W" immediately followed
                 by its own "<N> Mass of BVM" line; this one date's block
                 goes straight from the title line to "(Blessing of
                 throats in honour of St Blaise may be given on this
                 day)" -- the numeral line the Ordo's own layout convention
                 would otherwise print is simply MISSING from the printed
                 text, a publisher-side omission this session cannot
                 correct, not a colitur defect (colitur's own side is
                 independently right here: St Blaise is scan-verified
                 "Com." -- a bare commemoration, not a feast -- in the
                 calendarium itself, docs/research/LT.txt:4978, so RG 91's
                 table has no row for him and the BVM-Saturday office
                 correctly wins the day outright, exactly as colitur
                 computes). Excluded by name, not silently: a future
                 change to this population (a different date, or a SECOND
                 gap) would still be caught below. *)
              if String.equal fixture_path "fixtures/lms-ordo-2023-2024.sexp" && String.equal o.date "2024-02-03" then
                None
              else Some (Printf.sprintf "%s: colitur says Votive but the Ordo shows no BVM numeral" o.date))
        votive_pairs
    in
    Alcotest.(check (list string)) "every BVM-Saturday numeral matches its season -- none unexplained" [] mismatches
  in
  let test_bvm_numeral_implies_votive () =
    let ordo = ordo_rows fixture_path in
    let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in
    let mismatches =
      List.filter_map
        (fun (o, c) ->
          match (o.bvm_numeral, c.c_formulary) with
          | Some n, Some { MF.via = MF.Votive; _ } -> ignore n; None
          | Some n, other ->
              Some
                (Printf.sprintf "%s: Ordo shows %S Mass of BVM but colitur's via is %s" o.date n
                   (match other with Some f -> MF.source_to_string f.MF.via | None -> "NONE"))
          | None, _ -> None)
        (List.combine ordo colitur)
    in
    Alcotest.(check (list string)) "every Ordo BVM numeral day is a colitur Votive day" [] mismatches
  in
  let test_formulary_override_matches () =
    let ordo = ordo_rows fixture_path in
    let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in
    let r = check_formulary_overrides ordo colitur in
    Alcotest.(check (list string)) "every formulary-override mismatch is explained -- none unexplained" [] r.fc_bad;
    Alcotest.(check int) (Printf.sprintf "%d Proper days in the window" expected_proper) expected_proper r.fc_proper;
    Alcotest.(check int) (Printf.sprintf "%d Common days in the window" expected_common) expected_common r.fc_common;
    Alcotest.(check int) (Printf.sprintf "%d Preceding_sunday days in the window" expected_preceding_sunday)
      expected_preceding_sunday r.fc_preceding_sunday;
    Alcotest.(check int)
      (Printf.sprintf "%d Ascension-week days resolve via Own_slug and match the Ordo's own override, no more, no fewer"
         expected_ascension_week)
      expected_ascension_week r.fc_ascension_week
  in
  let test_via_distribution_totals () =
    let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in
    let via_name c = match c.c_formulary with None -> "none" | Some f -> MF.source_to_string f.MF.via in
    let counts = Hashtbl.create 8 in
    List.iter
      (fun c -> Hashtbl.replace counts (via_name c) (1 + try Hashtbl.find counts (via_name c) with Not_found -> 0))
      colitur;
    let total = Hashtbl.fold (fun _ n acc -> acc + n) counts 0 in
    Alcotest.(check int) "every via bucket sums to the full window" expected_rows total
  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 "only this window's own Good Friday 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 "only this window's own Good Friday has no Ordo Gloria marker" `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 ~expected_gloria_l5 ~expected_gloria_l6);
      Alcotest.test_case "every BVM-Saturday numeral matches its season" `Quick test_bvm_seasonal_selection;
      Alcotest.test_case "every Ordo BVM numeral day is a colitur Votive day" `Quick
        test_bvm_numeral_implies_votive;
      Alcotest.test_case "formulary override STRING matches on Proper/Common/Preceding_sunday days" `Quick
        test_formulary_override_matches;
      Alcotest.test_case "the via distribution sums to the full window" `Quick test_via_distribution_totals
    ] )

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

(* Guards against a stale allow-list entry (declared in the sexp file, but
   the OCaml-side [allow_list_id_for_date] predicate above no longer maps
   any date to it, or never did) -- the per-window suites above only prove
   an id that DOES fire has the right count; nothing in them can notice an
   id that never fires in ANY of the three windows, since each suite only
   ever looks at its own explained_counts. Fixed to the two ids this task
   added (L3, L4) -- L1 is CLOSED/removed and L2 is deliberately prose-only
   (see the sexp file's own header on why), so neither is expected here. *)
(* L3 CLOSED (bissextile-shift task, 2026-08-22): removed from the active
   sexp record set -- see its own now-prose closure note in
   data/ef/expected-divergences-lms.sexp for the fix and the measured
   blast radius. Only L4 remains active. *)
let test_allow_list_ids_are_exactly_l4 () =
  let ids = List.sort String.compare (List.map (fun e -> e.id) (load_allow_list allow_list_path)) in
  Alcotest.(check (list string)) "the LMS allow-list declares exactly L4" [ "L4" ] ids

let suite_allow_list = ("lms-ordo-allow-list", [ Alcotest.test_case "declares exactly L4" `Quick test_allow_list_ids_are_exactly_l4 ])

let suite_2023_2024 =
  make_suite ~label:"lms-ordo-2023-2024" ~fixture_path:"fixtures/lms-ordo-2023-2024.sexp"
    ~fixture_sha256:"c8d9d4b790f6b4438e932f70dad8ebe9a0316662f1eb02c26248e04c7be1a62b" ~window_first:"2023-12-01"
    ~window_last:"2024-12-31" ~year_lo:2022 ~year_hi:2025 ~allow_list_path ~expected_rows:397
    (* expected_proper 182 -> 181 (bissextile-shift task, 2026-08-22): 24
       February 2024, previously Matthias's own Proper Mass, is now the
       Ember Saturday of Lent's own temporal proper -- Own_slug-sourced, not
       Proper, so it leaves this bucket's population rather than merely
       changing which slug's proper it is (see this file's own header on why
       Own_slug is excluded). Matthias himself moves to 25 February, an
       I-class Sunday that admits him not even as a commemoration (RG16(a)),
       so his own Mass is not said anywhere in this window any more. *)
    (* expected_preceding_sunday 61 -> 60 (celebrant-rubrics-phase1, Bug 2
       fix, 2026-08-22): the window's own single Ascension-week day
       (2024-05-06 was BEFORE Ascension that year and never counted here in
       the first place -- see [ascension_week_override]'s own citation; the
       real Ascension-week day this window carries is elsewhere in May)
       now resolves via Own_slug (step 2, data/ef/lectionary.sexp's new
       [ascension_week_entries]), not Preceding_sunday -- it leaves this
       bucket's population the same way a Proper day already does, while
       expected_ascension_week itself is UNCHANGED (1): the same day is
       still counted, just via check_formulary_overrides' own new
       Own_slug branch instead of its Preceding_sunday one. *)
    ~expected_bvm_votive:12 ~expected_proper:181 ~expected_common:2 ~expected_preceding_sunday:60
    ~expected_ascension_week:1 ~expected_gloria_l5:5 ~expected_gloria_l6:0

let suite_2024_2025 =
  make_suite ~label:"lms-ordo-2024-2025" ~fixture_path:"fixtures/lms-ordo-2024-2025.sexp"
    ~fixture_sha256:"da817b75c5bf40ed3be1d5f6890b199705e02ce4d42111253ab8547bccabc3f7" ~window_first:"2024-11-27"
    ~window_last:"2025-12-31" ~year_lo:2023 ~year_hi:2026 ~allow_list_path ~expected_rows:400
    (* expected_preceding_sunday 66 -> 63 (celebrant-rubrics-phase1, Bug 2
       fix): this window's own three Ascension-week days move from
       Preceding_sunday to Own_slug, the same reasoning as the 2023-2024
       window's own note above; expected_ascension_week stays 3. *)
    ~expected_bvm_votive:14 ~expected_proper:179 ~expected_common:2 ~expected_preceding_sunday:63
    ~expected_ascension_week:3 ~expected_gloria_l5:5 ~expected_gloria_l6:0

let suite_2025_2026 =
  make_suite ~label:"lms-ordo-2025-2026" ~fixture_path:"fixtures/lms-ordo-2025-2026.sexp"
    ~fixture_sha256:"8839a61e0c7d1e6c8326114f4f45a5c183551287a88f4ed01127154add6ae5a5" ~window_first:"2025-11-28"
    ~window_last:"2026-12-31" ~year_lo:2024 ~year_hi:2027 ~allow_list_path ~expected_rows:399
    (* expected_preceding_sunday 68 -> 66 (celebrant-rubrics-phase1, Bug 2
       fix): this window's own two Ascension-week days move from
       Preceding_sunday to Own_slug, the same reasoning as the 2023-2024
       window's own note above; expected_ascension_week stays 2. *)
    ~expected_bvm_votive:13 ~expected_proper:174 ~expected_common:2 ~expected_preceding_sunday:66
    ~expected_ascension_week:2 ~expected_gloria_l5:6 ~expected_gloria_l6:0