From 60e87914718dd2c8e69bb89d8cc48d6bef9bfc74 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 22 Aug 2026 16:40:07 +0200 Subject: feat(ef): implement the calendarium's bissextile February shift The Missale Romanum's calendarium footnote for February (LT.txt:5011-5014, scan-corroborated scan2.txt:3050-3058): in a leap year the sixth kalends of March (24 February) is doubled rather than a 29th day appended, so St Matthias moves 24->25 February and St Gabriel of Our Lady of Sorrows 27->28, with 24 February itself carrying no fixed office that year. data/ef/expected-divergences-lms.sexp entry L3 tracked this as an open gap. Implemented as the rubric's own general mechanism (every fixed entry from 24 through 28 February shifts one civil day later in a leap year), not as "move these two named saints": the two readings are indistinguishable on shipped data (nothing else is fixed in that window) and diverge only for a future --overlay entry in the same range, which the mechanism reading covers correctly and a two-saints special case would not. This project has already been bitten once (RG 16(a)) by a rule implemented against shipped data's coincidental shape rather than the rubric itself. Kernel stays rite-agnostic: Rite.t gains a fixed_key field (Date.t -> (int * int) option), the same seam easter already establishes, read only by Layer.on_date's FIXED half -- the movable half (Easter_offset/Nth_weekday) is untouched. Default is the identity mapping, an optional argument on on_date, so every existing caller and every rite that supplies nothing is byte-identical to before this field existed. Date.is_leap is exposed in date.mli (already existed in date.ml) so the rite reads the kernel's own single-sourced leap-year definition. The EF implementation lives in Rite_ef.Temporal_ef.bissextile_fixed_key, fully cited, wired into Rite_ef.context. Blast radius, measured over the full 1583-9999 domain (Calendar.year run twice per civil year, real fixed_key vs identity, every day diffed): all 2,041 leap years in the domain show a visible change for each saint; 6,983 individual liturgical days change total, zero unclassified, across four shapes (Matthias vacates 24 Feb in 1,803 years / occupies 25 Feb in 1,811; Gabriel vacates 27 Feb in 1,699 / occupies 28 Feb in 1,670). L3's own prior estimate (1,650 years, observed-outright only) is confirmed close on the same definition (1,677 measured); the broader observed-or- commemorated count is higher, not lower, showing the narrow estimate undercounted visible impact rather than overcounting it. The lectio differential (2005-2050 fixture) gains a new cited entry, C40 in data/ef/expected-divergences.sexp: lectio implements no such shift, so every leap year in its window now diverges on the two shifted days (21 rows, not the naive 44 -- the comparator never compares commemorations, so a side where the shifted saint has zero comparator-visible footprint on both engines produces no row). The LMS Ordo's 2023-2024 fixture -- the window L3 was originally found in -- now matches on the Creed comparison; its formulary-override bucket count is corrected 182->181 (24 February leaves the counted Proper population, becoming Own_slug-sourced). The missalemeum oracle fixtures (2026-2027, 2038, 2035) cover no leap year and are unaffected. L3 is closed: converted from an active allow-list record to a prose closure paragraph (L1's own precedent), citation preserved verbatim, fix and measured blast radius recorded. The id-list assertion narrows to [L4] alone, and the now-dead "2024-02-24" -> "L3" date mapping is removed. Six golden values pinned across four new test cases: Matthias in a leap year (both the vacated and occupied sides) and a common-year control; Gabriel likewise, deliberately choosing a leap year where he is admitted only as an ordinary commemoration rather than winning outright, a different shape from Matthias's. Mutation-tested: forcing fixed_key back to identity reddens exactly five test cases -- the differential's Layer C count-pin (C40 drops to 0 actual vs 21 expected), the LMS Ordo's Creed and formulary-bucket checks, and both new golden pins -- confirming the tests actually exercise the fix. Reverted; dune test, the exhaustive sweep, and make check are all green. --- lib/rites/rite_ef/temporal_ef.ml | 62 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) (limited to 'lib/rites/rite_ef/temporal_ef.ml') diff --git a/lib/rites/rite_ef/temporal_ef.ml b/lib/rites/rite_ef/temporal_ef.ml index c16732b..1c916f4 100644 --- a/lib/rites/rite_ef/temporal_ef.ml +++ b/lib/rites/rite_ef/temporal_ef.ml @@ -1158,6 +1158,68 @@ let anchors y = | Some d -> [ ("ef-holy-name-sunday", d) ] | None -> [ ("ef-holy-name", holy_name_fallback_date y) ]) +(* The Missale Romanum's own CALENDARIUM table, February, footnote + (docs/research/LT.txt:5011-5014, scan-corroborated at + docs/research/scan2.txt:3050-3058, where OCR mangles it to + "bi&sextili"/"Matthue"/"Cabrielis" -- transcription and scan agree, so + this rests on two witnesses of the same document): + + "In anno bissextili mensis februarius est dierum 29, et festum S. + Matthiae celebratur die 25 februarii, ac festum S. Gabrielis a + Virgine perdolente 28 februarii, et bis dicitur sexto calendas, id + est die 24 et die 25; et littera dominicalis, quae assumpta fuit in + mense ianuario, mutetur in praecedentem..." + + In a leap year February has 29 days, St Matthias is kept on 25 February + (not 24), St Gabriel of Our Lady of Sorrows on 28 February (not 27), and + the sixth kalends of March is said TWICE -- "bis dicitur sexto + calendas... die 24 et die 25". That last clause names the MECHANISM, not + two independent exceptions: the Roman calendar's intercalary day is + inserted by DOUBLING the sixth kalends (civil 24 February in a common + year), not by appending a 29th day at the month's end. Reckoned by + kalends position: 24 Feb = VI Kal. Mart., 25 Feb = V Kal., 26 Feb = IV + Kal., 27 Feb = III Kal., 28 Feb = pridie Kal. In a leap year VI Kal. + itself falls on TWO consecutive civil days (24th and 25th, "bis"), and + every kalends position after it is pushed one civil day later as a + result: V Kal. from 25th to 26th, IV Kal. from 26th to 27th, III Kal. + (Gabriel) from 27th to 28th, pridie Kal. from 28th to 29th. A feast fixed + at VI Kal. itself (Matthias) is kept on the SECOND occurrence, not the + first -- 25 February carries it, 24 February carries no fixed office at + all that year (confirmed against the LMS Ordo witness, + data/ef/expected-divergences-lms.sexp entry L3: 24 February 2024 is + titled plainly "EMBER SATURDAY of LENT", no Matthias anywhere near it). + + IMPLEMENTED AS THE GENERAL MECHANISM, not as "shift these two named + feasts": only three sanctoral entries exist in 23-29 February on shipped + data (peter-damien the 23rd, before the doubled kalends and therefore + unaffected; matthias the 24th; gabriel-of-our-lady-of-sorrows the 27th), + so "shift the two named feasts" and "shift every fixed entry from 24 + February" are indistinguishable on universal data -- they produce + identical output. They differ only for a locally-supplied entry, e.g. an + [--overlay] patronal feast fixed at 26 February: under the mechanism + reading it shifts to 27 February in a leap year (IV Kal. -> the civil day + IV Kal. falls on that year), exactly as a diocesan calendar compiled + against the same kalends convention would expect. The rubric's own text + states a MECHANISM ("bis dicitur sexto calendas"), not a closed list of + two saints, and this project has already been bitten once by a rule + implemented against the shipped data's coincidental shape rather than the + rubric itself (RG 16(a), CLAUDE.md's own carried lesson) -- so the general + reading is the one implemented here. + + Threaded through {!Rite.t}'s [fixed_key] field, read only by + {!Colitur_kernel.Layer.on_date}'s FIXED half: the kernel stays + rite-agnostic (a Byzantine or other non-Roman rite supplies nothing and + is unaffected), and the MOVABLE half ({!Date_spec.Easter_offset}, + {!Date_spec.Nth_weekday}) is untouched -- nothing in the footnote + concerns Easter-relative dates. *) +let bissextile_fixed_key (d : Date.t) : (int * int) option = + let m = Date.month d and day = Date.day d in + if m = 2 && Date.is_leap (Date.year d) then + if day = 24 then None (* the FIRST VI Kal.: no fixed entry lands here in a leap year *) + else if day >= 25 && day <= 29 then Some (2, day - 1) (* the SECOND VI Kal. onward, one day later *) + else Some (m, day) + else Some (m, day) + (* Compile-time check that this module satisfies the kernel's rite contract. *) module _ : Colitur_kernel.Temporal.RITE = struct let id = id -- cgit v1.3