diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-24 16:29:23 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-24 16:29:23 +0200 |
| commit | 8a5da8756cf6b57aa36c01b71ce893f7967d3a29 (patch) | |
| tree | 10873a692400fa25ce54c67ac9fc34d46af86272 /test/test_fiuv_ordo.ml | |
| parent | 4f87cbde4ee79ac96aa7ebfed105b3a5ec02be43 (diff) | |
| parent | 73b15551804bb63ee0081005e2869d36afb54be2 (diff) | |
| download | colitur-8a5da8756cf6b57aa36c01b71ce893f7967d3a29.tar.gz colitur-8a5da8756cf6b57aa36c01b71ce893f7967d3a29.zip | |
merge: celebrant Mass rubrics, and four rubrical corrections
colitur now prints what a real ordo prints for the Mass: which formulary is
said and how it was reached, the Gloria, the Creed, the preface, and the
commemorations with their Low-Mass/sung distinction. colitur rubrics joins
day and readings.
Four defects were found and fixed on the way, each by an external witness
rather than by inspection: the Creed said at Requiem Masses (RG 476(f)),
the missing bissextile shift of St Matthias and St Gabriel (2 041 leap
years), Rogation Monday and Tuesday coloured violet in Paschaltide (RG
119(b)), and the ferias after the Ascension resuming the wrong Sunday's
Mass rather than the Ascension's.
Validation gained a sixth layer and then some: the preface is checked
against three independent publishers over seven witness-years (FIUV, three
LMS editions, three extraordinaryform.org editions), none of which shares
the Divinum Officium -> missalemeum -> lectio lineage the older layers all
descend from.
Diffstat (limited to 'test/test_fiuv_ordo.ml')
| -rw-r--r-- | test/test_fiuv_ordo.ml | 595 |
1 files changed, 595 insertions, 0 deletions
diff --git a/test/test_fiuv_ordo.ml b/test/test_fiuv_ordo.ml new file mode 100644 index 0000000..3bfed68 --- /dev/null +++ b/test/test_fiuv_ordo.ml @@ -0,0 +1,595 @@ +(* Witnesses task (2026-08-22-colitur-celebrant-rubrics-phase1): the + SEVENTH validation layer, and the first UNIVERSAL, SECOND-COMPILER + witness -- the FIUV (Foederatio Internationalis Una Voce) Ordo for + 2025-2026, compiled by Joseph Shaw, independent of the Latin Mass + Society's own three editions (test_lms_ordo.ml) except for sharing a + publisher/host (see test/fixtures/fiuv-ordo-2025-2026.sexp's own + provenance header, and docs/research/ordo/PROVENANCE-ordo-corpus.md, + for the full account of what independence this source does and does + not buy). + + WHY THIS LAYER IS MORE VALUABLE THAN THE LMS ONE, per the task brief's + own four reasons: (1) UNIVERSAL -- no diocesan variants to exclude at + all, so this fixture's own [row] carries no [has_diocesan_variant] + field, unlike the LMS one; (2) IN LATIN, the rubrics' own vocabulary; + (3) BOTH DIRECTIONS EXPLICIT ("Gloria"/"sine Gloria", "Credo"/"sine + Credo") -- used here exactly as with the LMS source; (4) RECORDS THE + TE DEUM, the hinge of RG 431(a)'s own Mass-Gloria rule (deferred to + Phase 2, Breviary nn. 237-238) -- captured into the fixture, NOT + compared here (no colitur-side Te Deum predicate exists yet). + + SCOPE: the CREED (RG 475-476, {!Rite_ef.Rubrics_ef.creed}) is the ONE + axis compared against colitur, per the task brief. [praef] and + [te_deum] are captured into the fixture (a genuinely useful data set + for Phase 2/3's still-unbuilt Gloria and preface rules) but + deliberately NOT validated here -- there is nothing on colitur's own + side yet to compare either against. [class_] (raw, including the FIUV + ordo's own explicit "III cl." vs "III cl. (Priv.)" Cum Sanctissima + convention, stated on its own title page) is likewise captured but not + compared: colitur's core is strictly the 1962 Missal with Cum + Sanctissima/Quo Magis modelled only as an OVERLAY, never core + (CLAUDE.md's own binding decision #2), so colitur's UNOVERLAID output + has no Cum-Sanctissima-aware notion of "III cl." vs "(Priv.)" to + compare this field against in the first place -- comparing it would be + comparing the Ordo against a question colitur's default configuration + was never asked. *) + +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 V = Rite_ef.Vocab_ef +module Preface = Colitur_kernel.Preface + +let sanctoral_path = "../data/ef/sanctoral.sexp" +let adjustments_path = "../data/ef/adjustments.sexp" +let fixture_path = "fixtures/fiuv-ordo-2025-2026.sexp" +let allow_list_path = "../data/ef/expected-divergences-fiuv.sexp" + +(* Duplicated, not shared -- test_lms_ordo.ml's own identical function + carries the same "no shared .mli" reasoning. *) +let sha256_of_file path = + let tmp = Filename.temp_file "colitur_fiuv_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 fixture_sha256 = "a744a2dbc7c8cf09590005e956c973f316209f3cf102b8035edadebf764110de" + +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 from tools/extract_fiuv_ordo.ml's *) +(* own [row] -- see this file's own header on why there is no *) +(* [has_diocesan_variant] field here, unlike the LMS mirror. *) +(* ---------------------------------------------------------------------- *) + +open Sexplib0.Sexp_conv + +type ordo_row = { + date : string; + class_ : string option; + title : string; + te_deum : bool option; + gloria : bool option; + credo : bool option; + praef : string option; + alt_te_deum : bool option; + (** the "-VEL-" Cum-Sanctissima-alternative office's own Te Deum, + when this day prints one -- see tools/extract_fiuv_ordo.ml's own + [row.alt_te_deum] citation. Captured, not compared here: closing + F3 needed [te_deum] itself to stop reading it, not a new + colitur-side axis to check it against. *) +} +[@@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 + +let window_first = "2025-11-27" +let window_last = "2026-12-31" + +(* ---------------------------------------------------------------------- *) +(* The colitur side, over the same window -- mirrors test_lms_ordo.ml's *) +(* own [colitur_rows] exactly (a liturgical year straddles two civil *) +(* years, so the resolution walk starts a civil year early and ends one *) +(* late). *) +(* ---------------------------------------------------------------------- *) + +type colitur_row = { c_date : string; c_creed : bool; c_gloria : bool; c_preface : Preface.t option } + +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 = 2024 to 2027 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: %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_creed = day.LD.creed; c_gloria = day.LD.gloria; + c_preface = day.LD.preface } + :: !rows + | None -> Alcotest.failf "no colitur day resolved for %s" (Date.to_iso8601 !d)); + d := Date.add_days !d 1 + done; + List.rev !rows + +(* Te Deum has no colitur-side [Liturgical_day.t] field of its own -- it is + a Breviary fact {!Rite_ef.Rubrics_ef.gloria} reads internally, not a Mass + part {!Colitur_kernel.Rite.t} exposes. Resolved separately, straight from + {!Rite_ef.Rubrics_ef.te_deum}, over the identical window/day set. *) +type colitur_te_deum_row = { + t_date : string; + t_te_deum : bool; + t_rank : V.rank; + t_slug : string; + t_has_commemoration : bool; +} + +let colitur_te_deum_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 = 2024 to 2027 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: %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 -> + let te_deum = + Rite_ef.Rubrics_ef.te_deum ~temporal:day.LD.temporal ~observed:day.LD.observed ~date:day.LD.date + in + rows := + { t_date = Date.to_iso8601 day.LD.date; t_te_deum = te_deum; + t_rank = day.LD.observed.Colitur_kernel.Celebration.rank; + t_slug = Colitur_kernel.Slug.to_string day.LD.observed.Colitur_kernel.Celebration.slug; + t_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 + +(* ---------------------------------------------------------------------- *) +(* data/ef/expected-divergences-fiuv.sexp -- same shape as the LMS/ *) +(* missalemeum allow-lists' own [allow_entry]. A SEPARATE file: this Ordo *) +(* is a fourth, independent lineage from all three others already in this *) +(* project. *) +(* ---------------------------------------------------------------------- *) + +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 (2025-11-27..2026-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 + +(* Holy Saturday (2026-04-04) is the ONLY day this fixture shows no Credo + marker for -- confirmed directly against the raw source: unlike the + LMS Ordo (whose "no Mass" day is Good Friday, the 1955-restored Holy + Week having no Mass that day at all), THIS Ordo's Good Friday DOES + carry a Mass rubric ("Missa pr., ... Gloria, sine Credo, praef. + comm."), leaving Holy Saturday as the sole day with no Mass mentioned + at all (its own Vigil Mass is conventionally counted under Easter + Sunday's own date in this source, matching a real, if narrower, + instance of the same "a vigil Mass is dated to the day it anticipates" + convention the LMS characterisation already established). Asserted as + the ONLY such day, not merely observed once: a parsing regression that + swallowed a real row would otherwise silently join this 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.credo = None) ordo in + Alcotest.(check (list string)) "only Holy Saturday has no Ordo Creed marker" [ "2026-04-04" ] + (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" o.date o.title c.c_creed (Option.get o.credo) + +(* Which allow-list id, if any, explains a Creed mismatch on this date -- + date-keyed, not "any declared entry excuses any mismatch", the same + discipline test_lms_ordo.ml's own [allow_list_id_for_date] uses and + for the identical reason: an entry that starts firing on an + UNEXPECTED new date must be visible as a real change, not silently + absorbed. Empty until characterisation found a real divergence to + name. *) +let fiuv_allow_list_id_for_date = ([] : (string * string) list) + +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 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.credo with + | None -> () + | Some ocreed -> + if Bool.equal ocreed c.c_creed then () + else + match List.assoc_opt o.date fiuv_allow_list_id_for_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)) "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; + 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 + +(* ---------------------------------------------------------------------- *) +(* Gloria (RG 431-432, {!Rite_ef.Rubrics_ef.gloria}) -- Phase 2. Same shape *) +(* as the Creed comparison above, over the identical 400-row window. *) +(* ---------------------------------------------------------------------- *) + +let test_gloria_coverage () = + let ordo = ordo_rows () in + let no_gloria = List.filter (fun o -> o.gloria = None) ordo in + Alcotest.(check int) "exactly one day has no Ordo Gloria marker" 1 (List.length no_gloria) + +let describe_gloria_mismatch (o : ordo_row) (c : colitur_row) = + Printf.sprintf "%s %S: colitur gloria=%b, Ordo gloria=%b" o.date o.title c.c_gloria (Option.get o.gloria) + +(* F1 -- OPEN, adjudicated FOR colitur. The single Gloria mismatch found: + Good Friday (2026-04-03). See data/ef/expected-divergences-fiuv.sexp's + own F1 for the full citation and the raw source text this was checked + against directly (docs/research/ordo/fiuv-ordo-2025-2026.pdf, page 46). *) +let is_f1_good_friday (o : ordo_row) = String.equal o.date "2026-04-03" + +let test_gloria_matches_or_is_explained () = + let ordo = ordo_rows () in + let colitur = colitur_rows () in + let unexplained = ref [] in + let f1_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_f1_good_friday o then incr f1_count + else unexplained := describe_gloria_mismatch o c :: !unexplained) + ordo colitur; + Alcotest.(check (list string)) "every Gloria mismatch is named in the allow-list -- none unexplained" [] + (List.rev !unexplained); + Alcotest.(check int) "F1 (Good Friday) count" 1 !f1_count + +(* ---------------------------------------------------------------------- *) +(* Preface (RG 482-499, {!Rite_ef.Rubrics_ef.preface}) -- Phase 3. Same *) +(* shape as Creed/Gloria above, over the identical 400-row window, using *) +(* the [praef] column already captured into the fixture (CAPTURED, NOT *) +(* VALIDATED at the time -- tools/extract_fiuv_ordo.ml's own [row.praef] *) +(* citation) but never compared until now. *) +(* *) +(* CHARACTERISATION FIRST, per the task's own standing instruction (this + project has been misled by trusting an Ordo field's surface shape + before, four times this session alone): [praef] is RAW TRAILING TEXT + after the literal token "praef." to the end of the primary Mass + clause -- it is not a clean enum, it can carry an arbitrary tail of + unrelated rubrical prose glued on by the same token-flattening that + produces it (Good Friday's own [gloria]/F1 finding above is the + worked example of exactly this shape). CLASSIFIED here by PREFIX + match against the FIRST WORD(S) only -- every prefix below was found + by direct inspection of the fixture's own distinct [praef] values + (test/fixtures/fiuv-ordo-2025-2026.sexp, 400 rows, all read by hand + while building this comparison) and cross-checked against the primary + rubric text, not guessed. Two genuine EXCEPTION SHAPES, THREE rows, + found the same way: + + - Christmas Day (both of them -- the fixture's own window, 27 + November 2025-31 December 2026, spans thirteen months and TWO 25 + Decembers, confirmed by running the coverage test below before + pinning it at "two", not three, the first time) and Epiphany Day + (2026-01-06, the window's only one) print "praef. et Communic..." + -- an ELLIPSIS: the compiler treats "this feast's own [i.e. + obviously implied] preface" as not needing its own name restated + on the ONE day that IS that feast, unlike every octave/de-Tempore + day governed by the SAME feast (which always restates the name + explicitly, e.g. 26 December's own "de Nativ."). Confirmed by + direct inspection: no OTHER day in the fixture omits the name this + way. [classify_praef] returns [None] (unrecognised) for all three + rather than special-casing them by date -- both feasts are + independently, directly citable from RG 484(a)/485(a)'s own text + (the Nativity/Epiphany feast itself is the paradigm case each + clause names first), so the oracle's corroboration is not needed + to trust colitur's own answer on these dates, and forcing a match + here would mean trusting an inference about the source's own + ellipsis rather than reading it. + - Good Friday (2026-04-03) prints "comm. Feria VI prima in mense." -- + classifies cleanly as [Common] by the same prefix rule as any + other day, but this file's own F1 (Gloria, above) already + adjudicated this EXACT date's raw text as unreliable (a copied, + not a considered, line -- confirmed against the PDF's own page + image, not merely the extracted fixture). The same defect + extends to whatever trails "praef." on the identical corrupted + line: excluded from this comparison by reusing [is_f1_good_friday] + directly, not re-argued. *) + +let has_prefix ~prefix s = + String.length s >= String.length prefix && String.sub s 0 (String.length prefix) = prefix + +(* Every prefix below maps 1:1 onto exactly one branch of + {!Rite_ef.Rubrics_ef.preface}'s own priority order -- see that + function's own header for the full citation of each. Order does not + matter here (unlike in [preface] itself): the Ordo's own printed text + never carries two of these prefixes on the same row, so this is a + partition, not a priority list. *) +let praef_prefixes = + [ ("comm.", Preface.Common); + ("Trinit.", Preface.Trinity); + ("Quadr.", Preface.Lent); + ("de Sancta Cruce", Preface.Holy_cross); + ("Pasch.", Preface.Easter); + ("etc. ut in festo", Preface.Easter); + (* Easter-octave ferias, "as on the feast [of Easter]" -- e.g. 6 + April 2026, "etc. ut in festo., ad Ite, missa est additur duplex + Alleluia." *) + ("Ascensionis", Preface.Ascension); + ("de Spirito Sancto", Preface.Holy_spirit); + ("BMV", Preface.Bvm); + ("App.", Preface.Apostles); + ("de Nativ.", Preface.Nativity); + ("de Epiphania", Preface.Epiphany); + ("de Ss.mi Corde", Preface.Sacred_heart); + ("de Ss.mo Corde", Preface.Sacred_heart); + ("de Domino Nostro Jesu Rege", Preface.Christ_the_king); + ("de S Iosepho", Preface.St_joseph); + ("de S Ioseph", Preface.St_joseph); + ("defunctorum", Preface.Requiem) ] + +let classify_praef (s : string) : Preface.t option = + let rec go = function + | [] -> None + | (prefix, p) :: rest -> if has_prefix ~prefix s then Some p else go rest + in + go praef_prefixes + +(* Measured directly, not assumed: every one of the 400 rows' own [praef] + text, classified, tallied by outcome. Two rows are genuinely + unclassifiable by this prefix table (the ellipsis dates above); every + other non-[None] [praef] value classifies. Pinned so a change to + either the fixture or [classify_praef] that silently drops coverage + fails loudly here rather than merely narrowing the comparison below. *) +let test_praef_classification_coverage () = + let ordo = ordo_rows () in + let with_praef = List.filter_map (fun o -> o.praef) ordo in + Alcotest.(check int) "399 of 400 rows carry a [praef] value (Holy Saturday is the one exception)" 399 + (List.length with_praef); + let unclassified = List.filter (fun s -> classify_praef s = None) with_praef in + (* THREE rows, not two: the fixture's own window (27 November 2025-31 + December 2026, 13 months) spans TWO Christmas Days, both printing the + identical ellipsis text -- found running this exact assertion, not + assumed from the date count alone. *) + Alcotest.(check int) "the three ellipsis rows (two Christmas Days, one Epiphany) are unclassified" 3 + (List.length unclassified); + Alcotest.(check bool) "every unclassified row starts with the ellipsis's own \"et Communic\"" true + (List.for_all (has_prefix ~prefix:"et Communic") unclassified) + +let describe_preface_mismatch (o : ordo_row) (c : colitur_row) (expected : Preface.t) = + Printf.sprintf "%s %S: colitur preface=%s, Ordo praef=%S (classified %s)" o.date o.title + (match c.c_preface with Some p -> Preface.to_string p | None -> "-") + (Option.get o.praef) (Preface.to_string expected) + +let test_preface_matches_or_is_explained () = + let ordo = ordo_rows () in + let colitur = colitur_rows () in + let unexplained = ref [] in + let unclassified_count = ref 0 in + let f1_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; + if is_f1_good_friday o then incr f1_count + else + match o.praef with + | None -> () + | Some raw -> ( + match classify_praef raw with + | None -> incr unclassified_count + | Some expected -> + if c.c_preface = Some expected then () + else unexplained := describe_preface_mismatch o c expected :: !unexplained)) + ordo colitur; + Alcotest.(check (list string)) "every classified preface difference is explained -- none unexplained" [] + (List.rev !unexplained); + Alcotest.(check int) "the three ellipsis rows are skipped, not silently counted as agreement" 3 + !unclassified_count; + Alcotest.(check int) "F1 (Good Friday) is skipped here too, the same root cause as the Gloria axis" 1 !f1_count + +(* ---------------------------------------------------------------------- *) +(* Te Deum (Breviary 237-238, {!Rite_ef.Rubrics_ef.te_deum}) -- Phase 2, *) +(* the mitigation the task brief names for this source's own stated *) +(* weakness (a single, not yet scan-verified, web transcription). A *) +(* SEPARATE colitur-side resolution ({!colitur_te_deum_rows}), since Te *) +(* Deum has no {!Colitur_kernel.Liturgical_day.t} field of its own. *) +(* *) +(* The SUNDAY shape this same comparison originally found (237(b)'s own *) +(* literal Septuagesima/Sexagesima/Quinquagesima exception, contradicted *) +(* 15/15) is FIXED at the source ({!Rite_ef.Rubrics_ef.te_deum}'s own *) +(* header has the correction and its full citation) -- not allow-listed, *) +(* because it no longer diverges. Three OTHER shapes remain OPEN, *) +(* single-witnessed (never captured by the LMS fixtures, which do not *) +(* record Te Deum at all) -- see data/ef/expected-divergences-fiuv.sexp's *) +(* own F2/F3/F4 for the full citations. Matched by PREDICATE, the same *) +(* "varies by shape, not by a fixed date list" reasoning L5/L6 already *) +(* establish for the LMS suite. *) +(* ---------------------------------------------------------------------- *) + +let test_te_deum_coverage () = + let ordo = ordo_rows () in + let no_te_deum = List.filter (fun o -> o.te_deum = None) ordo in + Alcotest.(check int) "the unresolved-Te-Deum population matches this fixture's own measured figure" 133 + (List.length no_te_deum) + +let describe_te_deum_mismatch (o : ordo_row) (t : colitur_te_deum_row) = + Printf.sprintf "%s %S: colitur te_deum=%b, Ordo te_deum=%b (rank=%s commemoration=%b)" o.date o.title t.t_te_deum + (Option.get o.te_deum) (V.rank_to_string t.t_rank) t.t_has_commemoration + +(* F2 (every I-class and omissible vigil), F4 (the three September Ember + days) and NOW F3 (the privileged-Lenten/Passiontide-feria-plus- + commemoration shape) were ALL THREE found against a buggy extraction + (see the fixture's own provenance header, "FIX HISTORY" section, and + {!Rite_ef.Rubrics_ef.te_deum}'s own 237(a) comment for F2/F4's own + account) -- re-run against the corrected data, NONE of the three fires + any more. + + F3 specifically was NEVER a Te Deum defect on either side: the six + dates print a complete SECOND office after a literal "-VEL-" ("or") + separator -- the same saint kept outright under the 2020 decree Cum + Sanctissima, with its OWN "Ad Mat. ... Te Deum" lessons -- and + tools/extract_fiuv_ordo.ml's own [extract_te_deum] used to scan the + WHOLE block unbounded, so on these six days (whose PRIMARY, strict- + 1962 office states no "Ad Mat." of its own at all) it silently read + the SECOND office's Te Deum instead. Fixed at the source + (tools/extract_fiuv_ordo.ml's own [split_at_alt_office]); the fixture + re-extracted from the SAME pdftotext dump. colitur's own answer + ([false], the primary office's own ferial Matins) was never wrong -- + the OTHER side of the comparison was reading the wrong printed office. + [te_deum] now reads [None] (unresolved, not [false]) on these six + dates, because the PRIMARY office's own text states no "Ad Mat." at + all, so there is nothing to positively confirm either direction from + -- consistent with [te_deum]'s own documented meaning elsewhere in + this fixture. + + NOTE, so a reader arriving here from data/ef/expected-divergences-lms.sexp's + own L5 does not draw the wrong conclusion: F3 never "contradicted" L5 + in the first place, and its removal does not corroborate L5 either. + Both were the SAME underlying fact read two different ways -- L5's own + 16 LMS dates and these 6 FIUV dates are exactly the shape the 2020 + decree Cum Sanctissima changes, and colitur, by CLAUDE.md's own binding + decision 2, models the 1962 core without it. See L5's own rewritten + entry for the full account, including the FIUV "-VEL-" dual printing + as the decisive evidence for BOTH files. + + Removed rather than kept as dead code with an [expected 0] pin, the + same "an entry that stops firing is a real change, not silently + absorbed" discipline data/ef/expected-divergences-lms.sexp's own L1/L3 + closures already establish -- see that file's own history for the + precedent this follows. *) + +let test_te_deum_matches_or_is_explained () = + let ordo = ordo_rows () in + let colitur = colitur_te_deum_rows () in + let unexplained = ref [] in + List.iter2 + (fun (o : ordo_row) (t : colitur_te_deum_row) -> + if not (String.equal o.date t.t_date) then Alcotest.failf "misaligned: ordo %s vs colitur %s" o.date t.t_date; + match o.te_deum with + | None -> () + | Some otd -> if Bool.equal otd t.t_te_deum then () else unexplained := describe_te_deum_mismatch o t :: !unexplained) + ordo colitur; + Alcotest.(check (list string)) "every Te Deum mismatch is named in the allow-list -- none unexplained" [] + (List.rev !unexplained) + +let suite = + ( "fiuv-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 Holy Saturday 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 "Ordo Gloria coverage matches the measured figure" `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; + Alcotest.test_case "praef classification coverage matches the measured figure" `Quick + test_praef_classification_coverage; + Alcotest.test_case "every classified preface difference is explained -- none unexplained" `Quick + test_preface_matches_or_is_explained; + Alcotest.test_case "Ordo Te Deum coverage matches the measured figure" `Quick test_te_deum_coverage; + Alcotest.test_case "every Te Deum difference is named in the cited allow-list -- none unexplained" `Quick + test_te_deum_matches_or_is_explained + ] ) |
