aboutsummaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/test_litcal_of.ml64
-rw-r--r--test/test_rite_of.ml136
-rw-r--r--test/test_temporal_of.ml114
-rw-r--r--test/test_validate_of.ml111
4 files changed, 271 insertions, 154 deletions
diff --git a/test/test_litcal_of.ml b/test/test_litcal_of.ml
index 0c53899..6a638a7 100644
--- a/test/test_litcal_of.ml
+++ b/test/test_litcal_of.ml
@@ -532,16 +532,22 @@ let test_unwitnessed_ordinary_time_is_counted () =
none is a loose pattern that could silently absorb an unrelated future
mismatch. *)
-(* L2/L3 -- Normae n. 35(a), the Holy Family fallback: KNOWN WRONG, pinned
- (not fixed) by test_rite_of.ml's own
- [test_holy_family_fallback_1583_known_wrong_ferial]/
- [is_known_holy_family_fallback_gap]. 30 December 2033 is this fixture's
- own live witness (2033: 25 December is a Sunday, so 26-31 December's own
- window has no Sunday of its own and {!Rite_of.Temporal_of.temporal}
- never reaches [holy_family]'s own fixed 30-December fallback branch).
- litcal correctly names ["HolyFamily"]; colitur observes an ordinary
- Nativity-octave feria (Tabula II.9, band 90) instead. *)
-let is_l2_l3_holy_family_2033 (l : litcal_row) = String.equal l.event_key "HolyFamily" && String.equal l.date "2033-12-30"
+(* L2/L3 -- CLOSED, REMOVED (W2 fix, CLAUDE.md, temporal_of.ml). This
+ paragraph used to describe a KNOWN WRONG, pinned-not-fixed divergence:
+ Normae n. 35(a)'s "vel, ea deficiente, die 30 decembris" Holy Family
+ fallback was reached by {!Rite_of.Temporal_of.holy_family} but never by
+ {!Rite_of.Temporal_of.temporal} itself (its Holy-Family test lived only
+ inside [sunday_slug]'s own Sunday-only dispatch arm), so a year 25
+ December is itself a Sunday (26-31 December then holding no Sunday of
+ its own) left the fallback unreached. 30 December 2033 was this
+ fixture's own live witness. [Temporal_of.temporal] now consults
+ [Temporal_of.holy_family] directly, independent of weekday, so litcal
+ and colitur now agree on this date on both axes -- both the L2 (grade)
+ and L3 (identity) allow-list entries in
+ data/of/expected-divergences-litcal.sexp are removed (their own
+ `expected_rows` would now be 0, not 1), and [test_no_holy_family_2033_
+ divergence] below asserts this directly rather than merely by omission. *)
+let is_holy_family_2033 (l : litcal_row) = String.equal l.event_key "HolyFamily" && String.equal l.date "2033-12-30"
(* L4 -- litcal's own [grade_lcl] "celebration with precedence over
solemnities" text covers Trinity Sunday and Corpus Christi too (24
@@ -653,7 +659,6 @@ let test_grade_matches_or_is_explained () =
let day = resolved_of l.date in
let band = band_of day in
if List.mem band bands then ()
- else if is_l2_l3_holy_family_2033 l then bump "L2"
else if is_l4_trinity_corpus_christi l then bump "L4"
else if is_l5_mary_magdalene l then bump "L5"
else if is_l6_2033_tie_grade l then bump "L6"
@@ -674,7 +679,7 @@ let test_grade_matches_or_is_explained () =
match List.assoc_opt id by_id with
| None -> Alcotest.failf "allow-list entry %s is used by the comparator but not declared in %s" id allow_list_path
| Some e -> Alcotest.(check int) (Printf.sprintf "%s expected_rows (grade)" id) e.expected_rows actual)
- [ "L2"; "L4"; "L5"; "L6"; "L8" ]
+ [ "L4"; "L5"; "L6"; "L8" ]
(* The complement, mirroring [test_unwitnessed_ordinary_time_is_counted]:
every row [expected_bands] returns [None] for, classified by which of
@@ -717,7 +722,6 @@ let test_identity_matches_or_is_explained () =
let day = resolved_of l.date in
let actual_slug = Slug.to_string day.LD.observed.Cel.slug in
if String.equal actual_slug expected_slug then ()
- else if is_l2_l3_holy_family_2033 l then bump "L3"
else if is_l7_2033_tie_identity l then bump "L7"
else if is_l8_l9_joseph_2035 l then bump "L9"
else
@@ -735,7 +739,7 @@ let test_identity_matches_or_is_explained () =
match List.assoc_opt id by_id with
| None -> Alcotest.failf "allow-list entry %s is used by the comparator but not declared in %s" id allow_list_path
| Some e -> Alcotest.(check int) (Printf.sprintf "%s expected_rows (identity)" id) e.expected_rows actual)
- [ "L3"; "L7"; "L9" ]
+ [ "L7"; "L9" ]
(* The complement: every row [identity_map] has no entry for, whether
because litcal names an event_key outside the closed FIXED/NAMED set
@@ -758,15 +762,41 @@ let test_identity_unresolved_is_counted () =
Alcotest.(check int) "unresolved + the 508 checked = 4346 non-Triduum rows" 4346
(List.length unresolved + 508)
+(* W2 fix, direct regression coverage: L2/L3's own former witness (2033-12-30,
+ HolyFamily) now matches on BOTH axes, checked here explicitly rather than
+ only by the allow-list's own [expected_rows] falling to 0 (see
+ [is_holy_family_2033]'s own updated header for the full history). Also
+ confirms the predicate is not vacuous: the fixture really does carry this
+ row. *)
+let test_no_holy_family_2033_divergence () =
+ let litcal = litcal_rows () in
+ let rows = List.filter is_holy_family_2033 litcal in
+ Alcotest.(check int) "the fixture really does carry a HolyFamily row on 2033-12-30" 1 (List.length rows);
+ let row = List.hd rows in
+ let day = resolved_of row.date in
+ let band = band_of day in
+ (match expected_bands row.grade_lcl with
+ | None -> Alcotest.fail "2033-12-30: HolyFamily's own grade_lcl is no longer a comparable band"
+ | Some bands ->
+ Alcotest.(check bool) "2033-12-30: colitur's band matches litcal's expected grade" true (List.mem band bands));
+ let actual_slug = Slug.to_string day.LD.observed.Cel.slug in
+ Alcotest.(check string) "2033-12-30: colitur observes of-holy-family" "of-holy-family" actual_slug;
+ match Hashtbl.find_opt identity_tbl row.event_key with
+ | None -> Alcotest.fail "HolyFamily is no longer in identity_tbl"
+ | Some expected_slug ->
+ Alcotest.(check string) "2033-12-30: identity_tbl's own expected slug agrees" expected_slug actual_slug
+
(* ---------------------------------------------------------------------- *)
(* The whole allow-list file, taken as a whole: every id it declares is *)
-(* recognised by exactly one comparator above (L1 season, L2 grade, L3 *)
+(* recognised by exactly one comparator above (L1 season, L4 grade, L7 *)
(* identity) -- no orphan entry a comparator no longer references, and no *)
(* comparator silently reading an id this file does not declare (each *)
(* comparator's own [List.assoc_opt] already fails loudly for that half). *)
+(* L2/L3 (Holy Family) are gone -- CLOSED, REMOVED, W2 fix -- see *)
+(* [is_holy_family_2033]'s own header. *)
(* ---------------------------------------------------------------------- *)
-let recognized_allow_ids = [ "L1"; "L2"; "L3"; "L4"; "L5"; "L6"; "L7"; "L8"; "L9" ]
+let recognized_allow_ids = [ "L1"; "L4"; "L5"; "L6"; "L7"; "L8"; "L9" ]
let test_allow_list_has_no_orphan_entries () =
let allow_list = load_allow_list () in
@@ -785,5 +815,7 @@ let suite =
Alcotest.test_case "grade-unresolved rows are counted, not skipped" `Quick test_grade_unresolved_is_counted;
Alcotest.test_case "identity matches or is explained" `Quick test_identity_matches_or_is_explained;
Alcotest.test_case "identity-unresolved rows are counted, not skipped" `Quick test_identity_unresolved_is_counted;
+ Alcotest.test_case "W2 fix: no more Holy Family divergence on 2033-12-30" `Quick
+ test_no_holy_family_2033_divergence;
Alcotest.test_case "allow-list has no orphan entries" `Quick test_allow_list_has_no_orphan_entries
] )
diff --git a/test/test_rite_of.ml b/test/test_rite_of.ml
index a7d3237..42c9c98 100644
--- a/test/test_rite_of.ml
+++ b/test/test_rite_of.ml
@@ -109,46 +109,49 @@ let contains ~substring s =
let is_known_nativity_gap (f : Val.failure) =
(f.Val.check = "citations-unresolved" || f.Val.check = "formulary") && ends_with ~suffix:"12-25" f.Val.date
-(* A SECOND, NEWLY-FOUND gap this task's own wiring surfaced -- [rite_of] was
- never previously wired into a real {!Colitur_kernel.Rite.t}, so
- [Validate.run]'s ["anchor"] check has never before run [Temporal_of
- .anchors] against [Temporal_of.temporal] for real. Normae n. 35(a)'s own
- text is "Dominica infra octavam Nativitatis, VEL, EA DEFICIENTE, die 30
- decembris" -- the Holy Family fallback is a FIXED-DATE fallback (30
- December, whatever weekday it falls on that year), confirmed by
- {!Temporal_of.holy_family}'s own [None -> mk y 12 30] branch and its own
- .mli citation ("this 6-day window CAN be [empty of a Sunday] -- confirmed
- by the fallback clause's own existence"). But {!Temporal_of.temporal}
- only ever TESTS [same d (holy_family y)] inside its [sunday_slug d]
- dispatch arm -- i.e. only when [d] genuinely IS a Sunday -- so in a year
- whose 26-31 December window has NO Sunday (equivalently: 25 December,
- Christmas Day, is itself a Sunday -- a 6-day window starting the day
- after a Sunday omits Sunday from every other position), the fallback
- date is computed correctly by [holy_family]/[anchors] but [temporal]
- itself silently falls through to an ordinary ferial slug instead
- (["of-christmas-0-friday"] et al.) -- exactly the "generic day slug
- masking a real named feast" shape CLAUDE.md already records EF's own
- Holy Family/Holy Name gaps as, before they were fixed there. Not rare:
- this fires whenever Christmas Day is a Sunday, roughly one year in
- seven (1583, 2005, 2011, 2016, 2022, 2033, 2039, 2044, 2050 all
- independently confirmed via Python's own proleptic-Gregorian
- [datetime]). OUT OF THIS TASK'S SCOPE to fix -- temporal_of.ml's own
- rubric logic is Task 1's deliverable, not Task 5's, and the task brief
- models exactly this "pin the known-wrong behaviour, do not fix it here"
- treatment for a different, already-known gap (Normae n.56(f), St
- Joseph/Palm Sunday) -- so the same discipline is applied to this
- newly-found one rather than silently working around it or fixing rite
- logic this task was not chartered to touch. Reported in this task's own
- report as a concern. *)
+(* CLOSED (W2, CLAUDE.md; fixed in temporal_of.ml). This paragraph used to
+ describe a SECOND, NEWLY-FOUND gap this task's own wiring surfaced --
+ [rite_of] was never previously wired into a real
+ {!Colitur_kernel.Rite.t}, so [Validate.run]'s ["anchor"] check had never
+ before run [Temporal_of.anchors] against [Temporal_of.temporal] for
+ real. Normae n. 35(a)'s own text is "Dominica infra octavam Nativitatis,
+ VEL, EA DEFICIENTE, die 30 decembris" -- the Holy Family fallback is a
+ FIXED-DATE fallback (30 December, whatever weekday it falls on that
+ year), confirmed by {!Temporal_of.holy_family}'s own [None -> mk y 12 30]
+ branch and its own .mli citation ("this 6-day window CAN be [empty of a
+ Sunday] -- confirmed by the fallback clause's own existence"). But
+ {!Temporal_of.temporal} used to only ever TEST [same d (holy_family y)]
+ inside its [sunday_slug d] dispatch arm -- i.e. only when [d] genuinely
+ WAS a Sunday -- so in a year whose 26-31 December window had NO Sunday
+ (equivalently: 25 December, Christmas Day, is itself a Sunday), the
+ fallback date was computed correctly by [holy_family]/[anchors] but
+ [temporal] itself silently fell through to an ordinary ferial slug
+ instead (["of-christmas-0-friday"] et al.) -- exactly the "generic day
+ slug masking a real named feast" shape CLAUDE.md already records EF's
+ own Holy Family/Holy Name gaps as, before they were fixed there. Not
+ rare: this fired whenever Christmas Day is a Sunday, roughly one year in
+ seven. [Temporal_of.temporal] now consults [Temporal_of.holy_family]
+ directly in that dispatch arm, independent of weekday, carrying the
+ identical identity (subject Lord, [holy_family_names], rank Festum) the
+ Sunday case already builds -- see temporal_of.ml's own citation on that
+ branch. [is_known_holy_family_fallback_gap] is kept, not deleted, but is
+ no longer in [is_known]'s own filter below -- the same discipline
+ [is_known_nativity_gap] above already established once ITS defect
+ closed: it exists ONLY as an explicit, named "this must never fire
+ again" check ({!check_year_allowing_known_gaps}'s own dedicated
+ assertion), not silently absorbed. *)
let is_known_holy_family_fallback_gap (f : Val.failure) =
f.Val.check = "anchor" && contains ~substring:"of-holy-family" f.Val.detail
let check_year_allowing_known_gaps year =
let fs = run year in
(* Fix wave I8: [is_known_nativity_gap] is deliberately NOT part of
- [is_known] any more -- see that predicate's own header. Only the
- Holy Family fallback gap is still filtered out here. *)
- let is_known f = is_known_holy_family_fallback_gap f in
+ [is_known] any more -- see that predicate's own header. W2 fix: neither
+ is [is_known_holy_family_fallback_gap] now -- see ITS own header. No
+ known gaps remain filtered here (landmark years validate cleanly, full
+ stop); [is_known] is kept as a named hook rather than inlined to
+ [false] so a future gap has an obvious place to land. *)
+ let is_known (_ : Val.failure) = false in
let unexpected = List.filter (fun f -> not (is_known f)) fs in
(match unexpected with
| [] -> ()
@@ -157,7 +160,10 @@ let check_year_allowing_known_gaps year =
(String.concat "; " (List.map Val.failure_to_string (List.filteri (fun i _ -> i < 5) fs))));
Alcotest.(check int)
(Printf.sprintf "%d: no of-nativity citations-unresolved/formulary gap (fix wave I8 -- was 2)" year)
- 0 (List.length (List.filter is_known_nativity_gap fs))
+ 0 (List.length (List.filter is_known_nativity_gap fs));
+ Alcotest.(check int)
+ (Printf.sprintf "%d: no Holy Family anchor gap (W2 fix -- was 1)" year)
+ 0 (List.length (List.filter is_known_holy_family_fallback_gap fs))
(* Landmark years, the same choice test_validate.ml's own
[test_landmark_years] makes for EF: both domain edges plus two ordinary
@@ -220,8 +226,15 @@ let test_year_9999_does_not_raise () =
also the ONLY unexplained [anchors]/[temporal] drift anywhere in the
domain -- the other two items in this paragraph (the lectionary
weekday-cycle-letter asymmetry, and the Holy Family 30-December
- fallback {!is_known_holy_family_fallback_gap} below already pins) are
- unrelated and remain open, unchanged by this fix. *)
+ fallback {!is_known_holy_family_fallback_gap} below already pins) were
+ unrelated and remained open, unchanged by this fix.
+
+ CORRECTED AGAIN (W2, CLAUDE.md, same day): the Holy Family 30-December
+ fallback named just above is ALSO now fixed -- {!Temporal_of.temporal}
+ consults {!Temporal_of.holy_family} directly, independent of weekday.
+ Only the lectionary weekday-cycle-letter asymmetry remains open; it is
+ untouched by this fix (lectionary_of.*/data/of/lectionary.sexp are out
+ of scope here, per the original paragraph's own citation). *)
let sample_years =
let rec range a b = if a > b then [] else a :: range (a + 1) b in
range 2005 2050
@@ -320,28 +333,35 @@ let test_joseph_palm_sunday_2062_anticipated_backward () =
transfer placement anywhere in it)" false
(List.exists (fun f -> f.Val.check = "unconverged") failures)
-(* Second NEWLY-FOUND defect, pinned the same way (see
- [is_known_holy_family_fallback_gap]'s own citation just above for the
- full argument): 1583 is the domain floor AND a year Christmas Day falls
- on a Sunday, so 26-31 December has no Sunday of its own and
- [Temporal_of.holy_family] falls back to its own fixed 30 December --
- correctly, per [Temporal_of.anchors] -- but [Temporal_of.temporal]
- itself never reaches that fallback (its Holy-Family test lives inside
- the Sunday-only dispatch arm), so 30 December 1583 is observed as an
- ordinary Friday-of-Christmastide ferial office instead of the Feast of
- the Holy Family. *)
-let test_holy_family_fallback_1583_known_wrong_ferial () =
+(* CLOSED, W2 fix (CLAUDE.md). This test used to pin a defect the same way
+ [is_known_holy_family_fallback_gap]'s own citation just above describes:
+ 1583 is the domain floor AND a year Christmas Day falls on a Sunday, so
+ 26-31 December has no Sunday of its own and [Temporal_of.holy_family]
+ falls back to its own fixed 30 December -- correctly, per
+ [Temporal_of.anchors] -- but [Temporal_of.temporal] used to never reach
+ that fallback (its Holy-Family test lived inside the Sunday-only dispatch
+ arm), so 30 December 1583 was observed as an ordinary
+ Friday-of-Christmastide ferial office instead of the Feast of the Holy
+ Family. Now that {!Temporal_of.temporal} consults {!Temporal_of
+ .holy_family} directly, this test asserts the FIXED, correct behaviour
+ instead -- through the full real-data {!Colitur_kernel.Calendar}/
+ {!Colitur_kernel.Precedence} pipeline, not merely {!Temporal_of.temporal}
+ in isolation (test_temporal_of.ml's own
+ [test_holy_family_fallback_temporal] already covers that layer) --
+ checking identity (subject, rank), not only the slug, so a fix that
+ landed the right slug on the wrong grade would still be caught. *)
+let test_holy_family_fallback_1583_now_correct () =
let christmas_1583 = Date.of_iso8601 "1583-12-25" |> Result.get_ok in
Alcotest.(check bool) "1583 Christmas Day really is a Sunday (the Normae n.35(a) trigger)" true
(Date.weekday christmas_1583 = Date.Sun);
let days = Cal.year real_of_rite real_of_layer 1583 in
let dec30 = Array.to_list days |> List.find (fun d -> Date.compare d.LD.date (Date.add_days christmas_1583 5) = 0) in
- Alcotest.(check string)
- "KNOWN WRONG (Normae n.35(a) fallback unreached, see [is_known_holy_family_fallback_gap]'s own \
- citation): 30 December 1583 is an ordinary ferial slug, not of-holy-family"
- "of-christmas-0-friday" (slug_of dec30.LD.observed);
- Alcotest.(check bool) "and that really is NOT the Holy Family (confirming this really is the defect)" false
- (slug_of dec30.LD.observed = "of-holy-family")
+ Alcotest.(check string) "FIXED (W2): 30 December 1583 is observed as of-holy-family, not an ordinary ferial"
+ "of-holy-family" (slug_of dec30.LD.observed);
+ Alcotest.(check string) "subject is Lord (same identity the Sunday case carries)" "lord"
+ (Colitur_kernel.Subject.to_string dec30.LD.observed.Cel.subject);
+ Alcotest.(check string) "rank is Festum, not a lesser grade" "festum"
+ (V.rank_to_string dec30.LD.observed.Cel.rank)
(* Fix wave I6 (final-review.md, 2026-08-25-colitur-of-phases-3-5): the
obligatory-memorial tie data/of/amendments/006-mary-mother-of-the-church
@@ -399,7 +419,7 @@ let test_mater_ecclesiae_anthony_tie_2011_known_arbitrary () =
let suite =
( "Rite_of (real data: assembled Rite.t end-to-end via Calendar/Validate)",
- [ Alcotest.test_case "landmark years validate cleanly (modulo two documented gaps)" `Quick
+ [ Alcotest.test_case "landmark years validate cleanly (W2 and the nativity gap both closed)" `Quick
test_landmark_years;
Alcotest.test_case "year 9999 does not raise; seasons flags the clamp, coverage stays clean" `Quick
test_year_9999_does_not_raise;
@@ -410,9 +430,9 @@ let suite =
n.56(f), converges, and is recorded as an arrival" `Quick
test_joseph_palm_sunday_2062_anticipated_backward;
Alcotest.test_case
- "NEWLY-FOUND DEFECT pinned, not fixed: Holy Family's Normae n.35(a) 30-December fallback is \
- unreached when Christmas Day is a Sunday (1583)" `Quick
- test_holy_family_fallback_1583_known_wrong_ferial;
+ "FIXED (W2): Holy Family's Normae n.35(a) 30-December fallback is now reached when Christmas \
+ Day is a Sunday (1583)" `Quick
+ test_holy_family_fallback_1583_now_correct;
Alcotest.test_case
"KNOWN ARBITRARY pinned: Mary Mother of the Church vs Anthony of Padua tie, 2011-06-13 \
(Easter+50)" `Quick
diff --git a/test/test_temporal_of.ml b/test/test_temporal_of.ml
index 2522e44..d8b1f4a 100644
--- a/test/test_temporal_of.ml
+++ b/test/test_temporal_of.ml
@@ -13,6 +13,10 @@ module D = Colitur_kernel.Date
let d y m dd = match D.make ~year:y ~month:m ~day:dd with Ok t -> t | Error e -> Alcotest.failf "%s" e
let iso = D.to_iso8601
+(* Moved up from the "Sunday and ferial slugs" section below so the W2
+ fallback tests (which need it) can use it without a forward reference. *)
+let slug_of dt = match T.temporal dt with t -> Colitur_kernel.Slug.to_string t.Colitur_kernel.Temporal.office.Colitur_kernel.Celebration.slug
+
(* ---- vocabulary ---- *)
let test_vocab_roundtrips () =
@@ -77,6 +81,49 @@ let test_holy_family () =
true;
Alcotest.(check string) "2022 fallback" "2022-12-30" (iso (T.holy_family 2022))
+(* CLAUDE.md's own W2: {!T.temporal} used to reach {!T.holy_family}'s own
+ Normae n. 35(a) fallback (30 December) ONLY through {!T.sunday_slug},
+ which returns [None] immediately for any non-Sunday date -- so in a year
+ 26-31 December held no Sunday (equivalently, 25 December, Christmas Day,
+ is itself a Sunday), the feast vanished entirely and 30 December fell
+ through to an ordinary Christmas ferial slug instead, even though
+ {!T.holy_family}/{!T.anchors} both already computed the right date. Fixed:
+ {!T.temporal} now consults {!T.holy_family} directly in that dispatch arm,
+ independent of weekday, carrying the identical identity (subject Lord,
+ {!T.holy_family_names} via [named_slug]'s own equivalent, rank Festum) the
+ Sunday case already builds. Five real affected years, per CLAUDE.md's own
+ W2 diagnosis (25 December a Sunday): 2022, 2033, 2039, 2044, 2050. *)
+let test_holy_family_fallback_temporal () =
+ List.iter
+ (fun y ->
+ let hf = T.holy_family y in
+ Alcotest.(check bool) (Printf.sprintf "%d: fallback really lands on 30 December" y) true
+ (D.month hf = 12 && D.day hf = 30);
+ Alcotest.(check bool) (Printf.sprintf "%d: and that day is not itself a Sunday" y) true
+ (D.weekday hf <> D.Sun);
+ Alcotest.(check string) (Printf.sprintf "%d: temporal resolves the fallback to of-holy-family" y)
+ "of-holy-family" (slug_of hf);
+ let t = T.temporal hf in
+ Alcotest.(check string) (Printf.sprintf "%d: subject is Lord" y) "lord"
+ (Colitur_kernel.Subject.to_string t.Colitur_kernel.Temporal.office.Colitur_kernel.Celebration.subject);
+ Alcotest.(check string) (Printf.sprintf "%d: rank is Festum" y) "festum"
+ (V.rank_to_string t.Colitur_kernel.Temporal.office.Colitur_kernel.Celebration.rank);
+ Alcotest.(check string) (Printf.sprintf "%d: colour is white (season default)" y) "white"
+ (Colitur_kernel.Colour.to_string t.Colitur_kernel.Temporal.office.Colitur_kernel.Celebration.colour))
+ [ 2022; 2033; 2039; 2044; 2050 ]
+
+(* Unaffected years -- 26-31 December DOES hold a Sunday, so {!T.sunday_slug}
+ already reaches the office and this fix changes nothing there. *)
+let test_holy_family_sunday_unaffected () =
+ List.iter
+ (fun y ->
+ let hf = T.holy_family y in
+ Alcotest.(check bool) (Printf.sprintf "%d: Holy Family really is a Sunday here" y) true
+ (D.weekday hf = D.Sun);
+ Alcotest.(check string) (Printf.sprintf "%d: temporal still resolves to of-holy-family" y) "of-holy-family"
+ (slug_of hf))
+ [ 2026; 2027; 2028; 2032 ]
+
let test_second_sunday_of_christmas () =
Alcotest.(check (option string)) "2026" (Some "2026-01-04") (Option.map iso (T.second_sunday_of_christmas 2026));
(* 2024: Baptism is already 7 January (6 Jan a Saturday), so no Sunday
@@ -211,7 +258,6 @@ let test_ordinary_time_resumption () =
(* ---- Sunday and ferial slugs ---- *)
let sunday_slug_of dt = match T.sunday_slug dt with Some s -> s | None -> "<none>"
-let slug_of dt = match T.temporal dt with t -> Colitur_kernel.Slug.to_string t.Colitur_kernel.Temporal.office.Colitur_kernel.Celebration.slug
let test_sunday_slugs () =
Alcotest.(check string) "Advent I 2026" "of-advent-sunday-1" (sunday_slug_of (T.advent_start 2026));
@@ -387,6 +433,22 @@ let prop_liturgical_years_partition_civil_time y =
in
check sorted
+(* CLAUDE.md's own W2: the property that would have caught the fallback gap
+ directly, rather than needing a dedicated example-based test to notice it
+ -- Holy Family must be OBSERVED somewhere in every single liturgical
+ year, never silently absent, whichever of the two Normae n. 35(a)
+ branches (a Sunday 26-31 December, or the 30 December fallback) fires
+ that year. [walk_year y] spans Advent of civil year [y] through the eve
+ of Advent [y + 1], which always contains December of civil year [y] in
+ full -- the same civil year {!T.holy_family} itself is keyed to -- so
+ this is a genuine same-year check, not an off-by-one across the Advent
+ boundary. *)
+let prop_holy_family_exists y =
+ List.exists
+ (fun (_, t) ->
+ Colitur_kernel.Slug.to_string t.Colitur_kernel.Temporal.office.Colitur_kernel.Celebration.slug = "of-holy-family")
+ (walk_year y)
+
let year_gen = QCheck.int_range 1584 9997 (* leaves room for [y - 1] and [y + 1] *)
let make_prop name f = QCheck.Test.make ~count:200 ~name year_gen f
@@ -399,7 +461,8 @@ let prop_tests =
make_prop "OF: temporal's weekday always agrees with Date.weekday" prop_weekday_agrees;
make_prop "OF: temporal is deterministic" prop_determinism;
make_prop "OF: consecutive liturgical years partition civil time with no gap or overlap"
- prop_liturgical_years_partition_civil_time ]
+ prop_liturgical_years_partition_civil_time;
+ make_prop "OF: Holy Family is observed every liturgical year (CLAUDE.md W2)" prop_holy_family_exists ]
(* ---- the committed exhaustive sweep, mirroring test_validate.ml's own
COLITUR_EXHAUSTIVE_SWEEP convention: every year 1583..9998, not a
@@ -416,19 +479,18 @@ let test_exhaustive_domain_sweep () =
(fixed just above, {!test_anchors_agree_with_temporal})? Checked for
EVERY entry [anchors y] returns, across the WHOLE exhaustive domain,
not merely 24 December, folded into this loop rather than a separate
- one so it shares its own env-var gate. Answer: no NEW drift -- the
- only mismatch found is the ALREADY-KNOWN "of-holy-family" gap
- (Normae n.35(a)'s own 30-December fallback, whenever 26-31 December
- holds no Sunday): there, unlike the Vigil bug, {!T.anchors} (via
- {!T.holy_family}) computes the RIGHT date and it is {!T.temporal}
- that fails to reach it (its Holy-Family test lives inside
- [sunday_slug]'s own Sunday-only dispatch arm) -- a different shape,
- already found, pinned and explicitly deferred by test_rite_of.ml's
- own [test_holy_family_fallback_1583_known_wrong_ferial]/
- [is_known_holy_family_fallback_gap] (see that file's own extensive
- citation), collected below rather than failing the sweep on it a
- second time in a second place. *)
- let holy_family_fallback_years = ref [] in
+ one so it shares its own env-var gate.
+
+ UPDATED (W2 fix): this loop used to carve out and separately collect
+ an "ALREADY-KNOWN of-holy-family gap" here -- {!T.anchors} (via
+ {!T.holy_family}) computed the right 30-December fallback date but
+ {!T.temporal} failed to reach it (its Holy-Family test lived only
+ inside [sunday_slug]'s own Sunday-only dispatch arm), pinned rather
+ than fixed by test_rite_of.ml's own (now also fixed)
+ [test_holy_family_fallback_1583_known_wrong_ferial]. {!T.temporal}
+ now consults {!T.holy_family} directly, independent of weekday, so
+ that carve-out is gone: EVERY entry [anchors y] returns must now
+ agree with [temporal] with no exception, across the whole domain. *)
for y = 1584 to 9997 do
let days = walk_year y in
if compressed_seasons days <> expected_season_runs then
@@ -438,28 +500,14 @@ let test_exhaustive_domain_sweep () =
if not (prop_advent_four_sundays y) then Alcotest.failf "%d: Advent does not have exactly four Sundays" y;
if not (prop_slug_uniqueness y) then Alcotest.failf "%d: a slug repeats within the liturgical year" y;
if not (prop_weekday_agrees y) then Alcotest.failf "%d: a weekday disagrees with Date.weekday" y;
+ if not (prop_holy_family_exists y) then Alcotest.failf "%d: Holy Family is not observed anywhere this year" y;
List.iter
(fun (expected_slug, date) ->
let actual = slug_of date in
if actual <> expected_slug then
- if expected_slug = "of-holy-family" then holy_family_fallback_years := y :: !holy_family_fallback_years
- else
- Alcotest.failf
- "%d: anchors' %S (%s) disagrees with temporal (%S) -- an UNEXPECTED drift, not the known \
- Holy Family fallback shape"
- y expected_slug (iso date) actual)
+ Alcotest.failf "%d: anchors' %S (%s) disagrees with temporal (%S)" y expected_slug (iso date) actual)
(T.anchors y)
- done;
- (* The known shape is real, not vacuous, and really is the Normae
- n.35(a) trigger, for every year collected above, not merely
- plausible-sounding. *)
- Alcotest.(check bool) "the Holy Family fallback shape is real and not empty" true
- (!holy_family_fallback_years <> []);
- List.iter
- (fun y ->
- Alcotest.(check bool) (Printf.sprintf "%d is really a Christmas-Day-is-Sunday year" y) true
- (D.weekday (d y 12 25) = D.Sun))
- !holy_family_fallback_years
+ done
end
(* 1583 and 9999 themselves sit at the domain edges, where the [y-1]/[y+1]
@@ -483,6 +531,8 @@ let suite =
Alcotest.test_case "seasons" `Quick test_seasons;
Alcotest.test_case "baptism of the lord" `Quick test_baptism_of_the_lord;
Alcotest.test_case "holy family" `Quick test_holy_family;
+ Alcotest.test_case "holy family fallback reaches temporal (W2 fix)" `Quick test_holy_family_fallback_temporal;
+ Alcotest.test_case "holy family Sunday case unaffected by the fix" `Quick test_holy_family_sunday_unaffected;
Alcotest.test_case "second sunday of christmas" `Quick test_second_sunday_of_christmas;
Alcotest.test_case "christ the king" `Quick test_christ_the_king;
Alcotest.test_case "named feasts" `Quick test_named_feasts;
diff --git a/test/test_validate_of.ml b/test/test_validate_of.ml
index 5c64710..bcf61cc 100644
--- a/test/test_validate_of.ml
+++ b/test/test_validate_of.ml
@@ -15,13 +15,20 @@
including their own three pinned defects -- see below).
THE KNOWN, PINNED-NOT-FIXED GAPS (Task 5's own report; do not relitigate
- here, only avoid papering over them). Originally three; now two -- see
- item 2's own CLOSED note:
- 1. Normae n. 35(a)'s Holy Family fallback is unreached whenever
- Christmas Day is itself a Sunday (test_rite_of.ml's own
- [is_known_holy_family_fallback_gap]/
- [test_holy_family_fallback_1583_known_wrong_ferial]) -- fires on
- roughly one year in seven, first hit at 1583.
+ here, only avoid papering over them). Originally three; now ONE -- items
+ 1 and 2 are both CLOSED (see each one's own CLOSED note):
+ 1. CLOSED, W2 fix (CLAUDE.md, temporal_of.ml). Used to read: "Normae
+ n. 35(a)'s Holy Family fallback is unreached whenever Christmas Day
+ is itself a Sunday (test_rite_of.ml's own
+ [is_known_holy_family_fallback_gap]/[test_holy_family_fallback_1583_
+ known_wrong_ferial]) -- fires on roughly one year in seven, first hit
+ at 1583." {!Rite_of.Temporal_of.temporal} now consults
+ {!Rite_of.Temporal_of.holy_family} directly, independent of weekday,
+ so this fires on ZERO years now, verified across the whole domain by
+ [test_exhaustive_domain_sweep]'s own [holy_family_gap_years] counter
+ below -- not merely assumed from the fix. [is_known_holy_family_
+ fallback_gap] itself is kept, as a dedicated "this must never recur"
+ check, not deleted -- the same discipline item 2 already established.
2. CLOSED, fix wave I8 (final-review.md, 2026-08-25-colitur-of-phases-
3-5). Used to read: "The lectionary (data/of/lectionary.sexp) was
audited against only one civil year (2026) -- surfaces here as
@@ -118,13 +125,20 @@ let contains ~substring s =
let rec go i = i + lx <= ls && (String.sub s i lx = substring || go (i + 1)) in
lx = 0 || go 0
+(* CLOSED, W2 fix (CLAUDE.md, temporal_of.ml): see this file's own header,
+ item 1. Kept as an explicit "this must never fire again" check, no
+ longer part of [is_known_gap]'s own filter below -- the same discipline
+ [is_known_nativity_gap] just above already established. *)
let is_known_holy_family_fallback_gap (f : Val.failure) =
f.Val.check = "anchor" && contains ~substring:"of-holy-family" f.Val.detail
-(* Fix wave I8: [is_known_nativity_gap] deliberately excluded -- see its own
- header just above. A recurrence now surfaces as an ordinary unexpected
- failure everywhere this predicate is used to filter. *)
-let is_known_gap f = is_known_holy_family_fallback_gap f
+(* Fix wave I8 + W2: both [is_known_nativity_gap] and
+ [is_known_holy_family_fallback_gap] are deliberately excluded now -- see
+ each one's own header just above. A recurrence of either now surfaces as
+ an ordinary unexpected failure everywhere this predicate is used to
+ filter. Kept as a named hook (not inlined to [false] at each call site)
+ so a future gap has an obvious place to land. *)
+let is_known_gap (_ : Val.failure) = false
let unexplained_failures year = List.filter (fun f -> not (is_known_gap f)) (run year)
@@ -152,13 +166,16 @@ let test_landmark_years () = List.iter check_year_allowing_known_gaps [ 1583; 20
Christmas-Day-is-Sunday shape can both still occur inside the truncated
walk) -- only "seasons" is additionally allowed here, and only here. *)
let test_year_9999_does_not_raise () =
+ (* [is_known_gap] now filters nothing (both gaps CLOSED) -- kept in the
+ pipeline anyway so a future gap has the same obvious place to land, but
+ [fs] here is really just [run 9999] unfiltered. *)
let fs = List.filter (fun f -> not (is_known_gap f)) (run 9999) in
Alcotest.(check bool) "no coverage failures (temporal stayed total through the clamp)" true
(not (List.exists (fun f -> f.Val.check = "coverage") fs));
Alcotest.(check bool) "seasons check flags the truncated final year as incomplete" true
(List.exists (fun f -> f.Val.check = "seasons") fs);
- Alcotest.(check (list string)) "nothing OTHER than the documented seasons truncation (and the three known \
- gaps, already filtered) fired"
+ Alcotest.(check (list string)) "nothing OTHER than the documented seasons truncation fired (both known \
+ gaps are CLOSED)"
[ "seasons" ]
(List.sort_uniq compare (List.map (fun f -> f.Val.check) fs))
@@ -166,18 +183,15 @@ let test_year_9999_does_not_raise () =
(* THE CONFIDENCE-TO-9999 CORE: random years across the whole domain, *)
(* mirroring test_validate.ml's own [prop_invariants] exactly, one rite *)
(* down -- 200 samples by default; every EXHAUSTIVE year under *)
-(* [COLITUR_EXHAUSTIVE_SWEEP=1] (below). Filtered of the three known gaps, *)
-(* same discipline [check_year_allowing_known_gaps] already applies to the *)
-(* landmark years -- a property that asserted [run y = []] UNFILTERED *)
-(* would not test anything new (it would just fail on ~1/7 of its own *)
-(* samples, the Holy Family gap's own real incidence), and one that *)
-(* filtered EVERYTHING unconditionally would risk hiding a genuinely NEW *)
-(* failure behind the same three names -- which is exactly why *)
-(* [is_known_gap] is the narrow, field-checked pair of predicates above, *)
-(* not a blanket "ignore anything on 25 December" rule. *)
+(* [COLITUR_EXHAUSTIVE_SWEEP=1] (below). Both known gaps are now CLOSED *)
+(* (this file's own header), so [is_known_gap] filters nothing any more -- *)
+(* [unexplained_failures y = []] below is really [run y = []] UNFILTERED, *)
+(* a strictly stronger assertion than this property used to make while *)
+(* either gap was still open. [is_known_gap] is kept in the pipeline (not *)
+(* inlined away) so a future gap has the same obvious, narrow, field- *)
+(* checked place to land that the two closed ones already used. *)
let prop_invariants =
- QCheck.Test.make ~count:200 ~name:"OF temporal invariants hold across 1583..9998 (modulo the three known, \
- pinned gaps)"
+ QCheck.Test.make ~count:200 ~name:"OF temporal invariants hold across 1583..9998 (no known gaps remain)"
(QCheck.int_range 1583 9998)
(fun y -> unexplained_failures y = [])
@@ -283,12 +297,13 @@ let test_exhaustive_domain_sweep () =
for y = 1583 to 9998 do
let fs = run y in
let hf = List.exists is_known_holy_family_fallback_gap fs in
- (* Fix wave I8: expected to be [false] every single year now -- kept
- as a live measurement, not deleted, so a regression anywhere in
- the domain increments this counter AND (via [is_known_gap] no
- longer absorbing it) fails the loop below immediately, on the
- first year it recurs, rather than only being caught by the
- summary assertion after the full sweep completes. *)
+ (* Fix wave I8 + W2: expected to be [false] every single year now for
+ BOTH counters -- kept as a live measurement, not deleted, so a
+ regression anywhere in the domain increments the relevant counter
+ AND (via [is_known_gap] no longer absorbing either shape) fails the
+ loop below immediately, on the first year it recurs, rather than
+ only being caught by the summary assertion after the full sweep
+ completes. *)
let nat = List.exists is_known_nativity_gap fs in
if hf then incr holy_family_gap_years;
if nat then incr nativity_gap_years;
@@ -309,24 +324,25 @@ let test_exhaustive_domain_sweep () =
(not (List.exists (fun f -> f.Val.check = "coverage") fs_9999));
Alcotest.(check bool) "9999: seasons check flags the truncated final year as incomplete" true
(List.exists (fun f -> f.Val.check = "seasons") fs_9999);
- Alcotest.(check (list string)) "9999: nothing OTHER than the documented seasons truncation (and the two \
- remaining known, pinned gaps) fired"
+ Alcotest.(check (list string)) "9999: nothing OTHER than the documented seasons truncation fired (both \
+ known gaps are CLOSED)"
[ "seasons" ]
(List.sort_uniq compare
(List.map (fun f -> f.Val.check) (List.filter (fun f -> not (is_known_gap f)) fs_9999)));
- (* The Holy Family gap is real and not vacuous across the FULL domain,
- not merely on the handful of years the sampled property happens to
- draw -- it fires on roughly one year in seven (whenever 25 December
- is a Sunday). Pinned as a range, not an exact count, deliberately:
- the exact figure is a real, computable fact about the Gregorian
- calendar's own 400-year cycle, but pinning it to the digit would
- make this test fail the moment a future COLITUR_EXHAUSTIVE_SWEEP
- run's own domain bounds shift by even one year at either edge, for a
- reason having nothing to do with colitur's own correctness. *)
- Alcotest.(check bool) "the Holy Family fallback gap fires on a real, non-trivial fraction of years \
- (roughly one in seven)"
- true
- (!holy_family_gap_years > 1000 && !holy_family_gap_years < 1400);
+ (* CLOSED, W2 fix: the Holy Family gap used to fire on roughly one year
+ in seven (whenever 25 December is a Sunday) -- real and not vacuous
+ across the FULL domain, not merely the handful of years the sampled
+ property happens to draw. {!Rite_of.Temporal_of.temporal} now
+ consults {!Rite_of.Temporal_of.holy_family} directly, independent of
+ weekday, so this now fires on ZERO years -- across the WHOLE domain,
+ not merely the 4 landmark years test_rite_of.ml's own
+ [test_landmark_years] already checks, which is the entire reason
+ this dedicated exhaustive counter is kept rather than deleted
+ alongside the rest of the old gap-tracking, mirroring the Nativity
+ counter's own already-established pattern just below. *)
+ Alcotest.(check int) "the Holy Family fallback gap fires on NONE of the 8 416 swept years (W2 fix -- was \
+ roughly one in seven, ~1200)"
+ 0 !holy_family_gap_years;
(* CLOSED, fix wave I8: the Nativity gap used to fire on literally every
one of the 8 416 swept years (25 December always exists). Now
exactly zero -- across the WHOLE domain, not merely the 4 landmark
@@ -340,10 +356,9 @@ let test_exhaustive_domain_sweep () =
let suite =
( "Validate (OF, real data: Rite_of.context)",
- [ Alcotest.test_case "landmark years validate cleanly (modulo the two remaining known gaps)" `Quick
+ [ Alcotest.test_case "landmark years validate cleanly (both known gaps are CLOSED)" `Quick
test_landmark_years;
- Alcotest.test_case "year 9999 does not raise; seasons flags the clamp, nothing else (beyond the known \
- gaps) fires" `Quick
+ Alcotest.test_case "year 9999 does not raise; seasons flags the clamp, nothing else fires" `Quick
test_year_9999_does_not_raise;
Alcotest.test_case "exhaustive domain sweep (1583..9999), committed not sampled" `Slow
test_exhaustive_domain_sweep ]