From 9ebb06983b7a26db5564302253f2551dfbcf834e Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 26 Aug 2026 23:45:16 +0200 Subject: fix(of): dispatch Holy Family's Normae n.35(a) fallback (W2) temporal_of.ml's temporal only ever reached holy_family's own correct 26-31 December / 30 December fallback logic through sunday_slug, which returns None immediately for a non-Sunday date. In a year 25 December is itself a Sunday (26-31 December then holding no Sunday of its own), the feast vanished entirely and fell through to an ordinary Christmas ferial slug -- confirmed on 2022, 2033, 2039, 2044, 2050 (and roughly 1583..9999 domain-wide, one year in seven). temporal's dispatch now consults holy_family directly, independent of weekday, between the sunday_slug and christmas_feria_slug branches, carrying the identical identity the Sunday case already builds (subject Lord, holy_family_names, rank Festum). holy_family and anchors were already correct and needed no change. Added a direct example-based test for the fallback across five affected years plus a control for the unaffected Sunday case, and a domain-wide property (Holy Family is observed in every liturgical year 1583-9999) folded into both the 200-sample QCheck run and the exhaustive sweep -- the property that would have caught this directly. Un-pinned the now-fixed behaviour everywhere it was recorded as known-wrong: test_rite_of.ml's own 1583 pin (rewritten to assert the fix), test_validate_of.ml's own independent exhaustive-sweep counter and landmark-year filter (found via make check, not the initial survey), and data/of/expected-divergences-litcal.sexp's L2/L3 allow-list entries against the litcal oracle (closed and removed, with a dedicated regression test replacing the allow-list's own silence). --- data/of/expected-divergences-litcal.sexp | 46 ++++------- lib/rites/rite_of/temporal_of.ml | 27 +++++- test/test_litcal_of.ml | 64 +++++++++++---- test/test_rite_of.ml | 136 ++++++++++++++++++------------- test/test_temporal_of.ml | 114 ++++++++++++++++++-------- test/test_validate_of.ml | 111 ++++++++++++++----------- 6 files changed, 315 insertions(+), 183 deletions(-) diff --git a/data/of/expected-divergences-litcal.sexp b/data/of/expected-divergences-litcal.sexp index f4de6f4..e0b8b2a 100644 --- a/data/of/expected-divergences-litcal.sexp +++ b/data/of/expected-divergences-litcal.sexp @@ -71,34 +71,24 @@ (note "Holy Thursday (evening Mass)/Good Friday/Holy Saturday: litcal tags EASTER_TRIDUUM, colitur has no Triduum season value and keeps the whole day Lent -- a cited design decision, not a defect.") (expected_rows 36)) -; L2, L3 -- task 6 (2026-08-26): the litcal layer extended beyond season/week -; to GRADE and IDENTITY (test_litcal_of.ml's own header). Both fire on the -; SAME date, the SAME already-known, already-PINNED-NOT-FIXED gap -; test_rite_of.ml's own [test_holy_family_fallback_1583_known_wrong_ferial]/ -; [is_known_holy_family_fallback_gap] record: Normae n. 35(a)'s "Dominica -; infra octavam Nativitatis, vel, ea deficiente, die 30 decembris" fallback -; is never reached by {!Rite_of.Temporal_of.temporal} in a year 25 December -; is itself a Sunday (its Holy-Family test lives inside the Sunday-only -; dispatch arm of [sunday_slug], so the 30-December fallback [holy_family] -; itself computes correctly is never actually consulted there). 2033 is this -; fixture's own live witness -- 1583 (test_rite_of.ml's own pin) is outside -; the fixture's 2023-2035 window, 2033 is the only affected year inside it -; (2005, 2011, 2016, 2022, 2039, 2044, 2050 are the gap's other known -; occurrences, all likewise outside this window). VERDICT "colitur": this is -; colitur's own known defect, not litcal's -- litcal correctly names -; "HolyFamily" on 30 December 2033; colitur observes an ordinary -; Nativity-octave feria instead. NOT fixed here, per this task's own brief -; ("do not fix any of them here. Keep them pinned"). -((id L2) - (citation "Normae n. 35(a); test_rite_of.ml's own test_holy_family_fallback_1583_known_wrong_ferial") - (verdict colitur) - (note "30 December 2033 (25 December 2033 is a Sunday, so the 26-31 December window has no Sunday of its own): litcal correctly names HolyFamily (band 50/60 expected); colitur's known Normae n.35(a) fallback gap leaves it an ordinary Nativity-octave feria (band 90) -- the GRADE half of the divergence.") - (expected_rows 1)) -((id L3) - (citation "Normae n. 35(a); test_rite_of.ml's own test_holy_family_fallback_1583_known_wrong_ferial") - (verdict colitur) - (note "30 December 2033: the IDENTITY half of the same divergence L2 records for grade -- colitur's observed slug is an ordinary ferial slug, not of-holy-family.") - (expected_rows 1)) +; L2, L3 -- CLOSED, REMOVED (W2 fix, CLAUDE.md, temporal_of.ml). These two +; entries used to record a defect from task 6 (2026-08-26): the litcal layer +; extended beyond season/week to GRADE and IDENTITY, and both fired on the +; SAME date, the SAME then-known, then-PINNED-NOT-FIXED gap test_rite_of.ml +; used to record: Normae n. 35(a)'s "Dominica infra octavam Nativitatis, vel, +; ea deficiente, die 30 decembris" fallback was never reached by +; {!Rite_of.Temporal_of.temporal} in a year 25 December is itself a Sunday +; (its Holy-Family test lived inside the Sunday-only dispatch arm of +; [sunday_slug], so the 30-December fallback [holy_family] itself computed +; correctly was never actually consulted there). 2033 was this fixture's own +; live witness. {!Rite_of.Temporal_of.temporal} now consults +; {!Rite_of.Temporal_of.holy_family} directly, independent of weekday, so +; litcal and colitur agree on 30 December 2033 on both axes -- test_litcal_of +; .ml's own [test_no_holy_family_2033_divergence] asserts this directly. +; `expected_rows` for both would now be 0 (no divergent rows remain), so the +; entries are removed rather than kept as zero-value ghosts, the same +; "CLOSED, REMOVED" discipline data/ef/expected-divergences-missalemeum.sexp +; already follows for a closed entry. ; L4 -- litcal's own [grade_lcl] text "celebration with precedence over ; solemnities" (this fixture's own display word for Tabula I.1/I.2) is ALSO diff --git a/lib/rites/rite_of/temporal_of.ml b/lib/rites/rite_of/temporal_of.ml index 1da6cf9..4f3194b 100644 --- a/lib/rites/rite_of/temporal_of.ml +++ b/lib/rites/rite_of/temporal_of.ml @@ -470,7 +470,32 @@ let temporal d = (Subject.Temporal, Names.empty, rank) in build ~subject ~names ~season:s ~slug ~colour ~rank ~week:(week d) () - | None -> ( + | None -> + if same d (holy_family y) then + (* Normae n. 35(a) fallback: "vel, ea deficiente, die 30 + decembris" -- reached here only when {!holy_family}'s own + 26-31 December Sunday search found none that year (26-31 + December holds no Sunday exactly when 25 December, Christmas + Day, is itself a Sunday), so the office falls on 30 December + civilly, whatever weekday that is. {!sunday_slug} only ever + recognises this office on a Sunday (its Christmas-time branch + tests [same d (holy_family y)] but the surrounding function + returns [None] immediately for any non-Sunday [d]), so + without this branch the date fell through to + {!christmas_feria_slug} below and the feast vanished + entirely -- CLAUDE.md's own W2. Carries the IDENTICAL + identity the Sunday-dispatch branch above builds (subject + Lord, {!holy_family_names}, rank Festum): Normae n. 35(a)'s + "vel... die 30 decembris" is the same feast on a fallback + date, not a lesser one. Colour is the plain season default + (white, Christmas time) -- {!is_rose_sunday} never applies + here (Christmas is not Advent/Lent) and 30 December is never + itself a Sunday when this branch fires (a Sunday would have + already matched {!sunday_slug} above), so there is nothing + to override it with. *) + build ~subject:Subject.Lord ~names:holy_family_names ~season:s ~slug:"of-holy-family" + ~colour:(season_colour s) ~rank:Festum ~week:(week d) () + else ( match christmas_feria_slug d y with | Some slug -> build ~season:s ~slug ~colour:(season_colour s) ~rank:Feria ~week:(week d) () | None -> diff --git a/test/test_litcal_of.ml b/test/test_litcal_of.ml index 0c53899..6a638a7 100644 --- a/test/test_litcal_of.ml +++ b/test/test_litcal_of.ml @@ -532,16 +532,22 @@ let test_unwitnessed_ordinary_time_is_counted () = none is a loose pattern that could silently absorb an unrelated future mismatch. *) -(* L2/L3 -- Normae n. 35(a), the Holy Family fallback: KNOWN WRONG, pinned - (not fixed) by test_rite_of.ml's own - [test_holy_family_fallback_1583_known_wrong_ferial]/ - [is_known_holy_family_fallback_gap]. 30 December 2033 is this fixture's - own live witness (2033: 25 December is a Sunday, so 26-31 December's own - window has no Sunday of its own and {!Rite_of.Temporal_of.temporal} - never reaches [holy_family]'s own fixed 30-December fallback branch). - litcal correctly names ["HolyFamily"]; colitur observes an ordinary - Nativity-octave feria (Tabula II.9, band 90) instead. *) -let is_l2_l3_holy_family_2033 (l : litcal_row) = String.equal l.event_key "HolyFamily" && String.equal l.date "2033-12-30" +(* L2/L3 -- CLOSED, REMOVED (W2 fix, CLAUDE.md, temporal_of.ml). This + paragraph used to describe a KNOWN WRONG, pinned-not-fixed divergence: + Normae n. 35(a)'s "vel, ea deficiente, die 30 decembris" Holy Family + fallback was reached by {!Rite_of.Temporal_of.holy_family} but never by + {!Rite_of.Temporal_of.temporal} itself (its Holy-Family test lived only + inside [sunday_slug]'s own Sunday-only dispatch arm), so a year 25 + December is itself a Sunday (26-31 December then holding no Sunday of + its own) left the fallback unreached. 30 December 2033 was this + fixture's own live witness. [Temporal_of.temporal] now consults + [Temporal_of.holy_family] directly, independent of weekday, so litcal + and colitur now agree on this date on both axes -- both the L2 (grade) + and L3 (identity) allow-list entries in + data/of/expected-divergences-litcal.sexp are removed (their own + `expected_rows` would now be 0, not 1), and [test_no_holy_family_2033_ + divergence] below asserts this directly rather than merely by omission. *) +let is_holy_family_2033 (l : litcal_row) = String.equal l.event_key "HolyFamily" && String.equal l.date "2033-12-30" (* L4 -- litcal's own [grade_lcl] "celebration with precedence over solemnities" text covers Trinity Sunday and Corpus Christi too (24 @@ -653,7 +659,6 @@ let test_grade_matches_or_is_explained () = let day = resolved_of l.date in let band = band_of day in if List.mem band bands then () - else if is_l2_l3_holy_family_2033 l then bump "L2" else if is_l4_trinity_corpus_christi l then bump "L4" else if is_l5_mary_magdalene l then bump "L5" else if is_l6_2033_tie_grade l then bump "L6" @@ -674,7 +679,7 @@ let test_grade_matches_or_is_explained () = match List.assoc_opt id by_id with | None -> Alcotest.failf "allow-list entry %s is used by the comparator but not declared in %s" id allow_list_path | Some e -> Alcotest.(check int) (Printf.sprintf "%s expected_rows (grade)" id) e.expected_rows actual) - [ "L2"; "L4"; "L5"; "L6"; "L8" ] + [ "L4"; "L5"; "L6"; "L8" ] (* The complement, mirroring [test_unwitnessed_ordinary_time_is_counted]: every row [expected_bands] returns [None] for, classified by which of @@ -717,7 +722,6 @@ let test_identity_matches_or_is_explained () = let day = resolved_of l.date in let actual_slug = Slug.to_string day.LD.observed.Cel.slug in if String.equal actual_slug expected_slug then () - else if is_l2_l3_holy_family_2033 l then bump "L3" else if is_l7_2033_tie_identity l then bump "L7" else if is_l8_l9_joseph_2035 l then bump "L9" else @@ -735,7 +739,7 @@ let test_identity_matches_or_is_explained () = match List.assoc_opt id by_id with | None -> Alcotest.failf "allow-list entry %s is used by the comparator but not declared in %s" id allow_list_path | Some e -> Alcotest.(check int) (Printf.sprintf "%s expected_rows (identity)" id) e.expected_rows actual) - [ "L3"; "L7"; "L9" ] + [ "L7"; "L9" ] (* The complement: every row [identity_map] has no entry for, whether because litcal names an event_key outside the closed FIXED/NAMED set @@ -758,15 +762,41 @@ let test_identity_unresolved_is_counted () = Alcotest.(check int) "unresolved + the 508 checked = 4346 non-Triduum rows" 4346 (List.length unresolved + 508) +(* W2 fix, direct regression coverage: L2/L3's own former witness (2033-12-30, + HolyFamily) now matches on BOTH axes, checked here explicitly rather than + only by the allow-list's own [expected_rows] falling to 0 (see + [is_holy_family_2033]'s own updated header for the full history). Also + confirms the predicate is not vacuous: the fixture really does carry this + row. *) +let test_no_holy_family_2033_divergence () = + let litcal = litcal_rows () in + let rows = List.filter is_holy_family_2033 litcal in + Alcotest.(check int) "the fixture really does carry a HolyFamily row on 2033-12-30" 1 (List.length rows); + let row = List.hd rows in + let day = resolved_of row.date in + let band = band_of day in + (match expected_bands row.grade_lcl with + | None -> Alcotest.fail "2033-12-30: HolyFamily's own grade_lcl is no longer a comparable band" + | Some bands -> + Alcotest.(check bool) "2033-12-30: colitur's band matches litcal's expected grade" true (List.mem band bands)); + let actual_slug = Slug.to_string day.LD.observed.Cel.slug in + Alcotest.(check string) "2033-12-30: colitur observes of-holy-family" "of-holy-family" actual_slug; + match Hashtbl.find_opt identity_tbl row.event_key with + | None -> Alcotest.fail "HolyFamily is no longer in identity_tbl" + | Some expected_slug -> + Alcotest.(check string) "2033-12-30: identity_tbl's own expected slug agrees" expected_slug actual_slug + (* ---------------------------------------------------------------------- *) (* The whole allow-list file, taken as a whole: every id it declares is *) -(* recognised by exactly one comparator above (L1 season, L2 grade, L3 *) +(* recognised by exactly one comparator above (L1 season, L4 grade, L7 *) (* identity) -- no orphan entry a comparator no longer references, and no *) (* comparator silently reading an id this file does not declare (each *) (* comparator's own [List.assoc_opt] already fails loudly for that half). *) +(* L2/L3 (Holy Family) are gone -- CLOSED, REMOVED, W2 fix -- see *) +(* [is_holy_family_2033]'s own header. *) (* ---------------------------------------------------------------------- *) -let recognized_allow_ids = [ "L1"; "L2"; "L3"; "L4"; "L5"; "L6"; "L7"; "L8"; "L9" ] +let recognized_allow_ids = [ "L1"; "L4"; "L5"; "L6"; "L7"; "L8"; "L9" ] let test_allow_list_has_no_orphan_entries () = let allow_list = load_allow_list () in @@ -785,5 +815,7 @@ let suite = Alcotest.test_case "grade-unresolved rows are counted, not skipped" `Quick test_grade_unresolved_is_counted; Alcotest.test_case "identity matches or is explained" `Quick test_identity_matches_or_is_explained; Alcotest.test_case "identity-unresolved rows are counted, not skipped" `Quick test_identity_unresolved_is_counted; + Alcotest.test_case "W2 fix: no more Holy Family divergence on 2033-12-30" `Quick + test_no_holy_family_2033_divergence; Alcotest.test_case "allow-list has no orphan entries" `Quick test_allow_list_has_no_orphan_entries ] ) diff --git a/test/test_rite_of.ml b/test/test_rite_of.ml index a7d3237..42c9c98 100644 --- a/test/test_rite_of.ml +++ b/test/test_rite_of.ml @@ -109,46 +109,49 @@ let contains ~substring s = 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. *) +(* CLOSED (W2, CLAUDE.md; fixed in temporal_of.ml). This paragraph used to + describe 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 had 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} used to only ever TEST [same d (holy_family y)] + inside its [sunday_slug d] dispatch arm -- i.e. only when [d] genuinely + WAS a Sunday -- so in a year whose 26-31 December window had NO Sunday + (equivalently: 25 December, Christmas Day, is itself a Sunday), the + fallback date was computed correctly by [holy_family]/[anchors] but + [temporal] itself silently fell 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 fired whenever Christmas Day is a Sunday, roughly one year in + seven. [Temporal_of.temporal] now consults [Temporal_of.holy_family] + directly in that dispatch arm, independent of weekday, carrying the + identical identity (subject Lord, [holy_family_names], rank Festum) the + Sunday case already builds -- see temporal_of.ml's own citation on that + branch. [is_known_holy_family_fallback_gap] is kept, not deleted, but is + no longer in [is_known]'s own filter below -- the same discipline + [is_known_nativity_gap] above already established once ITS defect + closed: it exists ONLY as an explicit, named "this must never fire + again" check ({!check_year_allowing_known_gaps}'s own dedicated + assertion), not silently absorbed. *) 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 (* Fix wave I8: [is_known_nativity_gap] is deliberately NOT part of - [is_known] any more -- see that predicate's own header. Only the - Holy Family fallback gap is still filtered out here. *) - let is_known f = is_known_holy_family_fallback_gap f in + [is_known] any more -- see that predicate's own header. W2 fix: neither + is [is_known_holy_family_fallback_gap] now -- see ITS own header. No + known gaps remain filtered here (landmark years validate cleanly, full + stop); [is_known] is kept as a named hook rather than inlined to + [false] so a future gap has an obvious place to land. *) + let is_known (_ : Val.failure) = false in let unexpected = List.filter (fun f -> not (is_known f)) fs in (match unexpected with | [] -> () @@ -157,7 +160,10 @@ let check_year_allowing_known_gaps year = (String.concat "; " (List.map Val.failure_to_string (List.filteri (fun i _ -> i < 5) fs)))); Alcotest.(check int) (Printf.sprintf "%d: no of-nativity citations-unresolved/formulary gap (fix wave I8 -- was 2)" year) - 0 (List.length (List.filter is_known_nativity_gap fs)) + 0 (List.length (List.filter is_known_nativity_gap fs)); + Alcotest.(check int) + (Printf.sprintf "%d: no Holy Family anchor gap (W2 fix -- was 1)" year) + 0 (List.length (List.filter is_known_holy_family_fallback_gap fs)) (* Landmark years, the same choice test_validate.ml's own [test_landmark_years] makes for EF: both domain edges plus two ordinary @@ -220,8 +226,15 @@ let test_year_9999_does_not_raise () = 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. *) + fallback {!is_known_holy_family_fallback_gap} below already pins) were + unrelated and remained open, unchanged by this fix. + + CORRECTED AGAIN (W2, CLAUDE.md, same day): the Holy Family 30-December + fallback named just above is ALSO now fixed -- {!Temporal_of.temporal} + consults {!Temporal_of.holy_family} directly, independent of weekday. + Only the lectionary weekday-cycle-letter asymmetry remains open; it is + untouched by this fix (lectionary_of.*/data/of/lectionary.sexp are out + of scope here, per the original paragraph's own citation). *) let sample_years = let rec range a b = if a > b then [] else a :: range (a + 1) b in range 2005 2050 @@ -320,28 +333,35 @@ let test_joseph_palm_sunday_2062_anticipated_backward () = transfer placement anywhere in it)" false (List.exists (fun f -> f.Val.check = "unconverged") failures) -(* 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 () = +(* CLOSED, W2 fix (CLAUDE.md). This test used to pin a defect the same way + [is_known_holy_family_fallback_gap]'s own citation just above describes: + 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] used to never reach + that fallback (its Holy-Family test lived inside the Sunday-only dispatch + arm), so 30 December 1583 was observed as an ordinary + Friday-of-Christmastide ferial office instead of the Feast of the Holy + Family. Now that {!Temporal_of.temporal} consults {!Temporal_of + .holy_family} directly, this test asserts the FIXED, correct behaviour + instead -- through the full real-data {!Colitur_kernel.Calendar}/ + {!Colitur_kernel.Precedence} pipeline, not merely {!Temporal_of.temporal} + in isolation (test_temporal_of.ml's own + [test_holy_family_fallback_temporal] already covers that layer) -- + checking identity (subject, rank), not only the slug, so a fix that + landed the right slug on the wrong grade would still be caught. *) +let test_holy_family_fallback_1583_now_correct () = 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") + Alcotest.(check string) "FIXED (W2): 30 December 1583 is observed as of-holy-family, not an ordinary ferial" + "of-holy-family" (slug_of dec30.LD.observed); + Alcotest.(check string) "subject is Lord (same identity the Sunday case carries)" "lord" + (Colitur_kernel.Subject.to_string dec30.LD.observed.Cel.subject); + Alcotest.(check string) "rank is Festum, not a lesser grade" "festum" + (V.rank_to_string dec30.LD.observed.Cel.rank) (* Fix wave I6 (final-review.md, 2026-08-25-colitur-of-phases-3-5): the obligatory-memorial tie data/of/amendments/006-mary-mother-of-the-church @@ -399,7 +419,7 @@ let test_mater_ecclesiae_anthony_tie_2011_known_arbitrary () = 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 + [ Alcotest.test_case "landmark years validate cleanly (W2 and the nativity gap both closed)" `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; @@ -410,9 +430,9 @@ let suite = n.56(f), converges, and is recorded as an arrival" `Quick test_joseph_palm_sunday_2062_anticipated_backward; 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; + "FIXED (W2): Holy Family's Normae n.35(a) 30-December fallback is now reached when Christmas \ + Day is a Sunday (1583)" `Quick + test_holy_family_fallback_1583_now_correct; Alcotest.test_case "KNOWN ARBITRARY pinned: Mary Mother of the Church vs Anthony of Padua tie, 2011-06-13 \ (Easter+50)" `Quick diff --git a/test/test_temporal_of.ml b/test/test_temporal_of.ml index 2522e44..d8b1f4a 100644 --- a/test/test_temporal_of.ml +++ b/test/test_temporal_of.ml @@ -13,6 +13,10 @@ 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 iso = D.to_iso8601 +(* Moved up from the "Sunday and ferial slugs" section below so the W2 + fallback tests (which need it) can use it without a forward reference. *) +let slug_of dt = match T.temporal dt with t -> Colitur_kernel.Slug.to_string t.Colitur_kernel.Temporal.office.Colitur_kernel.Celebration.slug + (* ---- vocabulary ---- *) let test_vocab_roundtrips () = @@ -77,6 +81,49 @@ let test_holy_family () = true; Alcotest.(check string) "2022 fallback" "2022-12-30" (iso (T.holy_family 2022)) +(* CLAUDE.md's own W2: {!T.temporal} used to reach {!T.holy_family}'s own + Normae n. 35(a) fallback (30 December) ONLY through {!T.sunday_slug}, + which returns [None] immediately for any non-Sunday date -- so in a year + 26-31 December held no Sunday (equivalently, 25 December, Christmas Day, + is itself a Sunday), the feast vanished entirely and 30 December fell + through to an ordinary Christmas ferial slug instead, even though + {!T.holy_family}/{!T.anchors} both already computed the right date. Fixed: + {!T.temporal} now consults {!T.holy_family} directly in that dispatch arm, + independent of weekday, carrying the identical identity (subject Lord, + {!T.holy_family_names} via [named_slug]'s own equivalent, rank Festum) the + Sunday case already builds. Five real affected years, per CLAUDE.md's own + W2 diagnosis (25 December a Sunday): 2022, 2033, 2039, 2044, 2050. *) +let test_holy_family_fallback_temporal () = + List.iter + (fun y -> + let hf = T.holy_family y in + Alcotest.(check bool) (Printf.sprintf "%d: fallback really lands on 30 December" y) true + (D.month hf = 12 && D.day hf = 30); + Alcotest.(check bool) (Printf.sprintf "%d: and that day is not itself a Sunday" y) true + (D.weekday hf <> D.Sun); + Alcotest.(check string) (Printf.sprintf "%d: temporal resolves the fallback to of-holy-family" y) + "of-holy-family" (slug_of hf); + let t = T.temporal hf in + Alcotest.(check string) (Printf.sprintf "%d: subject is Lord" y) "lord" + (Colitur_kernel.Subject.to_string t.Colitur_kernel.Temporal.office.Colitur_kernel.Celebration.subject); + Alcotest.(check string) (Printf.sprintf "%d: rank is Festum" y) "festum" + (V.rank_to_string t.Colitur_kernel.Temporal.office.Colitur_kernel.Celebration.rank); + Alcotest.(check string) (Printf.sprintf "%d: colour is white (season default)" y) "white" + (Colitur_kernel.Colour.to_string t.Colitur_kernel.Temporal.office.Colitur_kernel.Celebration.colour)) + [ 2022; 2033; 2039; 2044; 2050 ] + +(* Unaffected years -- 26-31 December DOES hold a Sunday, so {!T.sunday_slug} + already reaches the office and this fix changes nothing there. *) +let test_holy_family_sunday_unaffected () = + List.iter + (fun y -> + let hf = T.holy_family y in + Alcotest.(check bool) (Printf.sprintf "%d: Holy Family really is a Sunday here" y) true + (D.weekday hf = D.Sun); + Alcotest.(check string) (Printf.sprintf "%d: temporal still resolves to of-holy-family" y) "of-holy-family" + (slug_of hf)) + [ 2026; 2027; 2028; 2032 ] + let test_second_sunday_of_christmas () = Alcotest.(check (option string)) "2026" (Some "2026-01-04") (Option.map iso (T.second_sunday_of_christmas 2026)); (* 2024: Baptism is already 7 January (6 Jan a Saturday), so no Sunday @@ -211,7 +258,6 @@ let test_ordinary_time_resumption () = (* ---- Sunday and ferial slugs ---- *) let sunday_slug_of dt = match T.sunday_slug dt with Some s -> s | None -> "" -let slug_of dt = match T.temporal dt with t -> Colitur_kernel.Slug.to_string t.Colitur_kernel.Temporal.office.Colitur_kernel.Celebration.slug let test_sunday_slugs () = Alcotest.(check string) "Advent I 2026" "of-advent-sunday-1" (sunday_slug_of (T.advent_start 2026)); @@ -387,6 +433,22 @@ let prop_liturgical_years_partition_civil_time y = in check sorted +(* CLAUDE.md's own W2: the property that would have caught the fallback gap + directly, rather than needing a dedicated example-based test to notice it + -- Holy Family must be OBSERVED somewhere in every single liturgical + year, never silently absent, whichever of the two Normae n. 35(a) + branches (a Sunday 26-31 December, or the 30 December fallback) fires + that year. [walk_year y] spans Advent of civil year [y] through the eve + of Advent [y + 1], which always contains December of civil year [y] in + full -- the same civil year {!T.holy_family} itself is keyed to -- so + this is a genuine same-year check, not an off-by-one across the Advent + boundary. *) +let prop_holy_family_exists y = + List.exists + (fun (_, t) -> + Colitur_kernel.Slug.to_string t.Colitur_kernel.Temporal.office.Colitur_kernel.Celebration.slug = "of-holy-family") + (walk_year y) + let year_gen = QCheck.int_range 1584 9997 (* leaves room for [y - 1] and [y + 1] *) let make_prop name f = QCheck.Test.make ~count:200 ~name year_gen f @@ -399,7 +461,8 @@ let prop_tests = make_prop "OF: temporal's weekday always agrees with Date.weekday" prop_weekday_agrees; make_prop "OF: temporal is deterministic" prop_determinism; make_prop "OF: consecutive liturgical years partition civil time with no gap or overlap" - prop_liturgical_years_partition_civil_time ] + prop_liturgical_years_partition_civil_time; + make_prop "OF: Holy Family is observed every liturgical year (CLAUDE.md W2)" prop_holy_family_exists ] (* ---- the committed exhaustive sweep, mirroring test_validate.ml's own COLITUR_EXHAUSTIVE_SWEEP convention: every year 1583..9998, not a @@ -416,19 +479,18 @@ let test_exhaustive_domain_sweep () = (fixed just above, {!test_anchors_agree_with_temporal})? Checked for EVERY entry [anchors y] returns, across the WHOLE exhaustive domain, not merely 24 December, folded into this loop rather than a separate - one so it shares its own env-var gate. Answer: no NEW drift -- the - only mismatch found is the ALREADY-KNOWN "of-holy-family" gap - (Normae n.35(a)'s own 30-December fallback, whenever 26-31 December - holds no Sunday): there, unlike the Vigil bug, {!T.anchors} (via - {!T.holy_family}) computes the RIGHT date and it is {!T.temporal} - that fails to reach it (its Holy-Family test lives inside - [sunday_slug]'s own Sunday-only dispatch arm) -- a different shape, - already found, pinned and explicitly deferred by test_rite_of.ml's - own [test_holy_family_fallback_1583_known_wrong_ferial]/ - [is_known_holy_family_fallback_gap] (see that file's own extensive - citation), collected below rather than failing the sweep on it a - second time in a second place. *) - let holy_family_fallback_years = ref [] in + one so it shares its own env-var gate. + + UPDATED (W2 fix): this loop used to carve out and separately collect + an "ALREADY-KNOWN of-holy-family gap" here -- {!T.anchors} (via + {!T.holy_family}) computed the right 30-December fallback date but + {!T.temporal} failed to reach it (its Holy-Family test lived only + inside [sunday_slug]'s own Sunday-only dispatch arm), pinned rather + than fixed by test_rite_of.ml's own (now also fixed) + [test_holy_family_fallback_1583_known_wrong_ferial]. {!T.temporal} + now consults {!T.holy_family} directly, independent of weekday, so + that carve-out is gone: EVERY entry [anchors y] returns must now + agree with [temporal] with no exception, across the whole domain. *) for y = 1584 to 9997 do let days = walk_year y in if compressed_seasons days <> expected_season_runs then @@ -438,28 +500,14 @@ let test_exhaustive_domain_sweep () = if not (prop_advent_four_sundays y) then Alcotest.failf "%d: Advent does not have exactly four Sundays" y; if not (prop_slug_uniqueness y) then Alcotest.failf "%d: a slug repeats within the liturgical year" y; if not (prop_weekday_agrees y) then Alcotest.failf "%d: a weekday disagrees with Date.weekday" y; + if not (prop_holy_family_exists y) then Alcotest.failf "%d: Holy Family is not observed anywhere this year" y; List.iter (fun (expected_slug, date) -> let actual = slug_of date in if actual <> expected_slug then - if expected_slug = "of-holy-family" then holy_family_fallback_years := y :: !holy_family_fallback_years - else - Alcotest.failf - "%d: anchors' %S (%s) disagrees with temporal (%S) -- an UNEXPECTED drift, not the known \ - Holy Family fallback shape" - y expected_slug (iso date) actual) + Alcotest.failf "%d: anchors' %S (%s) disagrees with temporal (%S)" y expected_slug (iso date) actual) (T.anchors y) - done; - (* The known shape is real, not vacuous, and really is the Normae - n.35(a) trigger, for every year collected above, not merely - plausible-sounding. *) - Alcotest.(check bool) "the Holy Family fallback shape is real and not empty" true - (!holy_family_fallback_years <> []); - List.iter - (fun y -> - Alcotest.(check bool) (Printf.sprintf "%d is really a Christmas-Day-is-Sunday year" y) true - (D.weekday (d y 12 25) = D.Sun)) - !holy_family_fallback_years + done end (* 1583 and 9999 themselves sit at the domain edges, where the [y-1]/[y+1] @@ -483,6 +531,8 @@ let suite = Alcotest.test_case "seasons" `Quick test_seasons; Alcotest.test_case "baptism of the lord" `Quick test_baptism_of_the_lord; Alcotest.test_case "holy family" `Quick test_holy_family; + Alcotest.test_case "holy family fallback reaches temporal (W2 fix)" `Quick test_holy_family_fallback_temporal; + Alcotest.test_case "holy family Sunday case unaffected by the fix" `Quick test_holy_family_sunday_unaffected; Alcotest.test_case "second sunday of christmas" `Quick test_second_sunday_of_christmas; Alcotest.test_case "christ the king" `Quick test_christ_the_king; Alcotest.test_case "named feasts" `Quick test_named_feasts; diff --git a/test/test_validate_of.ml b/test/test_validate_of.ml index 5c64710..bcf61cc 100644 --- a/test/test_validate_of.ml +++ b/test/test_validate_of.ml @@ -15,13 +15,20 @@ including their own three pinned defects -- see below). THE KNOWN, PINNED-NOT-FIXED GAPS (Task 5's own report; do not relitigate - here, only avoid papering over them). Originally three; now two -- see - item 2's own CLOSED note: - 1. Normae n. 35(a)'s Holy Family fallback is unreached whenever - Christmas Day is itself a Sunday (test_rite_of.ml's own - [is_known_holy_family_fallback_gap]/ - [test_holy_family_fallback_1583_known_wrong_ferial]) -- fires on - roughly one year in seven, first hit at 1583. + here, only avoid papering over them). Originally three; now ONE -- items + 1 and 2 are both CLOSED (see each one's own CLOSED note): + 1. CLOSED, W2 fix (CLAUDE.md, temporal_of.ml). Used to read: "Normae + n. 35(a)'s Holy Family fallback is unreached whenever Christmas Day + is itself a Sunday (test_rite_of.ml's own + [is_known_holy_family_fallback_gap]/[test_holy_family_fallback_1583_ + known_wrong_ferial]) -- fires on roughly one year in seven, first hit + at 1583." {!Rite_of.Temporal_of.temporal} now consults + {!Rite_of.Temporal_of.holy_family} directly, independent of weekday, + so this fires on ZERO years now, verified across the whole domain by + [test_exhaustive_domain_sweep]'s own [holy_family_gap_years] counter + below -- not merely assumed from the fix. [is_known_holy_family_ + fallback_gap] itself is kept, as a dedicated "this must never recur" + check, not deleted -- the same discipline item 2 already established. 2. CLOSED, fix wave I8 (final-review.md, 2026-08-25-colitur-of-phases- 3-5). Used to read: "The lectionary (data/of/lectionary.sexp) was audited against only one civil year (2026) -- surfaces here as @@ -118,13 +125,20 @@ let contains ~substring s = let rec go i = i + lx <= ls && (String.sub s i lx = substring || go (i + 1)) in lx = 0 || go 0 +(* CLOSED, W2 fix (CLAUDE.md, temporal_of.ml): see this file's own header, + item 1. Kept as an explicit "this must never fire again" check, no + longer part of [is_known_gap]'s own filter below -- the same discipline + [is_known_nativity_gap] just above already established. *) let is_known_holy_family_fallback_gap (f : Val.failure) = f.Val.check = "anchor" && contains ~substring:"of-holy-family" f.Val.detail -(* Fix wave I8: [is_known_nativity_gap] deliberately excluded -- see its own - header just above. A recurrence now surfaces as an ordinary unexpected - failure everywhere this predicate is used to filter. *) -let is_known_gap f = is_known_holy_family_fallback_gap f +(* Fix wave I8 + W2: both [is_known_nativity_gap] and + [is_known_holy_family_fallback_gap] are deliberately excluded now -- see + each one's own header just above. A recurrence of either now surfaces as + an ordinary unexpected failure everywhere this predicate is used to + filter. Kept as a named hook (not inlined to [false] at each call site) + so a future gap has an obvious place to land. *) +let is_known_gap (_ : Val.failure) = false let unexplained_failures year = List.filter (fun f -> not (is_known_gap f)) (run year) @@ -152,13 +166,16 @@ let test_landmark_years () = List.iter check_year_allowing_known_gaps [ 1583; 20 Christmas-Day-is-Sunday shape can both still occur inside the truncated walk) -- only "seasons" is additionally allowed here, and only here. *) let test_year_9999_does_not_raise () = + (* [is_known_gap] now filters nothing (both gaps CLOSED) -- kept in the + pipeline anyway so a future gap has the same obvious place to land, but + [fs] here is really just [run 9999] unfiltered. *) let fs = List.filter (fun f -> not (is_known_gap f)) (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); - Alcotest.(check (list string)) "nothing OTHER than the documented seasons truncation (and the three known \ - gaps, already filtered) fired" + Alcotest.(check (list string)) "nothing OTHER than the documented seasons truncation fired (both known \ + gaps are CLOSED)" [ "seasons" ] (List.sort_uniq compare (List.map (fun f -> f.Val.check) fs)) @@ -166,18 +183,15 @@ let test_year_9999_does_not_raise () = (* THE CONFIDENCE-TO-9999 CORE: random years across the whole domain, *) (* mirroring test_validate.ml's own [prop_invariants] exactly, one rite *) (* down -- 200 samples by default; every EXHAUSTIVE year under *) -(* [COLITUR_EXHAUSTIVE_SWEEP=1] (below). Filtered of the three known gaps, *) -(* same discipline [check_year_allowing_known_gaps] already applies to the *) -(* landmark years -- a property that asserted [run y = []] UNFILTERED *) -(* would not test anything new (it would just fail on ~1/7 of its own *) -(* samples, the Holy Family gap's own real incidence), and one that *) -(* filtered EVERYTHING unconditionally would risk hiding a genuinely NEW *) -(* failure behind the same three names -- which is exactly why *) -(* [is_known_gap] is the narrow, field-checked pair of predicates above, *) -(* not a blanket "ignore anything on 25 December" rule. *) +(* [COLITUR_EXHAUSTIVE_SWEEP=1] (below). Both known gaps are now CLOSED *) +(* (this file's own header), so [is_known_gap] filters nothing any more -- *) +(* [unexplained_failures y = []] below is really [run y = []] UNFILTERED, *) +(* a strictly stronger assertion than this property used to make while *) +(* either gap was still open. [is_known_gap] is kept in the pipeline (not *) +(* inlined away) so a future gap has the same obvious, narrow, field- *) +(* checked place to land that the two closed ones already used. *) let prop_invariants = - QCheck.Test.make ~count:200 ~name:"OF temporal invariants hold across 1583..9998 (modulo the three known, \ - pinned gaps)" + QCheck.Test.make ~count:200 ~name:"OF temporal invariants hold across 1583..9998 (no known gaps remain)" (QCheck.int_range 1583 9998) (fun y -> unexplained_failures y = []) @@ -283,12 +297,13 @@ let test_exhaustive_domain_sweep () = for y = 1583 to 9998 do let fs = run y in let hf = List.exists is_known_holy_family_fallback_gap fs in - (* Fix wave I8: expected to be [false] every single year now -- kept - as a live measurement, not deleted, so a regression anywhere in - the domain increments this counter AND (via [is_known_gap] no - longer absorbing it) fails the loop below immediately, on the - first year it recurs, rather than only being caught by the - summary assertion after the full sweep completes. *) + (* Fix wave I8 + W2: expected to be [false] every single year now for + BOTH counters -- kept as a live measurement, not deleted, so a + regression anywhere in the domain increments the relevant counter + AND (via [is_known_gap] no longer absorbing either shape) fails the + loop below immediately, on the first year it recurs, rather than + only being caught by the summary assertion after the full sweep + completes. *) let nat = List.exists is_known_nativity_gap fs in if hf then incr holy_family_gap_years; if nat then incr nativity_gap_years; @@ -309,24 +324,25 @@ let test_exhaustive_domain_sweep () = (not (List.exists (fun f -> f.Val.check = "coverage") fs_9999)); Alcotest.(check bool) "9999: seasons check flags the truncated final year as incomplete" true (List.exists (fun f -> f.Val.check = "seasons") fs_9999); - Alcotest.(check (list string)) "9999: nothing OTHER than the documented seasons truncation (and the two \ - remaining known, pinned gaps) fired" + Alcotest.(check (list string)) "9999: nothing OTHER than the documented seasons truncation fired (both \ + known gaps are CLOSED)" [ "seasons" ] (List.sort_uniq compare (List.map (fun f -> f.Val.check) (List.filter (fun f -> not (is_known_gap f)) fs_9999))); - (* The Holy Family gap is real and not vacuous across the FULL domain, - not merely on the handful of years the sampled property happens to - draw -- it fires on roughly one year in seven (whenever 25 December - is a Sunday). Pinned as a range, not an exact count, deliberately: - the exact figure is a real, computable fact about the Gregorian - calendar's own 400-year cycle, but pinning it to the digit would - make this test fail the moment a future COLITUR_EXHAUSTIVE_SWEEP - run's own domain bounds shift by even one year at either edge, for a - reason having nothing to do with colitur's own correctness. *) - Alcotest.(check bool) "the Holy Family fallback gap fires on a real, non-trivial fraction of years \ - (roughly one in seven)" - true - (!holy_family_gap_years > 1000 && !holy_family_gap_years < 1400); + (* CLOSED, W2 fix: the Holy Family gap used to fire on roughly one year + in seven (whenever 25 December is a Sunday) -- real and not vacuous + across the FULL domain, not merely the handful of years the sampled + property happens to draw. {!Rite_of.Temporal_of.temporal} now + consults {!Rite_of.Temporal_of.holy_family} directly, independent of + weekday, so this now fires on ZERO years -- across the WHOLE domain, + not merely the 4 landmark years test_rite_of.ml's own + [test_landmark_years] already checks, which is the entire reason + this dedicated exhaustive counter is kept rather than deleted + alongside the rest of the old gap-tracking, mirroring the Nativity + counter's own already-established pattern just below. *) + Alcotest.(check int) "the Holy Family fallback gap fires on NONE of the 8 416 swept years (W2 fix -- was \ + roughly one in seven, ~1200)" + 0 !holy_family_gap_years; (* CLOSED, fix wave I8: the Nativity gap used to fire on literally every one of the 8 416 swept years (25 December always exists). Now exactly zero -- across the WHOLE domain, not merely the 4 landmark @@ -340,10 +356,9 @@ let test_exhaustive_domain_sweep () = let suite = ( "Validate (OF, real data: Rite_of.context)", - [ Alcotest.test_case "landmark years validate cleanly (modulo the two remaining known gaps)" `Quick + [ Alcotest.test_case "landmark years validate cleanly (both known gaps are CLOSED)" `Quick test_landmark_years; - Alcotest.test_case "year 9999 does not raise; seasons flags the clamp, nothing else (beyond the known \ - gaps) fires" `Quick + Alcotest.test_case "year 9999 does not raise; seasons flags the clamp, nothing else fires" `Quick test_year_9999_does_not_raise; Alcotest.test_case "exhaustive domain sweep (1583..9999), committed not sampled" `Slow test_exhaustive_domain_sweep ] -- cgit v1.3