From bbc8ce26916ca92b3c2286c57342a6565323b66f Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 13 Aug 2026 08:11:26 +0200 Subject: temporal(ef): the Holy Family, first Sunday after Epiphany (RG 17(b)) RG 17(b), verified against both photographic scans (missale-romanum- 1962.pdf and "Missale Romanum 1962_text.pdf" -- the electronic transcription carries the same paragraph, so this one is not itself a transcription-vs-scan divergence): the Holy Family of Jesus, Mary and Joseph is celebrated on the first Sunday after Epiphany, II class, EVERY year, taking the Sunday's own place "cum omnibus iuribus et privilegiis" -- the same formula RG 16(a) already uses. The slug, rank and colour temporal_ef.ml already computed for that Sunday (ef-time-after-epiphany-sunday-1, Class2, white) turn out to be exactly right by coincidence -- an ordinary, unnamed Sunday and Holy Family share both. The only field that was silently wrong is subject: always Temporal before this, when RG 91 entry 14 ("Festa Domini II classis, primum mobilia, deinde fixa") calls Holy Family a movable Feast of the Lord. temporal's build now takes an optional ~subject, applied only on Holy Family's own date -- every other Sunday, including the still-unbuilt Holy Name of Jesus (RG 17(a)), is unaffected. RG 17(b)'s window (7-13 January) can never be empty of a Sunday -- sunday_on_or_before(6 Jan) + 7 always lands in [7, 13] regardless of 6 January's weekday -- unlike RG 17(a)'s Holy Name (2-5 January, CAN be empty), whose calendarium entry carries an explicit "vel ea deficiente, die 2 ianuarii" fallback right next to Holy Family's own, fallback-free one. No fallback needed or built. Added to anchors (holy_family_sunday is independently computed, not routed through named -- see temporal_ef.ml's own comment on why) and to its own dedicated anchor/erosion test in test_temporal_ef.ml, kept separate from the generic named-days coverage test since this genuinely isn't one of named's own outputs. Does not yet touch precedence: band still has no notion of a movable Class2 Lord feast (entry 14 gates on sanctoral origin only), so on the seven years 13 January is itself Holy Family's date (2008, 2013, 2019, 2030, 2036, 2041, 2047), the fixed Commemoration of the Baptism of the Lord still wins the day exactly as before this commit -- the next commit fixes the ordering RG 91 entry 14's own text states ("primum mobilia, deinde fixa"). --- test/test_temporal_ef.ml | 116 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) (limited to 'test/test_temporal_ef.ml') diff --git a/test/test_temporal_ef.ml b/test/test_temporal_ef.ml index 751abd2..7837fb5 100644 --- a/test/test_temporal_ef.ml +++ b/test/test_temporal_ef.ml @@ -167,13 +167,95 @@ let test_resumed_sundays () = 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 +(* 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); + (* 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)) + (* 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 @@ -432,6 +514,36 @@ let test_anchors_erosion_is_caught () = [ "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. *) +let test_holy_family_anchor_present_and_erosion_is_caught () = + let y = 2026 in + let expected_slug = "ef-time-after-epiphany-sunday-1" in + let expected_date = T.holy_family_sunday y in + Alcotest.(check bool) "the anchor entry exists, at the right date" true + (List.mem (expected_slug, expected_date) (T.anchors y)); + (* Erosion: with the entry struck out, [T.temporal] itself still puts + [expected_slug] at [expected_date] (unaffected -- deleting an [anchors] + row never touches [temporal]'s own computation, only what is CHECKED + against it), so a reader who only trusted [anchors] would no longer be + told to look there at all. *) + let eroded = List.filter (fun (s, dt) -> not (String.equal s expected_slug && dt = expected_date)) (T.anchors y) in + Alcotest.(check bool) "the erosion is caught: the entry no longer appears" false + (List.mem (expected_slug, expected_date) eroded); + Alcotest.(check string) "sanity: temporal itself is unaffected by the anchors-list erosion" + expected_slug (slug_of expected_date) + 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, @@ -453,6 +565,8 @@ let suite_extra = 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 "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; @@ -465,6 +579,8 @@ let suite_extra = 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 "anchors erosion is caught" `Quick test_anchors_erosion_is_caught ] let suite = -- cgit v1.3 From b7da41445d89cc83e749ec19c57e321ceb6d5cf2 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 13 Aug 2026 09:16:39 +0200 Subject: temporal(ef): fix round 1 (F3) -- give Holy Family a primary-sourced Latin name On 7,197 of the 8,417 days Holy Family occurs (every year that isn't the 13-January collision), every emitted byte was identical to the pre-change binary -- subject appeared in no output path the CLI or any test read. Celebration.make already takes ?names, defaulting to empty, and Temporal_ef.build never passed it. build now takes an optional ?names, set only on Holy Family's own branch: a LATIN name, not English. The oracle's own observed-identity axis (test_oracle.ml) reads only `en`, so this is deliberately invisible to it -- an English name here would mean reading missalemeum's own title text, the oracle this exact axis is compared against, to decide colitur's "ground truth" name: the "expected value promoted from actual output" vacuity flavour this project's review process watches for. Latin has no such circularity: the calendarium's own January table and the Mass propers' own heading, both photographic scans, word for word, "Sanctae Familiae Iesu, Mariae, Ioseph" -- the same genitive-title convention test_names.ml's own worked example already uses for Easter ("Dominica Resurrectionis"). Every other temporal-cycle candidate, including the neighbouring Sundays, still carries no name of any kind -- a targeted addition for the one day this task built, not a claim that the general gap (register's own open item on Holy Name of Jesus, RG 17(a)) is closed. --- lib/rites/rite_ef/temporal_ef.ml | 41 +++++++++++++++++++++++++++++++++------- test/test_temporal_ef.ml | 16 ++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) (limited to 'test/test_temporal_ef.ml') diff --git a/lib/rites/rite_ef/temporal_ef.ml b/lib/rites/rite_ef/temporal_ef.ml index 0647aac..1ce9de9 100644 --- a/lib/rites/rite_ef/temporal_ef.ml +++ b/lib/rites/rite_ef/temporal_ef.ml @@ -444,9 +444,10 @@ let temporal d = let easter = Computus.gregorian_easter y in let s = season d in let weekday = Date.weekday d in - let build ?(subject = Colitur_kernel.Subject.Temporal) ~season ~slug ~colour ~rank ~week () = + let build ?(subject = Colitur_kernel.Subject.Temporal) ?(names = Colitur_kernel.Names.empty) ~season + ~slug ~colour ~rank ~week () = let office = - Colitur_kernel.Celebration.make ~slug:(Slug.of_string_exn slug) ~rank ~colour ~subject + Colitur_kernel.Celebration.make ~slug:(Slug.of_string_exn slug) ~rank ~colour ~subject ~names ~layer:"temporal" () in { Colitur_kernel.Temporal.season; week; weekday; office } @@ -508,12 +509,38 @@ let temporal d = OTHER Sunday this branch ever builds -- including the narrower Holy Name of Jesus window, RG 17(a), still unbuilt as its own named day, register §6 -- keeps - [Temporal], unaffected. *) - let subject = - if same d (holy_family_sunday y) then Colitur_kernel.Subject.Lord - else Colitur_kernel.Subject.Temporal + [Temporal] and no name, unaffected. + + [names] -- fix round 1 (coordinator finding 3): a LATIN + name, not English. The oracle's own observed-identity + axis (test_oracle.ml) reads only [en], so this is + deliberately invisible to it -- setting an ENGLISH name + here would mean reading missalemeum's own title text + (the oracle this exact axis is compared against) to + decide colitur's own "ground truth" name, precisely the + "expected value promoted from actual output" vacuity + flavour this project's own review process watches for. + Latin has no such circularity: the calendarium's own + January table, both photographic scans, word for word: + "Dominica I post Epiphaniam: S. Familiae, Iesu, Mariae, + Ioseph, II classis" -- and the Mass propers' own heading + (also both scans): "SANCTAE FAMILIAE IESU, MARIAE, + IOSEPH, II classis", the exact string used here, the + same genitive-title convention test_names.ml's own + worked example already uses for Easter ("Dominica + Resurrectionis"). Every other temporal-cycle candidate + still carries no name at all (register §6.2's own open + item on Holy Name of Jesus, RG 17(a)) -- this is a + targeted addition for the one day this task built, not a + claim that the gap is closed generally. *) + let subject, names = + if same d (holy_family_sunday y) then + ( Colitur_kernel.Subject.Lord, + Colitur_kernel.Names.of_list + [ (Colitur_kernel.Lang.of_string_exn "la", "Sanctae Familiae Iesu, Mariae, Ioseph") ] ) + else (Colitur_kernel.Subject.Temporal, Colitur_kernel.Names.empty) in - build ~subject ~season:s ~slug ~colour ~rank ~week:(week d) () + build ~subject ~names ~season:s ~slug ~colour ~rank ~week:(week d) () | None -> ( match christmastide_feria_slug d with | Some slug -> diff --git a/test/test_temporal_ef.ml b/test/test_temporal_ef.ml index 7837fb5..8a0f38e 100644 --- a/test/test_temporal_ef.ml +++ b/test/test_temporal_ef.ml @@ -175,6 +175,9 @@ 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, @@ -207,6 +210,19 @@ let test_holy_family () = 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/ -- cgit v1.3 From 9e6f099f7f1dd918cf5f9f418ed5e2408289dd40 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 13 Aug 2026 09:20:33 +0200 Subject: test(temporal_ef): fix round 1 (F5) -- the anchor erosion test proved nothing test_holy_family_anchor_present_and_erosion_is_caught built its "eroded" list by filtering the exact pair it then asserted was absent -- removing an element and checking it is gone proves List.filter works, not that any detector caught anything. Borrowed the name of test_anchors_erosion_is_caught, which uses a genuine one: missing_from_anchors, called with an "expected" set sourced independently of T.anchors (named_slugs_for_year, via T.named). Reworked to match that shape: the expected slug is now sourced from T.sunday_slug (season/weekday logic, a different function entirely from T.anchors's own hand-typed string), and both the presence and erosion checks reuse the real missing_from_anchors detector instead of a bespoke, self-referential one. --- test/test_temporal_ef.ml | 47 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 12 deletions(-) (limited to 'test/test_temporal_ef.ml') diff --git a/test/test_temporal_ef.ml b/test/test_temporal_ef.ml index 8a0f38e..23a817d 100644 --- a/test/test_temporal_ef.ml +++ b/test/test_temporal_ef.ml @@ -542,21 +542,44 @@ let test_anchors_erosion_is_caught () = 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. *) + 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_slug = "ef-time-after-epiphany-sunday-1" in let expected_date = T.holy_family_sunday y in - Alcotest.(check bool) "the anchor entry exists, at the right date" true - (List.mem (expected_slug, expected_date) (T.anchors y)); - (* Erosion: with the entry struck out, [T.temporal] itself still puts - [expected_slug] at [expected_date] (unaffected -- deleting an [anchors] - row never touches [temporal]'s own computation, only what is CHECKED - against it), so a reader who only trusted [anchors] would no longer be - told to look there at all. *) - let eroded = List.filter (fun (s, dt) -> not (String.equal s expected_slug && dt = expected_date)) (T.anchors y) in - Alcotest.(check bool) "the erosion is caught: the entry no longer appears" false - (List.mem (expected_slug, expected_date) eroded); + 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) -- cgit v1.3