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). --- test/test_temporal_of.ml | 114 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 82 insertions(+), 32 deletions(-) (limited to 'test/test_temporal_of.ml') 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; -- cgit v1.3