aboutsummaryrefslogtreecommitdiff
path: root/test/test_lms_ordo.ml
diff options
context:
space:
mode:
Diffstat (limited to 'test/test_lms_ordo.ml')
-rw-r--r--test/test_lms_ordo.ml1020
1 files changed, 1020 insertions, 0 deletions
diff --git a/test/test_lms_ordo.ml b/test/test_lms_ordo.ml
new file mode 100644
index 0000000..0ac314e
--- /dev/null
+++ b/test/test_lms_ordo.ml
@@ -0,0 +1,1020 @@
+(* 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
+module Preface = Colitur_kernel.Preface
+
+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;
+ praef : string 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_preface : Preface.t option;
+ 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_preface = day.LD.preface;
+ 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 }
+
+(* ---------------------------------------------------------------------- *)
+(* Preface (RG 482-499, {!Rite_ef.Rubrics_ef.preface}) -- Preface-witnesses *)
+(* task (2026-08-23), extending tools/extract_lms_ordo.ml's new [praef] *)
+(* column into a real comparison, the same shape as test_fiuv_ordo.ml's *)
+(* own preface axis but with a genuinely DIFFERENT classification rule -- *)
+(* see the fixture's own provenance header, finding 5, for the full *)
+(* CHARACTERISATION FIRST account this discipline requires. *)
+(* *)
+(* THE KEY DIFFERENCE FROM FIUV: this Ordo prints OPTION LISTS ("Pr of X *)
+(* or Pr of Y or Common Pr"), not a single value per day, and several of *)
+(* the named options are NOT among the fourteen RG 484-497 enumerate *)
+(* ("Martyrs", "All Saints and Patron Saints", "the Dedication of a *)
+(* Church", "the Most Holy Sacrament", "St John the Baptist", "the *)
+(* Angels" -- checked directly against docs/research/LT.txt lines *)
+(* 3936-4020: no such clause exists there). Every raw value in this *)
+(* corpus's own universal block that carries one of these extras also *)
+(* carries a genuine RG-482 alternative alongside it (never as the row's *)
+(* own and only option) -- so [classify_praef_options] parses the FULL *)
+(* option set (splitting on the standalone token "or", never a substring *)
+(* search: checked directly that no trailing descriptive prose in this *)
+(* corpus's universal block ever uses "or" as an ordinary English *)
+(* conjunction), drops any segment that does not name one of *)
+(* {!Preface.t}'s fifteen values, and the comparison below checks colitur's *)
+(* single computed answer is a MEMBER of what survives, not string *)
+(* equality against whichever option prints first. *)
+let has_prefix ~prefix s =
+ String.length s >= String.length prefix && String.sub s 0 (String.length prefix) = prefix
+
+(* Every prefix here was read directly off the three fixtures' own 47
+ distinct universal-block [praef] values (measured, not guessed) --
+ mirrors test_fiuv_ordo.ml's own [praef_prefixes] table in spirit, but
+ this publisher's own English vocabulary, not the FIUV Ordo's Latin.
+ None of these prefixes is a prefix of another (checked): "Pr of St
+ Joseph" and "Pr of St John the Baptist" are the one pair that shares an
+ opening ("Pr of St "), disambiguated by their very next word. *)
+let praef_prefixes =
+ [ ("Common Pr", Preface.Common);
+ ("Pr of the Nativity", Preface.Nativity);
+ ("Pr of the Epiphany", Preface.Epiphany);
+ ("Pr of Lent", Preface.Lent);
+ ("Pr of the Holy Cross", Preface.Holy_cross);
+ ("Pr of Easter", Preface.Easter);
+ ("Pr of the Ascension", Preface.Ascension);
+ ("Pr of the Sacred Heart", Preface.Sacred_heart);
+ ("Pr of Our Lord Jesus Christ the King", Preface.Christ_the_king);
+ ("Pr of the Holy Spirit", Preface.Holy_spirit);
+ ("Pr of the Trinity", Preface.Trinity);
+ ("Pr of the BVM", Preface.Bvm);
+ ("Pr of St Joseph", Preface.St_joseph);
+ ("Pr of the Apostles", Preface.Apostles);
+ ("Pr of the Dead", Preface.Requiem)
+ (* Deliberately UNMAPPED, found live in this corpus's own universal
+ block (not merely in the excluded diocesan variants): "Pr of
+ Martyrs", "Pr of All Saints and Patron Saints", "Pr of the
+ Dedication of a Church", "Pr of the Most Holy Sacrament", "Pr of
+ St John the Baptist", "Pr of the Angels" -- see this section's own
+ header. A segment matching none of the fifteen prefixes above is
+ simply dropped from the parsed option set, never forced into a
+ guess. *)
+ ]
+
+let classify_praef_segment (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
+
+(* Splits on the standalone token "or" (never a substring search -- see
+ this section's own header on why that is safe in this corpus), then
+ classifies each segment independently and keeps only the recognised
+ ones. *)
+let classify_praef_options (raw : string) : Preface.t list =
+ let toks = String.split_on_char ' ' raw |> List.filter (fun t -> t <> "") in
+ let rec split_on_or acc cur = function
+ | [] -> List.rev (List.rev cur :: acc)
+ | "or" :: rest -> split_on_or (List.rev cur :: acc) [] rest
+ | t :: rest -> split_on_or acc (t :: cur) rest
+ in
+ let segments = split_on_or [] [] toks in
+ List.filter_map (fun seg -> classify_praef_segment (String.concat " " seg)) segments
+
+(* ---------------------------------------------------------------------- *)
+(* [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_praef_coverage () =
+ let ordo = ordo_rows fixture_path in
+ let no_praef = List.filter (fun o -> o.praef = None) ordo in
+ let expected = window_good_fridays () in
+ Alcotest.(check (list string)) "only this window's own Good Friday has no Ordo Pr-of/Common-Pr marker" expected
+ (List.map (fun o -> o.date) no_praef)
+ in
+ let describe_praef_mismatch (o : ordo_row) (c : colitur_row) (options : Preface.t list) =
+ Printf.sprintf "%s %S: colitur preface=%s, Ordo praef=%S (parsed options: %s)" o.date o.title
+ (match c.c_preface with Some p -> Preface.to_string p | None -> "NONE")
+ (Option.get o.praef)
+ (String.concat ", " (List.map Preface.to_string options))
+ 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 unexplained = ref [] in
+ let unclassifiable = ref [] 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 -> (
+ let options = classify_praef_options raw in
+ match options with
+ | [] -> unclassifiable := o.date :: !unclassifiable
+ | opts -> (
+ match c.c_preface with
+ | None ->
+ unexplained :=
+ Printf.sprintf "%s %S: colitur preface=NONE (unexpected outside Good Friday), Ordo praef=%S"
+ o.date o.title raw
+ :: !unexplained
+ | Some p -> if List.mem p opts then () else unexplained := describe_praef_mismatch o c opts :: !unexplained)))
+ ordo colitur;
+ Alcotest.(check (list string))
+ (Printf.sprintf "[%s] every Pr-of/Common-Pr mismatch is named in the allow-list -- none unexplained" label)
+ [] (List.rev !unexplained);
+ Alcotest.(check (list string))
+ (Printf.sprintf "[%s] every option list has at least one recognised alternative -- none dropped entirely" label)
+ [] (List.rev !unclassifiable)
+ 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;
+ Alcotest.test_case "only this window's own Good Friday has no Ordo Pr-of/Common-Pr marker" `Quick
+ test_praef_coverage;
+ Alcotest.test_case "every Pr-of/Common-Pr mismatch is named in the allow-list -- none unexplained" `Quick
+ test_praef_matches_or_is_explained
+ ] )
+
+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:"631aedad5a749c814efcffbf1dfd0970181552a7b8985f6f74c5cfa4bf83baf5" ~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:"a2b549f10dfbebfa5f7df7dea74b0e5352f5abbf5e2ce33ef681a9eb8eb116e6" ~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:"8595ffebfc50b7a165643c172f9ba542871dd1d1e45f8c85a21b104da689535f" ~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