From 0d061abc7cc5cdc51ded6ee69e00e187813f5e1b Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 24 Aug 2026 16:16:59 +0200 Subject: feat(ordo): wire the extraordinaryform.org preface column in as a third witness Extends the preface comparison (RG 482-499) with a third, independent publisher, extraordinaryform.org's three annual Ordo editions (2024-2025, 2025-2026, 2026-2027), previously declined for two reasons that are now resolved: - "Advent" is not a preface: RG 494(b) grants the Trinity preface on every Advent SUNDAY, RG 498 the residual Common on every Advent FERIA; this source's own "Advent" label covers both indiscriminately (confirmed live on all 11 non-impeded Advent Sundays in the corpus). Classified by the day's own civil weekday, not a static table. - The known Vigil-of-St-Lawrence gap is reconfirmed, with a nuance not present in the earlier audits: colitur's own RG33-third-trigger now omits the vigil in some years too, so the corpus's gap and colitur's answer coincide in 2025 (a full match, not a coincidence) while 2027 still shows the classic identity-wrong-but-preface-invisible shape. Re-derives the known menu-trap office filter (Requiem-skip, "and "-chain) against the live JSON and finds a second, general pattern: "Saturday of Our Lady" (RG78's BVM Saturday Office), whenever listed, is unconditionally the office -- checked exhaustively across all 38 instances in the corpus, zero counter-examples. A third, generic "absorption" rule was deliberately not built: it would resolve 2 of the 11 residual divergences but wrongly misfires on a structurally identical but semantically different shape (an optional Rogation-day Mass option). The JSON's own extraction carries a genuine classifier gap: parse_ordo.py's hardcoded PREFACES set omits "Christ the King", misfiling it into comms; recovered in the generator, not left blank. 11 of 1088 comparable rows diverge, resolving to 7 adjudicated root causes (E1-E7): two Commemoration_only Marian titles reduced to commemorations (Mt Carmel, Ransom), two genuine sanctoral data gaps cross-confirmed absent by the FIUV/LMS witnesses (Miraculous Medal, St John before the Latin Gate), two single-witnessed efdotorg outliers against FIUV/LMS agreement (St Michael's Dedication, All Saints on a Sunday), and one isolated source labelling slip. Mutation-tested: corrupting the RG495 BVM-Saturday clause reddens 9 tests across this new layer and both existing Ordo witnesses. Per-edition agreement: 358/363 (2024-2025), 358/363 (2025-2026), 361/362 (2026-2027). --- test/test_efdotorg_ordo.ml | 403 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 403 insertions(+) create mode 100644 test/test_efdotorg_ordo.ml (limited to 'test/test_efdotorg_ordo.ml') diff --git a/test/test_efdotorg_ordo.ml b/test/test_efdotorg_ordo.ml new file mode 100644 index 0000000..6200cd9 --- /dev/null +++ b/test/test_efdotorg_ordo.ml @@ -0,0 +1,403 @@ +(* efdotorg-preface task (2026-08-24-colitur-celebrant-rubrics-phase1): the + THIRD independent preface witness, extraordinaryform.org's own three + annual Ordo editions (2024-2025, 2025-2026, 2026-2027). A prior task + (2026-08-21-colitur-celebrant-rubrics-phase1, see + .superpowers/sdd/2026-08-21-colitur-celebrant-rubrics-phase1/preface- + witnesses-report.md) DECLINED to wire this source in, for two reasons: + an unexplained "Advent" label in the preface column, and this corpus's + own known Vigil-of-St-Lawrence gap. Both are now characterisation work, + not blockers -- see tools/extract_efdotorg_ordo.py's own header for the + full account this file's own comparator relies on. + + WHY THIS LAYER IS DIFFERENT FROM test_fiuv_ordo.ml/test_lms_ordo.ml: + this source prints ONE preface value per day (unlike the LMS Ordo's own + option lists), already reduced to the day's own real OFFICE by the + menu-trap filter baked into the generator (see that script's own + header, finding 1) -- so the comparison here is a single equality + check per day, not a membership check. It is also the only one of the + three witnesses whose own JSON, not a fresh pdftotext dump, is the + input -- the JSON is already fidelity-audited + (.superpowers/ordo-fidelity-report.md, 1091/1091 against an + independently-written parser) and is treated as the source; this file + does not re-parse the PDFs. + + SCOPE: preface only (RG 482-499, {!Rite_ef.Rubrics_ef.preface}) -- this + source carries no Creed/Gloria/Te Deum data at all + (docs/research/ordo/PROVENANCE-ordo-corpus.md: "The extraordinaryform.org + editions carry NO Gloria or Creed"), so there is nothing else to + compare it against. *) + +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" + +(* Duplicated, not shared -- test_fiuv_ordo.ml/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_efdotorg_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 from tools/extract_efdotorg_ordo.py's *) +(* own row shape. *) +(* ---------------------------------------------------------------------- *) + +open Sexplib0.Sexp_conv + +type ordo_row = { date : string; mass : string option; cls : int option; colour : string option; praef : string option } +[@@deriving sexp] + +let ordo_rows fixture_path = + let sexp = + try Sexplib.Sexp.load_sexp fixture_path + with e -> Alcotest.failf "%s: failed to load: %s" fixture_path (Printexc.to_string e) + in + list_of_sexp ordo_row_of_sexp sexp + +(* ---------------------------------------------------------------------- *) +(* The colitur side, over the same window -- mirrors test_fiuv_ordo.ml's *) +(* own [colitur_rows]. *) +(* ---------------------------------------------------------------------- *) + +type colitur_row = { c_date : string; c_weekday : Date.weekday; c_preface : Preface.t option } + +let colitur_rows ~year_lo ~year_hi ~window_first ~window_last = + let layer = real_layer () in + let rite = Rite_ef.context ~lectionary:(real_lectionary ()) ~commons:(real_commons ()) in + let by_rata : (int, (V.season, V.rank) LD.t) Hashtbl.t = Hashtbl.create 800 in + for y = year_lo to year_hi do + let days = Cal.year rite layer y in + Array.iter (fun (d : (V.season, V.rank) LD.t) -> Hashtbl.replace by_rata (Date.to_rata d.LD.date) d) days + done; + let mk s = match Date.of_iso8601 s with Ok d -> d | Error e -> Alcotest.failf "%s: %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_weekday = Date.weekday day.LD.date; 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 + +(* ---------------------------------------------------------------------- *) +(* Classification -- see tools/extract_efdotorg_ordo.py's own header, *) +(* finding 3, for why "Advent" needs the day's own weekday rather than a *) +(* static table entry: RG 494(b) grants the TRINITY preface on every *) +(* Advent SUNDAY, RG 498 the residual COMMON on every Advent FERIA, and *) +(* this source's own "Advent" label does not distinguish the two. Every *) +(* other raw value maps 1:1 onto exactly one {!Preface.t} constructor -- *) +(* verified exhaustive over the whole 1091-day office-selected population *) +(* while building this comparison (16 distinct raw values: these 14 fixed *) +(* mappings, "Advent" itself, and [None]) -- unlike the FIUV/LMS sources, *) +(* nothing here is genuinely unclassifiable. *) +(* ---------------------------------------------------------------------- *) + +let classify_praef ~(weekday : Date.weekday) (raw : string) : Preface.t option = + match raw with + | "Common" -> Some Preface.Common + | "For the Dead" -> Some Preface.Requiem + | "Easter" -> Some Preface.Easter + | "Lent" -> Some Preface.Lent + | "BVM" -> Some Preface.Bvm + | "Holy Trinity" -> Some Preface.Trinity + | "Holy Cross" -> Some Preface.Holy_cross + | "Nativity" -> Some Preface.Nativity + | "Apostles" -> Some Preface.Apostles + | "Ascension" -> Some Preface.Ascension + | "Holy Spirit" -> Some Preface.Holy_spirit + | "Epiphany" -> Some Preface.Epiphany + | "St. Joseph" -> Some Preface.St_joseph + | "Sacred Heart" -> Some Preface.Sacred_heart + | "Christ the King" -> Some Preface.Christ_the_king + | "Advent" -> Some (if weekday = Date.Sun then Preface.Trinity else Preface.Common) + | _ -> None + +(* ---------------------------------------------------------------------- *) +(* data/ef/expected-divergences-efdotorg.sexp -- same shape as the other *) +(* Ordo allow-lists' own [allow_entry]. A SEPARATE file: this Ordo is a *) +(* FIFTH lineage (lectio/missalemeum, LMS, FIUV, and now this one), a *) +(* different publisher and a different compiler from all three others. *) +(* ---------------------------------------------------------------------- *) + +type allow_entry = { id : string; citation : string; verdict : string; note : string; expected_rows : int } +[@@deriving sexp] + +let load_allow_list allow_list_path = + let sexps = + try Sexplib.Sexp.load_sexps allow_list_path + with e -> Alcotest.failf "%s: failed to load: %s" allow_list_path (Printexc.to_string e) + in + List.map allow_entry_of_sexp sexps + +let allow_list_path = "../data/ef/expected-divergences-efdotorg.sexp" + +(* ---------------------------------------------------------------------- *) +(* [make_suite]: one edition's worth of tests, parameterised -- mirrors *) +(* test_lms_ordo.ml's own shape. *) +(* ---------------------------------------------------------------------- *) + +let make_suite ~label ~fixture_path ~fixture_sha256 ~window_first ~window_last ~year_lo ~year_hi ~expected_rows + ~expected_no_data ~allow_list_id_for_date = + 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), RE-DERIVED from + {!Colitur_kernel.Computus.gregorian_easter} for every civil year the + window touches (never hardcoded), plus this edition's own share of + the single known SOURCE GAP (2027-03-03) -- the only two shapes a row + can have no [praef] at all for (see tools/extract_efdotorg_ordo.py's + own header, findings 2 and 5). *) + let window_no_praef () = + 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 good_fridays = + List.map + (fun y -> Date.to_iso8601 (Date.add_days (Colitur_kernel.Computus.gregorian_easter y) (-2))) + (List.init (last_year - first_year + 1) (fun i -> first_year + i)) + in + let candidates = good_fridays @ [ "2027-03-03" ] in + List.filter (fun d -> String.compare d window_first >= 0 && String.compare d window_last <= 0) candidates + |> List.sort_uniq String.compare + in + let test_praef_coverage () = + let ordo = ordo_rows fixture_path in + let no_praef = List.filter (fun o -> o.praef = None) ordo in + let expected = window_no_praef () in + Alcotest.(check int) "the no-praef population matches this window's own Good-Friday-plus-source-gap count" + expected_no_data (List.length no_praef); + Alcotest.(check (list string)) "every no-praef row is either this window's own Good Friday or the known source gap" + expected (List.map (fun o -> o.date) no_praef) + in + let describe_praef_mismatch (o : ordo_row) (c : colitur_row) (expected : Preface.t) = + Printf.sprintf "%s %S: colitur preface=%s, %s praef=%S (classified %s, weekday %s)" o.date + (Option.value o.mass ~default:"?") + (match c.c_preface with Some p -> Preface.to_string p | None -> "NONE") + label (Option.get o.praef) (Preface.to_string expected) (Date.weekday_to_string c.c_weekday) + in + let test_praef_matches_or_is_explained () = + let ordo = ordo_rows fixture_path in + let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in + let 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 unclassifiable = 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.praef with + | None -> () + | Some raw -> ( + match classify_praef ~weekday:c.c_weekday raw with + | None -> unclassifiable := o.date :: !unclassifiable + | Some expected -> + if c.c_preface = Some expected 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_praef_mismatch o c expected :: !unexplained)) + ordo colitur; + Alcotest.(check (list string)) + (Printf.sprintf "[%s] every preface mismatch is named in the allow-list -- none unexplained" label) + [] (List.rev !unexplained); + Alcotest.(check (list string)) + (Printf.sprintf "[%s] every raw praef value classifies -- none dropped" label) + [] (List.rev !unclassifiable); + 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 + (* The known Vigil-of-St-Lawrence corpus gap (tools/extract_efdotorg_ordo.py's + own header, finding 4; docs/research/ordo/PROVENANCE-ordo-corpus.md; + .superpowers/ordo-class-report.md) is RECONFIRMED present, but its + effect on THIS axis now differs by year -- checked per date, not + assumed uniform, because colitur's own CURRENT behaviour (RG 33's + third trigger, which landed after the reports above were written -- + CLAUDE.md's own warning that `day`/`readings` output is not stable + across commits is exactly this) already omits the vigil in SOME of + these years for its own, entirely independent reason: + - 2025-08-09 (Saturday): colitur's own RG33-third-trigger ALSO + omits the vigil this year (RG 78's BVM Saturday Office wins + outright), and Rule A (this generator's own finding 6) picks the + SAME office from the corpus's own listing -- a full, direct + match (office identity AND preface), not a lucky coincidence + limited to preface alone. + - 2027-08-09 (Monday): colitur's own vigil IS genuinely observed + this year (RG33's trigger does not fire); the corpus still shows + no trace of it at all -- STILL the known gap, reconfirmed, naming + St Romanus instead -- but both the true Vigil (Class3, no + title-preface trigger) and the corpus's own substitute fall + through RG 482's chain to the identical Common preface, so the + identity error stays invisible to this one axis. Asserted + POSITIVELY (not merely "no failure occurred") so a future change + giving either office a real trigger would surface here. + - 2026-08-09 (Sunday): the vigil is impeded on every source (a + Sunday outranks a Class3 vigil outright); no special case is + needed and none is asserted here. *) + let test_known_vigil_of_st_lawrence_shape () = + let ordo = ordo_rows fixture_path in + let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in + let by_date = List.combine ordo colitur in + let find d = List.find_opt (fun ((o : ordo_row), _) -> String.equal o.date d) by_date in + let in_window d = String.compare d window_first >= 0 && String.compare d window_last <= 0 in + (match find "2025-08-09" with + | Some (o, c) when in_window "2025-08-09" -> + Alcotest.(check (option string)) "2025-08-09: Rule A selects the BVM Saturday Office directly" (Some "BVM") + o.praef; + Alcotest.(check (option string)) + "2025-08-09: colitur's own answer is the same office (RG33 3rd trigger + RG78)" (Some "bvm") + (Option.map Preface.to_string c.c_preface) + | _ -> ()); + match find "2027-08-09" with + | Some (o, c) when in_window "2027-08-09" -> + Alcotest.(check (option string)) + "2027-08-09: the known gap is reconfirmed -- the corpus still shows no Vigil of St Lawrence" + (Some "Common") o.praef; + Alcotest.(check (option string)) + "2027-08-09: colitur's own real Vigil office also reads Common -- the identity error stays invisible here" + (Some "common") (Option.map Preface.to_string c.c_preface) + | _ -> () + in + ( label, + [ Alcotest.test_case "fixture SHA-256 matches its provenance note" `Quick test_fixture_checksum; + Alcotest.test_case "streams are N rows each, dates aligned 1:1" `Quick test_dates_align; + Alcotest.test_case "no-praef population matches Good Friday plus the known source gap" `Quick + test_praef_coverage; + Alcotest.test_case "every preface mismatch is named in the allow-list -- none unexplained" `Quick + test_praef_matches_or_is_explained; + Alcotest.test_case "the known Vigil-of-St-Lawrence gap: reconfirmed in 2027, resolved by Rule A in 2025" `Quick + test_known_vigil_of_st_lawrence_shape + ] ) + +(* The seven adjudicated root causes behind this comparison's own 11 row- + level divergences (three editions combined) -- see + data/ef/expected-divergences-efdotorg.sexp for the full citation of + each. Date-keyed, the same discipline test_lms_ordo.ml's own + [allow_list_id_for_date] and test_fiuv_ordo.ml's own + [fiuv_allow_list_id_for_date] use, 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. Shared across all three editions + (unlike the LMS file's per-window function) because every id here is + adjudicated against colitur's own DATA or RULES, not against a single + edition's own printing quirk -- E1/E3/E4 each recur, unchanged, in + more than one edition. *) +let allow_list_id_for_date = function + | "2025-07-16" | "2026-07-16" | "2027-07-16" -> Some "E1" + | "2026-09-24" -> Some "E2" + | "2025-11-27" | "2026-11-27" -> Some "E3" + | "2025-05-06" | "2026-05-06" -> Some "E4" + | "2025-09-29" -> Some "E5" + | "2026-11-01" -> Some "E6" + | "2025-11-29" -> Some "E7" + | _ -> None + +let suite_2024_2025 = + make_suite ~label:"efdotorg-ordo-2024-2025" ~fixture_path:"fixtures/efdotorg-ordo-2024-2025.sexp" + ~fixture_sha256:"7de633e292fdb85415dd3ad87fcf9414fbb8f34c42772663b78e107e68c6e571" ~window_first:"2024-12-01" + ~window_last:"2025-11-29" ~year_lo:2023 ~year_hi:2026 ~expected_rows:364 ~expected_no_data:1 + ~allow_list_id_for_date + +let suite_2025_2026 = + make_suite ~label:"efdotorg-ordo-2025-2026" ~fixture_path:"fixtures/efdotorg-ordo-2025-2026.sexp" + ~fixture_sha256:"c810ec437cae9fde785f6b3453f77e667644fb82f465b3652040bb2a3b888b9b" ~window_first:"2025-11-30" + ~window_last:"2026-11-28" ~year_lo:2024 ~year_hi:2027 ~expected_rows:364 ~expected_no_data:1 + ~allow_list_id_for_date + +let suite_2026_2027 = + make_suite ~label:"efdotorg-ordo-2026-2027" ~fixture_path:"fixtures/efdotorg-ordo-2026-2027.sexp" + ~fixture_sha256:"19c35b45d7964dd357f92a871e81423af8217dff6c40178f3a95d3ec9c4091ea" ~window_first:"2026-11-29" + ~window_last:"2027-11-27" ~year_lo:2025 ~year_hi:2028 ~expected_rows:364 ~expected_no_data:2 + ~allow_list_id_for_date + +(* Guards against a stale allow-list entry, the same discipline + test_lms_ordo.ml's own [test_allow_list_ids_are_exactly_l4] uses: a + STATIC check that the sexp file's own declared ids are exactly this + set -- not a cross-edition "did every id actually fire" proof (neither + test_lms_ordo.ml nor test_fiuv_ordo.ml's own analogous guard attempts + that either; each edition's own [test_praef_matches_or_is_explained] + already fails loudly if an id fires the WRONG number of times, or if a + real divergence names an id that does not exist at all). *) +let test_allow_list_ids_are_exactly_e1_to_e7 () = + let ids = List.sort String.compare (List.map (fun e -> e.id) (load_allow_list allow_list_path)) in + Alcotest.(check (list string)) "the efdotorg allow-list declares exactly E1..E7" + [ "E1"; "E2"; "E3"; "E4"; "E5"; "E6"; "E7" ] ids + +let suite_allow_list = + ( "efdotorg-ordo-allow-list", + [ Alcotest.test_case "declares exactly E1..E7" `Quick test_allow_list_ids_are_exactly_e1_to_e7 ] ) -- cgit v1.3