(* 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 let annunciation = mk ~subject:Subject.Lord ~slug:"of-annunciation" ~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. *) 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) let test_target_is_strictly_later () = (* Rite.t.transfer_target's own stated obligation: Calendar treats target <= origin as a legitimate placement, so a rule that can stand still would loop candidates 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" ] 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 "transfer_target is 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 ] @ List.map QCheck_alcotest.to_alcotest [ prop_band_total ] )