From b6d0eba04d1da51515dad70127112898b500c1bc Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 26 Aug 2026 10:11:47 +0200 Subject: fix(of-lectionary): stop serving drifting readings for O-Antiphon and Christmas-season dates The bootstrap excluded 19 lectio bases as "date-keyed duplicates", but 17 of them were not duplicates at all: the 8 O-Antiphon days (17-24 December) and 8 further Christmas-season dates (29-31 December, 2-5 and 7 January) each carry unique per-date content found nowhere else among the emitted entries, verified against lectio's own ini directly. 17 December is Gen 49:2,8-10 / Matthew 1:1-17, but colitur was falling through to that year's unrelated weekday-keyed "advent-3-mon" content instead. OLM n. 69.3 explains why: those ferias are fixed by civil date, unlike every other Advent/Christmastide/Paschaltide feria, which is merely non-alternating within a weekday slot. Temporal_of's own ferial slugs for these dates are weekday-keyed, so looking one of those up served whichever OTHER date happened to share that year's weekday alignment -- a citation that silently drifted year to year on ~16 real civil dates (6 January stays excluded: Temporal_of fixes Epiphany there unconditionally, so that date can never reach the ferial path at all; two more, easter-6-thu and advent-4-sat, stay excluded for their own, different structural reasons). Fixed with a date-keyed lookup route (Lectionary_of.date_keyed_slug), tried before the weekday-keyed one in readings' own step 3. Temporal_of's slugs are unchanged -- only which lectionary key resolves a day's citations. tools/bootstrap_lectionary_of.ml now maps the 17 bases to synthetic date-keyed slugs instead of excluding them, and its own reachability sweep calls date_keyed_slug directly so the two can never drift apart. Re-emitted data/of/lectionary.sexp (754 -> 770 entries) and corrected its provenance header, which previously described these dates as excluded duplicates. Also states, for the first time, a Minor finding from the same review: 190 of 222 shipped sanctoral slugs have no dedicated lectionary entry and correctly fall through to the ferial per OLM norms -- expected behaviour, not a gap, but never said in the coverage report before. New tests pin 17 December (Gen 49:2,8-10 / Matthew 1:1-17) and 2 January (1 John 2:22-28 / John 1:19-28) in two years with different weekday alignments, proving neither reading drifts, plus direct boundary coverage of date_keyed_slug itself (both windows, the 6 January exclusion, and the Sunday guard). --- data/of/lectionary.sexp | 98 +++++++++++++-- lib/rites/rite_of/lectionary_of.ml | 36 +++++- lib/rites/rite_of/lectionary_of.mli | 50 +++++++- test/test_lectionary_of.ml | 119 ++++++++++++++++-- tools/bootstrap_lectionary_of.ml | 232 +++++++++++++++++++++++++++--------- 5 files changed, 458 insertions(+), 77 deletions(-) diff --git a/data/of/lectionary.sexp b/data/of/lectionary.sexp index 4f43f12..167a7c7 100644 --- a/data/of/lectionary.sexp +++ b/data/of/lectionary.sexp @@ -25,28 +25,56 @@ ; a MISSING source file is fatal, checked before any read is attempted -- ; see this generator's own [parse_ini]) and commit the diff instead. ; Every emitted key is asserted, at generation time, to be a slug -; Rite_of.Temporal_of actually computes OR a slug the real merged +; Rite_of.Temporal_of actually computes, OR a slug the real merged ; sanctoral layer (data/of/calendar-2002.sexp + all 13 decree overlays) -; actually carries -- see [assert_reachable]. +; actually carries, OR a date Rite_of.Lectionary_of.date_keyed_slug +; actually reaches -- see [assert_reachable]. +; +; CORRECTED 2026-08-26 (review): this file used to exclude 16 lectio +; bases -- the 8 O-Antiphon days (17-24 December) and 8 further +; Christmas-season dates (29-31 December, 2-5 and 7 January) -- as +; "date-keyed duplicates" of the weekday-keyed ferial families mapped +; above. That was false: each carries content found nowhere else among +; the emitted entries (17 December: Gen 49:2,8-10/Matt 1:1-17), which +; OLM n. 69.3 explains -- these ferias are fixed by CIVIL DATE, not +; merely non-alternating within a weekday slot like every other Advent/ +; Christmastide feria. Every day in 2005-2050 previously served a +; DRIFTING citation there (whichever weekday-keyed family that year's +; own alignment happened to land on) instead of the Missal's fixed one. +; Fixed via a new date-keyed lookup route, tried BEFORE the weekday- +; keyed one (Rite_of.Lectionary_of.date_keyed_slug, readings' own step +; 3) -- Temporal_of's slugs are UNCHANGED, only which lectionary key +; resolves the day's citations. 3 lectio bases remain excluded, for +; three genuinely different structural-unreachability reasons, none of +; them "duplicate" -- see [excluded_bases]'s own comment. ; ; Source: ../lectio/internal/caldata/of-lectionary.ini ; SHA-256: f0b527aef58b93e6ccdfbeafe52b340ed2a9d7d72805bf5083e3da04b080123c -; 988 ini sections (988 expected) -> 438 resolved (base, cycle-shape) pairs -> 419 colitur slugs mapped, 19 ini bases deliberately excluded (date- -; keyed duplicates/late-Advent, no colitur date-keyed lookup exists -- -; see [excluded_bases]), 0 ini bases genuinely unmapped (no pattern, no -; override, no matching sanctoral slug) -> 754 emitted lectionary entries. +; 988 ini sections (988 expected) -> 438 resolved (base, cycle-shape) pairs -> 435 colitur slugs mapped, 3 ini bases genuinely excluded (structurally +; unreachable -- see [excluded_bases]), 0 ini bases genuinely unmapped +; (no pattern, no override, no matching sanctoral slug) -> 770 emitted +; lectionary entries. ; ; COVERAGE, BOTH DIRECTIONS (Step 1 of this task's own brief): ; (1) Temporal-day gap: of the 365 days of civil year 2026, 1 produce a -; Rite_of.Temporal_of slug with NO entry in this file (tried flat, -; then both the Sunday- and weekday-cycle letter suffixes -- the -; same order Lectionary_of.readings itself tries). Those are days -; with no readings via the temporal-slug path (a sanctoral proper -; on the same civil day, where one exists, is Lectionary_of's own +; Rite_of.Temporal_of slug with NO entry in this file (tried the +; date-keyed route first, then flat, then both the Sunday- and +; weekday-cycle letter suffixes -- the same order +; Lectionary_of.readings itself tries). Those are days with no +; readings via the temporal-slug path (a sanctoral proper on the +; same civil day, where one exists, is Lectionary_of's own ; separate first chance). Named set below. ; (2) Unmapped lectio keys: 0 ini base names (of 988 sections, 438 ; distinct bases) map to no colitur slug at all -- data being ; silently dropped if unreported. Named below. +; (3) Sanctoral coverage (Minor, 2026-08-26 review): of the 222 shipped +; sanctoral slugs (data/of/calendar-2002.sexp + all 13 decree +; overlays), 32 have a DEDICATED entry in this file (a lectio base +; name this generator recognised verbatim as one of them). The +; other 190 have none and correctly fall through to the day's own +; ferial (Lectionary_of.readings' own step 2 -> step 3) -- this is +; OLM norms working as designed for a saint with no proper of +; their own, not a gap in this data. ; Regenerate with: ; eval $(opam env) && dune exec tools/bootstrap_lectionary_of.exe -- ../lectio/internal/caldata/of-lectionary.ini data/of/lectionary.sexp ; Unmapped lectio bases (0): @@ -203,6 +231,30 @@ (of-advent-4-wednesday-ii (((part First) (reference "1 Samuel 1:24-28")) ((part Gospel) (reference "Luke 1:46-56")))) + (of-advent-dec-17 + (((part First) (reference "Genesis 49:2,8-10")) + ((part Gospel) (reference "Matthew 1:1-17")))) + (of-advent-dec-18 + (((part First) (reference "Jeremiah 23:5-8")) + ((part Gospel) (reference "Matthew 1:18-24")))) + (of-advent-dec-19 + (((part First) (reference "Judges 13:2-7,24-25a")) + ((part Gospel) (reference "Luke 1:5-25")))) + (of-advent-dec-20 + (((part First) (reference "Isaiah 7:10-14")) + ((part Gospel) (reference "Luke 1:26-38")))) + (of-advent-dec-21 + (((part First) (reference "Song of Solomon 2:8-14")) + ((part Gospel) (reference "Luke 1:39-45")))) + (of-advent-dec-22 + (((part First) (reference "1 Samuel 1:24-28")) + ((part Gospel) (reference "Luke 1:46-56")))) + (of-advent-dec-23 + (((part First) (reference "Malachi 3:1-4;4:5-6")) + ((part Gospel) (reference "Luke 1:57-66")))) + (of-advent-dec-24 + (((part First) (reference "2 Samuel 7:1-5,8b-12,14a,16")) + ((part Gospel) (reference "Luke 1:67-79")))) (of-advent-sunday-1-a (((part First) (reference "Isaiah 2:1-5")) ((part Gospel) (reference "Matthew 24:37-44")))) @@ -356,6 +408,30 @@ (of-christmas-2-wednesday (((part First) (reference "1 John 4:11-18")) ((part Gospel) (reference "Mark 6:45-52")))) + (of-christmas-dec-29 + (((part First) (reference "1 John 2:3-11")) + ((part Gospel) (reference "Luke 2:22-35")))) + (of-christmas-dec-30 + (((part First) (reference "1 John 2:12-17")) + ((part Gospel) (reference "Luke 2:36-40")))) + (of-christmas-dec-31 + (((part First) (reference "1 John 2:18-21")) + ((part Gospel) (reference "John 1:1-18")))) + (of-christmas-jan-2 + (((part First) (reference "1 John 2:22-28")) + ((part Gospel) (reference "John 1:19-28")))) + (of-christmas-jan-3 + (((part First) (reference "1 John 2:29-3:6")) + ((part Gospel) (reference "John 1:29-34")))) + (of-christmas-jan-4 + (((part First) (reference "1 John 3:7-10")) + ((part Gospel) (reference "John 1:35-42")))) + (of-christmas-jan-5 + (((part First) (reference "1 John 3:11-21")) + ((part Gospel) (reference "John 1:43-51")))) + (of-christmas-jan-7 + (((part First) (reference "1 John 5:14-21")) + ((part Gospel) (reference "John 2:1-11")))) (of-christmas-sunday-2 (((part First) (reference "Sirach 24:1-4,12-16")) ((part Gospel) (reference "John 1:1-18")))) diff --git a/lib/rites/rite_of/lectionary_of.ml b/lib/rites/rite_of/lectionary_of.ml index 1664fad..eb3e4e0 100644 --- a/lib/rites/rite_of/lectionary_of.ml +++ b/lib/rites/rite_of/lectionary_of.ml @@ -60,6 +60,24 @@ let lookup_any lectionary ~year_start base date = | Some cs -> Some cs | None -> Lectionary.find lectionary (with_suffix (weekday_cycle_letter (weekday_cycle ~year_start date)))) +(* See lectionary_of.mli's own doc comment for the full citation (OLM n. + 69.3) and argument. Kept as ordinary [Printf.sprintf] string + construction, not a lookup table, so tools/bootstrap_lectionary_of.ml's + own reachability sweep (which calls this function directly, not a + hand-copied re-implementation) and this module's own runtime lookup can + never drift apart -- the single failure mode a hand-duplicated version + in each place would invite. *) +let date_keyed_slug date = + if Date.weekday date = Date.Sun then None + else + let m = Date.month date and dd = Date.day date in + if m = 12 && dd >= 17 && dd <= 24 then Some (Slug.of_string_exn (Printf.sprintf "of-advent-dec-%d" dd)) + else if m = 12 && dd >= 29 && dd <= 31 then + Some (Slug.of_string_exn (Printf.sprintf "of-christmas-dec-%d" dd)) + else if m = 1 && ((dd >= 2 && dd <= 5) || dd = 7) then + Some (Slug.of_string_exn (Printf.sprintf "of-christmas-jan-%d" dd)) + else None + let readings ~lectionary ~year_start ~(observed : Vocab_of.rank Celebration.t) ~(temporal : (Vocab_of.season, Vocab_of.rank) Temporal.t) ~date ~temporal_at:_ = match observed.Celebration.citations with @@ -99,9 +117,23 @@ let readings ~lectionary ~year_start ~(observed : Vocab_of.rank Celebration.t) Mass_formulary.source was never built to carry. *) (Some { Mass_formulary.said = Some observed.Celebration.slug; via = Mass_formulary.Proper }, cs) | None -> ( - match lookup_any lectionary ~year_start temporal.Temporal.office.Celebration.slug date with + (* OLM n. 69.3's own date-fixed windows (see date_keyed_slug's + own doc comment): tried BEFORE the ordinary weekday-keyed + slug lookup, not instead of it, so every other temporal day + is unaffected -- this branch only ever hits inside the two + named civil-date windows, and [date_keyed_slug] itself + already refuses a Sunday, where a NAMED Sunday office (never + one of these weekday-keyed ferial slugs) is what temporal. + office actually names. *) + match (match date_keyed_slug date with Some s -> Lectionary.find lectionary s | None -> None) with | Some cs -> ( Some { Mass_formulary.said = Some temporal.Temporal.office.Celebration.slug; via = Mass_formulary.Own_slug }, cs ) - | None -> (None, []))) + | None -> ( + match lookup_any lectionary ~year_start temporal.Temporal.office.Celebration.slug date with + | Some cs -> + ( Some { Mass_formulary.said = Some temporal.Temporal.office.Celebration.slug; + via = Mass_formulary.Own_slug }, + cs ) + | None -> (None, [])))) diff --git a/lib/rites/rite_of/lectionary_of.mli b/lib/rites/rite_of/lectionary_of.mli index 0c4d1d6..4a447d2 100644 --- a/lib/rites/rite_of/lectionary_of.mli +++ b/lib/rites/rite_of/lectionary_of.mli @@ -110,8 +110,13 @@ val weekday_cycle : year_start:(int -> Date.t) -> Date.t -> weekday_cycle closed kernel type outside this task's own scope to extend; see lectionary_of.ml's own implementation comment on this branch for the full argument. [said] the observed slug. - - {b Step 3} -- the day's own temporal slug, same three-way lookup. - {!Colitur_kernel.Mass_formulary.Own_slug}, [said] that slug. + - {b Step 3} -- the day's own temporal slug. FIRST tries + {!date_keyed_slug} (OLM n. 69.3's date-fixed windows -- the + O-Antiphons and the pre-Epiphany run of the Christmas season), THEN + falls back to the same three-way slug lookup step 2 uses. + {!Colitur_kernel.Mass_formulary.Own_slug} either way, [said] the + temporal office's own slug -- {!date_keyed_slug} changes which KEY + resolves the citations, not which office is said to have said them. - A day matching neither gets [(None, [])] -- see data/of/lectionary .sexp's own provenance header for exactly which days that is, both directions, measured, not guessed. @@ -134,3 +139,44 @@ val readings : date:Date.t -> temporal_at:(Date.t -> (Vocab_of.season, Vocab_of.rank) Temporal.t) -> Mass_formulary.t option * Citation.t list + +(** OLM 1981 Praenotanda n. 69.3 (the same passage {!weekday_cycle}'s own + doc comment cites: "Pro feriis Adventus, temporis Nativitatis et + paschalis cyclus eodem modo annualis est: lectiones ideo non + mutantur" -- Advent/Christmastide/Paschaltide ferial readings do not + change year to year). Every OTHER such family is non-alternating + WITHIN a weekday slot ({!weekday_cycle}); two short windows go + further and are keyed by CIVIL DATE instead: the O-Antiphon days + (17-24 December) and the pre-Epiphany run of the Christmas season + (29-31 December, then 2-5 and 7 January) -- verified concretely + against lectio's own ini, not merely inferred from the citation: 17 + December is Gen 49:2,8-10 / Ps 72:1-2,3-4ab,7-8,17 / Matthew 1:1-17 + regardless which weekday it falls on in a given year. + {!Rite_of.Temporal_of}'s own ferial slugs for these same civil dates + are WEEKDAY-keyed + ({!Rite_of.Temporal_of.christmas_feria_slug}/the Advent ferial + branch), so looking one of THOSE slugs up would silently serve + whichever OTHER date happens to share that year's weekday alignment + -- the defect this function exists to close (bug found by review, not + by a test: colitur used to serve 17 December the unrelated + "advent-3-mon" reading in years where the two happened to align). + + 6 January is deliberately NOT one of the six Christmas-season dates, + despite lectio's own "christmas-jan-6" base existing: + {!Rite_of.Temporal_of}'s own [named] fixes Epiphany to 6 January + unconditionally (temporal_of.ml, "m = 1 && dd = 6"), so that date is + never reached as a ferial by {!readings}'s own step 3 in the first + place -- a real lectio entry with no reachable colitur day, the same + "structurally unreachable" shape as tools/bootstrap_lectionary_of + .ml's own "easter-6-thu"/"advent-4-sat" (see its own [excluded_bases] + comment). + + [None] on a Sunday: Advent 4, Holy Family, the Second Sunday after + Christmas and the Baptism of the Lord can each land inside these two + windows, each is a NAMED office with its own distinct slug + ({!Rite_of.Temporal_of.sunday_slug}), and each must keep taking + priority over a ferial date -- checked here explicitly rather than + trusted to the call site, since this function's whole contract is + "this civil date, unconditionally, is this citation", which is false + on the Sunday the window shares its calendar date with. *) +val date_keyed_slug : Date.t -> Slug.t option diff --git a/test/test_lectionary_of.ml b/test/test_lectionary_of.ml index 6fb48a0..03838be 100644 --- a/test/test_lectionary_of.ml +++ b/test/test_lectionary_of.ml @@ -172,13 +172,14 @@ let sha256_of_file path = | hash :: _ -> hash | [] -> Alcotest.fail "sha256sum produced no output" -(* Pinned 2026-08-26 against the shipped file itself, independently - re-derived (`sha256sum data/of/lectionary.sexp`), the same "derive the - new value, don't transcribe it from generator stdout" discipline +(* Pinned 2026-08-26 (FIX 1, O-Antiphon/Christmas-season date-keyed review) + against the shipped file itself, independently re-derived + (`sha256sum data/of/lectionary.sexp`), the same "derive the new value, + don't transcribe it from generator stdout" discipline test_calendar_of_data.ml's own [test_sha256] already follows. *) let test_sha256_pinned () = Alcotest.(check string) "data/of/lectionary.sexp SHA-256" - "e7fd109bc30baf8a5531a614995e986959214424c80f4d444fa2b772538decde" (sha256_of_file lectionary_path) + "2277e3dc302e853d78b72608884f59eb67671bfdcca4f5e4c99358ee12811f89" (sha256_of_file lectionary_path) (* lectio's OWN of-lectionary.ini SHA-256, pinned inside data/of/ lectionary.sexp's own provenance header (tools/bootstrap_lectionary_of @@ -244,7 +245,7 @@ let test_missing_source_fails_loudly () = let test_load () = let l = real_lectionary () in - Alcotest.(check int) "754 entries" 754 (List.length (Lectionary.entries l)) + Alcotest.(check int) "770 entries" 770 (List.length (Lectionary.entries l)) (* ---- coverage: a full sample civil year, both directions, PINNED ------ *) @@ -357,6 +358,105 @@ let test_step2_sanctoral_wins () = (cs <> Option.get (Lectionary.find lectionary (Slug.of_string_exn "of-advent-1-monday"))) | _ -> Alcotest.fail "30 November 2026 should resolve readings via step 2" +(* ---- date-keyed windows (OLM n. 69.3) -- FIX 1, 2026-08-26 review ----- *) + +(* {!Lect_of.date_keyed_slug} itself, direct unit coverage of its own + boundaries: the O-Antiphon window (17-24 December), the Christmas-season + window (29-31 December, then 2-5 and 7 January), 6 January's own + deliberate exclusion (Temporal_of's own [named] fixes Epiphany there + unconditionally, so that date can never reach this function's caller as + a ferial), and the Sunday guard (a Sunday inside either window is a + NAMED office -- Advent 4, Holy Family, the Second Sunday after + Christmas -- never one of these ferial dates). *) +let test_date_keyed_slug_boundaries () = + let some_str dt = Option.map Slug.to_string (Lect_of.date_keyed_slug dt) in + Alcotest.(check (option string)) "16 December: outside the window" None (some_str (d 2026 12 16)); + Alcotest.(check (option string)) "17 December: in the window" (Some "of-advent-dec-17") (some_str (d 2026 12 17)); + Alcotest.(check (option string)) "24 December: in the window" (Some "of-advent-dec-24") (some_str (d 2029 12 24)); + Alcotest.(check (option string)) "25 December: outside the window" None (some_str (d 2026 12 25)); + Alcotest.(check (option string)) "28 December: outside the window" None (some_str (d 2026 12 28)); + Alcotest.(check (option string)) "29 December: in the window" (Some "of-christmas-dec-29") (some_str (d 2026 12 29)); + Alcotest.(check (option string)) "31 December: in the window" (Some "of-christmas-dec-31") (some_str (d 2029 12 31)); + Alcotest.(check (option string)) "1 January: outside the window" None (some_str (d 2026 1 1)); + Alcotest.(check (option string)) "2 January: in the window" (Some "of-christmas-jan-2") (some_str (d 2026 1 2)); + Alcotest.(check (option string)) "5 January: in the window" (Some "of-christmas-jan-5") (some_str (d 2026 1 5)); + Alcotest.(check (option string)) + "6 January: deliberately excluded, Epiphany is fixed there unconditionally" None (some_str (d 2026 1 6)); + Alcotest.(check (option string)) "7 January: in the window" (Some "of-christmas-jan-7") (some_str (d 2026 1 7)); + Alcotest.(check (option string)) "8 January: outside the window" None (some_str (d 2026 1 8)); + Alcotest.(check bool) "2028-12-24 is a Sunday, sanity check" true (D.weekday (d 2028 12 24) = D.Sun); + Alcotest.(check (option string)) "24 December on a Sunday (2028, Advent 4): guarded off" None + (some_str (d 2028 12 24)); + Alcotest.(check bool) "2025-01-05 is a Sunday, sanity check" true (D.weekday (d 2025 1 5) = D.Sun); + Alcotest.(check (option string)) "5 January on a Sunday (2025): guarded off" None (some_str (d 2025 1 5)) + +(* The fix's own headline claim, through the REAL resolution chain, not + just the slug function in isolation: the same civil date resolves the + SAME citation across two years with a different weekday alignment, + where it used to drift. Verified by hand against the pre-fix binary + before this fix landed: 2024-12-17 served "Zephaniah 3:1-2,9-13/ + Matth 21:28-32" (that year's own "of-advent-3-tuesday" content) while + 2029-12-17 served "Numbers 24:2-7,15-17a/Matth 21:23-27" ("of-advent-3- + monday") -- two DIFFERENT citations for the identical calendar date. + 2024 (Tuesday) and 2029 (Monday) are two different, non-Sunday + weekdays, so neither the sanctoral layer nor the Sunday guard is in + play -- both years reach {!Lect_of.readings}'s own step 3 through the + plain temporal-ferial branch, exactly like {!test_step3_temporal_ferial} + above. *) +let test_o_antiphon_no_drift () = + let check_year y expected_weekday_slug = + let lectionary = real_lectionary () in + let layer = real_sanctoral_layer () in + let index = L.index layer ~easter:Computus.gregorian_easter ~years:[ y - 1; y ] in + let date = d y 12 17 in + let expected_slug = (T.temporal date).Colitur_kernel.Temporal.office.Cel.slug in + Alcotest.(check string) (Printf.sprintf "%d-12-17: expected temporal slug" y) expected_weekday_slug + (Slug.to_string expected_slug); + match readings_on lectionary index date with + | Some fm, cs -> + Alcotest.(check bool) (Printf.sprintf "%d: tagged Own_slug" y) true + (fm.Mass_formulary.via = Mass_formulary.Own_slug); + Alcotest.(check (option string)) (Printf.sprintf "%d: said the temporal slug" y) + (Some (Slug.to_string expected_slug)) (Option.map Slug.to_string fm.Mass_formulary.said); + Alcotest.(check (list string)) (Printf.sprintf "%d: 17 December is Gen 49:2,8-10 / Matthew 1:1-17" y) + [ "Genesis 49:2,8-10"; "Matthew 1:1-17" ] + (List.map (fun (c : Colitur_kernel.Citation.t) -> c.reference) cs) + | None, _ -> Alcotest.failf "%d-12-17 should resolve readings" y + in + check_year 2024 "of-advent-3-tuesday"; + check_year 2029 "of-advent-3-monday" + +(* Same claim, the Christmas-season window, through a REALISTIC step 2 -> + step 3 path: 2 January carries the fixed memorial of Saints Basil and + Gregory Nazianzen (calendar-2002.sexp), which has no dedicated + lectionary entry of its own, so both years fall through to the day's + own ferial -- exactly the shape 2024-01-02's own CLI-verified pre-fix + drift took (that year served "1 John 3:22-4:6/Matt 4:12-17,23-25", + 2029's weekday-keyed content, before this fix). 2024 (Tuesday) and 2025 + (Thursday) are again two different non-Sunday weekdays. *) +let test_christmas_date_keyed_no_drift () = + let check_year y = + let lectionary = real_lectionary () in + let layer = real_sanctoral_layer () in + let index = L.index layer ~easter:Computus.gregorian_easter ~years:[ y - 1; y ] in + let date = d y 1 2 in + (match L.on_date index date with + | [ e ] -> + Alcotest.(check string) (Printf.sprintf "%d-01-02: Basil/Gregory's own entry" y) + "saints-basil-the-great-and-gregory-nazianzen-bishops-and-doctors" (Slug.to_string e.L.cel.Cel.slug) + | es -> Alcotest.failf "%d-01-02: expected exactly one sanctoral entry, got %d" y (List.length es)); + match readings_on lectionary index date with + | Some fm, cs -> + Alcotest.(check bool) (Printf.sprintf "%d: tagged Own_slug (fell through to the ferial)" y) true + (fm.Mass_formulary.via = Mass_formulary.Own_slug); + Alcotest.(check (list string)) (Printf.sprintf "%d: 2 January is 1 John 2:22-28 / John 1:19-28" y) + [ "1 John 2:22-28"; "John 1:19-28" ] + (List.map (fun (c : Colitur_kernel.Citation.t) -> c.reference) cs) + | None, _ -> Alcotest.failf "%d-01-02 should resolve readings" y + in + check_year 2024; + check_year 2025 + let suite = [ Alcotest.test_case "Sunday cycle table (OLM n.66, straddles Advent I)" `Quick test_sunday_cycle_table; Alcotest.test_case "weekday cycle table (OLM n.69.4, straddles Advent I)" `Quick test_weekday_cycle_table; @@ -364,9 +464,14 @@ let suite = Alcotest.test_case "data/of/lectionary.sexp SHA-256 pinned" `Quick test_sha256_pinned; Alcotest.test_case "lectio source SHA-256 pinned in the header" `Quick test_source_sha256_pinned_in_header; Alcotest.test_case "a missing lectio source fails loudly, not silently" `Quick test_missing_source_fails_loudly; - Alcotest.test_case "loads, 754 entries" `Quick test_load; + Alcotest.test_case "loads, 770 entries" `Quick test_load; Alcotest.test_case "2026 coverage: exactly {25 December} unresolved" `Quick test_coverage_2026; Alcotest.test_case "2026 coverage: 364 days resolve" `Quick test_pinned_resolved_count; Alcotest.test_case "step 3: temporal ferial fallback" `Quick test_step3_temporal_ferial; - Alcotest.test_case "step 2: sanctoral proper wins over the ferial" `Quick test_step2_sanctoral_wins + Alcotest.test_case "step 2: sanctoral proper wins over the ferial" `Quick test_step2_sanctoral_wins; + Alcotest.test_case "date_keyed_slug boundaries (OLM n. 69.3)" `Quick test_date_keyed_slug_boundaries; + Alcotest.test_case "O-Antiphon date-keyed reading does not drift (17 December)" `Quick + test_o_antiphon_no_drift; + Alcotest.test_case "Christmas-season date-keyed reading does not drift (2 January)" `Quick + test_christmas_date_keyed_no_drift ] diff --git a/tools/bootstrap_lectionary_of.ml b/tools/bootstrap_lectionary_of.ml index f521bb1..da37bd9 100644 --- a/tools/bootstrap_lectionary_of.ml +++ b/tools/bootstrap_lectionary_of.ml @@ -459,12 +459,14 @@ let named_overrides = and Epiphany, 2-5 Jan) -> stretch 1. - "christmas-after-epiphany-" (7 Jan..Baptism) -> stretch 2. lectio ALSO carries "christmas-dec-29/30/31" and "christmas-jan- - 2..7", a DATE-keyed duplicate of the same stretches -- deliberately - UNUSED (see [excluded_bases] below): colitur's own slugs are - weekday-keyed, not date-keyed, so a fixed civil date such as - "29 December" is not any single colitur slug -- it is a DIFFERENT - weekday (hence a different of-christmas-0-) in different - years, and Lectionary.t has no per-date lookup at all. *) + 2..7" -- content-verified (2026-08-26 review) NOT duplicates of the + weekday-keyed families above (17 December's own O-Antiphon sibling + proved the same shape first: Gen 49:2,8-10/Matt 1:1-17, found + nowhere else in the 754 emitted entries). These are OLM n. 69.3's + genuinely date-fixed readings; mapped separately below via + {!Rite_of.Lectionary_of.date_keyed_slug}, not through this table -- + see [excluded_bases]'s own comment for exactly which of the two + ranges are and are not still excluded, and why. *) ("christmas-octave-mon", [ "of-christmas-0-monday" ]); ("christmas-octave-tue", [ "of-christmas-0-tuesday" ]); ("christmas-octave-wed", [ "of-christmas-0-wednesday" ]); @@ -482,24 +484,58 @@ let named_overrides = ("christmas-after-epiphany-wed", [ "of-christmas-2-wednesday" ]); ("christmas-after-epiphany-thu", [ "of-christmas-2-thursday" ]); ("christmas-after-epiphany-fri", [ "of-christmas-2-friday" ]); - ("christmas-after-epiphany-sat", [ "of-christmas-2-saturday" ]) + ("christmas-after-epiphany-sat", [ "of-christmas-2-saturday" ]); + (* OLM n. 69.3's date-fixed windows (see the block comment above and + Rite_of.Lectionary_of.date_keyed_slug's own doc comment for the + full citation and argument -- this task's fix, 2026-08-26 review). + These slugs are NOT Temporal_of office slugs (no colitur day's + [Celebration.slug] is ever literally "of-advent-dec-17") -- they + exist purely as [Lectionary.t] lookup keys that + [Lectionary_of.readings]'s own step 3 constructs directly from the + civil date via [date_keyed_slug], bypassing this table's usual + "base name -> real colitur slug" contract. [assert_reachable] + below is widened with its own matching date-keyed reachability + sweep (calling the SAME function, not a re-implementation) so a + typo here still dies loudly rather than shipping a dead key. + 6 January is deliberately absent -- see [excluded_bases]. *) + ("advent-dec-17", [ "of-advent-dec-17" ]); + ("advent-dec-18", [ "of-advent-dec-18" ]); + ("advent-dec-19", [ "of-advent-dec-19" ]); + ("advent-dec-20", [ "of-advent-dec-20" ]); + ("advent-dec-21", [ "of-advent-dec-21" ]); + ("advent-dec-22", [ "of-advent-dec-22" ]); + ("advent-dec-23", [ "of-advent-dec-23" ]); + ("advent-dec-24", [ "of-advent-dec-24" ]); + ("christmas-dec-29", [ "of-christmas-dec-29" ]); + ("christmas-dec-30", [ "of-christmas-dec-30" ]); + ("christmas-dec-31", [ "of-christmas-dec-31" ]); + ("christmas-jan-2", [ "of-christmas-jan-2" ]); + ("christmas-jan-3", [ "of-christmas-jan-3" ]); + ("christmas-jan-4", [ "of-christmas-jan-4" ]); + ("christmas-jan-5", [ "of-christmas-jan-5" ]); + ("christmas-jan-7", [ "of-christmas-jan-7" ]) ] -(* Deliberately unmapped, with the reason named -- see [named_overrides]'s - own comment on the Christmas-season families for the date-vs-weekday - mismatch these two groups share: - - - "advent-dec-17".."advent-dec-24": the O-Antiphon days. colitur's own - Temporal_of has no per-date slug for late Advent at all (Temporal_ef's - EF counterpart doesn't either); these ferias fall through to the - ordinary week-numbered "of-advent-{3,4}-" slug, and WHICH - weekday-string a given December date is varies year to year, so no - static base-name mapping is possible. A future task wanting these - eight dates covered needs a genuinely date-keyed lookup path in - Lectionary_of.readings, not a slug rename here. - - "christmas-dec-29/30/31", "christmas-jan-2/3/4/5/6/7": the date-keyed - duplicates of the weekday-keyed families already mapped above. Same - reasoning, same remedy if ever closed. +(* Deliberately unmapped, with the reason named. CORRECTED 2026-08-26 + review: this list previously ALSO carried the 16 O-Antiphon/Christmas- + season date-keyed bases, under the claim that they were "date-keyed + duplicates" of the weekday-keyed families [named_overrides] already + maps. That claim was false -- diffed against lectio's own ini directly, + each carries unique per-date content (17 December: Gen 49:2,8-10/ + Matt 1:1-17, found nowhere else among the 754 emitted entries), which + OLM n. 69.3 explains: those ferias are date-fixed, not merely + non-alternating within a weekday slot. They are mapped now, via + [named_overrides]'s own date-keyed block, not here. Genuinely still + unreachable, for three DIFFERENT structural reasons, none of them + "duplicate": + + - "christmas-jan-6": 6 January is always Epiphany in colitur's model + (Temporal_of's own [named] fixes it unconditionally, "m = 1 && dd = + 6", before the ferial dispatch ever runs) -- "of-christmas-jan-6" + would be a real lectio entry with no colitur day that could ever look + it up. See Rite_of.Lectionary_of.date_keyed_slug's own doc comment, + which deliberately excludes 6 January from its date range for this + exact reason. - "easter-6-thu": Thursday of Easter week 6 is STRUCTURALLY, always, the Ascension (Easter+39 is always a Thursday) -- Temporal_of's own [named] intercepts it before the generic ferial branch ever runs, so @@ -510,19 +546,32 @@ let named_overrides = (when 24 December is itself the Fourth Sunday of Advent, week 4 has no ferial days whatsoever) -- "of-advent-4-saturday" is not a slug Temporal_of can ever produce either. - Both confirmed, not assumed: [assert_reachable]'s own reachability - sweep flagged each as a genuine dead key this generator's own pattern - rule produced (after fixing the suffix-stripping bug that function's - own comment describes, which had masked them under 580 false - positives on this generator's first run). *) -let excluded_bases = - [ "advent-dec-17"; "advent-dec-18"; "advent-dec-19"; "advent-dec-20"; "advent-dec-21"; - "advent-dec-22"; "advent-dec-23"; "advent-dec-24"; - "christmas-dec-29"; "christmas-dec-30"; "christmas-dec-31"; - "christmas-jan-2"; "christmas-jan-3"; "christmas-jan-4"; "christmas-jan-5"; "christmas-jan-6"; - "christmas-jan-7"; "easter-6-thu"; "advent-4-sat" ] - -type mapping_report = { mapped : int; excluded : int; unmapped : string list } + The latter two confirmed, not assumed: [assert_reachable]'s own + reachability sweep flagged each as a genuine dead key this + generator's own pattern rule produced (after fixing the suffix- + stripping bug that function's own comment describes, which had + masked them under 580 false positives on this generator's first + run). *) +let excluded_bases = [ "christmas-jan-6"; "easter-6-thu"; "advent-4-sat" ] + +type mapping_report = { + mapped : int; + excluded : int; + unmapped : string list; + sanctoral_passthrough : int; + (* Count of the [Hashtbl.mem sanctoral_slugs base] branch below -- + how many of the real merged sanctoral layer's own slugs got a + DEDICATED lectio entry this way, out of its full total (passed in + separately at the print site, [Hashtbl.length sanctoral_slugs]). + Named explicitly, 2026-08-26 review's own Minor finding: the + REMAINDER (222 shipped sanctoral slugs total, per that review -- + 189 without a dedicated entry) is not a gap, it is OLM norms + working as designed (a saint with no proper of its own falls + through to the day's own ferial, {!Lectionary_of.readings}'s own + step 2 -> step 3), but the provenance header never said so before + this fix, which reads as an unstated 85% gap rather than the + correctly-small dedicated-entry set it actually is. *) +} (* Resolves every (base, resolved-citations) pair into zero or more (colitur-slug, resolved-citations) pairs, and separately tracks what @@ -535,6 +584,7 @@ type mapping_report = { mapped : int; excluded : int; unmapped : string list } let map_bases resolved ~sanctoral_slugs = let unmapped = ref [] in let excluded = ref 0 in + let sanctoral_passthrough = ref 0 in let out = List.filter_map (fun (base, r) -> @@ -550,14 +600,19 @@ let map_bases resolved ~sanctoral_slugs = | Some [ slug ] -> Some (slug, r) | Some _ -> die "%s: named_overrides entry must name exactly one slug" base | None -> - if Hashtbl.mem sanctoral_slugs base then Some (base, r) + if Hashtbl.mem sanctoral_slugs base then begin + incr sanctoral_passthrough; + Some (base, r) + end else begin unmapped := base :: !unmapped; None end)) resolved in - (out, { mapped = List.length out; excluded = !excluded; unmapped = List.sort compare !unmapped }) + ( out, + { mapped = List.length out; excluded = !excluded; unmapped = List.sort compare !unmapped; + sanctoral_passthrough = !sanctoral_passthrough } ) (* ---- Emit one Lectionary.t entry per (slug, resolved) pair ---------- *) @@ -661,13 +716,42 @@ let strip_emitted_suffix s = None [ "-a"; "-b"; "-c"; "-ii"; "-i" ] |> Option.value ~default:s -let assert_reachable entries ~temporal_slugs ~sanctoral_slugs = +(* THIS TASK'S fix, 2026-08-26 review: the 16 new date-keyed slugs + ([named_overrides]'s own date-keyed block) are not Temporal_of office + slugs and not sanctoral slugs, so without this they would all be + flagged DEAD by [assert_reachable] below. Calls + Rite_of.Lectionary_of.date_keyed_slug directly -- the SAME function + {!Rite_of.Lectionary_of.readings} calls at runtime -- rather than + re-deriving the date ranges here a second time, so a typo in either + [named_overrides]'s literal strings or in [date_keyed_slug]'s own + ranges still dies loudly instead of silently drifting apart. Swept over + the same 2004-2051 range as [reachable_temporal_slugs] and for the same + reason: every distinct date recurs every year, only its weekday + alignment (irrelevant here, [date_keyed_slug] itself is weekday- + independent except for its Sunday guard) and which years hit a Sunday + change. *) +let reachable_date_keyed_slugs () = + let tbl = Hashtbl.create 32 in + let mk y m d = match Date.make ~year:y ~month:m ~day:d with Ok t -> t | Error e -> die "%s" e in + for y = 2004 to 2051 do + let d = ref (mk y 1 1) in + let stop = mk y 12 31 in + while Date.compare !d stop <= 0 do + (match Rite_of.Lectionary_of.date_keyed_slug !d with + | Some s -> Hashtbl.replace tbl (Slug.to_string s) true + | None -> ()); + d := Date.add_days !d 1 + done + done; + tbl + +let assert_reachable entries ~temporal_slugs ~sanctoral_slugs ~date_keyed_slugs = let base_of s = strip_emitted_suffix (Slug.to_string s) in let dead = List.filter (fun (s, _) -> let b = base_of s in - not (Hashtbl.mem temporal_slugs b || Hashtbl.mem sanctoral_slugs b)) + not (Hashtbl.mem temporal_slugs b || Hashtbl.mem sanctoral_slugs b || Hashtbl.mem date_keyed_slugs b)) entries in (match dead with @@ -676,8 +760,8 @@ let assert_reachable entries ~temporal_slugs ~sanctoral_slugs = List.iter (fun (s, _) -> Printf.eprintf - "bootstrap_lectionary_of: DEAD KEY -- %S is not a real Temporal_of slug (2004-2051) \ - nor a real sanctoral slug\n" + "bootstrap_lectionary_of: DEAD KEY -- %S is not a real Temporal_of slug (2004-2051), \ + nor a real sanctoral slug, nor a real date_keyed_slug (2004-2051)\n" (Slug.to_string s)) dead; die "%d emitted key(s) are unreachable (see above)" (List.length dead)); @@ -715,7 +799,14 @@ let temporal_day_gap ~lectionary ~year_start = let t = Rite_of.Temporal_of.temporal date in let base = Slug.to_string t.Temporal.office.Celebration.slug in let found = - Lectionary.mem lectionary (slug_or_die base) + (* THIS TASK'S fix, 2026-08-26 review: tried first, matching + Lectionary_of.readings' own step-3 order, so the O-Antiphon/ + Christmas-season date-keyed days this fix closed no longer + misreport as a gap in the header below. *) + (match Rite_of.Lectionary_of.date_keyed_slug date with + | Some s -> Lectionary.mem lectionary s + | None -> false) + || Lectionary.mem lectionary (slug_or_die base) || Lectionary.mem lectionary (slug_or_die (base ^ "-" @@ -749,7 +840,8 @@ let () = let mapped, report = map_bases resolved ~sanctoral_slugs in let entries = List.concat_map entries_of mapped in let temporal_slugs = reachable_temporal_slugs () in - let uncovered_temporal = assert_reachable entries ~temporal_slugs ~sanctoral_slugs in + let date_keyed_slugs = reachable_date_keyed_slugs () in + let uncovered_temporal = assert_reachable entries ~temporal_slugs ~sanctoral_slugs ~date_keyed_slugs in let lect = match Lectionary.of_entries entries with Ok l -> l | Error e -> die "%s" e in let gap_count, gap_total, gap_misses = temporal_day_gap ~lectionary:lect ~year_start:Rite_of.Temporal_of.year_start @@ -783,34 +875,64 @@ let () = ; a MISSING source file is fatal, checked before any read is attempted --\n\ ; see this generator's own [parse_ini]) and commit the diff instead.\n\ ; Every emitted key is asserted, at generation time, to be a slug\n\ - ; Rite_of.Temporal_of actually computes OR a slug the real merged\n\ + ; Rite_of.Temporal_of actually computes, OR a slug the real merged\n\ ; sanctoral layer (data/of/calendar-2002.sexp + all 13 decree overlays)\n\ - ; actually carries -- see [assert_reachable].\n\ + ; actually carries, OR a date Rite_of.Lectionary_of.date_keyed_slug\n\ + ; actually reaches -- see [assert_reachable].\n\ + ;\n\ + ; CORRECTED 2026-08-26 (review): this file used to exclude 16 lectio\n\ + ; bases -- the 8 O-Antiphon days (17-24 December) and 8 further\n\ + ; Christmas-season dates (29-31 December, 2-5 and 7 January) -- as\n\ + ; \"date-keyed duplicates\" of the weekday-keyed ferial families mapped\n\ + ; above. That was false: each carries content found nowhere else among\n\ + ; the emitted entries (17 December: Gen 49:2,8-10/Matt 1:1-17), which\n\ + ; OLM n. 69.3 explains -- these ferias are fixed by CIVIL DATE, not\n\ + ; merely non-alternating within a weekday slot like every other Advent/\n\ + ; Christmastide feria. Every day in 2005-2050 previously served a\n\ + ; DRIFTING citation there (whichever weekday-keyed family that year's\n\ + ; own alignment happened to land on) instead of the Missal's fixed one.\n\ + ; Fixed via a new date-keyed lookup route, tried BEFORE the weekday-\n\ + ; keyed one (Rite_of.Lectionary_of.date_keyed_slug, readings' own step\n\ + ; 3) -- Temporal_of's slugs are UNCHANGED, only which lectionary key\n\ + ; resolves the day's citations. 3 lectio bases remain excluded, for\n\ + ; three genuinely different structural-unreachability reasons, none of\n\ + ; them \"duplicate\" -- see [excluded_bases]'s own comment.\n\ ;\n\ ; Source: %s\n\ ; SHA-256: %s\n\ ; %d ini sections (988 expected) -> %d resolved (base, cycle-shape) \ -pairs -> %d colitur slugs mapped, %d ini bases deliberately excluded (date-\n\ - ; keyed duplicates/late-Advent, no colitur date-keyed lookup exists --\n\ - ; see [excluded_bases]), %d ini bases genuinely unmapped (no pattern, no\n\ - ; override, no matching sanctoral slug) -> %d emitted lectionary entries.\n\ +pairs -> %d colitur slugs mapped, %d ini bases genuinely excluded (structurally\n\ + ; unreachable -- see [excluded_bases]), %d ini bases genuinely unmapped\n\ + ; (no pattern, no override, no matching sanctoral slug) -> %d emitted\n\ + ; lectionary entries.\n\ ;\n\ ; COVERAGE, BOTH DIRECTIONS (Step 1 of this task's own brief):\n\ ; (1) Temporal-day gap: of the %d days of civil year 2026, %d produce a\n\ - ; Rite_of.Temporal_of slug with NO entry in this file (tried flat,\n\ - ; then both the Sunday- and weekday-cycle letter suffixes -- the\n\ - ; same order Lectionary_of.readings itself tries). Those are days\n\ - ; with no readings via the temporal-slug path (a sanctoral proper\n\ - ; on the same civil day, where one exists, is Lectionary_of's own\n\ + ; Rite_of.Temporal_of slug with NO entry in this file (tried the\n\ + ; date-keyed route first, then flat, then both the Sunday- and\n\ + ; weekday-cycle letter suffixes -- the same order\n\ + ; Lectionary_of.readings itself tries). Those are days with no\n\ + ; readings via the temporal-slug path (a sanctoral proper on the\n\ + ; same civil day, where one exists, is Lectionary_of's own\n\ ; separate first chance). Named set below.\n\ ; (2) Unmapped lectio keys: %d ini base names (of 988 sections, %d\n\ ; distinct bases) map to no colitur slug at all -- data being\n\ ; silently dropped if unreported. Named below.\n\ + ; (3) Sanctoral coverage (Minor, 2026-08-26 review): of the %d shipped\n\ + ; sanctoral slugs (data/of/calendar-2002.sexp + all 13 decree\n\ + ; overlays), %d have a DEDICATED entry in this file (a lectio base\n\ + ; name this generator recognised verbatim as one of them). The\n\ + ; other %d have none and correctly fall through to the day's own\n\ + ; ferial (Lectionary_of.readings' own step 2 -> step 3) -- this is\n\ + ; OLM norms working as designed for a saint with no proper of\n\ + ; their own, not a gap in this data.\n\ ; Regenerate with:\n\ ; eval $(opam env) && dune exec tools/bootstrap_lectionary_of.exe -- %s %s\n" src (sha256 src) (List.length secs) (List.length resolved) report.mapped (List.length excluded_bases) (List.length report.unmapped) (List.length entries) - gap_total gap_count (List.length report.unmapped) (List.length resolved) src dst; + gap_total gap_count (List.length report.unmapped) (List.length resolved) + (Hashtbl.length sanctoral_slugs) report.sanctoral_passthrough + (Hashtbl.length sanctoral_slugs - report.sanctoral_passthrough) src dst; Printf.fprintf oc "; Unmapped lectio bases (%d):\n" (List.length report.unmapped); List.iter (fun b -> Printf.fprintf oc "; %s\n" b) report.unmapped; Printf.fprintf oc "; Temporal_of slugs (2004-2051) with no lectionary entry (%d, informational --\n\ -- cgit v1.3