From 72dd2426f020f83a392158ead7bd15b36088fd08 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 22 Aug 2026 14:51:07 +0200 Subject: test(lms-ordo): extend the LMS witness layer from one year to three Generalises tools/extract_lms_ordo.ml's start-of-body detection: the hardcoded "End of November 2024" sentinel assumed every edition opens with an "End of November" stub section. The 2023-2024 edition has none (Advent Sunday 2023 fell on 3 December, so the compiler folded the two tail days into the "December 2023" header directly) -- the generalised detector anchors on the body's own "ORDO" title line instead, verified unique in all three known editions and distinct from the Table of Contents' lowercase "Ordo" entries. Extracts test/fixtures/lms-ordo-2023-2024.sexp (397 rows) and lms-ordo-2025-2026.sexp (399 rows), each with a provenance header carrying its own SHA-256, source citation, and a per-edition characterisation record: five opposite-prediction Creed probes, independently re-verified against this run's own extracted rows (a failed probe is now a hard die, not a printed claim). The 2025-2026 edition needed a different sharp II-class pairing than 2024-2025's Joachim/Stephen one, because Joachim (16 August) is impeded by a Sunday that year -- recorded in the fixture header, not silently swapped. test/test_lms_ordo.ml is refactored into a parameterised make_suite, instantiated once per edition; every count Task 6 hardcoded against the single 2024-2025 window is now a per-edition parameter, several of which turned out to differ once measured (BVM-Saturday population 12/14/13, Ascension-week exclusion 1/3/2). Two mapping gaps surfaced only by the new windows and fixed here (test-side, not colitur): Low Sunday reached via Preceding_sunday for the first time (absent from 2024-2025's own window), and the Ascension-week exclusion was wrongly keyed off week number alone -- Rogation Monday also resumes week 6's Sunday but falls BEFORE Ascension, not after, and the Ordo prints the ordinary Sunday text there. Re-bounded by date, against Ascension and Pentecost. Two genuine divergences surfaced in the 2023-2024 window and are allow-listed (L3, L4) rather than fixed, per this task's no-lib-changes scope: - L3 (verdict ordo): colitur has no bissextile calendar rule. The 1962 Missal's own calendarium moves St Matthias from 24 to 25 February in leap years (docs/research/LT.txt:5011); colitur keeps him on the 24th every year. Domain-wide upper bound measured with a throwaway sweep: 1,650 of 2,041 leap years in 1583-9999 currently observe him there. - L4 (verdict open): Christmas Eve falling on the 4th Sunday of Advent (2023-12-24, roughly 1 year in 7). colitur says the Creed is said, reading RG 475(a)'s Sunday clause; the Ordo says it is not. RG 30(a)'s own "locum tenet ... nulla fit commemoratio" for this exact vigil reads like RG 16(a)'s Feast-of-the-Lord-on-Sunday shape, which colitur already treats as Creed-preserving -- but RG 475(a) names "festo" specifically, and a vigil is its own liturgical-day category (RG 35), so the counter-reading is real too. Left open rather than adjudicated. All 630 tests green (dune test), 631 with the exhaustive sweep. Claude-Session: https://claude.ai/code/session_017ZBxCCRM2ojnBupp3SBxV9 --- test/test_lms_ordo.ml | 825 +++++++++++++++++++++++++------------------------- 1 file changed, 416 insertions(+), 409 deletions(-) (limited to 'test/test_lms_ordo.ml') diff --git a/test/test_lms_ordo.ml b/test/test_lms_ordo.ml index a728cbc..84c4776 100644 --- a/test/test_lms_ordo.ml +++ b/test/test_lms_ordo.ml @@ -1,112 +1,88 @@ -(* Task 6 (2026-08-21-colitur-celebrant-rubrics-phase1): validation layer 6 - -- the Latin Mass Society's own printed Ordo for England & Wales - (test/fixtures/lms-ordo-2024-2025.sexp; that file's own provenance - header carries the full source citation, SHA-256 and the - characterisation record demanded before trusting a single row of it). +(* 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, ISBN 9781739209636, 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. + 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 the fixture's own CHARACTERISATION section - and task-6-report.md for the measurements this scoping rests on): + 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. One genuine day is EXCLUDED, not silently - skipped: 2025-04-18 (Good Friday) has no Mass in the 1955-restored - Holy Week, so the Ordo prints no Gl/Cr line at all -- [test_ - creed_coverage] asserts this is the ONLY such day, so a future - parsing regression that swallowed a real row would fail loudly - here, not slip into "explained" silently. + 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 coordinator addendum's own "sharper check" -- 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. - Built, because the extraction supports it (whole-line anchored - match against all five, see the fixture header on why substring - matching is wrong): every {!Colitur_kernel.Mass_formulary.Votive} - day inside the fixture's own 2024-11-27..2025-12-31 window is - checked against the numeral's implied season. + 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, each independently - 100% clean against the real fixture: - [Proper] (179 of 400 days): the Ordo shows NO "Mass of ..." line. - [Preceding_sunday] (66 of 400): the Ordo ALWAYS shows one. - [Common] (2 of 400): the Ordo shows NO "Mass of ..." line either - -- a Common-routed saint (ubaldus, didacus) still keeps his OWN - title in the Ordo, borrowing only the Common's PREFACE ("Common - Pr" on the title line itself), never a separate override line. - [Votive] is covered by the BVM check above instead (its own - override line IS the roman-numeral line). - [Own_slug] (139 of 400) is DELIBERATELY EXCLUDED from this axis, - not silently -- a real, evidenced data-representation fact, not a - guess: 11 of its 139 days DO show a "Mass of ..." override - ("2nd Sunday of Advent", "the Epiphany", "1st Sunday after - Pentecost"), because data/ef/lectionary.sexp carries some ferial - slugs (e.g. [ef-advent-2-monday]) as an EXPLICIT entry whose - citations are byte-identical to the following Sunday's own - ([ef-advent-sunday-2]: both "Rom 15:4-13" / "Matt 11:2-10" -- - checked directly against the shipped data), rather than resolving - through the {!Colitur_kernel.Mass_formulary.Preceding_sunday} - fallback path. Both representations name the SAME texts -- this is - a provenance-field ambiguity in colitur's own data, not a rubric - question the Ordo could adjudicate either way, so asserting on it - would manufacture exactly the kind of false divergence the - coordinator addendum's "collapsed to ~0 real ones" warning is - about. Recorded here rather than dropped silently, per the task - brief's own instruction for exactly this situation. - - GLORIA is extracted into the fixture (a genuine, independently useful + 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 (the - design doc defers it explicitly), so there is nothing on colitur's own - side to compare it against. Comparing it would be comparing the Ordo - against nothing. + 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) -- the Ordo is an England & Wales diocesan - Ordo, and 156 of its 400 days (39%) carry at least one diocesan - variant. Every comparison in this file reads ONLY the fixture's own - universal-block fields ([formulary_override], [bvm_numeral], [gloria], - [creed] are all derived from the text BEFORE the first diocese-list - line -- see extract_lms_ordo.ml's own [scan_block]); the diocesan text - itself is never read at all, only its PRESENCE is recorded - ([has_diocesan_variant]), so no diocesan variant can leak into a - comparison here by construction, not merely by care. *) + 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" -let fixture_path = "fixtures/lms-ordo-2024-2025.sexp" -let allow_list_path = "../data/ef/expected-divergences-lms.sexp" - -(* Asserted, not merely documented -- test_oracle.ml's own [fixture_sha256] - comment explains why: a hand-edit or partial re-extraction would - otherwise silently turn the oracle into an unlabelled snapshot of - whatever someone last ran. Matches the fixture's own provenance header. *) -let fixture_sha256 = "da817b75c5bf40ed3be1d5f6890b199705e02ce4d42111253ab8547bccabc3f7" (* Duplicated, not shared, from test_oracle.ml/test_differential.ml's own identical function -- neither file exposes an .mli the other could @@ -176,23 +152,20 @@ type ordo_row = { } [@@deriving sexp] -let ordo_rows () = +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 fixture's own real window -- 2024-11-27 ("End of November 2024") - through 2025-12-31 (the day before the extractor's own stop marker). - 400 = 4 (27-30 Nov 2024) + 31 (Dec 2024) + 365 (2025, not a leap year). *) -let window_first = "2024-11-27" -let window_last = "2025-12-31" - (* ---------------------------------------------------------------------- *) -(* The colitur side, over the same window. Mirrors test_oracle.ml's own *) -(* [colitur_rows]: the resolution walk starts a year EARLY because a *) -(* liturgical year is Advent-anchored and straddles two civil years. *) +(* 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 = { @@ -202,18 +175,20 @@ type colitur_row = { c_creed : bool; } -let colitur_rows () = +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 = 2023 to 2025 do + 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 y m d = match Date.make ~year:y ~month:m ~day:d with Ok t -> t | Error e -> failwith e in + 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 2024 11 27) in - let stop = mk 2025 12 31 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 -> @@ -228,11 +203,7 @@ let colitur_rows () = (* ---------------------------------------------------------------------- *) (* RG 309(a), "iuxta temporum diversitatem" -- INDEPENDENTLY re-derived *) -(* from the rubric, not called from Lectionary_ef.bvm_saturday_citations *) -(* (private to that .ml, no .mli export, and re-deriving here is itself *) -(* the more useful check: importing colitur's own function would make *) -(* this axis tautological). Mirrors that function's own season match *) -(* exactly -- see its citation in lectionary_ef.ml for the scan lines. *) +(* from the rubric, not called from Lectionary_ef.bvm_saturday_citations. *) (* ---------------------------------------------------------------------- *) let expected_bvm_numeral season ~month ~day = @@ -246,13 +217,16 @@ let expected_bvm_numeral season ~month ~day = (* ---------------------------------------------------------------------- *) (* data/ef/expected-divergences-lms.sexp -- same shape as *) -(* test_oracle.ml's own [allow_entry]/[load_allow_list]. *) +(* 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 () = +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) @@ -260,213 +234,19 @@ let load_allow_list () = List.map allow_entry_of_sexp sexps (* ---------------------------------------------------------------------- *) -(* Tests *) -(* ---------------------------------------------------------------------- *) - -let test_fixture_checksum () = - Alcotest.(check string) "fixture SHA-256 matches its provenance note" fixture_sha256 - (sha256_of_file fixture_path) - -let test_dates_align () = - let ordo = ordo_rows () in - let colitur = colitur_rows () in - Alcotest.(check int) "the Ordo fixture has 400 rows (2024-11-27..2025-12-31)" 400 (List.length ordo); - Alcotest.(check int) "colitur resolved the same number of days" (List.length ordo) (List.length colitur); - (match ordo with o :: _ -> Alcotest.(check string) "first date" window_first o.date | [] -> Alcotest.fail "empty"); - (match List.rev ordo with - | o :: _ -> Alcotest.(check string) "last date" window_last o.date - | [] -> Alcotest.fail "empty"); - let mismatched = - List.filter_map - (fun (o, c) -> if String.equal o.date c.c_date then None else Some (o.date, c.c_date)) - (List.combine ordo colitur) - in - Alcotest.(check (list (pair string string))) "no misaligned dates" [] mismatched - -(* Good Friday (2025-04-18) has no Mass at all in the 1955-restored Holy - Week -- the Ordo prints no Gl/Cr line, confirmed directly against the - source (extract_lms_ordo's own row for that date, and the raw pdftotext - text: "The Solemn Liturgical Action is celebrated in the afternoon...", - no "Gl"/"Cr" token anywhere in the block). Asserted as the ONLY such - day, not merely observed once: a future parsing regression that - swallowed a real Gl/Cr line would otherwise silently join this - "not applicable" bucket instead of failing loudly as an unexplained - mismatch. *) -let test_creed_coverage () = - let ordo = ordo_rows () in - let no_creed = List.filter (fun o -> o.creed = None) ordo in - Alcotest.(check (list string)) "only Good Friday has no Ordo Creed marker" [ "2025-04-18" ] - (List.map (fun o -> o.date) no_creed) - -let describe_creed_mismatch (o : ordo_row) (c : colitur_row) = - Printf.sprintf "%s %S: colitur creed=%b, Ordo creed=%b (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") - -(* The core assertion: every Creed divergence, over all 400 days (Good - Friday excluded per [test_creed_coverage] above -- it is not a - divergence, it is a day with nothing on the Ordo's side to compare), - falls in a named, cited, adjudicated allow-list entry. Zero - unexplained. *) -let test_creed_matches_or_is_explained () = - let ordo = ordo_rows () in - let colitur = colitur_rows () in - let allow_list = load_allow_list () in - let by_id = List.map (fun e -> (e.id, e)) allow_list in - (* No known Creed divergence is adjudicated any more: L1 (RG 476(f), All - Souls' Day) was CLOSED by fixing {!Rite_ef.Rubrics_ef.creed} itself - (whole-branch review finding 1), not by re-adjudicating it -- see - expected-divergences-lms.sexp's own closure note for the citation and - the measured blast radius. [allow_list] is therefore expected to be - empty today; the [by_id]/[explained_counts] wiring below is kept - rather than deleted, because the "every declared entry must have - fired" check at the end is a real invariant worth keeping live even - while the list itself is empty -- exactly the shape a FUTURE entry of - this kind would need. *) - 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 -> () (* Good Friday, see test_creed_coverage *) - | Some ocreed -> - if Bool.equal ocreed c.c_creed then () else unexplained := describe_creed_mismatch o c :: !unexplained) - ordo colitur; - Alcotest.(check (list string)) "every Creed mismatch is named in the allow-list -- none unexplained" [] - (List.rev !unexplained); - Hashtbl.iter - (fun id n -> - match List.assoc_opt id by_id with - | None -> Alcotest.failf "allow-list entry %s used by the comparator but not declared in %s" id allow_list_path - | Some e -> - Alcotest.(check int) (Printf.sprintf "%s: expected_rows matches the actual count" id) e.expected_rows n) - explained_counts; - (* every declared entry must actually have fired, so the allow-list - cannot silently accumulate a dead entry nothing triggers any more *) - List.iter - (fun e -> - if not (Hashtbl.mem explained_counts e.id) then - Alcotest.failf "allow-list entry %s is declared but never matched a real divergence" e.id) - allow_list - -(* ---------------------------------------------------------------------- *) -(* The BVM-Saturday seasonal selection -- the coordinator addendum's own *) -(* "sharper check". Restricted to colitur's [Votive] days, which is *) -(* exactly {!Rite_ef.Lectionary_ef.is_bvm_saturday_office}'s own domain. *) -(* ---------------------------------------------------------------------- *) - -let test_bvm_seasonal_selection () = - let ordo = ordo_rows () in - let colitur = colitur_rows () 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) "14 BVM-Saturday (Votive) days fall inside the fixture's own window" 14 - (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 -> 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 - -(* Reverse direction: every Ordo BVM-numeral day is a colitur Votive day - too (rules out the Ordo naming a Saturday BVM Mass colitur never - computed the office for at all). *) -let test_bvm_numeral_implies_votive () = - let ordo = ordo_rows () in - let colitur = colitur_rows () 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 - -(* ---------------------------------------------------------------------- *) -(* Formulary override, restricted to Proper/Preceding_sunday/Common -- *) +(* 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 above instead. *) -(* *) -(* Whole-branch review finding 2: the ORIGINAL version of this test only *) -(* checked WHETHER a Preceding_sunday day showed an override line, never *) -(* WHICH Sunday it named -- so colitur could resume the WRONG Sunday on *) -(* every one of the 66 such days and this layer would stay green (step 3 *) -(* of the reading chain, {!Rite_ef.Lectionary_ef.readings}, is exactly the *) -(* code this was supposed to be testing). Fixed by deriving the EXPECTED *) -(* prose from {!MF.t.said}'s own resolved SLUG -- not by calling colitur's *) -(* own [sunday_slug] a second time (which would make the check tautological, *) -(* the same reasoning this file's own header already gives for *) -(* re-deriving the BVM numeral rather than calling {!Rite_ef. *) -(* Lectionary_ef.bvm_saturday_citations}). The FIRST version of this fix *) -(* instead recomputed the preceding Sunday's DATE and re-derived (season, *) -(* week) via {!Rite_ef.Temporal_ef.temporal} independently -- and was WRONG, caught by its *) -(* own assertion on the very first run: {!Rite_ef.Temporal_ef.temporal}'s raw [week] field *) -(* and the SLUG's own embedded number genuinely diverge for the last *) -(* Sunday before Advent (always slug-numbered "24", whatever the raw week *) -(* count that year -- temporal_ef.ml's own [sunday_slug], the *) -(* [same d last_sunday] branch) and for the "surplus Sundays" that resume *) -(* Epiphany's own numbering when Septuagesima cut it short (same function, *) -(* the [n > 23] branch) -- exactly the two hard cases that function's own *) -(* comment names. Parsing the number back OUT of the already-resolved slug *) -(* sidesteps both: it is not tautological with the wrong-Sunday failure *) -(* mode this whole fix exists to catch (a wrong slug still parses to a *) -(* wrong number, which still fails to match the Ordo's real text), and it *) -(* is simpler and more robust than re-deriving the two special cases a *) -(* second time by hand. *) +(* covered by the BVM checks instead. Unchanged from Task 6 except for *) +(* being read per-window rather than off a single hardcoded fixture. *) (* ---------------------------------------------------------------------- *) -(* English ordinal suffix: 1st, 2nd, 3rd, 4th, 11th, 12th, 13th, 21st... -- - the "teens" exception applies to every multiple of 100 plus 11-13, not - just 11-13 themselves, but nothing in this fixture's own domain (weeks - 1-24) ever reaches three digits, so the simpler [n mod 100] test below - is exercised fully by 11-13 alone and is not a latent bug for values - this function is actually ever called with. *) 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" + else match n mod 10 with 1 -> "st" | 2 -> "nd" | 3 -> "rd" | _ -> "th" in Printf.sprintf "%d%s" n suffix -(* [MF.t.said]'s own slug shape for every Preceding_sunday day observed in - the real fixture is [ef--sunday-] ({!Rite_ef.Temporal_ef.sunday_slug}'s - own generic branch -- the ONLY branch that ever reaches a weekday's - [Preceding_sunday] fallback; the specially-NAMED Sundays [ef-low-sunday], - [ef-palm-sunday] etc. are caught by {!Rite_ef.Temporal_ef.named} first and never fall - through to it). Manual parsing, not [Str]/regex (deps frozen): finds the - last "-sunday-" marker and splits on it, since a season word can itself - contain hyphens ("time-after-pentecost") but never contains "-sunday-". *) let parse_sunday_slug slug = let prefix = "ef-" in let plen = String.length prefix in @@ -485,14 +265,23 @@ let parse_sunday_slug slug = 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) -(* The Ordo's own prose, derived from [MF.t.said]'s own (season-word, - number) -- verified against every one of the 66 real Preceding_sunday - days in the fixture (test_formulary_override_matches below), not - invented from the naming convention alone. [None] means "this pair is - EXCLUDED from the mapping", handled explicitly by the caller, never - silently -- see [ascension_week_override] just below for the one real - population that lands there. *) -let expected_preceding_sunday_override (word : string) (n : int) = +(* [~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" @@ -501,113 +290,118 @@ let expected_preceding_sunday_override (word : string) (n : int) = | "septuagesima", 1 -> Some "Septuagesima Sunday" | "septuagesima", 2 -> Some "Sexagesima Sunday" | "septuagesima", 3 -> Some "Quinquagesima Sunday" - | "time-after-epiphany", n -> Some (Printf.sprintf "%s Sunday after the Epiphany" (ordinal n)) + | "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)) - (* [ef-easter-sunday-]: Paschaltide's own numbering counts Easter - Sunday itself as 1 (Low Sunday, named separately via {!Rite_ef.Temporal_ef.named} and - so never reaching this generic branch at all, is 2), so the ordinal - PRINTED is [n - 1] -- confirmed against every one of n=3..5 in the - fixture ("2nd" through "4th" Sunday after Easter). n=6,7 are - deliberately NOT given here: see [ascension_week_override]. *) - | "easter", n when n >= 3 && n <= 5 -> Some (Printf.sprintf "%s Sunday after Easter" (ordinal (n - 1))) + (* [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 -(* One NAMED Sunday (not a generic "ef--sunday-" slug at all) - reaches [Preceding_sunday] in the real 66-day population: Christ the - King ({!Rite_ef.Temporal_ef.christ_the_king}, "the last Sunday of - October"), whose own ferias resume it the same way any other Sunday's - would. Unlike the Septuagesima trio's fixed proper names, its ORDINAL - position among the Sundays after Pentecost is not fixed -- it varies - year to year with Easter's own date -- so there is no string this - function could hard-code the way [expected_preceding_sunday_override] - does for the others. [word]/[n] cannot express it EITHER, because - {!Rite_ef.Temporal_ef.named} overrides the SLUG for that Sunday but, - confirmed directly in temporal_ef.ml ([build ~season ~slug ~colour - ~rank ~week:(week d) ()], called identically whether or not [named] - matched), never the raw [week] FIELD -- so re-deriving that one field - for this one named exception is not the same mistake the first version - of this fix made re-deriving [week] WHOLESALE (that failed on the - season's own two special-numbering cases, both of which this function - sidesteps by reading the slug directly); here there is no slug number - to read in the first place, so [week] is the only source of truth, - narrowly applied to the single slug that needs it. *) 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 -(* [n] = 6 or 7 (Paschaltide's own numbering, see the comment above -- the - calendar Sundays traditionally called the 5th and 6th Sunday after - Easter) are STRUCTURALLY, not coincidentally, always Ascension-adjacent: - the Ascension is a fixed Easter+39 (a Thursday inside week 6), so EVERY - feria whose own [Preceding_sunday] fallback would otherwise resolve to - one of these two weeks falls between Ascension Thursday and the - following Saturday, every year, not merely in this fixture's own - 2024-2025 window. A REAL finding, not a fixture artefact: the Ordo - shows "Mass of the Ascension" on all three such days this window - contains (2025-05-30, 2025-06-02, 2025-06-03 -- Friday and the - following Monday/Tuesday, the days between Ascension and the Sunday - after it that have no proper of their own), never "5th"/"6th Sunday - after Easter" -- the Missal keeps saying ASCENSION's own Mass through - this stretch, not the last numbered Sunday's. colitur's step 3 has no - notion of "inside Ascension's own after-feast period" -- it always - resumes the nearest PRECEDING SUNDAY, numbered or not -- so on these - three real days {!MF.t.said} is a plain "ef-easter-sunday-6"/"-7" slug - the Missal would never actually print as such. A genuine content gap in - colitur's reading chain, found by this fix, NOT fixed here (fixing it - means teaching step 3 or the lectionary data about Ascension's own - after-feast period, a lectionary-data change out of this fix round's - own scope) -- recorded honestly, the same way L1 in - expected-divergences-lms.sexp already records the 476(f) gap this same - branch closed, and counted below so a change to this population would - be caught rather than silently absorbed. *) +(* See Task 6's own [ascension_week_override] citation (test_lms_ordo.ml + history, expected-divergences-lms.sexp L2): 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_ascension_week] + below is the corrected predicate: the day's own DATE compared against + {!Computus.ascension} for its own year, not [n]. *) let ascension_week_override = "Mass of the Ascension" -let test_formulary_override_matches () = - let ordo = ordo_rows () in - let colitur = colitur_rows () in +(* 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 counts = Hashtbl.create 8 in - let bump k = Hashtbl.replace counts k (1 + try Hashtbl.find counts k with Not_found -> 0) in + let proper = ref 0 and common = ref 0 and preceding_sunday = ref 0 and ascension_week = ref 0 in let bad = ref [] in - let ascension_week_count = ref 0 in List.iter - (fun (o, c) -> + (fun ((o : ordo_row), (c : colitur_row)) -> match c.c_formulary with | Some { MF.via = MF.Proper; _ } -> - bump "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; _ } -> - bump "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 } -> ( - bump "preceding_sunday"; - (* [said] is [Some] for every constructor except [Votive] (see - {!Colitur_kernel.Mass_formulary.t}'s own citation) -- a bare - [Option.get] here would raise an unhelpful exception if that - ever stopped being true; [Alcotest.failf] names the day - instead. *) + 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 - (* Try the generic [ef--sunday-] shape first; fall back - to the one NAMED Sunday that also reaches this population - ([expected_named_sunday_override], see its own citation) when - the slug doesn't parse that way at all. *) 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 word n + | Some (word, n) -> expected_preceding_sunday_override ~season:c.c_season word n | None -> expected_named_sunday_override slug ~year in match expected with @@ -618,13 +412,8 @@ let test_formulary_override_matches () = | 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 -> ( - (* The Ascension-week exclusion above -- checked, not - assumed: fails loudly if the Ordo's own text ever stops - matching the one string this population is adjudicated to - carry, or if a slug outside every adjudicated case above - ever reaches here. *) match o.formulary_override with - | Some got when String.equal got ascension_week_override -> incr ascension_week_count + | 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 @@ -634,33 +423,214 @@ let test_formulary_override_matches () = bad := Printf.sprintf "%s: unmapped (colitur resumed %S) and Ordo shows no override" o.date slug :: !bad)) | Some { MF.via = MF.Votive; _ } | Some { MF.via = MF.Own_slug; _ } | None -> ()) pairs; - Alcotest.(check (list string)) "every formulary-override mismatch is explained -- none unexplained" [] (List.rev !bad); - Alcotest.(check int) "179 Proper days in the window" 179 (try Hashtbl.find counts "proper" with Not_found -> 0); - Alcotest.(check int) "2 Common days in the window" 2 (try Hashtbl.find counts "common" with Not_found -> 0); - Alcotest.(check int) "66 Preceding_sunday days in the window" 66 - (try Hashtbl.find counts "preceding_sunday" with Not_found -> 0); - Alcotest.(check int) "3 Preceding_sunday days fall in the Ascension-week exclusion, no more, no fewer" 3 - !ascension_week_count - -let test_via_distribution_totals_400 () = - let colitur = colitur_rows () 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 400-day window" 400 total + { fc_bad = List.rev !bad; fc_proper = !proper; fc_common = !common; fc_preceding_sunday = !preceding_sunday; + fc_ascension_week = !ascension_week } -let suite = - ( "lms-ordo", +(* ---------------------------------------------------------------------- *) +(* [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. *) +(* ---------------------------------------------------------------------- *) + +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 = + 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. L3/L4 (Witnesses task, + 2026-08-22, both 2023-2024-only) are the first live tests of it. *) + let allow_list_id_for_date d = + if String.equal d "2024-02-24" then Some "L3" + else 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 + 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 Preceding_sunday days fall in the Ascension-week exclusion, 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 400 rows each, dates aligned 1:1" `Quick test_dates_align; - Alcotest.test_case "only Good Friday has no Ordo Creed marker" `Quick test_creed_coverage; + 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 "every BVM-Saturday numeral matches its season" `Quick test_bvm_seasonal_selection; @@ -668,5 +638,42 @@ let suite = 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_400 + 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. *) +let test_allow_list_ids_are_exactly_l3_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 L3 and L4" [ "L3"; "L4" ] ids + +let suite_allow_list = ("lms-ordo-allow-list", [ Alcotest.test_case "declares exactly L3 and L4" `Quick test_allow_list_ids_are_exactly_l3_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_bvm_votive:12 ~expected_proper:182 ~expected_common:2 ~expected_preceding_sunday:61 + ~expected_ascension_week:1 + +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_bvm_votive:14 ~expected_proper:179 ~expected_common:2 ~expected_preceding_sunday:66 + ~expected_ascension_week:3 + +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_bvm_votive:13 ~expected_proper:174 ~expected_common:2 ~expected_preceding_sunday:68 + ~expected_ascension_week:2 -- cgit v1.3