aboutsummaryrefslogtreecommitdiff
path: root/test/test_lms_ordo.ml
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-24 16:29:23 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-24 16:29:23 +0200
commit8a5da8756cf6b57aa36c01b71ce893f7967d3a29 (patch)
tree10873a692400fa25ce54c67ac9fc34d46af86272 /test/test_lms_ordo.ml
parent4f87cbde4ee79ac96aa7ebfed105b3a5ec02be43 (diff)
parent73b15551804bb63ee0081005e2869d36afb54be2 (diff)
downloadcolitur-8a5da8756cf6b57aa36c01b71ce893f7967d3a29.tar.gz
colitur-8a5da8756cf6b57aa36c01b71ce893f7967d3a29.zip
merge: celebrant Mass rubrics, and four rubrical corrections
colitur now prints what a real ordo prints for the Mass: which formulary is said and how it was reached, the Gloria, the Creed, the preface, and the commemorations with their Low-Mass/sung distinction. colitur rubrics joins day and readings. Four defects were found and fixed on the way, each by an external witness rather than by inspection: the Creed said at Requiem Masses (RG 476(f)), the missing bissextile shift of St Matthias and St Gabriel (2 041 leap years), Rogation Monday and Tuesday coloured violet in Paschaltide (RG 119(b)), and the ferias after the Ascension resuming the wrong Sunday's Mass rather than the Ascension's. Validation gained a sixth layer and then some: the preface is checked against three independent publishers over seven witness-years (FIUV, three LMS editions, three extraordinaryform.org editions), none of which shares the Divinum Officium -> missalemeum -> lectio lineage the older layers all descend from.
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