aboutsummaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-22 16:40:07 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-22 16:40:24 +0200
commit60e87914718dd2c8e69bb89d8cc48d6bef9bfc74 (patch)
tree814f451ac97e61d8ab83a2e24877b1b5223734db /test
parent219c0ca6884a634e83293c62b27cca990fa25854 (diff)
downloadcolitur-60e87914718dd2c8e69bb89d8cc48d6bef9bfc74.tar.gz
colitur-60e87914718dd2c8e69bb89d8cc48d6bef9bfc74.zip
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.
Diffstat (limited to 'test')
-rw-r--r--test/test_calendar.ml3
-rw-r--r--test/test_differential.ml32
-rw-r--r--test/test_golden.ml71
-rw-r--r--test/test_lms_ordo.ml36
-rw-r--r--test/test_validate.ml3
5 files changed, 133 insertions, 12 deletions
diff --git a/test/test_calendar.ml b/test/test_calendar.ml
index 74fe26a..0d402e6 100644
--- a/test/test_calendar.ml
+++ b/test/test_calendar.ml
@@ -116,6 +116,9 @@ module Fixture = struct
any for a fixture; nothing here is Easter-relative, so the value
is never actually read. *)
easter = Colitur_kernel.Computus.gregorian_easter;
+ (* Not a Roman rite either, so no bissextile-doubling convention:
+ identity, {!Rite.t.fixed_key}'s own documented default. *)
+ fixed_key = (fun d -> Some (D.month d, D.day d));
rules; season_runs = [ A; B ]; transfer_target; readings; creed }
let entry ~month ~day ~slug ~rank =
diff --git a/test/test_differential.ml b/test/test_differential.ml
index 8c1dd8d..2f63df6 100644
--- a/test/test_differential.ml
+++ b/test/test_differential.ml
@@ -1096,6 +1096,36 @@ let rg33_vigil_slugs =
[ "vigil-of-st-lawrence"; "vigil-of-the-nativity-of-st-john-the-baptist";
"vigil-of-sts-peter-paul" ]
+(* C40 -- the calendarium's own bissextile (leap-year) footnote (February,
+ docs/research/LT.txt:5011-5014; see rite_ef/temporal_ef.ml's
+ [bissextile_fixed_key] for the full citation and mechanism), implemented
+ 2026-08-22. lectio builds no such shift at all (confirmed directly
+ against the fixture: Matthias sits at 24 February and Gabriel of Our Lady
+ of Sorrows at 27 February in EVERY leap year the 2005-2050 fixture
+ covers, 11 of them), so every leap year now diverges on the two shifted
+ civil days: colitur vacates 24/27 February (Matthias/Gabriel move away)
+ and occupies 25/28 February instead, while lectio keeps the pre-shift
+ assignment on both.
+
+ Four shapes, one mechanism -- OLD position vacated (colitur no longer
+ matches lectio's still-there Matthias/Gabriel) and NEW position occupied
+ (colitur now shows a saint lectio's own unmoved date does not) -- each
+ checked by NAME on both sides, the same identity discipline C39 and
+ C6/C14 already apply, not merely a diff-set shape: a coincidental
+ same-shape diff on an unrelated 24/25/27/28 February slug would not match
+ any of the four literal-slug conjuncts below. *)
+let is_bissextile_shift_row (l : row) (c : row) ~month ~day =
+ month = 2
+ && Date.is_leap (year_of_date c.date)
+ && ((day = 24 && String.equal l.slug "matthias" && not (String.equal c.slug "matthias"))
+ || (day = 25 && String.equal c.slug "matthias" && not (String.equal l.slug "matthias"))
+ || (day = 27
+ && String.equal l.slug "gabriel-of-our-lady-of-sorrows"
+ && not (String.equal c.slug "gabriel-of-our-lady-of-sorrows"))
+ || (day = 28
+ && String.equal c.slug "gabriel-of-our-lady-of-sorrows"
+ && not (String.equal l.slug "gabriel-of-our-lady-of-sorrows")))
+
let layer_c_reason (l : row) (c : row) diffs =
let m = month_of_date l.date and d = day_of_date l.date in
if diffs = [] then None
@@ -1158,6 +1188,8 @@ let layer_c_reason (l : row) (c : row) diffs =
&& (not (List.mem c.slug rg33_vigil_slugs))
&& subset diffs [ Slug_f; Rank; Colour_f; First_f; Gospel_f ]
then Some "C39"
+ else if is_bissextile_shift_row l c ~month:m ~day:d && subset diffs [ Slug_f; Rank; Colour_f; First_f; Gospel_f ]
+ then Some "C40"
(* C25 -- CLOSED, BVM Saturday Mass (2026-08-17), predicate removed: 0 rows.
Its days are unoccupied IV-class Saturdays, so they carry RG 78's office
and now RG 309(a)'s Mass for it, which answers before step 3's walkback
diff --git a/test/test_golden.ml b/test/test_golden.ml
index ba25d8a..d680720 100644
--- a/test/test_golden.ml
+++ b/test/test_golden.ml
@@ -1315,6 +1315,67 @@ let test_rogation_wednesday_yields_to_a_feast_2026 () =
"2026-05-13 wednesday season=paschaltide week=6 slug=ef-ascension-vigil rank=class-2 colour=white \
subject=temporal name_la=- comms=[robert-bellarmine:ordinary] in=- out=[]"
+(* ---- The bissextile (leap-year) calendarium footnote, bissextile-shift
+ task, 2026-08-22 ----
+
+ The Missale Romanum's own CALENDARIUM table, February, footnote
+ (docs/research/LT.txt:5011-5014, scan-corroborated docs/research/
+ scan2.txt:3050-3058): "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" -- in a leap year the sixth kalends
+ of March (24 February) is doubled rather than a 29th day appended, so
+ St Matthias (ordinarily 24 February) is kept on 25 February and St
+ Gabriel of Our Lady of Sorrows (ordinarily 27 February) on 28 February;
+ 24 February itself carries no fixed office that year.
+ {!Rite_ef.Temporal_ef.bissextile_fixed_key} implements this as the
+ rubric's own general MECHANISM, so a leap year moves the VACATED date's
+ slug/rank/colour away and the OCCUPIED date's toward it -- both sides
+ pinned for each saint, plus one common-year control apiece proving the
+ identity default is unchanged where the footnote does not apply. *)
+
+(* Matthias, 2044 (leap): 24 February carries no fixed office at all
+ (Class4 temporal only, zero commemorations); Matthias (Class2, an
+ Apostle) wins 25 February outright against that day's own Class4
+ feria. *)
+let test_bissextile_matthias_leap_2044 () =
+ check ~msg:"leap year: 24 February carries no fixed office, Matthias has moved away" 2044 2 24
+ "2044-02-24 wednesday season=septuagesima week=2 slug=ef-septuagesima-2-wednesday rank=class-4 colour=violet \
+ subject=temporal name_la=- comms=[] in=- out=[]";
+ check ~msg:"leap year: Matthias observed outright on 25 February, not 24" 2044 2 25
+ "2044-02-25 thursday season=septuagesima week=2 slug=matthias rank=class-2 colour=red subject=saint name_la=- \
+ comms=[] in=- out=[]"
+
+(* Matthias, 2025 (common): unaffected control -- the identity default
+ applies, Matthias stays at his ordinary 24 February. *)
+let test_bissextile_matthias_common_2025 () =
+ check ~msg:"common year: Matthias unaffected, still 24 February" 2025 2 24
+ "2025-02-24 monday season=septuagesima week=2 slug=matthias rank=class-2 colour=red subject=saint name_la=- \
+ comms=[] in=- out=[]"
+
+(* Gabriel of Our Lady of Sorrows, 2008 (leap): 27 February carries no
+ trace of him at all (not even a commemoration -- fixed_key removes him
+ from the candidate set entirely, RG 111's admission machinery never
+ sees him); on 28 February he is admitted as an ORDINARY commemoration
+ (Class3 ties the day's own privileged Lenten feria, which keeps
+ observed). A different shape from Matthias's outright win above,
+ deliberately pinned: the mechanism moves WHERE the saint is tried, not
+ whether he wins once he gets there. *)
+let test_bissextile_gabriel_leap_2008 () =
+ check ~msg:"leap year: 27 February carries no trace of Gabriel, he has moved away" 2008 2 27
+ "2008-02-27 wednesday season=lent week=3 slug=ef-lent-3-wednesday rank=class-3 colour=violet subject=temporal \
+ name_la=- comms=[] in=- out=[]";
+ check ~msg:"leap year: Gabriel admitted as an ordinary commemoration on 28 February, not 27" 2008 2 28
+ "2008-02-28 thursday season=lent week=3 slug=ef-lent-3-thursday rank=class-3 colour=violet subject=temporal \
+ name_la=- comms=[gabriel-of-our-lady-of-sorrows:ordinary] in=- out=[]"
+
+(* Gabriel of Our Lady of Sorrows, 2025 (common): unaffected control --
+ observed outright at his ordinary 27 February. *)
+let test_bissextile_gabriel_common_2025 () =
+ check ~msg:"common year: Gabriel unaffected, still observed on 27 February" 2025 2 27
+ "2025-02-27 thursday season=septuagesima week=2 slug=gabriel-of-our-lady-of-sorrows rank=class-3 colour=white \
+ subject=saint name_la=- comms=[] in=- out=[]"
+
(* RG 128, transcribed in docs/research/rules-register.md ยง3b: violet is used
for "II/III-class vigils outside Paschaltide". Two of colitur's five vigils
disagreed with that rule until the ef-oconnell-rubrics branch -- the
@@ -1510,5 +1571,13 @@ let suite =
Alcotest.test_case "RG87: Rogation Wednesday admitted (2024)" `Quick
test_rogation_wednesday_admitted_2024;
Alcotest.test_case "RG87/113: Rogation Wednesday yields to an impeded feast (2026)" `Quick
- test_rogation_wednesday_yields_to_a_feast_2026
+ test_rogation_wednesday_yields_to_a_feast_2026;
+ Alcotest.test_case "bissextile footnote: Matthias moves 24->25 February (leap, 2044)" `Quick
+ test_bissextile_matthias_leap_2044;
+ Alcotest.test_case "bissextile footnote: Matthias unaffected (common, 2025)" `Quick
+ test_bissextile_matthias_common_2025;
+ Alcotest.test_case "bissextile footnote: Gabriel of Our Lady of Sorrows moves 27->28 February (leap, 2008)"
+ `Quick test_bissextile_gabriel_leap_2008;
+ Alcotest.test_case "bissextile footnote: Gabriel of Our Lady of Sorrows unaffected (common, 2025)" `Quick
+ test_bissextile_gabriel_common_2025
] )
diff --git a/test/test_lms_ordo.ml b/test/test_lms_ordo.ml
index 84c4776..219e51f 100644
--- a/test/test_lms_ordo.ml
+++ b/test/test_lms_ordo.ml
@@ -502,13 +502,15 @@ let make_suite ~label ~fixture_path ~fixture_sha256 ~window_first ~window_last ~
this function declared [by_id]/[explained_counts] but never actually
populated the latter (the mechanism this comment sits in) -- L1 was
already CLOSED by the time that task shipped, so it had nothing real
- to explain and the gap went unexercised. L3/L4 (Witnesses task,
- 2026-08-22, both 2023-2024-only) are the first live tests of it. *)
- let allow_list_id_for_date d =
- if String.equal d "2024-02-24" then Some "L3"
- else if String.equal d "2023-12-24" then Some "L4"
- else None
- in
+ to explain and the gap went unexercised. L4 (Witnesses task,
+ 2026-08-22, 2023-2024-only) is the first live test of it. L3's own
+ "2024-02-24" branch (bissextile-shift task, 2026-08-22) is REMOVED,
+ not left dead: the fix makes that date's Creed match outright, so the
+ branch would never fire again, and a stale date->id mapping for a
+ now-closed entry is exactly the kind of thing this file's own
+ "must be visible as a real change" discipline (this comment's own
+ opening sentence) argues against leaving in place. *)
+ let allow_list_id_for_date d = if String.equal d "2023-12-24" then Some "L4" else None in
let test_creed_matches_or_is_explained () =
let ordo = ordo_rows fixture_path in
let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in
@@ -651,17 +653,29 @@ let allow_list_path = "../data/ef/expected-divergences-lms.sexp"
ever looks at its own explained_counts. Fixed to the two ids this task
added (L3, L4) -- L1 is CLOSED/removed and L2 is deliberately prose-only
(see the sexp file's own header on why), so neither is expected here. *)
-let test_allow_list_ids_are_exactly_l3_l4 () =
+(* L3 CLOSED (bissextile-shift task, 2026-08-22): removed from the active
+ sexp record set -- see its own now-prose closure note in
+ data/ef/expected-divergences-lms.sexp for the fix and the measured
+ blast radius. Only L4 remains active. *)
+let test_allow_list_ids_are_exactly_l4 () =
let ids = List.sort String.compare (List.map (fun e -> e.id) (load_allow_list allow_list_path)) in
- Alcotest.(check (list string)) "the LMS allow-list declares exactly L3 and L4" [ "L3"; "L4" ] ids
+ Alcotest.(check (list string)) "the LMS allow-list declares exactly L4" [ "L4" ] ids
-let suite_allow_list = ("lms-ordo-allow-list", [ Alcotest.test_case "declares exactly L3 and L4" `Quick test_allow_list_ids_are_exactly_l3_l4 ])
+let suite_allow_list = ("lms-ordo-allow-list", [ Alcotest.test_case "declares exactly L4" `Quick test_allow_list_ids_are_exactly_l4 ])
let suite_2023_2024 =
make_suite ~label:"lms-ordo-2023-2024" ~fixture_path:"fixtures/lms-ordo-2023-2024.sexp"
~fixture_sha256:"c8d9d4b790f6b4438e932f70dad8ebe9a0316662f1eb02c26248e04c7be1a62b" ~window_first:"2023-12-01"
~window_last:"2024-12-31" ~year_lo:2022 ~year_hi:2025 ~allow_list_path ~expected_rows:397
- ~expected_bvm_votive:12 ~expected_proper:182 ~expected_common:2 ~expected_preceding_sunday:61
+ (* expected_proper 182 -> 181 (bissextile-shift task, 2026-08-22): 24
+ February 2024, previously Matthias's own Proper Mass, is now the
+ Ember Saturday of Lent's own temporal proper -- Own_slug-sourced, not
+ Proper, so it leaves this bucket's population rather than merely
+ changing which slug's proper it is (see this file's own header on why
+ Own_slug is excluded). Matthias himself moves to 25 February, an
+ I-class Sunday that admits him not even as a commemoration (RG16(a)),
+ so his own Mass is not said anywhere in this window any more. *)
+ ~expected_bvm_votive:12 ~expected_proper:181 ~expected_common:2 ~expected_preceding_sunday:61
~expected_ascension_week:1
let suite_2024_2025 =
diff --git a/test/test_validate.ml b/test/test_validate.ml
index 1ceaca2..671c706 100644
--- a/test/test_validate.ml
+++ b/test/test_validate.ml
@@ -339,6 +339,9 @@ module Synthetic = struct
any for a fixture; nothing here is Easter-relative, so the value
is never actually read. *)
easter = Colitur_kernel.Computus.gregorian_easter;
+ (* Not a Roman rite either, so no bissextile-doubling convention:
+ identity, {!Rite.t.fixed_key}'s own documented default. *)
+ fixed_key = (fun d -> Some (D.month d, D.day d));
transfer_target; readings; creed }
(* Empty by default: every check built before Task 12 exercises the