module V = Rite_ef.Vocab_ef let test_vocab_roundtrips () = List.iter (fun s -> Alcotest.(check bool) "season string roundtrip" true (V.season_of_string (V.season_to_string s) = Some s)) V.seasons; List.iter (fun r -> Alcotest.(check bool) "rank string roundtrip" true (V.rank_of_string (V.rank_to_string r) = Some r)) V.ranks; Alcotest.(check int) "eight seasons" 8 (List.length V.seasons); Alcotest.(check int) "four ranks" 4 (List.length V.ranks) (* Slug words are lectio's lectionary-key vocabulary, deliberately distinct from the season names (spec §4.4, plan correction 2). *) let test_slug_words () = Alcotest.(check string) "paschaltide slugs as easter" "easter" (V.season_slug_word V.Paschaltide); Alcotest.(check string) "christmastide slugs as christmas" "christmas" (V.season_slug_word V.Christmastide); Alcotest.(check string) "season name differs" "paschaltide" (V.season_to_string V.Paschaltide) module T = Rite_ef.Temporal_ef module D = Colitur_kernel.Date let d y m dd = match D.make ~year:y ~month:m ~day:dd with | Ok t -> t | Error e -> Alcotest.failf "%s" e let season_str dt = V.season_to_string (T.season dt) let test_advent_start () = (* Advent 2026 begins Sunday 29 November 2026. *) Alcotest.(check string) "advent 2026" "2026-11-29" (D.to_iso8601 (T.advent_start 2026)); Alcotest.(check bool) "always a Sunday" true (D.weekday (T.advent_start 2026) = D.Sun) let test_seasons () = (* Easter 2026 is 5 April. *) Alcotest.(check string) "1 Dec 2026" "advent" (season_str (d 2026 12 1)); Alcotest.(check string) "24 Dec 2026" "advent" (season_str (d 2026 12 24)); Alcotest.(check string) "25 Dec 2026" "christmastide" (season_str (d 2026 12 25)); (* RG 72-73: Christmas Time runs to 13 January INCLUSIVE -- the deliberate divergence from lectio, which starts time-after-epiphany at 6 January. *) Alcotest.(check string) "6 Jan 2026" "christmastide" (season_str (d 2026 1 6)); Alcotest.(check string) "13 Jan 2026" "christmastide" (season_str (d 2026 1 13)); Alcotest.(check string) "14 Jan 2026" "time-after-epiphany" (season_str (d 2026 1 14)); Alcotest.(check string) "Septuagesima 1 Feb 2026" "septuagesima" (season_str (d 2026 2 1)); Alcotest.(check string) "Ash Wed 18 Feb 2026" "lent" (season_str (d 2026 2 18)); Alcotest.(check string) "Passion Sun 22 Mar 2026" "passiontide" (season_str (d 2026 3 22)); (* Holy Saturday stays in Passiontide: the Vigil is a night Mass (spec §4.2). *) Alcotest.(check string) "Holy Sat 4 Apr 2026" "passiontide" (season_str (d 2026 4 4)); Alcotest.(check string) "Easter 5 Apr 2026" "paschaltide" (season_str (d 2026 4 5)); Alcotest.(check string) "Sat after Pentecost 30 May 2026" "paschaltide" (season_str (d 2026 5 30)); Alcotest.(check string) "Trinity 31 May 2026" "time-after-pentecost" (season_str (d 2026 5 31)) let named_slug dt = match T.named dt with | Some (_, slug, _, _) -> slug | None -> "" let test_named_feasts () = (* Easter 2026 = 5 April. *) Alcotest.(check string) "nativity" "ef-nativity" (named_slug (d 2026 12 25)); Alcotest.(check string) "nativity vigil" "ef-nativity-vigil" (named_slug (d 2026 12 24)); Alcotest.(check string) "circumcision" "ef-circumcision" (named_slug (d 2026 1 1)); Alcotest.(check string) "epiphany" "ef-epiphany" (named_slug (d 2026 1 6)); Alcotest.(check string) "ash wednesday" "ef-ash-wednesday" (named_slug (d 2026 2 18)); Alcotest.(check string) "passion sunday" "ef-passion-sunday" (named_slug (d 2026 3 22)); Alcotest.(check string) "palm sunday" "ef-palm-sunday" (named_slug (d 2026 3 29)); Alcotest.(check string) "easter" "ef-easter-sunday" (named_slug (d 2026 4 5)); Alcotest.(check string) "low sunday" "ef-low-sunday" (named_slug (d 2026 4 12)); Alcotest.(check string) "ascension vigil" "ef-ascension-vigil" (named_slug (d 2026 5 13)); Alcotest.(check string) "ascension" "ef-ascension" (named_slug (d 2026 5 14)); Alcotest.(check string) "pentecost vigil" "ef-pentecost-vigil" (named_slug (d 2026 5 23)); Alcotest.(check string) "pentecost" "ef-pentecost" (named_slug (d 2026 5 24)); Alcotest.(check string) "trinity" "ef-trinity" (named_slug (d 2026 5 31)); Alcotest.(check string) "corpus christi" "ef-corpus-christi" (named_slug (d 2026 6 4)); Alcotest.(check string) "sacred heart" "ef-sacred-heart" (named_slug (d 2026 6 12)); Alcotest.(check string) "an ordinary day is not named" "" (named_slug (d 2026 7 15)) let test_christ_the_king () = (* 1960 calendar: the LAST Sunday of October, not the OF's last before Advent. *) Alcotest.(check string) "2026" "2026-10-25" (D.to_iso8601 (T.christ_the_king 2026)); Alcotest.(check bool) "always a Sunday" true (D.weekday (T.christ_the_king 2026) = D.Sun); Alcotest.(check string) "slug" "ef-christ-the-king" (named_slug (T.christ_the_king 2026)) let test_nativity_octave () = Alcotest.(check string) "29 Dec" "ef-nativity-octave-day-5" (named_slug (d 2026 12 29)); Alcotest.(check string) "31 Dec" "ef-nativity-octave-day-7" (named_slug (d 2026 12 31)) let sunday_slug_of dt = match T.sunday_slug dt with Some s -> s | None -> "" let test_week_numbers () = Alcotest.(check (option int)) "Advent I 2026" (Some 1) (T.week (T.advent_start 2026)); Alcotest.(check (option int)) "Advent II 2026" (Some 2) (T.week (D.add_days (T.advent_start 2026) 7)); (* Weeks are Sunday-aligned: the Saturday of week 1 is still week 1. *) Alcotest.(check (option int)) "Advent I Saturday" (Some 1) (T.week (D.add_days (T.advent_start 2026) 6)); (* Time after Pentecost counts from Pentecost, so Trinity is week 1. *) Alcotest.(check (option int)) "Trinity 2026 is week 1" (Some 1) (T.week (d 2026 5 31)); (* Christmastide has no numbered weeks. *) Alcotest.(check (option int)) "Christmastide has no week" None (T.week (d 2026 12 30)); (* Ash Wednesday is 4 days before the Lent I origin (22 Feb 2026): it belongs to no numbered week. This is the only place in the system [floor_div]'s negative branch fires -- guard against a regression to plain [/], which would wrongly round this up to week 1. *) Alcotest.(check (option int)) "Ash Wednesday has no week" None (T.week (d 2026 2 18)) let test_sunday_slugs () = Alcotest.(check string) "Advent I" "ef-advent-sunday-1" (sunday_slug_of (T.advent_start 2026)); Alcotest.(check string) "Lent I 2026 (22 Feb)" "ef-lent-sunday-1" (sunday_slug_of (d 2026 2 22)); (* Slugs use lectio's season words: Paschaltide slugs as "easter". *) Alcotest.(check string) "Paschaltide III 2026 (19 Apr)" "ef-easter-sunday-3" (sunday_slug_of (d 2026 4 19)); (* The Sunday within the Octave of the Nativity keeps lectio's key. *) Alcotest.(check string) "27 Dec 2026" "ef-christmas-sunday-0" (sunday_slug_of (d 2026 12 27)); (* The Sunday falling 7-13 Jan is the 1st Sunday after Epiphany. Its season is Christmastide (RG 72-73) but its lectionary key stays lectio's. *) Alcotest.(check string) "11 Jan 2026" "ef-time-after-epiphany-sunday-1" (sunday_slug_of (d 2026 1 11)) (* Cross-check: on an ordinary (non-Last, non-resumed) Sunday after Pentecost, the number embedded in [sunday_slug] must equal [week]. The two functions independently compute Pentecost-relative week arithmetic; nothing else pins them together, so a future rubric fix to one that is not mirrored in the other would otherwise diverge silently. *) let test_week_sunday_slug_agree () = let easter = Colitur_kernel.Computus.gregorian_easter 2026 in let pentecost = D.add_days easter 49 in List.iter (fun n -> let dt = D.add_days pentecost (7 * n) in let slug = sunday_slug_of dt in match Scanf.sscanf_opt slug "ef-time-after-pentecost-sunday-%d" (fun k -> k) with | None -> Alcotest.failf "slug %S did not match the expected ...-sunday-N pattern" slug | Some embedded -> Alcotest.(check (option int)) (Printf.sprintf "week %d after Pentecost matches slug" n) (Some embedded) (T.week dt)) [ 1; 5; 10; 15; 20 ] (* The resumed-Sunday tail: when Easter is early there are more than 23 Sundays after Pentecost, and the surplus resume the Sundays after Epiphany that Septuagesima cut short. 2038 has Easter on 25 April (the latest possible) and 1943-style early years have the most Sundays; 2035 (Easter 25 March) is an early-Easter year with a long tail. *) let test_resumed_sundays () = let last_sunday_before_advent y = D.add_days (T.advent_start y) (-7) in (* The last Sunday before Advent always keeps the 24th (Last) Mass. *) Alcotest.(check string) "2035 last Sunday" "ef-time-after-pentecost-sunday-24" (sunday_slug_of (last_sunday_before_advent 2035)); Alcotest.(check string) "2026 last Sunday" "ef-time-after-pentecost-sunday-24" (sunday_slug_of (last_sunday_before_advent 2026)); (* In an early-Easter year the surplus Sundays route to Epiphany Masses. *) let resumed = List.filter_map (fun n -> let dt = D.add_days (T.advent_start 2035) (-7 - (7 * n)) in let s = sunday_slug_of dt in if String.length s >= 27 && String.sub s 0 27 = "ef-time-after-epiphany-sund" then Some s else None) [ 1; 2; 3; 4; 5 ] in Alcotest.(check bool) "2035 has resumed Epiphany Sundays" true (resumed <> []) module Cel = Colitur_kernel.Celebration module Sl = Colitur_kernel.Slug module Colr = Colitur_kernel.Colour module Sub = Colitur_kernel.Subject let office dt = (T.temporal dt).Colitur_kernel.Temporal.office let slug_of dt = Sl.to_string (office dt).Cel.slug let rank_of dt = V.rank_to_string (office dt).Cel.rank let colour_of dt = Colr.to_string (office dt).Cel.colour let subject_of dt = (office dt).Cel.subject let temporal_week dt = (T.temporal dt).Colitur_kernel.Temporal.week let la = Colitur_kernel.Lang.of_string_exn "la" let en = Colitur_kernel.Lang.of_string_exn "en" let name_la_of dt = Colitur_kernel.Names.find (office dt).Cel.names la (* RG 17(b) (Caput III, "De Dominicis"), primary text (scan-verified, docs/research/rules-register.md §4): "festum S. Familiae Iesu, Mariae, Ioseph, celebrandum dominica prima post Epiphaniam" -- the Holy Family is celebrated on the first Sunday after Epiphany, EVERY year (unlike RG 17(a)'s Holy Name, whose narrower 2-5 January window can be empty and carries an explicit "vel ea deficiente, die 2 ianuarii" fallback in the calendarium itself -- 7-13 January can never be empty of a Sunday, so Holy Family needs, and the calendarium carries, no such fallback). [T.holy_family_sunday] is [named]'s own [week_origin Time_after_epiphany] formula, restated for its own citation -- not re-derived a second time. Deliberately NOT part of [T.named]: [named] is checked before [T.sunday_slug]'s own Christmastide branch in [T.temporal]'s dispatch, but this feast's slug, rank and colour are EXACTLY what that branch already computes for the 7-13 January Sunday (register's own pre-existing note on [sunday_slug]: "the key stays lectio's") -- the only field that actually needed to change is [subject] (silently always [Temporal] before this), so [T.temporal] applies it as a targeted override inside that branch rather than duplicating slug/rank/colour a second time through [named]'s 4-tuple. *) let test_holy_family () = (* 2026: Epiphany (6 Jan) is a Tuesday, so Holy Family falls 11 January -- an ordinary instance, no collision with the Baptism (13 Jan). *) Alcotest.(check string) "2026: slug is the ordinary I-post-Epiphany Sunday key" "ef-time-after-epiphany-sunday-1" (slug_of (d 2026 1 11)); Alcotest.(check string) "2026: II class" "class-2" (rank_of (d 2026 1 11)); Alcotest.(check string) "2026: white" "white" (colour_of (d 2026 1 11)); Alcotest.(check bool) "2026: subject is Lord (RG 91 entry 14, \"Festa Domini II \ classis\" -- the mystery of the Holy Family touches Christ)" true (subject_of (d 2026 1 11) = Sub.Lord); (* fix round 1 (coordinator finding 3): a primary-sourced LATIN name, zero circularity (the calendarium's own January table and the Mass propers' own heading, both photographic scans, word for word: "Sanctae Familiae Iesu, Mariae, Ioseph"). No ENGLISH name -- deliberate (temporal_ef.ml's own comment), and every OTHER temporal-cycle candidate still carries no name of any kind. *) Alcotest.(check (option string)) "2026: Latin name from the calendarium/propers, verbatim" (Some "Sanctae Familiae Iesu, Mariae, Ioseph") (name_la_of (d 2026 1 11)); Alcotest.(check (option string)) "2026: no English name (deliberate -- would read the oracle it is \ compared against)" None (Colitur_kernel.Names.find (office (d 2026 1 11)).Cel.names en); Alcotest.(check (option string)) "2026: an ordinary neighbouring Sunday carries no Latin name either" None (name_la_of (d 2026 1 18)); (* 2019: Epiphany itself is a Sunday, so the first Sunday AFTER it is the LATEST possible date, 13 January -- the one date Holy Family collides with the fixed Commemoration of the Baptism of the Lord (data/ef/ sanctoral.sexp: "commemoration-of-the-baptism-of-the-lord", also 13 Jan, II class, subject Lord). [T.temporal]'s own resolution of WHO wins that day is {!Rite_ef.Precedence_ef}'s business (band/disposition, see test_precedence_ef.ml), not this function's -- this only proves [T.temporal] itself still identifies 13 January 2019 as Holy Family's own date, unconditionally, regardless of what else might compete for the day once precedence is applied. *) Alcotest.(check bool) "2019: 6 January is a Sunday" true (D.weekday (d 2019 1 6) = D.Sun); Alcotest.(check string) "2019: Holy Family falls on the latest possible date, 13 Jan" "2019-01-13" (D.to_iso8601 (T.holy_family_sunday 2019)); Alcotest.(check string) "2019: still the same slug" "ef-time-after-epiphany-sunday-1" (slug_of (T.holy_family_sunday 2019)); Alcotest.(check bool) "2019: still subject Lord" true (subject_of (T.holy_family_sunday 2019) = Sub.Lord); (* Every possible weekday for 6 January exercised, proving the formula never leaves the 7-13 window empty (register's own citation above) -- not merely the two years already checked above. *) List.iter (fun y -> let hf = T.holy_family_sunday y in Alcotest.(check bool) (Printf.sprintf "%d: Holy Family is a Sunday" y) true (D.weekday hf = D.Sun); Alcotest.(check bool) (Printf.sprintf "%d: Holy Family falls 7-13 January" y) true (D.month hf = 1 && D.day hf >= 7 && D.day hf <= 13)) [ 2019; 2020; 2021; 2022; 2023; 2024; 2025; 2026; 2027 ]; (* Negative control: an ORDINARY Sunday after Epiphany (the 2nd) stays [subject Temporal] -- the override is scoped to exactly one Sunday a year, not every Time-after-Epiphany-shaped Sunday. *) Alcotest.(check bool) "2026: the following Sunday (18 Jan, II after Epiphany) is NOT Lord" true (subject_of (d 2026 1 18) = Sub.Temporal) (* Three of [test_holy_family]'s own exhaustive-weekday sample, pinned to their literal ISO dates and independently cross-checked against `date -d +%A` (the same discipline test_golden.ml's own header describes for its own weekday assertions) -- the earliest possible date (Epiphany a Saturday), the latest (Epiphany a Sunday), and one ordinary middle case, rather than trusting the structural "always 7-13" check above on its own without ever anchoring it to real calendar dates. *) let test_holy_family_no_gap_year () = Alcotest.(check string) "2024 (6 Jan a Saturday): earliest possible date, 7 Jan" "2024-01-07" (D.to_iso8601 (T.holy_family_sunday 2024)); Alcotest.(check string) "2019 (6 Jan a Sunday): latest possible date, 13 Jan" "2019-01-13" (D.to_iso8601 (T.holy_family_sunday 2019)); Alcotest.(check string) "2025 (6 Jan a Monday): 12 Jan" "2025-01-12" (D.to_iso8601 (T.holy_family_sunday 2025)) (* RG 17(a) (Caput III, "De Dominicis"; full text on [T.holy_name_sunday]'s own citation), Sunday shape: "festum Ss.mi Nominis Iesu, celebrandum dominica quae occurrit a die 2 ad 5 ianuarii". Deliberately NOT part of [T.named], same reason as Holy Family (that test's own comment): the slug/rank/colour this branch produces for a plain Sunday 2-5 January already match what [T.sunday_slug]'s Christmastide branch always computed; only [subject]/[names] change. *) let test_holy_name_sunday_shape () = (* 2026: 4 January is the Sunday (independently cross-checked against `date -d 2026-01-04 +%u` = 7). Oracle-corroborated: missalemeum's own 2026-01-04 row is rank 2, white, title "Holy Name of Jesus" (the task brief's own example). *) Alcotest.(check string) "2026: slug is the ordinary Holy-Name-window Sunday key" "ef-holy-name-sunday" (slug_of (d 2026 1 4)); Alcotest.(check string) "2026: II class" "class-2" (rank_of (d 2026 1 4)); Alcotest.(check string) "2026: white" "white" (colour_of (d 2026 1 4)); Alcotest.(check bool) "2026: subject is Lord (RG 91 entry 14, \"Festa Domini II classis\")" true (subject_of (d 2026 1 4) = Sub.Lord); Alcotest.(check (option string)) "2026: Latin name from the calendarium/propers, verbatim" (Some "Sanctissimi Nominis Iesu") (name_la_of (d 2026 1 4)); Alcotest.(check (option string)) "2026: no English name (deliberate -- would read the oracle it is \ compared against)" None (Colitur_kernel.Names.find (office (d 2026 1 4)).Cel.names en); (* Negative controls: every OTHER day in the 2-5 January window that year is NOT Lord -- RG 17(a)'s own override is scoped to exactly the one Sunday [T.holy_name_sunday] names, not the whole window. 2 and 5 January (Friday, Monday) stay plain [Temporal] ferias, as before. 3 January is a SATURDAY -- an unrelated rubric (RG 78, "De sancta Maria in sabbato", {!bvm_saturday_names}'s own citation in temporal_ef.ml) legitimately retags it [Bvm], not [Temporal]: it is an otherwise-unoccupied IV-class Saturday (Christmastide's "ef-christmas-1-*" ferial branch, [ferial_rank]'s unconditional Class4 there), so RG 78 fires regardless of RG 17(a) being in play the same week -- the two rubrics are independent and this is not a collision. This is deliberately NOT folded into the [Temporal] check above: asserting "stays Temporal" for 3 January would be WRONG once RG 78 is built, not merely incomplete. *) List.iter (fun dd -> Alcotest.(check bool) (Printf.sprintf "2026-01-%02d: NOT Lord (not the Sunday)" dd) true (subject_of (d 2026 1 dd) <> Sub.Lord)) [ 2; 3; 5 ]; Alcotest.(check bool) "2026-01-02: plain Temporal feria (not a Saturday)" true (subject_of (d 2026 1 2) = Sub.Temporal); Alcotest.(check bool) "2026-01-05: plain Temporal feria (not a Saturday)" true (subject_of (d 2026 1 5) = Sub.Temporal); Alcotest.(check bool) "2026-01-03: a Saturday -- RG 78's BVM Saturday office fires here \ independently of RG 17(a)" true (subject_of (d 2026 1 3) = Sub.Bvm); (* 2027: 3 January is the Sunday (`date -d 2027-01-03 +%u` = 7) -- the oracle window's own second instance of the Sunday shape. missalemeum's 2027-01-03 row independently corroborates: rank 2, white, title "Holy Name of Jesus". *) Alcotest.(check bool) "2027: 3 January is a Sunday" true (D.weekday (d 2027 1 3) = D.Sun); Alcotest.(check string) "2027: same slug" "ef-holy-name-sunday" (slug_of (d 2027 1 3)); Alcotest.(check bool) "2027: still subject Lord" true (subject_of (d 2027 1 3) = Sub.Lord); (* Cross-checked against [T.holy_name_sunday] itself, independent of the literal dates above. *) Alcotest.(check (option string)) "2026: T.holy_name_sunday agrees" (Some "2026-01-04") (Option.map D.to_iso8601 (T.holy_name_sunday 2026)); Alcotest.(check (option string)) "2027: T.holy_name_sunday agrees" (Some "2027-01-03") (Option.map D.to_iso8601 (T.holy_name_sunday 2027)) (* RG 17(a)'s own fallback clause, "(secus die 2 ianuarii)" -- the calendarium confirms it in its own words: "vel, ea deficiente, die 2 ianuarii: Sanctissimi Nominis Iesu, II classis". Unlike Holy Family's 7-13 January window, this narrower 2-5 January window CAN be empty of a Sunday -- 3,619 of the 8,417 years in [1583, 9999] -- and colitur emitted NO Holy Name office at all on any of them before this fix (register §6.2's own account of the gap this closes). *) let test_holy_name_fallback_shape () = (* 2029: no Sunday falls 2-5 January (independently cross-checked: `date -d 2029-01-0{2,3,4,5} +%u` are 2,3,4,5 -- Tuesday through Friday, none a 7). The task brief's own reported example: "2029-01-02 ef-christmas-1-tuesday class-4 white (no Sunday in window: feast MISSING)" -- that is the pre-fix behaviour this test pins the fix against. *) Alcotest.(check (option string)) "2029: no Sunday in the window" None (Option.map D.to_iso8601 (T.holy_name_sunday 2029)); Alcotest.(check string) "2029: slug is the fallback key" "ef-holy-name" (slug_of (d 2029 1 2)); Alcotest.(check string) "2029: II class" "class-2" (rank_of (d 2029 1 2)); Alcotest.(check string) "2029: white" "white" (colour_of (d 2029 1 2)); Alcotest.(check bool) "2029: subject is Lord, same as the Sunday shape" true (subject_of (d 2029 1 2) = Sub.Lord); Alcotest.(check (option string)) "2029: SAME Latin name as the Sunday shape (one feast, two dates)" (Some "Sanctissimi Nominis Iesu") (name_la_of (d 2029 1 2)); (* Negative controls: 3, 4 and 5 January stay ordinary Christmastide ferias in a fallback year -- the fallback names 2 January specifically, not "some day near the start of the window". *) List.iter (fun dd -> Alcotest.(check bool) (Printf.sprintf "2029-01-%02d: NOT the feast (ordinary feria)" dd) true (subject_of (d 2029 1 dd) = Sub.Temporal && Sl.to_string (office (d 2029 1 dd)).Cel.slug <> "ef-holy-name")) [ 3; 4; 5 ]; (* A second, independently cross-checked fallback year, at the OTHER end of the week (2008: `date -d 2008-01-02 +%A` = Wednesday), so this is not a single-year accident. *) Alcotest.(check (option string)) "2008: no Sunday in the window either" None (Option.map D.to_iso8601 (T.holy_name_sunday 2008)); Alcotest.(check string) "2008: same fallback slug" "ef-holy-name" (slug_of (d 2008 1 2)) (* [T.holy_name_sunday]'s own [Some]/[None] split, cross-checked against `date -d` independently for a sample spanning both shapes -- not merely the two years the shape tests above already pin literally, so the boundary itself (not only two hand-picked instances) is exercised. *) let test_holy_name_sunday_or_none () = List.iter (fun (y, expected) -> Alcotest.(check (option string)) (Printf.sprintf "%d: holy_name_sunday" y) expected (Option.map D.to_iso8601 (T.holy_name_sunday y))) [ (2026, Some "2026-01-04"); (2027, Some "2027-01-03"); (2009, Some "2009-01-04"); (2010, Some "2010-01-03"); (2020, Some "2020-01-05"); (2025, Some "2025-01-05"); (2006, None); (2007, None); (2008, None); (2018, None); (2029, None) ] (* Regression for a Task 14 Validate finding, reworked (register finding 4): [named] no longer carries its own week at all -- [temporal] computes it uniformly via [week] for every day, named or not -- so "a named day inside a numbered season run carries that run's week" now holds by construction rather than by a hand-set value on some branches and not others. These four sat inside a numbered run (Paschaltide week 6, Time after Pentecost weeks 1 and 2 respectively) but carried [None] under the old convention-based version; each assertion below would have failed against it (first to fail: Ascension Vigil, expected [Some 6], got [None]). *) let test_named_days_carry_their_week () = Alcotest.(check (option int)) "Ascension Vigil carries week 6" (Some 6) (temporal_week (d 2026 5 13)); Alcotest.(check (option int)) "Ascension carries week 6" (Some 6) (temporal_week (d 2026 5 14)); Alcotest.(check (option int)) "Corpus Christi carries week 1" (Some 1) (temporal_week (d 2026 6 4)); Alcotest.(check (option int)) "Sacred Heart carries week 2" (Some 2) (temporal_week (d 2026 6 12)) (* The total replacement for the old property: with [week] removed from [named]'s own return type, "the day's week" has exactly one source, so this can be asserted for *every* day, not merely wherever [named] happened to supply an explicit value -- the old version's [| _ -> true] escape hatch for every unnamed and every None-week day is gone, and with it the reason that version could not detect an omission. *) let prop_temporal_week_matches_week = QCheck.Test.make ~count:200 ~name:"temporal's week always equals week, for every day of the year" (QCheck.int_range 1583 9998) (fun y -> let start = d y 1 1 in let stop = d (y + 1) 1 1 in let n = D.to_rata stop - D.to_rata start in let rec check i = i >= n || let dt = D.add_days start i in temporal_week dt = T.week dt && check (i + 1) in check 0) let test_ferial_slugs_and_ranks () = (* Ferias key on season-week-weekday, matching lectio's lectionary keys. *) Alcotest.(check string) "Lent feria" "ef-lent-3-monday" (slug_of (d 2026 3 9)); (* RG 91 entry 22: Lent and Passiontide ferias are III class. *) Alcotest.(check string) "Lent feria is III class" "class-3" (rank_of (d 2026 3 9)); (* RG 91 entry 25: Advent ferias to 16 December are III class ... *) Alcotest.(check string) "Advent 15 Dec is III class" "class-3" (rank_of (d 2026 12 15)); (* ... entry 18: Advent 17-23 December are II class. lectio marks all Advent ferias III class; this is a deliberate divergence. 21 Dec (not 18 Dec) is used deliberately: 18 Dec 2026 is also the Advent Ember Friday, which would independently be II class via the Ember rule and so would not isolate this one. *) Alcotest.(check string) "Advent 21 Dec is II class" "class-2" (rank_of (d 2026 12 21)); (* RG 91 entry 28: per annum ferias are IV class. *) Alcotest.(check string) "per annum feria is IV class" "class-4" (rank_of (d 2026 7 15)); (* RG 91 entries 7 and 10: Holy Week and the privileged octaves are I class. *) Alcotest.(check string) "Holy Monday is I class" "class-1" (rank_of (d 2026 3 30)); (* Good Friday: RG 91 entry 7 covers only Ash Wed and Mon-Wed of Holy Week -- Thursday-Saturday are the Sacred Triduum, entry 2, still I class. *) Alcotest.(check string) "Good Friday is I class" "class-1" (rank_of (d 2026 4 3)); Alcotest.(check string) "Easter Monday is I class" "class-1" (rank_of (d 2026 4 6)); Alcotest.(check string) "Whit Monday is I class" "class-1" (rank_of (d 2026 5 25)) let test_after_ashes () = Alcotest.(check string) "Thursday after Ash Wednesday" "ef-lent-after-ashes-thursday" (slug_of (d 2026 2 19)); Alcotest.(check (option int)) "outside a numbered week" None (T.temporal (d 2026 2 19)).Colitur_kernel.Temporal.week let test_ember_days () = (* September Ember days: Wed/Fri/Sat after the third Sunday of September. Third Sunday of September 2026 = 20 September. *) Alcotest.(check string) "September Ember Wed" "ef-september-ember-wed" (slug_of (d 2026 9 23)); Alcotest.(check string) "September Ember Sat" "ef-september-ember-sat" (slug_of (d 2026 9 26)); Alcotest.(check string) "September Ember is II class" "class-2" (rank_of (d 2026 9 23)); (* Advent Ember days: after Advent III (2026: 13 December). *) Alcotest.(check string) "Advent Ember Wed" "ef-advent-ember-wed" (slug_of (d 2026 12 16)); (* Lenten Ember days: after Lent I (2026: 22 February). RG 91 entry 18 makes them II class -- entry 22 excepts them from the III-class Lenten ferias. lectio treats them as ordinary Lenten ferias. *) Alcotest.(check string) "Lent Ember Wed" "ef-lent-ember-wed" (slug_of (d 2026 2 25)); Alcotest.(check string) "Lent Ember is II class" "class-2" (rank_of (d 2026 2 25)); (* Whitsun Ember days sit inside the I-class Pentecost octave. *) Alcotest.(check string) "Whit Ember Wed" "ef-pentecost-ember-wed" (slug_of (d 2026 5 27)); Alcotest.(check string) "Whit Ember is I class" "class-1" (rank_of (d 2026 5 27)); (* Thursday is not an Ember day: the [day_of] guard matches only Wed/Fri/Sat (3/5/6 days after the anchor), never day 4. 24 Sept 2026 is the Thursday after the September anchor and must fall back to an ordinary IV-class per annum feria, not an Ember slug. *) Alcotest.(check string) "Thursday after the September anchor is not Ember" "class-4" (rank_of (d 2026 9 24)) let test_rogations () = (* RG 80/87: Minor Litanies on the Monday and Tuesday before Ascension. The Wednesday is the Ascension vigil (see Task 11). *) Alcotest.(check string) "Rogation Monday" "ef-rogation-monday" (slug_of (d 2026 5 11)); Alcotest.(check string) "Rogation Tuesday" "ef-rogation-tuesday" (slug_of (d 2026 5 12)); Alcotest.(check string) "Wednesday is the vigil" "ef-ascension-vigil" (slug_of (d 2026 5 13)); (* RG 88: "de Litaniis minoribus nihil fit in Officio" -- the Rogation changes the Mass, not the Office, and no RG 91 table entry ranks these days specially, so they keep the ordinary IV-class rank of an unprivileged Paschaltide feria (entry 28's catch-all). *) Alcotest.(check string) "Rogation Monday keeps the ordinary ferial rank" "class-4" (rank_of (d 2026 5 11)) let test_colours () = Alcotest.(check string) "Advent is violet" "violet" (colour_of (d 2026 12 1)); Alcotest.(check string) "per annum is green" "green" (colour_of (d 2026 7 15)); Alcotest.(check string) "Paschaltide is white" "white" (colour_of (d 2026 4 20)); Alcotest.(check string) "Pentecost octave is red" "red" (colour_of (d 2026 5 25)); (* Gaudete = Advent III, Laetare = Lent IV. *) Alcotest.(check string) "Gaudete is rose" "rose" (colour_of (d 2026 12 13)); Alcotest.(check string) "Laetare is rose" "rose" (colour_of (d 2026 3 15)) (* Register finding 1 / controller finding A: Christmastide's ferial fallback used to collapse every day to literal week "0" (no numbered weeks in Christmastide), and because colitur's Christmastide spans 25 Dec - 13 Jan, the same weekday recurred and collided. 2026: Easter 5 Apr, Epiphany (6 Jan) is a Tuesday, so the actual first-Sunday-after-Epiphany origin is 11 Jan -- exercising all four sub-stretches of the fix. *) let test_christmastide_feria_slugs () = (* 26 Dec 2026 is a SATURDAY -- but, fix round 1 (coordinator finding F2), it can NEVER actually hold the BVM Saturday office in the real resolved pipeline: 26/27/28 December are St Stephen/St John/the Holy Innocents (RG 67-70), fixed, unconditional, real data/ef/sanctoral.sexp entries every single year (rank >= Class2), which always outrank a Class4 temporal candidate -- so RG 78's own protasis ("in quibus OCCURRIT Officium de feria IV classis") is never satisfied by ANY day in the 26-28 December stretch, in any year. [T.temporal] itself has no visibility into the sanctoral layer at all (by design -- the temporal candidate is computed independent of what saint's day it is; Precedence decides the winner afterward), so it still unconditionally tags this date [subject = Bvm] -- a genuine, correct fact about [T.temporal]'s own pure computation, but NOT a claim that this shape is ever the real OBSERVED office, and pinning it with RG-78 citation language as if it were would overclaim. Only the SLUG is asserted here (unaffected either way, unconditionally correct); the override demonstration itself uses 3 January and 10 January below instead -- both, checked directly against data/ef/sanctoral.sexp, have no competing entry in any year, so the BVM Saturday office genuinely IS reachable there. *) Alcotest.(check string) "26 Dec (Sat)" "ef-christmas-0-saturday" (slug_of (d 2026 12 26)); Alcotest.(check string) "28 Dec (Mon)" "ef-christmas-0-monday" (slug_of (d 2026 12 28)); (* 3 Jan 2026 is a SATURDAY, genuinely reachable (data/ef/sanctoral.sexp has no 3 January entry) -- the real demonstration for the "-1-" stretch that 26 December's own Saturday could not honestly provide. *) Alcotest.(check string) "3 Jan (Sat): slug stays the ordinary \"-1-\" naming" "ef-christmas-1-saturday" (slug_of (d 2026 1 3)); Alcotest.(check string) "3 Jan (Sat): the BVM Saturday office overrides the season colour (white, RG 120(b))" "white" (colour_of (d 2026 1 3)); Alcotest.(check bool) "3 Jan (Sat): subject Bvm" true (subject_of (d 2026 1 3) = Sub.Bvm); Alcotest.(check string) "2 Jan (Fri)" "ef-christmas-1-friday" (slug_of (d 2026 1 2)); Alcotest.(check string) "5 Jan (Mon)" "ef-christmas-1-monday" (slug_of (d 2026 1 5)); (* 7-10 Jan precede the actual origin Sunday (11 Jan): colitur-only, not lectio's "week 1" key, precisely to avoid colliding with 12-13 Jan below. 10 January is ALSO genuinely reachable (no sanctoral entry that date). *) Alcotest.(check string) "7 Jan (Wed, before the origin)" "ef-christmas-2-wednesday" (slug_of (d 2026 1 7)); Alcotest.(check string) "10 Jan (Sat, before the origin)" "ef-christmas-2-saturday" (slug_of (d 2026 1 10)); Alcotest.(check bool) "10 Jan (Sat): subject Bvm" true (subject_of (d 2026 1 10) = Sub.Bvm); (* 12-13 Jan are on/after the origin: genuinely week 1, lectio's key. *) Alcotest.(check string) "12 Jan (Mon, on/after the origin)" "ef-time-after-epiphany-1-monday" (slug_of (d 2026 1 12)); Alcotest.(check string) "13 Jan (Tue, on/after the origin)" "ef-time-after-epiphany-1-tuesday" (slug_of (d 2026 1 13)) (* The general property behind the fix above -- no two dates in one liturgical year may share a slug -- moved to [Colitur_kernel.Validate]'s own ["slugs"] check (Plan 2 carried item 4), asserted outright with no resumed-Sunday exemption: Plan 2 verified zero duplicate slugs domain-wide, so the exemption this property used to carry protected nothing real. [Validate]'s own 200-sample property (test_validate.ml's [prop_invariants]) now covers every consumer, including this rite, over the same 1583..9998 domain this property used to sweep alone. *) (* ---- Plan 2 carried item 5: the anchors list has no guard against its own erosion ---- [Colitur_kernel.Validate] cannot own this completeness check: which of [named]'s entries are Easter-derived is knowledge only [named] itself has. [Rite.t] deliberately exposes just [temporal] (the merged result) and [anchors] (the independent restatement), never [named] -- so a rite-agnostic [Validate] has no ground truth to compare [anchors] against, short of inventing one. Two ways of inventing one were considered and rejected: - Hardcoding a specific Easter offset (Ash Wednesday = Easter-46, say) inside [Validate] would smuggle Western/Gregorian-Paschal-cycle knowledge into code the design intends to also serve a future Julian-reckoning rite (Byzantine, named explicitly as a future module in this project's own architecture note) -- for which neither that offset, nor even Gregorian Easter itself as the reference point ([Computus.gregorian_easter], not [julian_easter]), is the right one. Even [Computus]'s own [ash_wednesday]/[palm_sunday]/[ascension]/ [pentecost] helpers are documented "(OF + EF)" -- i.e. already scoped to the two WESTERN forms, not to "any rite" the way [Validate] must stay. - Rediscovering "Easter-derived" structurally from [temporal] alone (scan near Easter, keep whatever recurs at the same offset across years with different Easters) is unsound for EF specifically: [Time_after_epiphany] onward, week numbering itself is computed from Easter-relative origins ([week_origin]), so almost every ORDINARY Sunday/feria slug in Septuagesima/Lent/Passiontide/Paschaltide/Time_after_pentecost is ALSO constant-offset-from-Easter across years -- structurally indistinguishable from a genuinely named day by that test alone. Rank does not separate them either: RG 91 entry 10 makes the privileged Easter/Pentecost octave FERIAS class 1 too, same as many named days. This guard is therefore entirely EF-specific and lives here, against [T.named] and [T.anchors] directly -- both accessible in this file, not through the [Rite.t] boundary. EXTENDED (final fix wave, item 3): the guard originally scanned only a 60/+75-day window around Easter, so it caught erosion of an Easter-relative anchor (Ascension, say) but not of a FIXED-date one -- Nativity, the Nativity vigil, the three Nativity octave days, the Circumcision, Epiphany -- nor of [christ_the_king], which is neither fixed nor Easter-relative (the last Sunday of October). Deleting any of those from [T.anchors] left the whole suite green: Plan 2's carried item 5 was therefore only half-closed while being reported as closed, and the worst-covered anchor was exactly the one with the weakest citation (Christ the King -- oracle-backed, not yet primary-verified when this note was written; see docs/research/rules-register.md §4/§6, since primary-verified at RG 17(d) by this same fix wave). Fixed by widening the scan from an Easter-centred window to the WHOLE civil year: [named] only ever answers [Some] for its ~20 genuinely proper/named days (ordinary Sundays and ferias are produced entirely by [temporal]'s own [None] branch, never by [named]), so scanning every day of the year cannot pick up an ordinary week's slug by accident any more than the narrower window could -- it is simply no longer selective about WHICH kind of named day it is willing to notice. *) (* Every slug [named] can produce for civil year [y] -- fixed-date AND Easter-relative alike -- discovered mechanically by asking [named] itself about every day of the year (366 days from 1 January, safely covering a leap year plus one day of spillover into the next, which duplicates rather than misleads: every slug [named] returns is a year-invariant string), not hand-copied from either [named]'s or [anchors]'s own source. *) let named_slugs_for_year y = let jan1 = d y 1 1 in List.filter_map (fun i -> match T.named (D.add_days jan1 i) with Some (_, slug, _, _) -> Some slug | None -> None) (List.init 366 (fun i -> i)) |> List.sort_uniq compare let anchor_slugs y = List.map fst (T.anchors y) |> List.sort_uniq compare (* The mechanism both tests below share: which of [named]'s slugs [anchors] fails to restate. [] means complete. *) let missing_from_anchors ~named_slugs ~anchors = List.filter (fun slug -> not (List.mem slug anchors)) named_slugs (* The real guard: for the domain's own Easter extremes (1598 earliest, 1666 latest -- see test_validate.ml's own [extreme_years], corrected by this same task) plus an ordinary year, nothing [named] produces -- fixed-date or Easter-relative -- is missing from [anchors]. *) let test_anchors_cover_all_named_days () = List.iter (fun y -> let missing = missing_from_anchors ~named_slugs:(named_slugs_for_year y) ~anchors:(anchor_slugs y) in Alcotest.(check (list string)) (Printf.sprintf "%d: every named slug is restated in anchors" y) [] missing) [ 1598; 1666; 2026 ] (* Proves the guard above actually has teeth, per this task's negative-fixture requirement, now over BOTH shapes of anchor: [T.anchors]'s real slug set with one Easter-relative entry ("ef-ascension") AND one fixed-date entry ("ef-nativity") struck out together must fail [missing_from_anchors] the same way the real list passes it -- reproducing, in miniature, exactly what "deleting ef-nativity, ef-epiphany and ef-christ-the-king leaves the whole suite green" (final fix wave, item 3) looked like before this test existed. *) let test_anchors_erosion_is_caught () = let y = 2026 in let named_slugs = named_slugs_for_year y in Alcotest.(check bool) "sanity: ef-ascension is genuinely a named slug" true (List.mem "ef-ascension" named_slugs); Alcotest.(check bool) "sanity: ef-nativity is genuinely a named slug" true (List.mem "ef-nativity" named_slugs); let eroded_anchors = List.filter (fun s -> s <> "ef-ascension" && s <> "ef-nativity") (anchor_slugs y) in Alcotest.(check (list string)) "the erosion is caught: both missing entries are reported, and only them" [ "ef-ascension"; "ef-nativity" ] (missing_from_anchors ~named_slugs ~anchors:eroded_anchors) (* Holy Family's own anchor coverage, kept SEPARATE from the two tests above: it is genuinely NOT one of [T.named]'s outputs (test_holy_family's own comment explains why -- [T.temporal] applies it as a targeted override inside [T.sunday_slug]'s branch instead), so [named_slugs_for_year]'s scan cannot see it, and folding it into [test_anchors_cover_all_named_days]/ [test_anchors_erosion_is_caught] would misrepresent what those two are actually proving ("everything [named] produces"). This proves the same two properties directly against [T.holy_family_sunday] instead: the entry is present, AND deleting it is caught -- {!Validate}'s own production anchor check (validate.ml, register/spec §5.7) calls [temporal], not [named], so it is unaffected by this distinction and already covers this entry regardless; this is this file's own unit-level guard against the same erosion. CORRECTED, fix round 1 (coordinator finding 5): the original version of this test built [eroded] by [List.filter]-ing the exact pair it then asserted [List.mem ... eroded = false] against -- tautological (removing an element from a list and then checking it is not there proves nothing about a DETECTOR; it proves [List.filter] works). The genuine detector the OTHER erosion test above actually exercises is [missing_from_anchors] itself, called with an "expected" set sourced INDEPENDENTLY of [T.anchors] ([named_slugs_for_year], via [T.named] -- a different function entirely) -- the interesting claim is that the SAME slug is confirmed by two independent computations, not merely that a filtered list lacks what was filtered out of it. Reworked to match: the "expected" side here is [T.sunday_slug expected_date] -- computed via season/weekday logic, not read out of [T.anchors]'s own hand-typed string -- so the presence check below is a genuine cross-check between two independent sources, and the erosion check reuses the real [missing_from_anchors] detector rather than re-deriving a bespoke, self-referential one. *) let test_holy_family_anchor_present_and_erosion_is_caught () = let y = 2026 in let expected_date = T.holy_family_sunday y in let expected_slug = match T.sunday_slug expected_date with | Some s -> s | None -> Alcotest.fail "sanity: holy_family_sunday must itself be a Sunday" in Alcotest.(check (list string)) "the anchor entry exists: T.sunday_slug's own independent computation is not missing from T.anchors" [] (missing_from_anchors ~named_slugs:[ expected_slug ] ~anchors:(anchor_slugs y)); (* Erosion: with the entry struck out of [anchors], [missing_from_anchors] -- the SAME real detector, not a bespoke re-check -- must now report it missing, using [expected_slug]'s own independent source ([sunday_slug]) as the thing being checked FOR, not derived from the erosion itself. *) let eroded_anchors = List.filter (fun s -> s <> expected_slug) (anchor_slugs y) in Alcotest.(check (list string)) "the erosion is caught: missing_from_anchors reports it, and only it" [ expected_slug ] (missing_from_anchors ~named_slugs:[ expected_slug ] ~anchors:eroded_anchors); Alcotest.(check string) "sanity: temporal itself is unaffected by the anchors-list erosion" expected_slug (slug_of expected_date) (* Holy Name of Jesus (RG 17(a)), SUNDAY shape -- same structure as Holy Family's own test immediately above, same reasoning (also not one of [T.named]'s outputs). 2026 is a Sunday-shape year (4 January). *) let test_holy_name_sunday_anchor_present_and_erosion_is_caught () = let y = 2026 in let expected_date = match T.holy_name_sunday y with | Some d -> d | None -> Alcotest.fail "sanity: 2026 must have a Sunday in the Holy Name window" in let expected_slug = match T.sunday_slug expected_date with | Some s -> s | None -> Alcotest.fail "sanity: holy_name_sunday must itself be a Sunday" in Alcotest.(check (list string)) "the anchor entry exists: T.sunday_slug's own independent computation is not missing from T.anchors" [] (missing_from_anchors ~named_slugs:[ expected_slug ] ~anchors:(anchor_slugs y)); let eroded_anchors = List.filter (fun s -> s <> expected_slug) (anchor_slugs y) in Alcotest.(check (list string)) "the erosion is caught: missing_from_anchors reports it, and only it" [ expected_slug ] (missing_from_anchors ~named_slugs:[ expected_slug ] ~anchors:eroded_anchors); Alcotest.(check string) "sanity: temporal itself is unaffected by the anchors-list erosion" expected_slug (slug_of expected_date) (* Holy Name of Jesus, FALLBACK shape -- the SAME two properties (presence, erosion caught), for a year with no Sunday in the window. Unlike the Sunday shape above, there is no SECOND independent function (the way [T.sunday_slug] is independent of [T.anchors]) that names "ef-holy-name" for a non-Sunday date -- [expected_slug] is the literal this task chose, cross-checked instead against [T.temporal]'s own output (the function under test) via the final sanity line, same as every other branch of this cross-check, honestly not a second independent source. 2029 has no Sunday 2-5 January (test_holy_name_fallback_shape's own citation). *) let test_holy_name_fallback_anchor_present_and_erosion_is_caught () = let y = 2029 in (match T.holy_name_sunday y with | None -> () | Some _ -> Alcotest.fail "sanity: 2029 must have no Sunday in the Holy Name window"); let expected_date = T.holy_name_fallback_date y in let expected_slug = "ef-holy-name" in Alcotest.(check (list string)) "the anchor entry exists for the fallback shape" [] (missing_from_anchors ~named_slugs:[ expected_slug ] ~anchors:(anchor_slugs y)); let eroded_anchors = List.filter (fun s -> s <> expected_slug) (anchor_slugs y) in Alcotest.(check (list string)) "the erosion is caught: missing_from_anchors reports it, and only it" [ expected_slug ] (missing_from_anchors ~named_slugs:[ expected_slug ] ~anchors:eroded_anchors); Alcotest.(check string) "sanity: temporal itself is unaffected by the anchors-list erosion" expected_slug (slug_of expected_date) (* Gap 1 (ef-triduum-litanies, docs/research/rules-register.md, "the Sacred Triduum has no proper office"): the Missal's own Mass-propers headings for Holy Thursday/Good Friday/Holy Saturday, both photographic scans, word for word (missale-romanum-1962.pdf and "Missale Romanum 1962_text.pdf", corroborated by the electronic transcription's own table-of-contents-style listing at the identical three headings -- three-way agreement, no scan-vs-transcription conflict to adjudicate): "FERIA QUINTA IN CENA DOMINI" / running header "Feria V in Cena Domini"; "FERIA SEXTA / IN PASSIONE ET MORTE DOMINI"; "SABBATO SANCTO". The slug stays the generic Passiontide ferial one (RG 91 entry 2 already identifies the Triduum structurally, off the day's own Easter offset and rank -- {!Rite_ef.Precedence_ef.band}'s own entry-2 branch -- never off the slug, so renaming it would touch precedence for no reason and would break the lectio differential's own slug mapping for these three lectionary keys); the missing IDENTITY is carried in [names], the same treatment RG 17(b)/(a) (Holy Family/Holy Name) already established -- Latin, not English, for the same zero-circularity reason those two entries give (would otherwise read the oracle this exact axis is compared against). [subject] is ALSO set to [Lord] here, going further than Holy Family/Holy Name's own precedent strictly required (they needed it because {!Rite_ef.Precedence_ef.band}'s entry 14 reads [subject] to outrank a colliding fixed feast). The Triduum has no such live need: entry 2 is decided purely by [rank]/Easter-offset, never [subject] -- confirmed by reading {!Rite_ef.Precedence_ef.band}'s own entry-2 branch, which tests neither. Tagged anyway because it is simply true (the Last Supper, the Passion and Death, the entombment are textbook mysteries of the Lord) and safe: {!Rite_ef.Precedence_ef.disposition}'s own RG 112(a) branch (Lord vs Lord) only fires when BOTH sides are tagged [Lord], and no [Lord]-subject sanctoral entry anywhere in data/ef/sanctoral.sexp has a FIXED civil date inside Holy Week's own movable range (earliest 19 March, latest 24 April -- the register's own subject audit lists exactly six [Lord]-tagged entries, all fixed in January, February, JULY, August, September or November -- the Precious Blood, 1 July, was omitted from this list until the fix-round review counted six entries against five months); any [Class1] sanctoral entry that DOES land there (e.g. a transferred Annunciation) reaches {!Rite_ef.Precedence_ef.disposition}'s EARLIER, subject-blind "I class, not a Sunday -> Transfer" branch first, so RG 112(a)'s own branch is never reached for it regardless of this tag. Measured empirically too: this task's own full 1583-9999 blast-radius sweep shows no [observed]/[commemorations]/[transferred_*] difference traceable to this tag anywhere in the domain. *) let test_sacred_triduum_identity () = (* 2026: Easter 5 April, so Holy Thursday/Good Friday/Holy Saturday are 2/3/4 April. *) Alcotest.(check string) "2026-04-02 Holy Thursday: slug stays the generic ferial key" "ef-passiontide-2-thursday" (slug_of (d 2026 4 2)); Alcotest.(check bool) "2026-04-02 Holy Thursday: subject Lord" true (subject_of (d 2026 4 2) = Sub.Lord); Alcotest.(check (option string)) "2026-04-02 Holy Thursday: Latin name, both photographic scans verbatim" (Some "Feria V in Cena Domini") (name_la_of (d 2026 4 2)); Alcotest.(check string) "2026-04-03 Good Friday: slug stays the generic ferial key" "ef-passiontide-2-friday" (slug_of (d 2026 4 3)); Alcotest.(check bool) "2026-04-03 Good Friday: subject Lord" true (subject_of (d 2026 4 3) = Sub.Lord); Alcotest.(check (option string)) "2026-04-03 Good Friday: Latin name, both photographic scans verbatim" (Some "Feria VI in Passione et Morte Domini") (name_la_of (d 2026 4 3)); Alcotest.(check string) "2026-04-04 Holy Saturday: slug stays the generic ferial key" "ef-passiontide-2-saturday" (slug_of (d 2026 4 4)); Alcotest.(check bool) "2026-04-04 Holy Saturday: subject Lord" true (subject_of (d 2026 4 4) = Sub.Lord); Alcotest.(check (option string)) "2026-04-04 Holy Saturday: Latin name, both photographic scans verbatim" (Some "Sabbato sancto") (name_la_of (d 2026 4 4)); (* Neighbouring days carry no name/subject override -- the tag is exactly three days wide, not the whole Passiontide-2 week. *) Alcotest.(check (option string)) "2026-04-01 (Wednesday of Holy Week): no Latin name" None (name_la_of (d 2026 4 1)); Alcotest.(check bool) "2026-04-01 (Wednesday of Holy Week): subject stays temporal" true (subject_of (d 2026 4 1) = Sub.Temporal); Alcotest.(check (option string)) "2026-04-05 Easter Sunday: no Latin name from this branch" None (name_la_of (d 2026 4 5)); (* A second, independent year (1666: the latest possible Easter, 25 April, already this file's own Easter-extreme witness elsewhere) -- proves the mechanism generalises off [days_between easter d], not hand-pinned to 2026's own civil dates. Holy Thursday/Good Friday/Holy Saturday are 22/23/24 April 1666. *) Alcotest.(check (option string)) "1666-04-22 Holy Thursday (latest-Easter year): Latin name still fires" (Some "Feria V in Cena Domini") (name_la_of (d 1666 4 22)); Alcotest.(check bool) "1666-04-22 Holy Thursday: subject Lord" true (subject_of (d 1666 4 22) = Sub.Lord) (* RG 91 entry 27 / RG 78-79 (Caput IX, "De sancta Maria in sabbato") -- {!Rite_ef.Temporal_ef.bvm_saturday_names}'s own citation has the full primary-source argument (both photographic scans and the electronic transcription for RG 78/79 themselves; both photographic scans for RG 120(b)'s colour rule and the Missal's own "Missae de sancta Maria in sabbato" heading). This test proves it holds across every season family [ferial_rank] can give Class4 outside a privileged run -- Christmastide, Septuagesima, Time after Epiphany, Time after Pentecost and ordinary Paschaltide -- and that it does NOT fire where RG 78's own protasis is false (Advent/Lent/Passiontide Saturdays, already II/III class; Ember Saturdays, II/I class; a non-Saturday day in the very same week). Every date below was independently checked against `colitur day `'s own real output (data/ef/sanctoral.sexp + adjustments.sexp) before being pinned here, exactly like test_golden.ml's own discipline -- not merely asserted against [T.temporal]'s own predicted shape a second time. *) let test_bvm_saturday () = (* Septuagesima, clean (no competing sanctoral candidate that year): 19 February 2028, Septuagesima I Saturday. Colour is the clearest possible witness here -- Septuagesima's own season colour is violet (RG 128(b)), so a white result can only come from RG 120(b)'s override, never from [season_colour] agreeing by coincidence the way Christmastide/Paschaltide do below. *) Alcotest.(check string) "2028-02-19 (Septuagesima I Saturday): slug stays the ordinary ferial key" "ef-septuagesima-1-saturday" (slug_of (d 2028 2 19)); Alcotest.(check string) "2028-02-19: class-4, unconditional (RG 91's own IV-class table position)" "class-4" (rank_of (d 2028 2 19)); Alcotest.(check string) "2028-02-19: white overrides Septuagesima's own violet (RG 120(b), not RG 128(b))" "white" (colour_of (d 2028 2 19)); Alcotest.(check bool) "2028-02-19: subject Bvm" true (subject_of (d 2028 2 19) = Sub.Bvm); Alcotest.(check (option string)) "2028-02-19: Latin name, both photographic scans and the transcription, \ word for word" (Some "Officium sanctae Mariae in sabbato") (name_la_of (d 2028 2 19)); (* Time after Epiphany, clean: 16 February 2030. Season colour there is green (RG 127), like Time after Pentecost below -- a second, independent witness that the override is season-blind, not merely "true for Septuagesima's violet and Time after Pentecost's green happen to share a fix". *) Alcotest.(check string) "2030-02-16 (Time after Epiphany V Saturday): slug stays the ordinary ferial key" "ef-time-after-epiphany-5-saturday" (slug_of (d 2030 2 16)); Alcotest.(check string) "2030-02-16: white overrides Time after Epiphany's own green (RG 127)" "white" (colour_of (d 2030 2 16)); Alcotest.(check bool) "2030-02-16: subject Bvm" true (subject_of (d 2030 2 16) = Sub.Bvm); (* Time after Pentecost, clean: 27 June 2026. *) Alcotest.(check string) "2026-06-27 (Time after Pentecost IV Saturday): slug stays the ordinary ferial key" "ef-time-after-pentecost-4-saturday" (slug_of (d 2026 6 27)); Alcotest.(check string) "2026-06-27: white overrides Time after Pentecost's own green (RG 127)" "white" (colour_of (d 2026 6 27)); Alcotest.(check bool) "2026-06-27: subject Bvm" true (subject_of (d 2026 6 27) = Sub.Bvm); (* Ordinary Paschaltide, clean, outside the privileged Easter octave: 18 April 2026. Season colour there is ALREADY white (RG 119) -- colour alone cannot distinguish the override firing from it simply not being needed, so subject/name are the load-bearing assertions on this one, not colour (the SAME reasoning [test_holy_family]'s own "Christmastide already white" gap does not arise for -- this is a genuine, deliberate exception recorded here, not an oversight). *) Alcotest.(check string) "2026-04-18 (Paschaltide II Saturday): slug stays the ordinary ferial key" "ef-easter-2-saturday" (slug_of (d 2026 4 18)); Alcotest.(check string) "2026-04-18: white (coincides with Paschaltide's own RG 119 white -- not a \ distinguishing witness on its own)" "white" (colour_of (d 2026 4 18)); Alcotest.(check bool) "2026-04-18: subject Bvm (the real witness this date proves, since colour alone \ cannot)" true (subject_of (d 2026 4 18) = Sub.Bvm); Alcotest.(check (option string)) "2026-04-18: Latin name present too" (Some "Officium sanctae Mariae in sabbato") (name_la_of (d 2026 4 18)); (* Negative controls: RG 78's own protasis ("in quibus occurrit Officium de feria IV classis") is false for these -- each is already II or III class, never reaching Class4 at all, so none of them is overridden. *) Alcotest.(check string) "2026-03-14 (an ordinary Lent Saturday): stays III class, violet, Temporal" "class-3" (rank_of (d 2026 3 14)); Alcotest.(check string) "2026-03-14: violet, NOT overridden (RG 78 never applies -- already III class)" "violet" (colour_of (d 2026 3 14)); Alcotest.(check bool) "2026-03-14: subject stays Temporal" true (subject_of (d 2026 3 14) = Sub.Temporal); Alcotest.(check string) "2026-12-12 (an ordinary Advent Saturday, before 17 Dec): stays III class" "class-3" (rank_of (d 2026 12 12)); Alcotest.(check bool) "2026-12-12: subject stays Temporal (not overridden)" true (subject_of (d 2026 12 12) = Sub.Temporal); Alcotest.(check string) "2026-02-28 (Lent Ember Saturday): stays II class, NOT overridden" "class-2" (rank_of (d 2026 2 28)); Alcotest.(check bool) "2026-02-28: subject stays Temporal" true (subject_of (d 2026 2 28) = Sub.Temporal); Alcotest.(check string) "2026-09-26 (September Ember Saturday): stays II class, NOT overridden" "class-2" (rank_of (d 2026 9 26)); (* A non-Saturday day in the SAME week as a clean BVM Saturday stays an ordinary ferial -- the override is exactly one weekday wide, not the whole week. 26 June 2026 is the Friday immediately before the 27 June Saturday pinned above. *) Alcotest.(check string) "2026-06-26 (Friday, day before a BVM Saturday): plain ferial, not overridden" "ef-time-after-pentecost-4-friday" (slug_of (d 2026 6 26)); Alcotest.(check string) "2026-06-26: green, the season's own colour, unaffected" "green" (colour_of (d 2026 6 26)); Alcotest.(check bool) "2026-06-26: subject stays Temporal" true (subject_of (d 2026 6 26) = Sub.Temporal) let test_totality () = (* Every day of 2026 yields an office without raising. Not a slug re-validation -- Slug.t is a private string validated on construction, so round-tripping to_string/of_string on an existing value is a tautology (the same vacuous check Validate's own comment identifies and explains for the same reason). This still has value: it forces full evaluation of [temporal] across a whole year and would fail if any day in it raised. *) let jan1 = d 2026 1 1 in for i = 0 to 364 do ignore (T.temporal (D.add_days jan1 i)) done let suite_extra = [ Alcotest.test_case "advent start" `Quick test_advent_start; Alcotest.test_case "seasons" `Quick test_seasons; Alcotest.test_case "named feasts" `Quick test_named_feasts; Alcotest.test_case "christ the king" `Quick test_christ_the_king; Alcotest.test_case "nativity octave" `Quick test_nativity_octave; Alcotest.test_case "week numbers" `Quick test_week_numbers; Alcotest.test_case "sunday slugs" `Quick test_sunday_slugs; Alcotest.test_case "holy family (RG 17(b))" `Quick test_holy_family; Alcotest.test_case "holy family: no gap year (RG 17(b))" `Quick test_holy_family_no_gap_year; Alcotest.test_case "holy name: Sunday shape (RG 17(a))" `Quick test_holy_name_sunday_shape; Alcotest.test_case "holy name: fallback shape (RG 17(a))" `Quick test_holy_name_fallback_shape; Alcotest.test_case "holy name: Sunday-or-none cross-check" `Quick test_holy_name_sunday_or_none; Alcotest.test_case "week/sunday_slug agree" `Quick test_week_sunday_slug_agree; Alcotest.test_case "resumed sundays" `Quick test_resumed_sundays; Alcotest.test_case "ferial slugs and ranks" `Quick test_ferial_slugs_and_ranks; Alcotest.test_case "after ashes" `Quick test_after_ashes; Alcotest.test_case "ember days" `Quick test_ember_days; Alcotest.test_case "rogations" `Quick test_rogations; Alcotest.test_case "colours" `Quick test_colours; Alcotest.test_case "sacred triduum identity (RG 91 entry 2, Gap 1)" `Quick test_sacred_triduum_identity; Alcotest.test_case "BVM Saturday office (RG 91 entry 27, RG 78-79)" `Quick test_bvm_saturday; Alcotest.test_case "christmastide feria slugs" `Quick test_christmastide_feria_slugs; Alcotest.test_case "named days carry their week" `Quick test_named_days_carry_their_week; Alcotest.test_case "totality" `Quick test_totality; Alcotest.test_case "anchors cover all named days" `Quick test_anchors_cover_all_named_days; Alcotest.test_case "holy family anchor present, erosion caught" `Quick test_holy_family_anchor_present_and_erosion_is_caught; Alcotest.test_case "holy name (Sunday shape) anchor present, erosion caught" `Quick test_holy_name_sunday_anchor_present_and_erosion_is_caught; Alcotest.test_case "holy name (fallback shape) anchor present, erosion caught" `Quick test_holy_name_fallback_anchor_present_and_erosion_is_caught; Alcotest.test_case "anchors erosion is caught" `Quick test_anchors_erosion_is_caught ] let suite = ( "Rite_ef", [ Alcotest.test_case "vocab roundtrips" `Quick test_vocab_roundtrips; Alcotest.test_case "slug words" `Quick test_slug_words ] @ suite_extra @ List.map QCheck_alcotest.to_alcotest [ prop_temporal_week_matches_week ] )