(* Task 5 (2026-08-25-colitur-of-phases-3-5): [Rite_of.context], wired together with the REAL data/of/calendar-2002.sexp + all 13 decree overlays + data/of/lectionary.sexp, resolved through {!Colitur_kernel.Calendar} and checked by {!Colitur_kernel.Validate.run} -- the same pipeline `colitur day --rite of` uses, and the same discipline test_validate.ml/test_rite_ef.ml already apply to the EF side: exactly one observed office per day, the year covered once with no gaps, slug-uniqueness within the liturgical year, and transfers reaching a fixed point are all invariants {!Colitur_kernel.Validate.run} already checks generically -- this file supplies the REAL OF rite and data and asserts the result is clean, rather than re-deriving each invariant by hand. *) module Val = Colitur_kernel.Validate module Layer = Colitur_kernel.Layer module Overlay = Colitur_kernel.Overlay module Cal = Colitur_kernel.Calendar module LD = Colitur_kernel.Liturgical_day module Slug = Colitur_kernel.Slug module Date = Colitur_kernel.Date module Cel = Colitur_kernel.Celebration module P = Colitur_kernel.Precedence module Computus = Colitur_kernel.Computus module V = Rite_of.Vocab_of (* Relative to this test's own build directory (_build/default/test/), same convention test_amendments_of.ml/test_calendar_of_data.ml already use -- test/dune declares each of these as a dep of the (test ...) stanza. *) let base_path = "../data/of/calendar-2002.sexp" let amendments_dir = "../data/of/amendments/" let lectionary_path = "../data/of/lectionary.sexp" (* Decree-chronological order -- the exact list and order test_amendments_of.ml's own [files] table already pins by SHA-256; not re-pinning the hashes here (that is that file's own job), just applying them in the same order so the rite under test here is the one bin/main.ml will assemble. *) let amendment_files = [ "001-padre-pio.sexp"; "002-juan-diego-cuauhtlatoatzin.sexp"; "003-our-lady-of-guadalupe.sexp"; "004-john-xxiii-john-paul-ii.sexp"; "005-mary-magdalene-rank.sexp"; "006-mary-mother-of-the-church.sexp"; "007-paul-vi.sexp"; "008-our-lady-of-loreto.sexp"; "009-faustina-kowalska.sexp"; "010-narek-avila-hildegard.sexp"; "011-martha-mary-lazarus.sexp"; "012-teresa-of-calcutta.sexp"; "013-john-henry-newman.sexp" ] let real_of_layer = let base = match Layer.load V.rank_of_sexp base_path with | Ok l -> l | Error e -> failwith (Printf.sprintf "%s: failed to load: %s" base_path e) in let overlays = List.map (fun name -> let path = amendments_dir ^ name in match Overlay.load V.rank_of_sexp path with | Ok o -> o | Error e -> failwith (Printf.sprintf "%s: failed to load: %s" path e)) amendment_files in let layer, diagnostics = Overlay.merge base overlays in if diagnostics <> [] then failwith (Printf.sprintf "unexpected amendment diagnostics: %s" (String.concat "; " (List.map Overlay.diagnostic_to_string diagnostics))); layer (* [Rite_of.context] takes [~lectionary] as a caller-supplied parameter, not a value closed over an internal load -- see rite_of.ml's own citation for why (the exact `colitur easter ` startup-death lesson {!Rite_ef.Rite_ef.context}'s own comment records). *) let real_of_lectionary = match Colitur_kernel.Lectionary.load lectionary_path with | Ok l -> l | Error e -> failwith (Printf.sprintf "%s: failed to load: %s" lectionary_path e) let real_of_rite = Rite_of.context ~lectionary:real_of_lectionary let run year = Val.run real_of_rite real_of_layer ~year (* data/of/lectionary.sexp's own provenance header names ONE informational temporal-slug gap: [of-nativity] (25 December -- Christmas Day itself) has no entry, because niedziela.pl's own source keys the Nativity's several Masses (Vigil/Night/Dawn/Day) differently than a single flat slug can address, and [Lectionary_of.readings] has no fallback beyond its own three-step chain (unlike EF's RG 299 preceding-Sunday rule, which OF's own OLM Praenotanda gives no equivalent for -- see lectionary_of.mli's own citation). This makes {!Colitur_kernel.Validate .run}'s ["citations-unresolved"] check fire exactly once a year, on 25 December, on real shipped data -- a documented, measured gap in lectionary_of's own data file (out of this task's scope to touch or close: lectionary_of.* is excluded from this task's brief), not a defect this module's wiring introduced. Named and filtered explicitly, never silently dropped, so a future SECOND gap (a regression) still reddens this test. *) let ends_with ~suffix s = let ls = String.length s and lx = String.length suffix in ls >= lx && String.sub s (ls - lx) lx = suffix (* No [Str]/regex (deps frozen) -- a plain substring scan. *) let contains ~substring s = let ls = String.length s and lx = String.length substring in let rec go i = i + lx <= ls && (String.sub s i lx = substring || go (i + 1)) in lx = 0 || go 0 let is_known_nativity_gap (f : Val.failure) = (f.Val.check = "citations-unresolved" || f.Val.check = "formulary") && ends_with ~suffix:"12-25" f.Val.date (* A SECOND, NEWLY-FOUND gap this task's own wiring surfaced -- [rite_of] was never previously wired into a real {!Colitur_kernel.Rite.t}, so [Validate.run]'s ["anchor"] check has never before run [Temporal_of .anchors] against [Temporal_of.temporal] for real. Normae n. 35(a)'s own text is "Dominica infra octavam Nativitatis, VEL, EA DEFICIENTE, die 30 decembris" -- the Holy Family fallback is a FIXED-DATE fallback (30 December, whatever weekday it falls on that year), confirmed by {!Temporal_of.holy_family}'s own [None -> mk y 12 30] branch and its own .mli citation ("this 6-day window CAN be [empty of a Sunday] -- confirmed by the fallback clause's own existence"). But {!Temporal_of.temporal} only ever TESTS [same d (holy_family y)] inside its [sunday_slug d] dispatch arm -- i.e. only when [d] genuinely IS a Sunday -- so in a year whose 26-31 December window has NO Sunday (equivalently: 25 December, Christmas Day, is itself a Sunday -- a 6-day window starting the day after a Sunday omits Sunday from every other position), the fallback date is computed correctly by [holy_family]/[anchors] but [temporal] itself silently falls through to an ordinary ferial slug instead (["of-christmas-0-friday"] et al.) -- exactly the "generic day slug masking a real named feast" shape CLAUDE.md already records EF's own Holy Family/Holy Name gaps as, before they were fixed there. Not rare: this fires whenever Christmas Day is a Sunday, roughly one year in seven (1583, 2005, 2011, 2016, 2022, 2033, 2039, 2044, 2050 all independently confirmed via Python's own proleptic-Gregorian [datetime]). OUT OF THIS TASK'S SCOPE to fix -- temporal_of.ml's own rubric logic is Task 1's deliverable, not Task 5's, and the task brief models exactly this "pin the known-wrong behaviour, do not fix it here" treatment for a different, already-known gap (Normae n.56(f), St Joseph/Palm Sunday) -- so the same discipline is applied to this newly-found one rather than silently working around it or fixing rite logic this task was not chartered to touch. Reported in this task's own report as a concern. *) let is_known_holy_family_fallback_gap (f : Val.failure) = f.Val.check = "anchor" && contains ~substring:"of-holy-family" f.Val.detail let check_year_allowing_known_gaps year = let fs = run year in let is_known f = is_known_nativity_gap f || is_known_holy_family_fallback_gap f in let unexpected = List.filter (fun f -> not (is_known f)) fs in (match unexpected with | [] -> () | fs -> Alcotest.failf "%d: %s" year (String.concat "; " (List.map Val.failure_to_string (List.filteri (fun i _ -> i < 5) fs)))); let nativity_gaps = List.filter is_known_nativity_gap fs in Alcotest.(check int) (Printf.sprintf "%d: exactly the two documented of-nativity checks (citations-unresolved + \ formulary), no more" year) 2 (List.length nativity_gaps) (* Landmark years, the same choice test_validate.ml's own [test_landmark_years] makes for EF: both domain edges plus two ordinary years well inside it. *) let test_landmark_years () = List.iter check_year_allowing_known_gaps [ 1583; 2026; 2035; 9998 ] (* 9999 mirrors test_validate.ml's own [test_year_9999_does_not_raise]: the liturgical year opening in civil year 9999 continues into out-of-domain civil year 10000, so [Val.run]/[Calendar.year] clamp the walk to 31 December 9999 rather than raising, and the truncated season run is *expected* to fail the "seasons" check -- pinning that this surfaces as an ordinary failure, not an uncaught exception, and that resolution itself ("coverage"/"lost"/"duplicated") stays clean through the clamp. *) let test_year_9999_does_not_raise () = let fs = run 9999 in Alcotest.(check bool) "no coverage failures (temporal stayed total through the clamp)" true (not (List.exists (fun f -> f.Val.check = "coverage") fs)); Alcotest.(check bool) "seasons check flags the truncated final year as incomplete" true (List.exists (fun f -> f.Val.check = "seasons") fs) (* A real transfer-bearing sample, the OF counterpart of test_rite_ef.ml's own 2005-2050 sweep: every impeded Sollemnitas [Precedence_of.disposition] defers is either placed by [transfer_target] and reaches a fixed point (no "unconverged" failure -- Validate's own check), or is recorded [omitted] with a reason, never silently dropped ("lost"/"duplicated"), over the project's own 2005-2050 differential-testing window (CLAUDE.md). STRUCTURAL checks only -- ["coverage"; "weekday"; "rank"; "colour"; "determinism"; "slugs"; "seasons"; "week"; "lost"; "duplicated"; "unconverged"; "admission"; "observed"] -- deliberately NOT the citation-chain ones ("anchor"/"citations"/"citations-unresolved"/ "formulary"), and this exclusion is itself a finding, not a shortcut: sweeping the full 46-year window (not just the 4 landmark years above) surfaced that [data/of/lectionary.sexp]'s own coverage was measured against exactly ONE civil year (2026, per that file's own provenance header) -- OTHER years exercise the OTHER weekday-cycle letter for the same temporal slug (OLM n.69.4, {!Lectionary_of.weekday_cycle}: which letter applies flips on the LITURGICAL year label's parity, independent of which civil year is being swept), and at least one such pairing is asymmetric on real shipped data: [of-christmas-0-friday-ii] exists, [of-christmas-0-friday-i] does not (confirmed directly against data/of/lectionary.sexp; first reached in this sweep at 2006-12-29). Separately, 2006-12-24 (a year Christmas Eve itself is a Sunday) also exposed a THIRD newly-found gap: {!Temporal_of.anchors} lists [of-nativity-vigil] on 24 December UNCONDITIONALLY, but {!Temporal_of.named}'s own coded exception (Tabula I.2/Normae n.5: Advent IV outranks the Vigil when they coincide) means {!Temporal_of.temporal} does not always agree. All three are OUT OF THIS TASK'S SCOPE to fix -- lectionary_of.* and data/of/lectionary.sexp are excluded from this task's brief, and temporal_of.ml's own rubric logic is Task 1's deliverable -- reported as concerns in this task's own report rather than fixed here or silently excluded without explanation. CORRECTED (fix-lectionary-anchors task, 2026-08-26): the of-nativity- vigil/[anchors] disagreement described in the paragraph above is now FIXED -- {!Temporal_of.anchors} carries the same Sunday guard [named] already had. A dedicated exhaustive sweep (test_temporal_of.ml's own [test_exhaustive_domain_sweep], every civil year 1584-9997 under COLITUR_EXHAUSTIVE_SWEEP=1) confirmed this was also the ONLY unexplained [anchors]/[temporal] drift anywhere in the domain -- the other two items in this paragraph (the lectionary weekday-cycle-letter asymmetry, and the Holy Family 30-December fallback {!is_known_holy_family_fallback_gap} below already pins) are unrelated and remain open, unchanged by this fix. *) let sample_years = let rec range a b = if a > b then [] else a :: range (a + 1) b in range 2005 2050 let structural_check = function | "anchor" | "citations" | "citations-unresolved" | "formulary" -> false | _ -> true let check_year_structural_only year = match List.filter (fun f -> structural_check f.Val.check) (run year) with | [] -> () | fs -> Alcotest.failf "%d: %s" year (String.concat "; " (List.map Val.failure_to_string (List.filteri (fun i _ -> i < 5) fs))) let test_transfers_reach_a_fixed_point_2005_2050 () = List.iter check_year_structural_only sample_years let slug_of (c : V.rank Cel.t) = Slug.to_string c.Cel.slug (* precedence_of.mli's own documented, KNOWN-UNIMPLEMENTED fourth transfer_target rule (final review Important #5, ledger ruling R9): Normae n. 56(f) -- St Joseph's solemnity, falling on Palm Sunday, is ANTICIPATED BACKWARD to 18 March, not carried forward. colitur's [transfer_target] has no backward-search branch (rite.mli's own [transfer_target] contract requires the result be strictly LATER than the origin -- an EF-shaped kernel obligation, per that value's own citation), so it instead sends Joseph forward via the general rule 3, the same target date the Annunciation would already be heading to that same year (Easter + 8). This is NOT fixed by this task (explicitly out of scope, per the task brief) -- pinned here, on a REAL affected year, so the current (known-wrong) behaviour is asserted deliberately rather than passing silently as if it were correct. All years with Gregorian Easter = 26 March put Palm Sunday on 19 March, coinciding with Joseph; 2062 is the nearest one after today (2026) still inside the differential-adjacent range this project favours for worked examples. 2062 is a doubly-worked example, not chosen only for Joseph: that same year, 25 March (the Annunciation) falls on HOLY SATURDAY (Easter - 1), so BOTH solemnities are impeded and BOTH nominally target Easter + 8 via the general rule -- {!Colitur_kernel.Calendar}'s own placement pass settles that collision by landing whichever it processes first (Joseph, departing the earlier date, 19 March) on Easter + 8 and pushing the other to the next admissible day; empirically, on shipped data, it is JOSEPH who is pushed one day further still, to Easter + 9 -- read as further confirmation this is genuinely rule 3's general forward search (sensitive to whatever else is competing that year), not the fixed, collision-proof Easter+8 destination rule 3's cousin (the Annunciation's own Holy-Week branch) names outright -- not as a claim about exactly which of the two wins the tie, which this test does not depend on. *) let test_joseph_palm_sunday_2062_known_wrong_forward_transfer () = let easter_2062 = Computus.gregorian_easter 2062 in Alcotest.(check string) "2062 Easter is really 26 March (the Normae n.56(f) trigger)" "2062-03-26" (Date.to_iso8601 easter_2062); let palm_sunday = Date.add_days easter_2062 (-7) in Alcotest.(check string) "Palm Sunday 2062 is really 19 March, coinciding with St Joseph" "2062-03-19" (Date.to_iso8601 palm_sunday); let holy_saturday = Date.add_days easter_2062 (-1) in Alcotest.(check string) "25 March 2062 is really Holy Saturday, coinciding with the Annunciation too" "2062-03-25" (Date.to_iso8601 holy_saturday); let days = Cal.year real_of_rite real_of_layer 2061 in let joseph_day = Array.to_list days |> List.find (fun d -> Date.compare d.LD.date palm_sunday = 0) in Alcotest.(check string) "Palm Sunday itself is observed on 19 March (Joseph impeded, correctly)" "of-palm-sunday" (slug_of joseph_day.LD.observed); let joseph_slug = "joseph-husband-of-the-blessed-virgin-mary" in let departure = List.find_opt (fun (c, _) -> slug_of c = joseph_slug) joseph_day.LD.transferred_out in match departure with | None -> Alcotest.fail "expected Joseph to be transferred_out from 19 March 2062 (impeded by Palm Sunday)" | Some (_, target) -> (* Concrete, not merely qualitative: 2062-04-04 is the actual pinned value on shipped data today (Easter + 9, one day past the Annunciation's own Easter + 8 -- see this test's own header). A future data or Calendar change that moves it is meant to be caught here; the point being pinned is the SHAPE (forward, past Easter, not 18 March), asserted again below in a form that does not depend on the exact day. *) Alcotest.(check string) "KNOWN WRONG (Normae n.56(f) unimplemented, precedence_of.mli): Joseph lands forward, past \ Easter (currently Easter+9, one day beyond the Annunciation's own Easter+8), not the \ rubrically-correct 18 March (backward)" "2062-04-04" (Date.to_iso8601 target); Alcotest.(check bool) "not 18 March -- confirming this really is the anticipation defect, not a \ coincidence" false (Date.compare target (Date.add_days palm_sunday (-1)) = 0); Alcotest.(check bool) "and it lands on or after Easter itself (rule 3's forward search, not a \ backward anticipation)" true (Date.compare target easter_2062 >= 0) (* Second NEWLY-FOUND defect, pinned the same way (see [is_known_holy_family_fallback_gap]'s own citation just above for the full argument): 1583 is the domain floor AND a year Christmas Day falls on a Sunday, so 26-31 December has no Sunday of its own and [Temporal_of.holy_family] falls back to its own fixed 30 December -- correctly, per [Temporal_of.anchors] -- but [Temporal_of.temporal] itself never reaches that fallback (its Holy-Family test lives inside the Sunday-only dispatch arm), so 30 December 1583 is observed as an ordinary Friday-of-Christmastide ferial office instead of the Feast of the Holy Family. *) let test_holy_family_fallback_1583_known_wrong_ferial () = let christmas_1583 = Date.of_iso8601 "1583-12-25" |> Result.get_ok in Alcotest.(check bool) "1583 Christmas Day really is a Sunday (the Normae n.35(a) trigger)" true (Date.weekday christmas_1583 = Date.Sun); let days = Cal.year real_of_rite real_of_layer 1583 in let dec30 = Array.to_list days |> List.find (fun d -> Date.compare d.LD.date (Date.add_days christmas_1583 5) = 0) in Alcotest.(check string) "KNOWN WRONG (Normae n.35(a) fallback unreached, see [is_known_holy_family_fallback_gap]'s own \ citation): 30 December 1583 is an ordinary ferial slug, not of-holy-family" "of-christmas-0-friday" (slug_of dec30.LD.observed); Alcotest.(check bool) "and that really is NOT the Holy Family (confirming this really is the defect)" false (slug_of dec30.LD.observed = "of-holy-family") let suite = ( "Rite_of (real data: assembled Rite.t end-to-end via Calendar/Validate)", [ Alcotest.test_case "landmark years validate cleanly (modulo two documented gaps)" `Quick test_landmark_years; Alcotest.test_case "year 9999 does not raise; seasons flags the clamp, coverage stays clean" `Quick test_year_9999_does_not_raise; Alcotest.test_case "2005-2050: transfers reach a fixed point, nothing lost or duplicated" `Slow test_transfers_reach_a_fixed_point_2005_2050; Alcotest.test_case "KNOWN OPEN DEFECT pinned, not fixed: St Joseph on Palm Sunday 2062 transfers forward, not \ backward per Normae n.56(f)" `Quick test_joseph_palm_sunday_2062_known_wrong_forward_transfer; Alcotest.test_case "NEWLY-FOUND DEFECT pinned, not fixed: Holy Family's Normae n.35(a) 30-December fallback is \ unreached when Christmas Day is a Sunday (1583)" `Quick test_holy_family_fallback_1583_known_wrong_ferial ] )