(* Task 6 (2026-08-21-colitur-celebrant-rubrics-phase1) built this layer against ONE LMS Ordo edition (2024-2025). The Witnesses task (2026-08-22, same phase) extends it to THREE editions -- 2023-2024, 2024-2025, 2025-2026 -- each compared in its OWN window, counted and reported SEPARATELY, never merged: a divergence appearing in one year and not another is informative on its own, and merging would hide it. [make_suite] below is the shared machinery Task 6 built as a single flat file; every per-year fact that Task 6 hardcoded (window dates, fixture SHA-256, the "only Good Friday has no Creed marker" pin, the formulary-bucket totals, the BVM-Saturday population) is now a parameter or a value RE-DERIVED from the rubric independently per window, not copied forward -- see each call site below for the per-edition numbers, which do differ (397/399/400 rows; not every edition even has the same II-class "sharp pairing" saint available, see [fixture_probes] in tools/extract_lms_ordo.ml's own header for 2025-2026's own Joachim-is-impeded finding). WHY THIS LAYER EXISTS, AND WHAT IT IS INDEPENDENT OF: layers 3-4 (test_differential.ml, test_oracle.ml) are ONE LINEAGE running Divinum Officium -> missalemeum -> lectio -> colitur (CLAUDE.md's "know what each layer cannot see" section); layer 5 (test_golden.ml) pins colitur's own output against itself. This Ordo is compiled independently by the Latin Mass Society (a printed book, England & Wales diocesan propers) from the 1962 Missal/1960 rubrics directly -- it never passed through Divinum Officium, missalemeum or lectio at any point, so an error inherited by that whole lineage (colitur's own bootstrap included) is visible here for the first time. Three editions of the SAME publisher's book are still only ONE compiler's judgement (see docs/research/ordo/ PROVENANCE-ordo-corpus.md's own "what this corpus still cannot show" -- genuine cross-compiler independence needs the FIUV layer, test_fiuv_ ordo.ml, built separately in this same task). SCOPE, decided after running the comparison against real data (not guessed in advance -- see each fixture's own CHARACTERISATION section): - The CREED (RG 475-476, {!Rite_ef.Rubrics_ef.creed}): compared on every day, full 1:1, in every edition's own window. Exactly one day per window is EXCLUDED, not silently skipped: that window's own Good Friday (RE-DERIVED per window from {!Colitur_kernel.Computus. gregorian_easter}, never hardcoded to a specific date) has no Mass in the 1955-restored Holy Week, so the Ordo prints no Gl/Cr line at all there -- [test_creed_coverage] asserts this is the ONLY such day per window. - The BVM-SATURDAY SEASONAL SELECTION (RG 309(a), "iuxta temporum diversitatem", {!Rite_ef.Lectionary_ef.bvm_saturday_citations}): the Ordo's own roman numeral (I-V) names WHICH of the five seasonal "Missae de sancta Maria in sabbato" is said, so it validates colitur's SEASONAL SELECTION, not merely that some BVM Mass was chosen. Checked against every {!Colitur_kernel.Mass_formulary. Votive} day inside each edition's own window. - The FORMULARY OVERRIDE (does the day say its own Mass, or a borrowed one) -- compared for THREE of {!Colitur_kernel. Mass_formulary.source}'s five constructors ([Proper]/ [Preceding_sunday]/[Common]), on the same [Own_slug]-excluded, [Votive]-covered-separately basis Task 6 established (see that task's own report for the full reasoning: 11 of [Own_slug]'s real days show an Ordo override anyway, a data-representation ambiguity in colitur's own lectionary, not a rubric question the Ordo could adjudicate). GLORIA is extracted into every fixture (a genuine, independently useful field for the still-unbuilt Phase 2 Gloria rule) but NOT compared here: colitur has no {!Rite_ef.Rubrics_ef}-level Gloria predicate yet. DIOCESAN SCOPE: colitur computes the UNIVERSAL General Roman Calendar only (no diocesan overlay loaded by the differential/oracle harnesses anywhere in this suite) -- every LMS edition is an England & Wales diocesan Ordo, and each fixture's own provenance header quantifies how many of its days carry at least one diocesan variant. Every comparison in this file reads ONLY each fixture's own universal-block fields. *) module Cal = Colitur_kernel.Calendar module Layer = Colitur_kernel.Layer module Overlay = Colitur_kernel.Overlay module LD = Colitur_kernel.Liturgical_day module Date = Colitur_kernel.Date module Computus = Colitur_kernel.Computus module MF = Colitur_kernel.Mass_formulary module V = Rite_ef.Vocab_ef module TE = Rite_ef.Temporal_ef let sanctoral_path = "../data/ef/sanctoral.sexp" let adjustments_path = "../data/ef/adjustments.sexp" (* Duplicated, not shared, from test_oracle.ml/test_differential.ml's own identical function -- neither file exposes an .mli the other could depend on, and this is three lines (test_oracle.ml's own comment). *) let sha256_of_file path = let tmp = Filename.temp_file "colitur_lms_ordo_sha256" ".txt" in Fun.protect ~finally:(fun () -> try Sys.remove tmp with Sys_error _ -> ()) (fun () -> let cmd = Printf.sprintf "sha256sum %s > %s" (Filename.quote path) (Filename.quote tmp) in let rc = Sys.command cmd in if rc <> 0 then Alcotest.failf "sha256sum exited %d for %s (is it on PATH?)" rc path; let ic = open_in tmp in let line = try input_line ic with End_of_file -> close_in ic; Alcotest.failf "sha256sum produced no output for %s" path in close_in ic; match String.index_opt line ' ' with | Some i -> String.sub line 0 i | None -> Alcotest.failf "unexpected sha256sum output for %s: %S" path line) let real_layer () = let layer = match Layer.load V.rank_of_sexp sanctoral_path with | Ok l -> l | Error e -> Alcotest.failf "%s: failed to load: %s" sanctoral_path e in let overlay = match Overlay.load V.rank_of_sexp adjustments_path with | Ok o -> o | Error e -> Alcotest.failf "%s: failed to load: %s" adjustments_path e in let layer, diagnostics = Overlay.apply layer overlay in Alcotest.(check (list string)) "the committed overlay applies cleanly, no diagnostics" [] (List.map Overlay.diagnostic_to_string diagnostics); layer let real_lectionary () = match Colitur_kernel.Lectionary.load "../data/ef/lectionary.sexp" with | Ok l -> l | Error e -> Alcotest.failf "../data/ef/lectionary.sexp: failed to load: %s" e let real_commons () = match Rite_ef.Lectionary_ef.Commons.load "../data/ef/commons.sexp" with | Ok c -> c | Error e -> Alcotest.failf "../data/ef/commons.sexp: failed to load: %s" e (* ---------------------------------------------------------------------- *) (* The Ordo side: the sexp row, mirrored (not shared -- see this file's *) (* own header) from tools/extract_lms_ordo.ml's own [row]. *) (* ---------------------------------------------------------------------- *) open Sexplib0.Sexp_conv type ordo_row = { date : string; weekday : string; title : string; formulary_override : string option; bvm_numeral : string option; gloria : bool option; creed : bool option; has_diocesan_variant : bool; } [@@deriving sexp] let ordo_rows fixture_path = let sexp = try Sexplib.Sexp.load_sexp fixture_path with e -> Alcotest.failf "%s: failed to load: %s" fixture_path (Printexc.to_string e) in list_of_sexp ordo_row_of_sexp sexp (* ---------------------------------------------------------------------- *) (* The colitur side, over the SAME window a given fixture covers. Mirrors *) (* test_oracle.ml's own [colitur_rows]: the resolution walk starts a civil *) (* year EARLY (and ends one late) because a liturgical year is *) (* Advent-anchored and straddles two civil years -- generalised from Task *) (* 6's single hardcoded [2023..2025] to a [~year_lo]/[~year_hi] parameter *) (* per window, since the three editions' own civil-year spans differ. *) (* ---------------------------------------------------------------------- *) type colitur_row = { c_date : string; c_season : V.season; c_formulary : MF.t option; c_creed : bool; c_gloria : bool; c_rank : V.rank; c_slug : string; c_has_commemoration : bool; (** whether {!Colitur_kernel.Liturgical_day.t.commemorations} is non-empty -- needed by the Gloria comparison's own L5 shape (RG 431(b)/n.302(b)), not by anything Task 6 built. *) } let colitur_rows ~year_lo ~year_hi ~window_first ~window_last = let layer = real_layer () in let rite = Rite_ef.context ~lectionary:(real_lectionary ()) ~commons:(real_commons ()) in let by_rata : (int, (V.season, V.rank) LD.t) Hashtbl.t = Hashtbl.create 800 in for y = year_lo to year_hi do let days = Cal.year rite layer y in Array.iter (fun (d : (V.season, V.rank) LD.t) -> Hashtbl.replace by_rata (Date.to_rata d.LD.date) d) days done; let mk s = match Date.of_iso8601 s with Ok d -> d | Error e -> Alcotest.failf "%s: not a valid ISO-8601 date: %s" s e in let rows = ref [] in let d = ref (mk window_first) in let stop = mk window_last in while Date.compare !d stop <= 0 do (match Hashtbl.find_opt by_rata (Date.to_rata !d) with | Some day -> rows := { c_date = Date.to_iso8601 day.LD.date; c_season = day.LD.temporal.Colitur_kernel.Temporal.season; c_formulary = day.LD.formulary; c_creed = day.LD.creed; c_gloria = day.LD.gloria; c_rank = day.LD.observed.Colitur_kernel.Celebration.rank; c_slug = Colitur_kernel.Slug.to_string day.LD.observed.Colitur_kernel.Celebration.slug; c_has_commemoration = day.LD.commemorations <> [] } :: !rows | None -> Alcotest.failf "no colitur day resolved for %s" (Date.to_iso8601 !d)); d := Date.add_days !d 1 done; List.rev !rows (* ---------------------------------------------------------------------- *) (* RG 309(a), "iuxta temporum diversitatem" -- INDEPENDENTLY re-derived *) (* from the rubric, not called from Lectionary_ef.bvm_saturday_citations. *) (* ---------------------------------------------------------------------- *) let expected_bvm_numeral season ~month ~day = match (season : V.season) with | V.Advent -> "I" | V.Christmastide -> "II" | V.Time_after_epiphany when month = 1 || (month = 2 && day <= 2) -> "II" | V.Time_after_epiphany | V.Septuagesima | V.Lent | V.Passiontide -> "III" | V.Paschaltide -> "IV" | V.Time_after_pentecost -> "V" (* ---------------------------------------------------------------------- *) (* data/ef/expected-divergences-lms.sexp -- same shape as *) (* test_oracle.ml's own [allow_entry]/[load_allow_list]. ONE allow-list *) (* file for all three editions (they are one lineage, the same publisher *) (* and compiler) -- an entry's own [note] names which edition(s) it fired *) (* on when that matters. *) (* ---------------------------------------------------------------------- *) type allow_entry = { id : string; citation : string; verdict : string; note : string; expected_rows : int } [@@deriving sexp] let load_allow_list allow_list_path = let sexps = try Sexplib.Sexp.load_sexps allow_list_path with e -> Alcotest.failf "%s: failed to load: %s" allow_list_path (Printexc.to_string e) in List.map allow_entry_of_sexp sexps (* ---------------------------------------------------------------------- *) (* Formulary override, restricted to Proper/Common/Preceding_sunday -- *) (* see this file's own header for why Own_slug is excluded, and Votive is *) (* covered by the BVM checks instead. Unchanged from Task 6 except for *) (* being read per-window rather than off a single hardcoded fixture. *) (* ---------------------------------------------------------------------- *) let ordinal n = let suffix = if n mod 100 >= 11 && n mod 100 <= 13 then "th" else match n mod 10 with 1 -> "st" | 2 -> "nd" | 3 -> "rd" | _ -> "th" in Printf.sprintf "%d%s" n suffix let parse_sunday_slug slug = let prefix = "ef-" in let plen = String.length prefix in if String.length slug <= plen || String.sub slug 0 plen <> prefix then None else let body = String.sub slug plen (String.length slug - plen) in let marker = "-sunday-" in let mlen = String.length marker and blen = String.length body in let rec find_last i best = if i + mlen > blen then best else find_last (i + 1) (if String.sub body i mlen = marker then Some i else best) in match find_last 0 None with | None -> None | Some i -> ( let word = String.sub body 0 i in let num_str = String.sub body (i + mlen) (blen - i - mlen) in match int_of_string_opt num_str with Some n -> Some (word, n) | None -> None) (* [~season] disambiguates ONE genuinely ambiguous case, found extending this suite to the 2023-2024 window (2024-11-05..08): colitur's own "resumed-Sunday tail" (CLAUDE.md's own term) REUSES an Epiphany Sunday's own slug ([ef-time-after-epiphany-sunday-4]) for a feria whose REAL calendar date falls in November, inside Time after Pentecost -- Easter was late enough in 2024 that not every Epiphany Sunday got "used" before Septuagesima cut the season short, so the leftover ones resume near the END of the year instead. The Ordo's own prose distinguishes the two occurrences of the SAME slug by wording alone: "4th Sunday after the Epiphany" in January, "4th Sunday REMAINING after the Epiphany" in November (verified directly against the raw dump). [c_season] is the feria's own actual calendar season -- [Time_after_epiphany] for the January occurrence, [Time_after_pentecost] for the November one -- so comparing it against the WORD parsed out of the resumed slug ("time- after-epiphany" either way) is exactly the signal needed, with no new colitur-side field required. *) let expected_preceding_sunday_override ~(season : V.season) (word : string) (n : int) = match (word, n) with | "advent", 1 -> Some "Advent Sunday" | "advent", 2 -> Some "2nd Sunday of Advent" | "advent", 3 -> Some "3rd Sunday of Advent (Gaudete Sunday)" | "advent", 4 -> Some "4th Sunday of Advent" | "septuagesima", 1 -> Some "Septuagesima Sunday" | "septuagesima", 2 -> Some "Sexagesima Sunday" | "septuagesima", 3 -> Some "Quinquagesima Sunday" | "time-after-epiphany", n when season = V.Time_after_epiphany -> Some (Printf.sprintf "%s Sunday after the Epiphany" (ordinal n)) | "time-after-epiphany", n -> Some (Printf.sprintf "%s Sunday remaining after the Epiphany" (ordinal n)) | "time-after-pentecost", 24 -> Some "24th & Last Sunday After Pentecost" | "time-after-pentecost", n -> Some (Printf.sprintf "%s Sunday after Pentecost" (ordinal n)) (* [n >= 3], no upper bound: Task 6's original [n <= 5] excluded n=6/7 on the (overfit, 2024-2025-window-only) assumption that EVERY such day is Ascension-adjacent. Extending to 2023-2024/2025-2026 found a REAL counter-example -- Rogation Monday (RG 87-89), which also resumes week 6's own Sunday but falls BEFORE Ascension, not after, and the Ordo prints the ordinary "5th Sunday after Easter" there, not "Mass of the Ascension". The caller now decides Ascension-adjacency by the day's own DATE against {!Computus.ascension}, not by [n] -- so this mapping can be trusted for n=6/7 whenever the caller does not first divert to [ascension_week_override]. *) | "easter", n when n >= 3 -> Some (Printf.sprintf "%s Sunday after Easter" (ordinal (n - 1))) | _ -> None let expected_named_sunday_override slug ~year = if String.equal slug "ef-christ-the-king" then match TE.week (TE.christ_the_king year) with | Some n -> Some (Printf.sprintf "%s Sunday after Pentecost" (ordinal n)) | None -> None (* [ef-low-sunday]: a SECOND named-Sunday population reaching [Preceding_sunday], found the same way [ef-christ-the-king] was -- absent from the 2024-2025 window Task 6 built this mapping against (every Low-Sunday-week feria that year had its own proper), present in both 2023-2024 (9-12 April 2024) and 2025-2026. The Ordo's own text is the plain, unnumbered "Mass of Low Sunday". *) else if String.equal slug "ef-low-sunday" then Some "Low Sunday" else None (* See Task 6's own [ascension_week_override] citation (test_lms_ordo.ml history, data/ef/expected-divergences-lms.sexp's own now-CLOSED L2, celebrant-rubrics-phase1 Bug 2, 2026-08-22): STRUCTURAL, not a fixture artefact -- the Ascension is a fixed Easter+39, so every feria whose own [Preceding_sunday] fallback would otherwise resolve to a week-6/7 Sunday falls, in SOME years, between Ascension Thursday and the following Sunday. Extending this suite past the single 2024-2025 window sharpened the finding: it is specifically the days AFTER Ascension (not merely "any week-6/7 Preceding_sunday day") that get this treatment -- see [expected_preceding_sunday_override]'s own citation for the Rogation Monday counter-example that exposed the difference. [is_after_ascension] below is the corrected predicate: the day's own DATE compared against {!Computus.ascension} for its own year, not [n]. FIXED (celebrant-rubrics-phase1, Bug 2): data/ef/lectionary.sexp's new [ascension_week_entries] now answers these six ferial slugs directly at step 2 (Own_slug), so [check_formulary_overrides]'s own [Preceding_sunday] branch below never reaches them any more -- moved to a dedicated [Own_slug] branch in that same function, which still asserts the Ordo's override reads exactly [ascension_week_override], now as a genuine confirmation that colitur's OWN citation matches (previously this predicate only served to EXCLUDE the population from the general Preceding_sunday mapping, because colitur had no correct answer to compare). This constant and [is_after_ascension] itself are still load- bearing for that confirmation -- kept, not deleted, the same "the regression net survives its own fix" discipline data/ef/expected-divergences-lms.sexp's own L6 note states for [is_l6_rogation_colour]. *) let ascension_week_override = "Mass of the Ascension" (* The exact six slugs data/ef/lectionary.sexp's own [ascension_week_entries] (tools/bootstrap_lectionary.ml) now answers at step 2 -- gated on SLUG IDENTITY, not merely [is_after_ascension date], because that date range also covers "Dominica post Ascensionem" and the Vigil of Pentecost, both genuinely [Own_slug] and both carrying their OWN, different citation, not the Ascension's (caught live: an earlier, date-only version of this gate wrongly flagged both as missing an Ordo override before this list was added). *) let ascension_week_slugs = [ "ef-easter-6-friday"; "ef-easter-7-monday"; "ef-easter-7-tuesday"; "ef-easter-7-wednesday"; "ef-easter-7-thursday"; "ef-easter-7-friday" ] (* Bounded ABOVE by Pentecost, not merely "later than Ascension" -- a bare lower bound is wrong for any date past Ascension in the SAME civil year (every date from June to December satisfies "> Ascension", which a first version of this predicate did and it manufactured 8 false failures across the 2024-2025/2025-2026 windows, in July/November/ December, caught immediately by re-running against those two previously-clean windows). Task 6's own original population (Friday after Ascension, then the following Monday/Tuesday) already showed the window extends PAST the immediately-following Sunday, not merely up to it, so Pentecost -- the next fixed liturgical anchor, Easter+49 -- is the natural, provable upper bound: nothing in this stretch has its own proper once Ascension's octave-like privilege takes over, and Pentecost itself always does (RG 91 entry 3). *) let is_after_ascension date = let year = Date.year date in Date.compare date (Computus.ascension year) > 0 && Date.compare date (Computus.pentecost year) < 0 type formulary_counts = { fc_bad : string list; fc_proper : int; fc_common : int; fc_preceding_sunday : int; fc_ascension_week : int; } let check_formulary_overrides ordo colitur = let pairs = List.combine ordo colitur in let proper = ref 0 and common = ref 0 and preceding_sunday = ref 0 and ascension_week = ref 0 in let bad = ref [] in List.iter (fun ((o : ordo_row), (c : colitur_row)) -> match c.c_formulary with | Some { MF.via = MF.Proper; _ } -> incr proper; if o.formulary_override <> None then bad := Printf.sprintf "%s: Proper day but Ordo shows override %s" o.date (Option.get o.formulary_override) :: !bad | Some { MF.via = MF.Common; _ } -> incr common; if o.formulary_override <> None then bad := Printf.sprintf "%s: Common day but Ordo shows override %s" o.date (Option.get o.formulary_override) :: !bad | Some { MF.via = MF.Preceding_sunday; said } -> ( incr preceding_sunday; let slug = match said with | Some s -> Colitur_kernel.Slug.to_string s | None -> Alcotest.failf "%s: Preceding_sunday day with said = None (should be impossible)" o.date in let ordo_says = Printf.sprintf "Mass of %s" in let year = int_of_string (String.sub o.date 0 4) in let date = match Date.of_iso8601 o.date with Ok d -> d | Error e -> Alcotest.failf "%s: %s" o.date e in if is_after_ascension date then ( match o.formulary_override with | Some got when String.equal got ascension_week_override -> incr ascension_week | Some got -> bad := Printf.sprintf "%s: colitur resumed %S (after Ascension), Ordo shows %S, not the adjudicated %S" o.date slug got ascension_week_override :: !bad | None -> bad := Printf.sprintf "%s: colitur resumed %S (after Ascension), Ordo shows no override" o.date slug :: !bad) else let expected = match parse_sunday_slug slug with | Some (word, n) -> expected_preceding_sunday_override ~season:c.c_season word n | None -> expected_named_sunday_override slug ~year in match expected with | Some expected -> ( let expected_line = ordo_says expected in match o.formulary_override with | Some got when String.equal got expected_line -> () | Some got -> bad := Printf.sprintf "%s: colitur resumed %S, Ordo says %S" o.date expected_line got :: !bad | None -> bad := Printf.sprintf "%s: colitur resumed %S, Ordo shows no override" o.date expected_line :: !bad) | None -> ( match o.formulary_override with | Some got when String.equal got ascension_week_override -> incr ascension_week | Some got -> bad := Printf.sprintf "%s: unmapped (colitur resumed %S) and Ordo shows %S, not the adjudicated %S" o.date slug got ascension_week_override :: !bad | None -> bad := Printf.sprintf "%s: unmapped (colitur resumed %S) and Ordo shows no override" o.date slug :: !bad)) | Some { MF.via = MF.Own_slug; said } -> ( (* celebrant-rubrics-phase1, Bug 2 fix: the six Ascension-week ferias moved from [Preceding_sunday] (the branch above, which used to special-case them via [is_after_ascension]) to [Own_slug] -- data/ef/lectionary.sexp's own [ascension_week_entries] now answers at step 2, before step 3 is ever reached. Checked HERE, not merely left to fall through silently the way every OTHER [Own_slug] day already does (RG 299's ordinary case, a real proper Mass, needs no Ordo cross-check because nothing about it is adjudicated): this is the one [Own_slug] population this suite has an OUTSIDE witness for, and losing that check when the fix landed would have been a silent regression net, not merely dead code -- the Ordo's own override text must still read "Mass of the Ascension" on exactly these days. *) let slug = match said with Some s -> Colitur_kernel.Slug.to_string s | None -> "" in if List.mem slug ascension_week_slugs then match o.formulary_override with | Some got when String.equal got ascension_week_override -> incr ascension_week | Some got -> bad := Printf.sprintf "%s: colitur resolved %S via Own_slug (after Ascension), Ordo shows %S, not %S" o.date slug got ascension_week_override :: !bad | None -> bad := Printf.sprintf "%s: colitur resolved %S via Own_slug (after Ascension), Ordo shows no override" o.date slug :: !bad) | Some { MF.via = MF.Votive; _ } | None -> ()) pairs; { fc_bad = List.rev !bad; fc_proper = !proper; fc_common = !common; fc_preceding_sunday = !preceding_sunday; fc_ascension_week = !ascension_week } (* ---------------------------------------------------------------------- *) (* [make_suite]: one edition's worth of tests, parameterised. Every count *) (* below is a genuine per-edition PIN (measured once against real data, *) (* not guessed), the same discipline every other golden/oracle fixture in *) (* this suite already uses -- a change to any of them is a real finding, *) (* not noise to silence. *) (* ---------------------------------------------------------------------- *) (* No Str/regex (frozen deps) -- the same hand-rolled substring test every other file in this codebase reaches for (rubrics_ef.ml's own [contains_substring], precedence_ef.ml's [contains_substring]). Local to this file, not shared, on the same "no common .mli to hang it from" footing those other copies already document. *) let contains_substring s ~needle = let ls = String.length s and ln = String.length needle in let rec at i = i + ln <= ls && (String.sub s i ln = needle || at (i + 1)) in ln = 0 || at 0 let make_suite ~label ~fixture_path ~fixture_sha256 ~window_first ~window_last ~year_lo ~year_hi ~allow_list_path ~expected_rows ~expected_bvm_votive ~expected_proper ~expected_common ~expected_preceding_sunday ~expected_ascension_week ~expected_gloria_l5 ~expected_gloria_l6 = let test_fixture_checksum () = Alcotest.(check string) "fixture SHA-256 matches its provenance note" fixture_sha256 (sha256_of_file fixture_path) in let test_dates_align () = let ordo = ordo_rows fixture_path in let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in Alcotest.(check int) (Printf.sprintf "the Ordo fixture has %d rows (%s..%s)" expected_rows window_first window_last) expected_rows (List.length ordo); Alcotest.(check int) "colitur resolved the same number of days" (List.length ordo) (List.length colitur); (match ordo with o :: _ -> Alcotest.(check string) "first date" window_first o.date | [] -> Alcotest.fail "empty"); (match List.rev ordo with | o :: _ -> Alcotest.(check string) "last date" window_last o.date | [] -> Alcotest.fail "empty"); let mismatched = List.filter_map (fun (o, c) -> if String.equal o.date c.c_date then None else Some (o.date, c.c_date)) (List.combine ordo colitur) in Alcotest.(check (list (pair string string))) "no misaligned dates" [] mismatched in (* This window's own Good Friday(s) (Easter-2, RE-DERIVED here from {!Computus.gregorian_easter} for every civil year the window touches, never hardcoded) is the only day expected to carry no Ordo Creed marker at all -- Good Friday has no Mass in the 1955-restored Holy Week. Exactly one such date falls inside any one edition's ~13-month window (its OTHER Easter, a year apart, falls outside it), but this is computed, not assumed: a window that unexpectedly contained zero or two would be a real finding this test would catch. *) let window_good_fridays () = let first_year = int_of_string (String.sub window_first 0 4) in let last_year = int_of_string (String.sub window_last 0 4) in let candidates = List.concat_map (fun y -> let gf = Date.add_days (Computus.gregorian_easter y) (-2) in [ Date.to_iso8601 gf ]) (List.init (last_year - first_year + 1) (fun i -> first_year + i)) in List.filter (fun d -> String.compare d window_first >= 0 && String.compare d window_last <= 0) candidates in let test_creed_coverage () = let ordo = ordo_rows fixture_path in let no_creed = List.filter (fun o -> o.creed = None) ordo in let expected = window_good_fridays () in Alcotest.(check (list string)) "only this window's own Good Friday has no Ordo Creed marker" expected (List.map (fun o -> o.date) no_creed) in let describe_creed_mismatch (o : ordo_row) (c : colitur_row) = Printf.sprintf "%s %S: colitur creed=%b, Ordo creed=%b (formulary=%s)" o.date o.title c.c_creed (Option.get o.creed) (match c.c_formulary with | Some { MF.said = Some s; _ } -> Colitur_kernel.Slug.to_string s | Some { MF.said = None; via = MF.Votive } -> "votive (said unnamed in the data)" | Some { MF.said = None; _ } -> "NONE (said, unexpectedly outside Votive)" | None -> "NONE") in (* Which allow-list id, if any, explains a Creed mismatch on this date. Date-keyed (not merely "any mismatch is fine") deliberately: an id that stops firing, or fires on a NEW date it was never adjudicated for, must be visible as a real change, not silently absorbed by a loose "some entry explains something" check. Task 6's own version of this function declared [by_id]/[explained_counts] but never actually populated the latter (the mechanism this comment sits in) -- L1 was already CLOSED by the time that task shipped, so it had nothing real to explain and the gap went unexercised. L4 (Witnesses task, 2026-08-22, 2023-2024-only) is the first live test of it. L3's own "2024-02-24" branch (bissextile-shift task, 2026-08-22) is REMOVED, not left dead: the fix makes that date's Creed match outright, so the branch would never fire again, and a stale date->id mapping for a now-closed entry is exactly the kind of thing this file's own "must be visible as a real change" discipline (this comment's own opening sentence) argues against leaving in place. *) let allow_list_id_for_date d = if String.equal d "2023-12-24" then Some "L4" else None in let test_creed_matches_or_is_explained () = let ordo = ordo_rows fixture_path in let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in let allow_list = load_allow_list allow_list_path in let by_id = List.map (fun e -> (e.id, e)) allow_list in let unexplained = ref [] in let explained_counts = Hashtbl.create 8 in List.iter2 (fun (o : ordo_row) (c : colitur_row) -> if not (String.equal o.date c.c_date) then Alcotest.failf "misaligned: ordo %s vs colitur %s" o.date c.c_date; match o.creed with | None -> () | Some ocreed -> if Bool.equal ocreed c.c_creed then () else ( match allow_list_id_for_date o.date with | Some id -> Hashtbl.replace explained_counts id (1 + try Hashtbl.find explained_counts id with Not_found -> 0) | None -> unexplained := describe_creed_mismatch o c :: !unexplained)) ordo colitur; Alcotest.(check (list string)) (Printf.sprintf "[%s] every Creed mismatch is named in the allow-list -- none unexplained" label) [] (List.rev !unexplained); Hashtbl.iter (fun id n -> match List.assoc_opt id by_id with | None -> Alcotest.failf "allow-list entry %s used by the comparator but not declared in %s" id allow_list_path | Some e -> Alcotest.(check int) (Printf.sprintf "%s: expected_rows matches the actual count" id) e.expected_rows n) explained_counts in (* ---- Gloria (RG 431-432, {!Rite_ef.Rubrics_ef.gloria}) -- Phase 2 of this task. Same shape as the Creed comparison immediately above: coverage (which dates carry no Ordo "Gl"/"No Gl" marker at all) is checked separately from the value comparison, and both are date-keyed against their own allow-list ids, never a loose "some divergence is fine" check. *) let test_gloria_coverage () = let ordo = ordo_rows fixture_path in let no_gloria = List.filter (fun o -> o.gloria = None) ordo in let expected = window_good_fridays () in Alcotest.(check (list string)) "only this window's own Good Friday has no Ordo Gloria marker" expected (List.map (fun o -> o.date) no_gloria) in let describe_gloria_mismatch (o : ordo_row) (c : colitur_row) = Printf.sprintf "%s %S: colitur gloria=%b, Ordo gloria=%b (rank=%s commemoration=%b formulary=%s)" o.date o.title c.c_gloria (Option.get o.gloria) (V.rank_to_string c.c_rank) c.c_has_commemoration (match c.c_formulary with | Some { MF.said = Some s; _ } -> Colitur_kernel.Slug.to_string s | Some { MF.said = None; via = MF.Votive } -> "votive (said unnamed in the data)" | Some { MF.said = None; _ } -> "NONE (said, unexpectedly outside Votive)" | None -> "NONE") in (* L5 is the one REAL, STRUCTURAL shape still open (see data/ef/expected-divergences-lms.sexp's own L5 for the full citation) -- not a fixed date list, a property of the day itself, because it recurs every year the underlying condition holds, not on a fixed calendar date the way L4 above does. Matched by PREDICATE, not by date, for the same reason L2's own note gives for why it is prose-only rather than wired through the shared [expected_rows] mechanism: the count varies window to window (L5: 6/5/6), so a single static count cannot check it, and [make_suite] threads the per-window expected total directly (~expected_gloria_l5 below) instead. L6 is CLOSED (celebrant-rubrics-phase1, Bug 1 fix, this task): {!Rite_ef.Temporal_ef.temporal}'s Rogation Monday/Tuesday branch no longer hardcodes [Colour.Violet] -- see temporal_ef.ml's own citation on that branch and data/ef/expected-divergences-lms.sexp's L6 entry for the fix and its measured blast radius. [is_l6_rogation_colour] and ~expected_gloria_l6 are kept, at 0 in every window, as the regression net the same way L3's own bissextile fix keeps a predicate whose count is now provably zero rather than deleting it. *) let is_l5_lenten_commemoration (o : ordo_row) (c : colitur_row) = (* NOT a Gloria defect (CORRECTED, l5-f3-readjudication task, 2026-08-22 -- data/ef/expected-divergences-lms.sexp's own L5 has the full account, including the RG 431(b)/n.302(b) hypothesis this comment used to state as settled and RG 303(b)'s own "dici potest tantum si occurrit dies liturgicus IV classis", which prohibits 302(b) on exactly these THIRD-class privileged ferias). Every instance is a privileged Lenten/Passiontide feria (Class3, violet -- {!TE}'s own [ferial_rank], RG25) carrying exactly one commemoration of an impeded Class3 saint, colitur reading [gloria]=false (the ferial Mass, correctly, per the 1962 core) where the Ordo reads [true] (the LMS Ordo follows the 2020 decree Cum Sanctissima, colitur excludes it by binding design, CLAUDE.md decision 2) -- an OCCURRENCE difference between two different, both genuine, calendars, not a rule dispute. Predicate kept AS A MATCHER (the 16-date population is unchanged); only the citation explaining WHY it is allow-listed was wrong. *) (not c.c_gloria) && Option.value o.gloria ~default:false && c.c_has_commemoration && c.c_rank = V.Class3 in let is_l6_rogation_colour (o : ordo_row) (c : colitur_row) = (* CLOSED (celebrant-rubrics-phase1, Bug 1 fix). Was root-caused to a DIFFERENT, pre-existing bug this comparison merely surfaced -- {!Rite_ef.Temporal_ef.temporal}'s own Rogation Monday/Tuesday branch used to hardcode [Colour.Violet] with no RG citation at all, where the Ordo shows this exact date "FERIA IV Cl W" (white, matching Paschaltide's own [season_colour] and RG88's "nihil fit in Officio" -- the Office, hence its colour, is unchanged by the Rogation, only the Mass TEXT is proper) -- so [gloria]'s own 432(b) violet guard wrongly fired. Now fixed at the source (temporal_ef.ml); this predicate and its own ~expected_gloria_l6 count (0 in every window) are kept as the regression net, not deleted. *) (not c.c_gloria) && Option.value o.gloria ~default:false && (contains_substring c.c_slug ~needle:"rogation-monday" || contains_substring c.c_slug ~needle:"rogation-tuesday") in let test_gloria_matches_or_is_explained ~expected_gloria_l5 ~expected_gloria_l6 () = let ordo = ordo_rows fixture_path in let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in let unexplained = ref [] in let l5_count = ref 0 and l6_count = ref 0 in List.iter2 (fun (o : ordo_row) (c : colitur_row) -> if not (String.equal o.date c.c_date) then Alcotest.failf "misaligned: ordo %s vs colitur %s" o.date c.c_date; match o.gloria with | None -> () | Some ogloria -> if Bool.equal ogloria c.c_gloria then () else if is_l5_lenten_commemoration o c then incr l5_count else if is_l6_rogation_colour o c then incr l6_count else unexplained := describe_gloria_mismatch o c :: !unexplained) ordo colitur; Alcotest.(check (list string)) (Printf.sprintf "[%s] every Gloria mismatch is named in the allow-list -- none unexplained" label) [] (List.rev !unexplained); Alcotest.(check int) (Printf.sprintf "[%s] L5 (Lenten privileged feria + commemoration) count" label) expected_gloria_l5 !l5_count; Alcotest.(check int) (Printf.sprintf "[%s] L6 (Rogation Monday/Tuesday colour bug) count" label) expected_gloria_l6 !l6_count in let test_bvm_seasonal_selection () = let ordo = ordo_rows fixture_path in let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in let votive_pairs = List.filter_map (fun (o, c) -> match c.c_formulary with Some { MF.via = MF.Votive; _ } -> Some (o, c) | _ -> None) (List.combine ordo colitur) in Alcotest.(check int) (Printf.sprintf "%d BVM-Saturday (Votive) days fall inside this window" expected_bvm_votive) expected_bvm_votive (List.length votive_pairs); let mismatches = List.filter_map (fun (o, c) -> let expected = expected_bvm_numeral c.c_season ~month:(int_of_string (String.sub c.c_date 5 2)) ~day:(int_of_string (String.sub c.c_date 8 2)) in match o.bvm_numeral with | Some got when String.equal got expected -> None | Some got -> Some (Printf.sprintf "%s: Ordo says %s, expected %s (season-derived)" o.date got expected) | None -> (* One SOURCE-TEXT anomaly, found extending this suite to 2023-2024: 2024-02-03 is the ONLY BVM-Saturday day in the 2023-2024 fixture (of 12) whose Ordo entry carries no roman-numeral " 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 CELEBRATION of the BVM IV Cl W" immediately followed by its own " Mass of BVM" line; this one date's block goes straight from the title line to "(Blessing of throats in honour of St Blaise may be given on this day)" -- the numeral line the Ordo's own layout convention would otherwise print is simply MISSING from the printed text, a publisher-side omission this session cannot correct, not a colitur defect (colitur's own side is independently right here: St Blaise is scan-verified "Com." -- a bare commemoration, not a feast -- in the calendarium itself, docs/research/LT.txt:4978, so RG 91's table has no row for him and the BVM-Saturday office correctly wins the day outright, exactly as colitur computes). Excluded by name, not silently: a future change to this population (a different date, or a SECOND gap) would still be caught below. *) if String.equal fixture_path "fixtures/lms-ordo-2023-2024.sexp" && String.equal o.date "2024-02-03" then None else Some (Printf.sprintf "%s: colitur says Votive but the Ordo shows no BVM numeral" o.date)) votive_pairs in Alcotest.(check (list string)) "every BVM-Saturday numeral matches its season -- none unexplained" [] mismatches in let test_bvm_numeral_implies_votive () = let ordo = ordo_rows fixture_path in let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in let mismatches = List.filter_map (fun (o, c) -> match (o.bvm_numeral, c.c_formulary) with | Some n, Some { MF.via = MF.Votive; _ } -> ignore n; None | Some n, other -> Some (Printf.sprintf "%s: Ordo shows %S Mass of BVM but colitur's via is %s" o.date n (match other with Some f -> MF.source_to_string f.MF.via | None -> "NONE")) | None, _ -> None) (List.combine ordo colitur) in Alcotest.(check (list string)) "every Ordo BVM numeral day is a colitur Votive day" [] mismatches in let test_formulary_override_matches () = let ordo = ordo_rows fixture_path in let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in let r = check_formulary_overrides ordo colitur in Alcotest.(check (list string)) "every formulary-override mismatch is explained -- none unexplained" [] r.fc_bad; Alcotest.(check int) (Printf.sprintf "%d Proper days in the window" expected_proper) expected_proper r.fc_proper; Alcotest.(check int) (Printf.sprintf "%d Common days in the window" expected_common) expected_common r.fc_common; Alcotest.(check int) (Printf.sprintf "%d Preceding_sunday days in the window" expected_preceding_sunday) expected_preceding_sunday r.fc_preceding_sunday; Alcotest.(check int) (Printf.sprintf "%d Ascension-week days resolve via Own_slug and match the Ordo's own override, no more, no fewer" expected_ascension_week) expected_ascension_week r.fc_ascension_week in let test_via_distribution_totals () = let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in let via_name c = match c.c_formulary with None -> "none" | Some f -> MF.source_to_string f.MF.via in let counts = Hashtbl.create 8 in List.iter (fun c -> Hashtbl.replace counts (via_name c) (1 + try Hashtbl.find counts (via_name c) with Not_found -> 0)) colitur; let total = Hashtbl.fold (fun _ n acc -> acc + n) counts 0 in Alcotest.(check int) "every via bucket sums to the full window" expected_rows total in ( label, [ Alcotest.test_case "fixture SHA-256 matches its provenance note" `Quick test_fixture_checksum; Alcotest.test_case "streams are N rows each, dates aligned 1:1" `Quick test_dates_align; Alcotest.test_case "only this window's own Good Friday has no Ordo Creed marker" `Quick test_creed_coverage; Alcotest.test_case "every Creed difference is named in the cited allow-list -- none unexplained" `Quick test_creed_matches_or_is_explained; Alcotest.test_case "only this window's own Good Friday has no Ordo Gloria marker" `Quick test_gloria_coverage; Alcotest.test_case "every Gloria difference is named in the cited allow-list -- none unexplained" `Quick (test_gloria_matches_or_is_explained ~expected_gloria_l5 ~expected_gloria_l6); Alcotest.test_case "every BVM-Saturday numeral matches its season" `Quick test_bvm_seasonal_selection; Alcotest.test_case "every Ordo BVM numeral day is a colitur Votive day" `Quick test_bvm_numeral_implies_votive; Alcotest.test_case "formulary override STRING matches on Proper/Common/Preceding_sunday days" `Quick test_formulary_override_matches; Alcotest.test_case "the via distribution sums to the full window" `Quick test_via_distribution_totals ] ) let allow_list_path = "../data/ef/expected-divergences-lms.sexp" (* Guards against a stale allow-list entry (declared in the sexp file, but the OCaml-side [allow_list_id_for_date] predicate above no longer maps any date to it, or never did) -- the per-window suites above only prove an id that DOES fire has the right count; nothing in them can notice an id that never fires in ANY of the three windows, since each suite only ever looks at its own explained_counts. Fixed to the two ids this task added (L3, L4) -- L1 is CLOSED/removed and L2 is deliberately prose-only (see the sexp file's own header on why), so neither is expected here. *) (* L3 CLOSED (bissextile-shift task, 2026-08-22): removed from the active sexp record set -- see its own now-prose closure note in data/ef/expected-divergences-lms.sexp for the fix and the measured blast radius. Only L4 remains active. *) let test_allow_list_ids_are_exactly_l4 () = let ids = List.sort String.compare (List.map (fun e -> e.id) (load_allow_list allow_list_path)) in Alcotest.(check (list string)) "the LMS allow-list declares exactly L4" [ "L4" ] ids let suite_allow_list = ("lms-ordo-allow-list", [ Alcotest.test_case "declares exactly L4" `Quick test_allow_list_ids_are_exactly_l4 ]) let suite_2023_2024 = make_suite ~label:"lms-ordo-2023-2024" ~fixture_path:"fixtures/lms-ordo-2023-2024.sexp" ~fixture_sha256:"c8d9d4b790f6b4438e932f70dad8ebe9a0316662f1eb02c26248e04c7be1a62b" ~window_first:"2023-12-01" ~window_last:"2024-12-31" ~year_lo:2022 ~year_hi:2025 ~allow_list_path ~expected_rows:397 (* expected_proper 182 -> 181 (bissextile-shift task, 2026-08-22): 24 February 2024, previously Matthias's own Proper Mass, is now the Ember Saturday of Lent's own temporal proper -- Own_slug-sourced, not Proper, so it leaves this bucket's population rather than merely changing which slug's proper it is (see this file's own header on why Own_slug is excluded). Matthias himself moves to 25 February, an I-class Sunday that admits him not even as a commemoration (RG16(a)), so his own Mass is not said anywhere in this window any more. *) (* expected_preceding_sunday 61 -> 60 (celebrant-rubrics-phase1, Bug 2 fix, 2026-08-22): the window's own single Ascension-week day (2024-05-06 was BEFORE Ascension that year and never counted here in the first place -- see [ascension_week_override]'s own citation; the real Ascension-week day this window carries is elsewhere in May) now resolves via Own_slug (step 2, data/ef/lectionary.sexp's new [ascension_week_entries]), not Preceding_sunday -- it leaves this bucket's population the same way a Proper day already does, while expected_ascension_week itself is UNCHANGED (1): the same day is still counted, just via check_formulary_overrides' own new Own_slug branch instead of its Preceding_sunday one. *) ~expected_bvm_votive:12 ~expected_proper:181 ~expected_common:2 ~expected_preceding_sunday:60 ~expected_ascension_week:1 ~expected_gloria_l5:5 ~expected_gloria_l6:0 let suite_2024_2025 = make_suite ~label:"lms-ordo-2024-2025" ~fixture_path:"fixtures/lms-ordo-2024-2025.sexp" ~fixture_sha256:"da817b75c5bf40ed3be1d5f6890b199705e02ce4d42111253ab8547bccabc3f7" ~window_first:"2024-11-27" ~window_last:"2025-12-31" ~year_lo:2023 ~year_hi:2026 ~allow_list_path ~expected_rows:400 (* expected_preceding_sunday 66 -> 63 (celebrant-rubrics-phase1, Bug 2 fix): this window's own three Ascension-week days move from Preceding_sunday to Own_slug, the same reasoning as the 2023-2024 window's own note above; expected_ascension_week stays 3. *) ~expected_bvm_votive:14 ~expected_proper:179 ~expected_common:2 ~expected_preceding_sunday:63 ~expected_ascension_week:3 ~expected_gloria_l5:5 ~expected_gloria_l6:0 let suite_2025_2026 = make_suite ~label:"lms-ordo-2025-2026" ~fixture_path:"fixtures/lms-ordo-2025-2026.sexp" ~fixture_sha256:"8839a61e0c7d1e6c8326114f4f45a5c183551287a88f4ed01127154add6ae5a5" ~window_first:"2025-11-28" ~window_last:"2026-12-31" ~year_lo:2024 ~year_hi:2027 ~allow_list_path ~expected_rows:399 (* expected_preceding_sunday 68 -> 66 (celebrant-rubrics-phase1, Bug 2 fix): this window's own two Ascension-week days move from Preceding_sunday to Own_slug, the same reasoning as the 2023-2024 window's own note above; expected_ascension_week stays 2. *) ~expected_bvm_votive:13 ~expected_proper:174 ~expected_common:2 ~expected_preceding_sunday:66 ~expected_ascension_week:2 ~expected_gloria_l5:6 ~expected_gloria_l6:0