aboutsummaryrefslogtreecommitdiff
path: root/test/test_lms_ordo.ml
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-22 12:58:15 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-22 12:58:15 +0200
commit3bfa0c459e3c0b5bfa962b7dbb0227db5a8ba09f (patch)
tree04eb619e3b8de656364a2de808865ab229052219 /test/test_lms_ordo.ml
parent89b6cfbc8c2a9f5921c19543f893decc2497ef69 (diff)
downloadcolitur-3bfa0c459e3c0b5bfa962b7dbb0227db5a8ba09f.tar.gz
colitur-3bfa0c459e3c0b5bfa962b7dbb0227db5a8ba09f.zip
test(ef): the LMS Ordo as a sixth validation layer
An independent lineage -- neither Divinum Officium, missalemeum nor lectio -- covering the Mass formulary and the Creed. Characterised before use, per the discipline the extraordinaryform.org Ordo's blanket St Lawrence vigil gap taught this session: five opposite- prediction Creed probes (Sunday/feria, octave-override vs plain II-class, I-class) all confirmed against the raw extracted text before a single divergence was adjudicated, recorded in the fixture's own provenance header. tools/extract_lms_ordo.ml hand-parses pdftotext -layout output (no Str/regex, frozen deps) into test/fixtures/lms-ordo-2024-2025.sexp, 400 day-rows (2024-11-27..2025-12-31). Gl/Cr print per Mass-option, not per day; the extractor takes the first pair in reading order, which the source's own layout guarantees is the universal entry's, since a diocesan variant always follows it, never precedes it. The Ordo's I-V roman numeral ("Missae de sancta Maria in sabbato", RG 309(a)) is matched whole-line only, never by substring -- "V Mass of BVM" is a substring of "IV Mass of BVM". test/test_lms_ordo.ml compares two axes 1:1 by date: - the Creed (RG 475-476): 399 of 400 days comparable (Good Friday has no Mass at all, asserted as the only such day); one genuine divergence, adjudicated to the ORDO, not colitur -- All Souls' Day 2025-11-03 is a Requiem Mass, and colitur's own creed predicate has no notion of one, a pre-existing scope gap rubrics_ef.ml already documented in its own header before this task existed. - the BVM-Saturday seasonal Mass selection, the addendum's own "sharper check": all 14 Votive days in the window match their season's own numeral exactly, both directions, independently re-derived from RG 309(a) rather than calling colitur's private function. - the formulary override, for the three sources it can honestly discriminate (Proper 179, Preceding_sunday 66, Common 2, all clean); Own_slug (139) is excluded with evidence, not silently -- several ferial slugs carry citations byte-identical to their Sunday's own, so the Ordo's override line cannot distinguish the two representations. data/ef/expected-divergences-lms.sexp carries the one adjudicated entry (L1), cited to RG 476(f). Mutation-tested: inverting RG 475(a)'s Sunday clause reddens 10 existing Rubrics_ef unit tests AND this new layer's own comparator, independently, on a data source none of the other five layers touch. Reverted after confirming. No lib/ changes. day/readings/rubrics output re-confirmed byte- identical (1583, 2026, 2038, 9999) after this task; all three gates green (dune test, exhaustive sweep, make check).
Diffstat (limited to 'test/test_lms_ordo.ml')
-rw-r--r--test/test_lms_ordo.ml467
1 files changed, 467 insertions, 0 deletions
diff --git a/test/test_lms_ordo.ml b/test/test_lms_ordo.ml
new file mode 100644
index 0000000..c5cae1a
--- /dev/null
+++ b/test/test_lms_ordo.ml
@@ -0,0 +1,467 @@
+(* 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).
+
+ 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.
+
+ 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):
+
+ - 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.
+
+ - 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 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
+ 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.
+
+ 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. *)
+
+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 MF = Colitur_kernel.Mass_formulary
+module V = Rite_ef.Vocab_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
+ 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 () =
+ 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. *)
+(* ---------------------------------------------------------------------- *)
+
+type colitur_row = {
+ c_date : string;
+ c_season : V.season;
+ c_formulary : MF.t option;
+ c_creed : bool;
+}
+
+let colitur_rows () =
+ 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
+ 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 rows = ref [] in
+ let d = ref (mk 2024 11 27) in
+ let stop = mk 2025 12 31 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 }
+ :: !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 *)
+(* (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. *)
+(* ---------------------------------------------------------------------- *)
+
+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]. *)
+(* ---------------------------------------------------------------------- *)
+
+type allow_entry = { id : string; citation : string; verdict : string; note : string; expected_rows : int }
+[@@deriving sexp]
+
+let load_allow_list () =
+ 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
+
+(* ---------------------------------------------------------------------- *)
+(* 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 f -> Colitur_kernel.Slug.to_string f.MF.said
+ | 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
+ let l1_dates = [ "2025-11-03" ] 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 -> () (* Good Friday, see test_creed_coverage *)
+ | Some ocreed ->
+ if Bool.equal ocreed c.c_creed then ()
+ else if List.mem o.date l1_dates then
+ Hashtbl.replace explained_counts "L1" (1 + try Hashtbl.find explained_counts "L1" with Not_found -> 0)
+ 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 -- *)
+(* see this file's own header for why Own_slug is excluded, and Votive is *)
+(* covered by the BVM checks above instead. *)
+(* ---------------------------------------------------------------------- *)
+
+let test_formulary_override_by_source () =
+ let ordo = ordo_rows () in
+ let colitur = colitur_rows () in
+ 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 bad = ref [] in
+ List.iter
+ (fun (o, c) ->
+ match c.c_formulary with
+ | Some { MF.via = MF.Proper; _ } ->
+ bump "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";
+ 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; _ } ->
+ bump "preceding_sunday";
+ if o.formulary_override = None then bad := Printf.sprintf "%s: Preceding_sunday day but Ordo shows no override" o.date :: !bad
+ | Some { MF.via = MF.Votive; _ } | Some { MF.via = MF.Own_slug; _ } | None -> ())
+ pairs;
+ Alcotest.(check (list string)) "no unexplained formulary-override mismatch on Proper/Common/Preceding_sunday days"
+ [] (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)
+
+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
+
+let suite =
+ ( "lms-ordo",
+ [ 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 "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;
+ Alcotest.test_case "every Ordo BVM numeral day is a colitur Votive day" `Quick
+ test_bvm_numeral_implies_votive;
+ Alcotest.test_case "formulary override matches on Proper/Common/Preceding_sunday days" `Quick
+ test_formulary_override_by_source;
+ Alcotest.test_case "the via distribution sums to the full window" `Quick test_via_distribution_totals_400
+ ] )