(* Table-driven, one Alcotest case per Tabula entry, so a transcription error names the entry it broke. Mirrors test/test_precedence_ef.ml. *) open Colitur_kernel module Vocab_of = Rite_of.Vocab_of let mk_cel ?(layer = Rite_of.Precedence_of.universal_layer) ?(status = Celebration.Feast) ?(colour = Colour.White) ?(subject = Subject.Saint) ~slug ~rank () = { Celebration.slug = Slug.of_string_exn slug; names = Names.empty; rank; status; colour; subject; citations = []; layer } let mk ?layer ?status ?colour ?subject ~slug ~rank ~origin () = { Precedence.cel = mk_cel ?layer ?status ?colour ?subject ~slug ~rank (); origin } let ctx ~iso ~season = let date = Date.of_iso8601 iso |> Result.get_ok in { Precedence.date; season; weekday = Date.weekday date } (* Each row: label, context, candidate, expected band value. *) let part1_cases = [ ( "entry 1: Good Friday", ctx ~iso:"2026-04-03" ~season:Vocab_of.Lent, mk ~slug:"of-lent-6-friday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (), 10 ); ( "entry 1: Holy Saturday", ctx ~iso:"2026-04-04" ~season:Vocab_of.Lent, mk ~slug:"of-lent-6-saturday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (), 10 ); ( "entry 1: Easter Sunday", ctx ~iso:"2026-04-05" ~season:Vocab_of.Easter, mk ~slug:"of-easter-sunday" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (), 10 ); ( "entry 2: Holy Thursday is a Holy Week feria, not the Triduum", ctx ~iso:"2026-04-02" ~season:Vocab_of.Lent, mk ~slug:"of-lent-6-thursday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (), 20 ); ( "entry 2: the Nativity", ctx ~iso:"2026-12-25" ~season:Vocab_of.Christmas, mk ~slug:"of-nativity" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (), 20 ); ( "entry 2: a Sunday of Advent", ctx ~iso:"2026-11-29" ~season:Vocab_of.Advent, mk ~slug:"of-advent-sunday-1" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (), 20 ); ( "entry 2: Ash Wednesday", ctx ~iso:"2026-02-18" ~season:Vocab_of.Lent, mk ~slug:"of-ash-wednesday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (), 20 ); ( "entry 2: a day within the Octave of Easter", ctx ~iso:"2026-04-07" ~season:Vocab_of.Easter, mk ~slug:"of-easter-octave-day-3" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (), 20 ); ( "entry 3: a universal solemnity of a saint", ctx ~iso:"2026-06-29" ~season:Vocab_of.Ordinary_time, mk ~slug:"of-peter-and-paul" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Sanctoral (), 30 ); ( "entry 4: a proper solemnity", ctx ~iso:"2026-06-29" ~season:Vocab_of.Ordinary_time, mk ~layer:"proper:diocese-of-poznan" ~slug:"of-local-patron" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Sanctoral (), 40 ) ] let test_part1 () = List.iter (fun (label, c, cand, expected) -> Alcotest.(check int) label expected (Rite_of.Precedence_of.band c cand)) part1_cases let part2_cases = [ ( "entry 5: a universal feast of the Lord", ctx ~iso:"2026-08-06" ~season:Vocab_of.Ordinary_time, mk ~subject:Subject.Lord ~slug:"of-transfiguration" ~rank:Vocab_of.Festum ~origin:Precedence.Sanctoral (), 50 ); ( "entry 6: a Sunday per annum", ctx ~iso:"2026-07-12" ~season:Vocab_of.Ordinary_time, (* rank is Festum, not the brief's original Sollemnitas: a real Sunday per annum is never Sollemnitas (temporal_of.ml only grades Advent, Lent and Easter Sundays that way, and those are entry 2, tested before this one), and a Sollemnitas candidate here would hit entry 3 first regardless of layer -- the brief's own adjacent "Sunday of Christmas time" row already uses Festum for the identical reason. *) mk ~slug:"of-ordinary-time-sunday-15" ~rank:Vocab_of.Festum ~origin:Precedence.Temporal (), 60 ); ( "entry 6: a Sunday of Christmas time", ctx ~iso:"2027-01-03" ~season:Vocab_of.Christmas, mk ~slug:"of-christmas-sunday-2" ~rank:Vocab_of.Festum ~origin:Precedence.Temporal (), 60 ); ( "entry 7: a universal feast of a saint", ctx ~iso:"2026-04-25" ~season:Vocab_of.Easter, mk ~subject:Subject.Saint ~slug:"of-mark" ~rank:Vocab_of.Festum ~origin:Precedence.Sanctoral (), 70 ); ( "entry 7: a universal feast of the BVM", ctx ~iso:"2026-05-31" ~season:Vocab_of.Ordinary_time, mk ~subject:Subject.Bvm ~slug:"of-visitation" ~rank:Vocab_of.Festum ~origin:Precedence.Sanctoral (), 70 ); ( "entry 8: a proper feast", ctx ~iso:"2026-04-25" ~season:Vocab_of.Easter, mk ~layer:"proper:diocese-of-poznan" ~slug:"of-cathedral-dedication" ~rank:Vocab_of.Festum ~origin:Precedence.Sanctoral (), 80 ); ( "entry 9: an Advent feria on 17 December", ctx ~iso:"2026-12-17" ~season:Vocab_of.Advent, mk ~slug:"of-advent-3-thursday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (), 90 ); ( "entry 9: a day within the Octave of the Nativity", ctx ~iso:"2026-12-29" ~season:Vocab_of.Christmas, mk ~slug:"of-christmas-0-tuesday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (), 90 ); ( "entry 9: a Lenten feria", ctx ~iso:"2026-03-03" ~season:Vocab_of.Lent, mk ~slug:"of-lent-2-tuesday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (), 90 ); (* MY RULING (2026-08-25, task-2-brief correction): every temporal-origin celebration Temporal_of.temporal constructs carries [layer = "temporal"], never [Precedence_of.universal_layer] -- so entries 3, 5 and 7 must test [not (is_proper layer)], not [is_universal layer]. These four rows pin exactly what that correction fixes: without it, Corpus Christi comes out unclassified (a hole) and Trinity/Christ the King land at entry 6 instead of entry 3. *) ( "entry 3: Trinity Sunday is a solemnity of the general calendar", ctx ~iso:"2026-05-31" ~season:Vocab_of.Ordinary_time, mk ~slug:"of-trinity" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (), 30 ); ( "entry 3: Corpus Christi, a Thursday, is not unclassified", ctx ~iso:"2026-06-04" ~season:Vocab_of.Ordinary_time, mk ~slug:"of-corpus-christi" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (), 30 ); (* Fix round 1 (coordinator review, 2026-08-25). (a) THE TENSION: the Tabula's own entry 9 reads "Feriae Adventus a die 17 ad 24 decembris INCLUSIVE" -- 24 December is textually a late-Advent feria -- yet Phase 1's temporal_of.ml gives that civil day the identity [of-nativity-vigil] at rank Sollemnitas, because the Vigil Mass is celebrated that evening; at colitur's one-office-per-civil-day granularity only one identity can hold the day. (b) THE RESOLUTION: Phase 2 classifies whatever identity Phase 1 already chose to emit, it does not reach back and override that choice -- the same shape, and the same settlement, as Holy Thursday (entry 2, not entry 1) above. So this candidate's rank (Sollemnitas) and layer (temporal, hence [not (is_proper layer)]) correctly route it to entry 3, band 30, not entry 9's 90. (c) OBSERVABILITY: on the universal calendar nothing else competes for 24 December, so this value can never be OBSERVED today -- it is unobservable, not untested. It becomes live the moment a proper/diocesan overlay places a proper solemnity (entry 4, band 40) on 24 December: at 30 the Vigil beats it, but at the textually-arguable 90 the proper solemnity would beat the Vigil instead. That reversal is exactly what this row guards against a silent branch-reorder. (d) CORRECTED (fix wave, 2026-08-25): band 30 is NOT the value for every 24 December -- this row's own [ctx] is 2026-12-24, a THURSDAY. When 24 December falls on a SUNDAY (e.g. 2028-12-24, 2034-12-24), [is_privileged_sunday] fires instead (Sunday + Advent + temporal origin) and the SAME slug/rank/origin bands 20 via entry 2, not 30 via entry 3. The pinned assertion above is still correct for the Thursday it names; only the earlier "band 30" framing read as unconditional, which it is not. RESOLVED (task R8, 2026-08-25): the Phase 1 defect this paragraph used to describe -- [named] (temporal_of.ml) claiming 24 December unconditionally, matched BEFORE [sunday_slug] in [temporal]'s dispatch, so a 24-December Sunday silently lost the Fourth Sunday of Advent to [of-nativity-vigil] -- is fixed. [named] now returns [None] for 24 December when that date is itself a Sunday, citing Tabula I.2/Normae n. 5 and the identical Holy Thursday granularity argument (b) above already makes; [temporal]'s dispatch then falls through to [sunday_slug] exactly as it does for every other Sunday [named] does not claim, landing on [of-advent-sunday-4]. Paragraph (d) above already anticipated the RIGHT band (20, via [is_privileged_sunday]) for that case; this fix is what makes that band actually reachable from a real [temporal] call rather than only from this row's own hand-built candidate. Confirmed on both 2028-12-24 and 2034-12-24 (test_temporal_of.ml, "nativity vigil yields to Advent Sunday IV"): [named] claims nothing, [temporal] resolves to [of-advent-sunday-4], and the non-Sunday case (2026-12-24 here) is unchanged. The litcal layer was re-run: no allow-list count moved (L1's 36, the 1876 witnessed Ordinary Time weeks, and the 859 unwitnessed count are all unchanged), because that layer compares season and Ordinary-Time week only and both readings of 24 December are "advent" either way -- the exact blind spot this same paragraph named above. This row's own pinned assertion (a hand-built candidate, Thursday 2026-12-24, band 30) needed no change: it was never wrong, only the claim that no test anywhere exercised the Sunday case. *) ( "entry 3, not 9: the Nativity Vigil is a solemnity, though 24 December \ is also a late-Advent feria", ctx ~iso:"2026-12-24" ~season:Vocab_of.Advent, mk ~slug:"of-nativity-vigil" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (), 30 ); ( "entry 5: the Baptism of the Lord is a feast OF THE LORD", ctx ~iso:"2026-01-11" ~season:Vocab_of.Christmas, mk ~subject:Subject.Lord ~slug:"of-baptism-of-the-lord" ~rank:Vocab_of.Festum ~origin:Precedence.Temporal (), 50 ); ( "entry 6, not 5: an ordinary Christmas Sunday has no Lord subject", ctx ~iso:"2027-01-03" ~season:Vocab_of.Christmas, mk ~subject:Subject.Temporal ~slug:"of-christmas-sunday-2" ~rank:Vocab_of.Festum ~origin:Precedence.Temporal (), 60 ) ] let test_part2 () = List.iter (fun (label, c, cand, expected) -> Alcotest.(check int) label expected (Rite_of.Precedence_of.band c cand)) part2_cases let part3_cases = [ ( "entry 10: a universal obligatory memorial", ctx ~iso:"2026-01-21" ~season:Vocab_of.Ordinary_time, mk ~slug:"of-agnes" ~rank:Vocab_of.Memoria_obligatoria ~origin:Precedence.Sanctoral (), 100 ); ( "entry 11: a proper obligatory memorial", ctx ~iso:"2026-01-21" ~season:Vocab_of.Ordinary_time, mk ~layer:"proper:diocese-of-poznan" ~slug:"of-secondary-patron" ~rank:Vocab_of.Memoria_obligatoria ~origin:Precedence.Sanctoral (), 110 ); ( "entry 12: an optional memorial", ctx ~iso:"2026-01-13" ~season:Vocab_of.Ordinary_time, mk ~slug:"of-hilary" ~rank:Vocab_of.Memoria_ad_libitum ~origin:Precedence.Sanctoral (), 120 ); ( "entry 12: an optional memorial is entry 12 on a proper layer too", ctx ~iso:"2026-01-13" ~season:Vocab_of.Ordinary_time, mk ~layer:"proper:diocese-of-poznan" ~slug:"of-local-optional" ~rank:Vocab_of.Memoria_ad_libitum ~origin:Precedence.Sanctoral (), 120 ); ( "entry 13: an ordinary feria per annum", ctx ~iso:"2026-07-14" ~season:Vocab_of.Ordinary_time, mk ~slug:"of-ordinary-time-15-tuesday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (), 130 ); ( "entry 13: a Christmas-time feria after 1 January", ctx ~iso:"2027-01-05" ~season:Vocab_of.Christmas, mk ~slug:"of-christmas-1-tuesday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (), 130 ); ( "entry 13, not 9: an Advent feria on 16 December", ctx ~iso:"2026-12-16" ~season:Vocab_of.Advent, mk ~slug:"of-advent-3-wednesday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (), 130 ); ( "entry 13: a paschal feria after the Octave", ctx ~iso:"2026-04-14" ~season:Vocab_of.Easter, mk ~slug:"of-easter-2-tuesday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (), 130 ) ] let test_part3 () = List.iter (fun (label, c, cand, expected) -> Alcotest.(check int) label expected (Rite_of.Precedence_of.band c cand)) part3_cases (* The temporal candidate Temporal_of produces for [d], as Precedence.resolve would build it. *) let temporal_candidate d = let t = Rite_of.Temporal_of.temporal d in { Precedence.cel = t.Temporal.office; origin = Precedence.Temporal } let ctx_of_date d = let t = Rite_of.Temporal_of.temporal d in { Precedence.date = d; season = t.Temporal.season; weekday = Date.weekday d } let band_of_date d = Rite_of.Precedence_of.band (ctx_of_date d) (temporal_candidate d) (* Every day of a year: the temporal candidate must land on a real table entry, never [unclassified]. *) let check_year y = let mk_date y m dd = Date.make ~year:y ~month:m ~day:dd |> Result.get_ok in let d = ref (mk_date y 1 1) in let last = mk_date y 12 31 in while Date.compare !d last <= 0 do let b = band_of_date !d in if b = Rite_of.Precedence_of.unclassified then Alcotest.failf "%s: temporal candidate is unclassified" (Date.to_iso8601 !d); d := Date.add_days !d 1 done let prop_band_total = QCheck.Test.make ~count:200 ~name:"band classifies every temporal candidate" (QCheck.int_range 1583 9999) (fun y -> check_year y; true) let test_exhaustive_band_total () = if Sys.getenv_opt "COLITUR_EXHAUSTIVE_SWEEP" = None then Alcotest.skip () else for y = 1583 to 9999 do check_year y done let disposition_cases = [ ( "a losing solemnity transfers (Normae n. 60)", mk ~slug:"of-advent-sunday-2" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (), mk ~slug:"of-immaculate-conception" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Sanctoral (), Precedence.Transfer ); ( "a losing feast is omitted, never commemorated", mk ~slug:"of-lent-sunday-3" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (), mk ~slug:"of-mark" ~rank:Vocab_of.Festum ~origin:Precedence.Sanctoral (), Precedence.Omit ); ( "a losing obligatory memorial is omitted", mk ~slug:"of-lent-2-monday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (), mk ~slug:"of-agnes" ~rank:Vocab_of.Memoria_obligatoria ~origin:Precedence.Sanctoral (), Precedence.Omit ); ( "a losing optional memorial is omitted", mk ~slug:"of-ordinary-time-15-tuesday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (), mk ~slug:"of-hilary" ~rank:Vocab_of.Memoria_ad_libitum ~origin:Precedence.Sanctoral (), Precedence.Omit ); ( "a losing feria is omitted", mk ~slug:"of-peter-and-paul" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Sanctoral (), mk ~slug:"of-ordinary-time-13-monday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (), Precedence.Omit ) ] let test_disposition () = List.iter (fun (label, winner, loser, expected) -> Alcotest.(check string) label (Precedence.sexp_of_disposition expected |> Sexplib0.Sexp.to_string) (Rite_of.Precedence_of.disposition ~winner ~loser |> Precedence.sexp_of_disposition |> Sexplib0.Sexp.to_string)) disposition_cases (* The rule stated as a property rather than as five rows: across every rank/rank pairing, disposition returns Transfer exactly when the loser is a solemnity, and Omit otherwise -- never Commemorate, never Repose. *) let test_disposition_never_commemorates () = List.iter (fun lr -> List.iter (fun wr -> let winner = mk ~slug:"of-winner" ~rank:wr ~origin:Precedence.Temporal () in let loser = mk ~slug:"of-loser" ~rank:lr ~origin:Precedence.Sanctoral () in match Rite_of.Precedence_of.disposition ~winner ~loser with | Precedence.Transfer -> Alcotest.(check bool) "only a solemnity transfers" true (lr = Vocab_of.Sollemnitas) | Precedence.Omit -> Alcotest.(check bool) "a solemnity never merely omits" false (lr = Vocab_of.Sollemnitas) | Precedence.Commemorate _ -> Alcotest.fail "OF has no commemorations (Normae n. 60)" | Precedence.Repose -> Alcotest.fail "OF never reposes a celebration") Vocab_of.ranks) Vocab_of.ranks let test_admit_is_always_empty () = let cand slug rank = mk ~slug ~rank ~origin:Precedence.Sanctoral () in let observed = cand "of-winner" Vocab_of.Sollemnitas in let temporal = cand "of-feria" Vocab_of.Feria in let losers = [ (cand "of-a" Vocab_of.Festum, Precedence.Ordinary, 70); (cand "of-b" Vocab_of.Memoria_obligatoria, Precedence.Privileged, 100) ] in Alcotest.(check int) "nothing is ever admitted" 0 (List.length (Rite_of.Precedence_of.admit ~observed ~temporal losers)) let test_vigil_feast_is_always_none () = List.iter (fun slug -> let c = mk ~slug ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Sanctoral () in Alcotest.(check bool) (Printf.sprintf "%s has no vigil-feast link" slug) true (Rite_of.Precedence_of.vigil_feast c = None)) [ "of-nativity-vigil"; "of-nativity"; "of-pentecost"; "of-peter-and-paul" ] (* An [occupant] callback of the shape Rite.t.transfer_target expects, backed by the real temporal cycle -- so the search meets genuine Tabula entries rather than a hand-made fiction. *) let occupant d = (Rite_of.Temporal_of.temporal d).Temporal.office (* W6 (of-known-wrongs, 2026-08-26): this used to carry the placeholder slug "of-annunciation" -- the very bug precedence_of.ml's own [annunciation_ slug] carried, so this synthetic candidate coincidentally matched the bug rather than testing against it. Now the REAL shipped slug (data/of/calendar-2002.sexp: "annunciation-of-the-lord"), so the tests below that use it (Rule 1/n. 60) actually exercise Rule 1 against the slug production code now checks -- see [test_annunciation_rule1_actually_fires] below for the test that tells "Rule 1 fired" apart from "Rule 3 happened to agree", which none of these did before it. *) let annunciation = mk ~subject:Subject.Lord ~slug:"annunciation-of-the-lord" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Sanctoral () let iso s = Date.of_iso8601 s |> Result.get_ok let test_annunciation_in_holy_week () = (* 2027: Easter is 28 March, so 25 March is Holy Thursday -- inside Holy Week. Normae n. 60 sends it to the Monday after the Second Sunday of Easter, Easter + 8 = 5 April 2027. *) let target = Rite_of.Precedence_of.transfer_target annunciation (iso "2027-03-25") occupant in Alcotest.(check string) "Annunciation 2027 goes to Easter+8" "2027-04-05" (Date.to_iso8601 target) let test_annunciation_in_easter_octave () = (* 2035: Easter is 25 March, so the Annunciation collides with Easter Sunday itself -- NOT Holy Week, so n. 60's Annunciation clause does not fire and the general search runs. Every Octave day is Tabula entry 2, so the first day free of entries 1-8 is the Monday after the Second Sunday of Easter: the same 2 April the explicit rule would have named. Asserted rather than assumed. *) let target = Rite_of.Precedence_of.transfer_target annunciation (iso "2035-03-25") occupant in Alcotest.(check string) "Annunciation 2035 also lands on Easter+8" "2035-04-02" (Date.to_iso8601 target) let test_solemnity_on_a_lenten_sunday () = (* Normae n. 5: a solemnity occurring on a Sunday of Advent, Lent or Easter goes to the FOLLOWING MONDAY, not to a searched free day. 19 March 2028 (St Joseph) is the Third Sunday of Lent, not Palm Sunday -- Rule 0 (n. 56(f)) does not fire here even with the REAL slug (see the test right below this one for that), because its own guard is [is_palm_sunday origin], not merely "this candidate is St Joseph". A synthetic slug is used deliberately, to keep this test about rule 2's own generic "solemnity on a privileged Sunday" behaviour, not about St Joseph specifically. *) let joseph = mk ~slug:"of-joseph" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Sanctoral () in let target = Rite_of.Precedence_of.transfer_target joseph (iso "2028-03-19") occupant in Alcotest.(check string) "St Joseph 2028 goes to the following Monday" "2028-03-20" (Date.to_iso8601 target) (* Normae n. 56(f), Rule 0 -- the one EARLIER-target shape {!Colitur_kernel.Rite.t.transfer_target}'s relaxed obligation now permits (rite.mli's own comment carries the argument; W1, [of-normae-56f], 2026-08-26). Unit-level counterpart to test_rite_of.ml's own full-Calendar [test_joseph_palm_sunday_2062_ anticipated_backward] -- this one calls [transfer_target] directly, the same shape every other test in this file uses, and checks the REAL shipped slug (unlike [test_solemnity_on_a_lenten_sunday] above, which uses a placeholder deliberately). Two independent years, not one: 2062 and 1978 both have Gregorian Easter = 26 March (Palm Sunday = 19 March), confirmed directly rather than assumed. *) let test_st_joseph_on_palm_sunday_anticipated_backward () = let joseph = mk ~slug:"joseph-husband-of-the-blessed-virgin-mary" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Sanctoral () in List.iter (fun (palm_sunday, expected_target) -> let origin = iso palm_sunday in Alcotest.(check bool) (Printf.sprintf "%s really is a Sunday (Easter = 26 March that year)" palm_sunday) true (Date.weekday origin = Date.Sun); let target = Rite_of.Precedence_of.transfer_target joseph origin occupant in Alcotest.(check string) (Printf.sprintf "St Joseph on Palm Sunday %s is anticipated BACKWARD to the preceding Saturday" palm_sunday) expected_target (Date.to_iso8601 target); Alcotest.(check bool) (Printf.sprintf "%s -> earlier than origin, not later" palm_sunday) true (Date.compare target origin < 0)) [ ("2062-03-19", "2062-03-18"); ("1978-03-19", "1978-03-18") ] let test_target_is_strictly_later () = (* This is the Annunciation's OWN obligation (rules 1-3), not a claim about {!Rite_of.Precedence_of.transfer_target} as a whole any more -- {!Colitur_kernel.Rite.t.transfer_target}'s kernel-level obligation was relaxed from "strictly later" to "different from origin" (W1, [of-normae-56f], 2026-08-26; see rite.mli's own comment), precisely so Rule 0 above could return an EARLIER target for St Joseph. The Annunciation's own three rules are untouched by that relaxation -- none of them was rewritten, and Calendar's placement pass still treats [target = origin] as a legitimate (bad) placement for whichever candidate returns it, so a rule that can stand still would still loop in place rather than fail loudly. *) List.iter (fun s -> let d = iso s in let t = Rite_of.Precedence_of.transfer_target annunciation d occupant in Alcotest.(check bool) (Printf.sprintf "%s -> strictly later" s) true (Date.compare t d > 0)) [ "2027-03-25"; "2035-03-25"; "2026-03-25"; "2029-03-25"; "2032-03-25" ] let test_target_is_free_of_entries_1_to_8 () = List.iter (fun s -> let t = Rite_of.Precedence_of.transfer_target annunciation (iso s) occupant in let b = band_of_date t in Alcotest.(check bool) (Printf.sprintf "%s -> a day free of Tabula entries 1-8 (band %d)" s b) true (b > 80)) [ "2027-03-25"; "2035-03-25"; "2026-03-25"; "2029-03-25" ] (* Fix round 2 (Task 1, 2026-08-25): the shipped All Souls entry (2 November, data/of/calendar-2002.sexp) actually beats an ordinary per-annum Sunday when they coincide -- the exact hazard the [band] comment above named ("PHASE 3 HAZARD ... before shipping OF sanctoral data"). Loads the REAL shipped entry (not a synthetic reconstruction) and resolves it through {!Colitur_kernel.Precedence.resolve} with [Rite_of.Precedence_of.rules] -- not [band] in isolation -- against a real per-annum Sunday context, 2 November 2025 (independently a real Sunday: date -d confirms it, and it is the same date CLAUDE.md's own EF golden pin "All Souls falling on a Sunday" already uses). *) let test_all_souls_beats_an_ordinary_sunday () = let path = "../data/of/calendar-2002.sexp" in let l = match Colitur_kernel.Layer.load Vocab_of.rank_of_sexp path with | Ok l -> l | Error e -> Alcotest.failf "%s: failed to load: %s" path e in let all_souls = match Colitur_kernel.Layer.find l (Slug.of_string_exn "all-souls") with | Some e -> e.Colitur_kernel.Layer.cel | None -> Alcotest.fail "\"all-souls\" not found in the shipped calendar" in Alcotest.(check bool) "shipped All Souls is tagged Sollemnitas" true (all_souls.Celebration.rank = Vocab_of.Sollemnitas); let all_souls_candidate = { Precedence.cel = all_souls; origin = Precedence.Sanctoral } in let d = Date.of_iso8601 "2025-11-02" |> Result.get_ok in Alcotest.(check bool) "2025-11-02 is really a Sunday" true (Date.weekday d = Date.Sun); let ctx = { Precedence.date = d; season = Vocab_of.Ordinary_time; weekday = Date.Sun } in (* An ordinary per-annum Sunday's own candidate, shaped exactly as Temporal_of.temporal builds one (rank Festum, Tabula II.6) -- independent of temporal_candidate/ctx_of_date above, which read the office off Temporal_of.temporal directly; this test instead builds it by hand so it does not depend on 2025-11-02 having no competing named temporal office of its own. *) let sunday_candidate = mk ~slug:"of-ordinary-time-sunday" ~rank:Vocab_of.Festum ~origin:Precedence.Temporal () in let resolution = Precedence.resolve Rite_of.Precedence_of.rules ctx ~temporal:sunday_candidate ~sanctoral:[ all_souls_candidate ] in Alcotest.(check bool) "All Souls, not the Sunday, is observed" true (resolution.Precedence.observed = all_souls_candidate) (* W6 (of-known-wrongs, 2026-08-26). [annunciation_slug] used to read "of-annunciation", a placeholder that never matched the real shipped entry -- Rule 1 (Normae n. 60's fixed Easter+8 destination) was DEAD CODE, only ever exercised by this file's own synthetic candidate, which happened to carry the identical placeholder. Every test above this one proves nothing about whether Rule 1 fires against real data: they all land on Easter+8, but Rule 3's general forward search converges on the IDENTICAL date whenever the 3-9 April window is empty of anything at Tabula entries 1-8 (true on the shipped universal calendar), so the output is the same whichever rule actually answers. This test tells the two apart. It loads the REAL shipped "annunciation-of-the-lord" entry (not a synthetic reconstruction, the same discipline [test_all_souls_beats_an_ordinary_sunday] above uses) and RIGS the [occupant] callback to occupy Easter+8 itself with a synthetic Tabula-entry-3 candidate (band 30, well inside entries 1-8). Rule 3's search, if it were what answered, would have to step past that occupied day onto Easter+9 -- confirmed by a CONTROL case, a different solemnity on the same origin and the same rig, which can only reach Rule 3 (it is neither the Annunciation nor St Joseph on Palm Sunday) and does indeed land on Easter+9. Rule 1's own fixed computation never consults [occupant] at all, so the rig has no effect on it: the real Annunciation still landing on Easter+8 DESPITE the block is something only Rule 1 firing can explain. *) let test_annunciation_rule1_actually_fires () = let path = "../data/of/calendar-2002.sexp" in let l = match Colitur_kernel.Layer.load Vocab_of.rank_of_sexp path with | Ok l -> l | Error e -> Alcotest.failf "%s: failed to load: %s" path e in let annunciation_real = match Colitur_kernel.Layer.find l (Slug.of_string_exn "annunciation-of-the-lord") with | Some e -> { Precedence.cel = e.Colitur_kernel.Layer.cel; origin = Precedence.Sanctoral } | None -> Alcotest.fail "\"annunciation-of-the-lord\" not found in the shipped calendar" in (* 2027: Easter 28 March, so 25 March is Holy Thursday -- inside Holy Week -- and Easter+8 is 5 April, the date this rig occupies. *) let origin = iso "2027-03-25" in let blocked = iso "2027-04-05" in let rigged d = if Date.compare d blocked = 0 then mk_cel ~slug:"of-w6-synthetic-blocker" ~rank:Vocab_of.Sollemnitas () else occupant d in let control = mk ~slug:"of-w6-control" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Sanctoral () in let control_target = Rite_of.Precedence_of.transfer_target control origin rigged in Alcotest.(check string) "control (not the Annunciation, not St Joseph): the rig really blocks -- pushed past 5 April to 6 April" "2027-04-06" (Date.to_iso8601 control_target); let target = Rite_of.Precedence_of.transfer_target annunciation_real origin rigged in Alcotest.(check string) "the REAL shipped Annunciation still lands on Easter+8 despite the block -- Rule 1, not Rule 3, fired" "2027-04-05" (Date.to_iso8601 target) (* W3 (of-known-wrongs, 2026-08-26). Mary, Mother of the Church (Easter+50) and a fixed obligatory memorial (e.g. Anthony of Padua, 13 June) both used to band at Tabula III.10 (100) whenever they coincide -- a tie {!Rite_of.Precedence_of.band} could not break, silently resolved instead by {!Colitur_kernel.Precedence.resolve}'s own generic alphabetical tie-break. See {!Rite_of.Precedence_of.movable_bvm_memorials}'s own comment for the primary source (the CDWDS's own 24 March 2018 Notification) this fix cites: the Marian memorial now bands at 95 and always prevails. This is the direct unit-level counterpart to test_rite_of.ml's own end-to-end [test_mater_ecclesiae_anthony_tie_2011_resolved] -- this one calls {!Colitur_kernel.Precedence.resolve} directly, the same shape every other test in this file uses, and checks BOTH real years the sweep in this task's own report found (2011 for Mater Ecclesiae, 2015 for the Immaculate Heart -- three years apart via the other offset, colliding with the SAME rival, Anthony of Padua, both confirmed via {!Colitur_kernel.Computus.gregorian_easter} rather than trusted). *) let test_movable_bvm_memorial_beats_a_fixed_one () = let anthony = mk ~slug:"anthony-of-padua-priest-and-doctor" ~rank:Vocab_of.Memoria_obligatoria ~origin:Precedence.Sanctoral () in List.iter (fun (label, slug, easter_offset, year, expected_date) -> let movable = mk ~slug ~rank:Vocab_of.Memoria_obligatoria ~subject:Subject.Bvm ~origin:Precedence.Sanctoral () in Alcotest.(check bool) (Printf.sprintf "%s: alphabetically \"anthony-of-padua...\" sorts BEFORE %S -- confirms this \ test exercises the tie-break the fix overrides, not a vacuous check" label slug) true (String.compare "anthony-of-padua-priest-and-doctor" slug < 0); let easter = Computus.gregorian_easter year in let d = Date.add_days easter easter_offset in Alcotest.(check string) (Printf.sprintf "%s: Easter+%d %d really is %s" label easter_offset year expected_date) expected_date (Date.to_iso8601 d); let temporal_c = mk ~slug:"of-ordinary-time-feria" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal () in let ctx = { Precedence.date = d; season = Vocab_of.Ordinary_time; weekday = Date.weekday d } in let resolution = Precedence.resolve Rite_of.Precedence_of.rules ctx ~temporal:temporal_c ~sanctoral:[ anthony; movable ] in Alcotest.(check bool) (Printf.sprintf "%s prevails over Anthony of Padua, not the alphabetical order" label) true (resolution.Precedence.observed = movable)) [ ("Mary, Mother of the Church vs Anthony of Padua", "mary-mother-of-the-church", 50, 2011, "2011-06-13"); ("Immaculate Heart of Mary vs Anthony of Padua", "immaculate-heart-of-mary", 69, 2015, "2015-06-13") ] let suite = ( "precedence-of", [ Alcotest.test_case "Tabula part I (entries 1-4)" `Quick test_part1; Alcotest.test_case "Tabula part II (entries 5-9)" `Quick test_part2; Alcotest.test_case "Tabula part III (entries 10-13)" `Quick test_part3; Alcotest.test_case "band is total over the domain" `Slow test_exhaustive_band_total; Alcotest.test_case "disposition (Normae n. 60)" `Quick test_disposition; Alcotest.test_case "disposition never commemorates" `Quick test_disposition_never_commemorates; Alcotest.test_case "admit is always empty" `Quick test_admit_is_always_empty; Alcotest.test_case "vigil_feast is always none" `Quick test_vigil_feast_is_always_none; Alcotest.test_case "Annunciation in Holy Week -> Easter+8" `Quick test_annunciation_in_holy_week; Alcotest.test_case "Annunciation in the Easter Octave -> Easter+8 too" `Quick test_annunciation_in_easter_octave; Alcotest.test_case "solemnity on a Lenten Sunday -> following Monday" `Quick test_solemnity_on_a_lenten_sunday; Alcotest.test_case "Normae n.56(f), W1: St Joseph on Palm Sunday is anticipated backward to 18 March" `Quick test_st_joseph_on_palm_sunday_anticipated_backward; Alcotest.test_case "the Annunciation's own rules 1-3 are always strictly later" `Quick test_target_is_strictly_later; Alcotest.test_case "transfer_target lands free of Tabula entries 1-8" `Quick test_target_is_free_of_entries_1_to_8; Alcotest.test_case "fix round 2: shipped All Souls beats an ordinary per-annum Sunday (2025-11-02)" `Quick test_all_souls_beats_an_ordinary_sunday; Alcotest.test_case "W6: the REAL shipped Annunciation slug makes Rule 1 fire (not merely agree with Rule 3)" `Quick test_annunciation_rule1_actually_fires; Alcotest.test_case "W3: a movable BVM obligatory memorial beats a fixed one, per the CDWDS's 24 March 2018 \ Notification" `Quick test_movable_bvm_memorial_beats_a_fixed_one ] @ List.map QCheck_alcotest.to_alcotest [ prop_band_total ] )