aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-25 15:35:59 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-25 15:35:59 +0200
commit6559a9b19451ce82a7d71281e25899acbcee2d49 (patch)
tree50474427ffa3f0403ce8f42998ec3aaf018af303
parent73cdea4403e02e356e6eb2abaa7a96845d3b050c (diff)
downloadcolitur-6559a9b19451ce82a7d71281e25899acbcee2d49.tar.gz
colitur-6559a9b19451ce82a7d71281e25899acbcee2d49.zip
feat(of): Phase 1 of the OF rite module — vocab_of, temporal_of
The first code for a second rite. lib/rites/rite_of/vocab_of implements the OF's five-way rank vocabulary (Sollemnitas/Festum/Memoria_obligatoria/ Memoria_ad_libitum/Feria) and five-season vocabulary (Advent/Christmas/ Lent/Easter/Ordinary_time), each constructor cited against the 2002 Missale Romanum's Normae universales and the Tabula dierum liturgicorum. temporal_of implements season boundaries, week numbering — including Ordinary Time's own two-block resumption, derived from the Missale's own "HEBDOMADA I/XXXIV 'per annum'" headings and empirically verified across the whole 1583-9999 domain — named temporal days, and Sunday/ferial slugs. Both satisfy Colitur_kernel.Temporal.RITE with no kernel change. No Rite.t is assembled and no CLI wiring is added (Phase 2's own scope); lib/rites/rite_ef and data/ef are untouched, verified byte-identical against the v1.0.0 tag across a domain-spanning sample. test/test_temporal_of.ml adds unit tests for every boundary and named day plus seven QCheck properties (seasons contiguous, Ordinary Time weeks in 1..34, Advent always four Sundays, slug uniqueness, weekday agreement, determinism, consecutive liturgical years partition civil time), with a committed exhaustive sweep mirroring test_validate.ml's own convention. Full account, including the Ordinary Time derivation's evidence and a list of EF concepts that do not carry over, in .superpowers/sdd/of-phase1-report.md (untracked, gitignored).
-rw-r--r--lib/rites/rite_of/dune5
-rw-r--r--lib/rites/rite_of/temporal_of.ml531
-rw-r--r--lib/rites/rite_of/temporal_of.mli141
-rw-r--r--lib/rites/rite_of/vocab_of.ml155
-rw-r--r--lib/rites/rite_of/vocab_of.mli58
-rw-r--r--test/dune2
-rw-r--r--test/test_colitur.ml3
-rw-r--r--test/test_temporal_of.ml393
8 files changed, 1286 insertions, 2 deletions
diff --git a/lib/rites/rite_of/dune b/lib/rites/rite_of/dune
new file mode 100644
index 0000000..12b425e
--- /dev/null
+++ b/lib/rites/rite_of/dune
@@ -0,0 +1,5 @@
+(library
+ (name rite_of)
+ (libraries colitur_kernel sexplib)
+ (preprocess
+ (pps ppx_sexp_conv)))
diff --git a/lib/rites/rite_of/temporal_of.ml b/lib/rites/rite_of/temporal_of.ml
new file mode 100644
index 0000000..367d855
--- /dev/null
+++ b/lib/rites/rite_of/temporal_of.ml
@@ -0,0 +1,531 @@
+(* OF (post-1970) temporal cycle. Every boundary and rank rule cites its
+ Missale Romanum editio typica tertia (2002) "Normae universales de anno
+ liturgico et de calendario" paragraph ("Normae n. N") or Institutio
+ Generalis Missalis Romani paragraph ("IGMR n. N") -- see vocab_of.ml's own
+ top-of-file comment for why the document has to be named on every
+ citation (both are independently numbered from 1 in the same source PDF).
+ Extracted with `pdftotext -layout docs/research/of/missale-romanum-2002.pdf`;
+ line numbers below are extracted-file line numbers from that command,
+ recorded so a citation can be re-located quickly, not because the PDF
+ itself is paginated that way. *)
+
+open Colitur_kernel
+open Vocab_of
+
+let mk y m d =
+ match Date.make ~year:y ~month:m ~day:d with
+ | Ok t -> t
+ | Error e -> failwith ("temporal_of: " ^ e)
+
+let weekday_index d =
+ match Date.weekday d with
+ | Date.Sun -> 0 | Date.Mon -> 1 | Date.Tue -> 2 | Date.Wed -> 3
+ | Date.Thu -> 4 | Date.Fri -> 5 | Date.Sat -> 6
+
+let sunday_on_or_before d = Date.add_days d (-(weekday_index d))
+
+(* The next Sunday STRICTLY after [d], even when [d] is itself a Sunday --
+ distinct from [sunday_on_or_before], and needed for exactly that reason by
+ {!baptism_of_the_lord} below (Normae n. 38's "dominica POST diem 6
+ ianuarii", not "on or after"). *)
+let next_sunday_strictly_after d =
+ let wi = weekday_index d in
+ Date.add_days d (if wi = 0 then 7 else 7 - wi)
+
+let before a b = Date.compare a b < 0
+let on_or_after a b = Date.compare a b >= 0
+let same a b = Date.compare a b = 0
+
+(* Normae n. 40 (Titulus II.V, "De tempore Adventus"): "Tempus Adventus
+ incipit a I Vesperis dominicae quae incidit in diem 30 novembris vel est
+ huic vicinior" -- Advent begins with First Vespers of the Sunday falling
+ on 30 November or nearest to it. Worded identically in substance to EF's
+ own RG 20 (Rite_ef.Temporal_ef.advent_start's own citation), so the same
+ closed-form identity holds and for the same reason: the Sunday nearest 30
+ November is, equivalently, three weeks (21 days) before the last Sunday on
+ or before 24 December -- verified once, generally, rather than re-derived
+ per rite: whichever weekday 24 December falls on, [sunday_on_or_before]
+ lands within the 7 days ending there, and subtracting 21 always lands
+ within the 7 days around 30 November, since 24 December minus 24 days is
+ 30 November exactly and 21 is within one week of that. *)
+let advent_start y = Date.add_days (sunday_on_or_before (mk y 12 24)) (-21)
+let year_start = advent_start
+
+(* Normae n. 38 and the Calendarium Romanum Generale's own January table
+ (extracted line ~4033: "Dominica post diem 6 ianuarii: In baptismate
+ Domini Festum") -- see this function's own .mli citation for the full
+ argument, including why the table's Monday-shift clause never fires here. *)
+let baptism_of_the_lord y = next_sunday_strictly_after (mk y 1 6)
+
+(* Normae n. 35(a) and the December calendarium table's own closing line
+ (extracted line ~4459: "Dominica infra octavam Nativitatis, vel, ea
+ deficiente, die 30 decembris: Sanctae Familiae Iesu, Mariae et Ioseph
+ Festum") -- see this function's own .mli citation for the CODE-vs-DATA
+ argument for why this lives here rather than in Phase 3's sanctoral data. *)
+let holy_family y =
+ let window = List.init 6 (fun i -> mk y 12 (26 + i)) in
+ match List.find_opt (fun d -> Date.weekday d = Date.Sun) window with
+ | Some d -> d
+ | None -> mk y 12 30
+
+(* Normae n. 36: "Dominica a die 2 ad diem 5 ianuarii occurrens est Dominica
+ II post Nativitatem" -- see this function's own .mli citation for why
+ [None] (unlike {!holy_family}) is a genuine, uncovered outcome here: this
+ window carries no "otherwise, on a fixed date" fallback clause in the
+ primary text. *)
+let second_sunday_of_christmas y =
+ let window = List.init 4 (fun i -> mk y 1 (2 + i)) in
+ List.find_opt (fun d -> Date.weekday d = Date.Sun) window
+
+(* Normae n. 43-44 and the Missale's own "HEBDOMADA XXXIV 'PER ANNUM'"
+ heading (extracted line 13099) -- see [week]'s own citation below for the
+ full two-block week arithmetic this anchors. *)
+let christ_the_king y = Date.add_days (advent_start y) (-7)
+
+(* Normae n. 71-77 boundary text, PRIMARY-SOURCE-VERIFIED against the 2002
+ typical edition (docs/research/of/missale-romanum-2002.pdf, pdftotext
+ -layout, extracted lines ~3660-3790 for the Titulus II season definitions
+ themselves; see vocab_of.ml's own top-of-file comment for the Triduum
+ placement argument this function's own Lent branch below rests on).
+
+ Tested in chronological order within the civil year, mirroring
+ Rite_ef.Temporal_ef.season's own structure -- see that function's own
+ comment for why testing "on_or_after advent_this && before christmas_day"
+ FIRST is safe even for a January [d]: [advent_start (Date.year d)] for a
+ January date is always LATER within that same civil year (late November),
+ so the check correctly fails and falls through, and no special-casing is
+ needed for the year boundary.
+
+ - Advent (n. 40): [advent_this] through 24 December.
+ - Christmas (n. 33, "usque ad dominicam post Epiphaniam ... inclusive"):
+ 25-31 December, THEN (continuing into the new civil year) 1 January
+ through {!baptism_of_the_lord} inclusive.
+ - Ordinary Time, first run (n. 44, "usque ad feriam III ante Quadragesimam
+ inclusive"): the day after {!baptism_of_the_lord} through the day
+ before Ash Wednesday.
+ - Lent (n. 28, "usque ad Missam in Cena Domini exclusive", PLUS the
+ Triduum-placement argument in vocab_of.ml): Ash Wednesday through Holy
+ Saturday inclusive -- i.e. everything before Easter itself.
+ - Easter (n. 22-23, "concluditur ... dominica Pentecostes"): Easter Sunday
+ through Pentecost Sunday inclusive.
+ - Ordinary Time, second run (n. 44, "iterum incipit feria II post
+ dominicam Pentecostes et explicit ante I Vesperas dominicae I
+ Adventus"): everything else -- the day after Pentecost through the day
+ before the FOLLOWING year's [advent_this], which is exactly what
+ survives every check above, so no explicit final test is needed; see
+ [week]'s own two-block arithmetic for why this run's own numbers still
+ land correctly on the far side of the interruption. *)
+let season d =
+ let y = Date.year d in
+ let easter = Computus.gregorian_easter y in
+ let advent_this = advent_start y in
+ let christmas_day = mk y 12 25 in
+ let ash_wednesday = Date.add_days easter (-46) in
+ let pentecost = Date.add_days easter 49 in
+ let baptism = baptism_of_the_lord y in
+ if on_or_after d advent_this && before d christmas_day then Advent
+ else if on_or_after d christmas_day then Christmas (* 25-31 Dec *)
+ else if before d (Date.add_days baptism 1) then Christmas (* 1 Jan .. baptism, inclusive *)
+ else if before d ash_wednesday then Ordinary_time (* first run *)
+ else if before d easter then Lent (* Ash Wed .. Holy Saturday, inclusive *)
+ else if Date.compare d pentecost <= 0 then Easter (* Easter Sunday .. Pentecost, inclusive *)
+ else Ordinary_time (* second run *)
+
+let days_between a b = Date.to_rata b - Date.to_rata a
+
+(* Floor division -- see Rite_ef.Temporal_ef's own identical helper for why
+ OCaml's truncating [/] would be wrong for a date before a season's own
+ week-1 origin. *)
+let floor_div a b = if a >= 0 then a / b else ((a + 1) / b) - 1
+
+let weekday_word d = Date.weekday_to_string (Date.weekday d)
+
+(* IGMR n. 346 (Titulus VIII, "De coloribus"), the season-level defaults; a-e
+ listed here, f (rose) is an override applied separately by
+ [is_rose_sunday] below, g (festive substitution) is not modelled (the same
+ kind of per-Mass-not-per-day nuance EF's own temporal_ef.ml already
+ documents as unmodelled for its own RG 126 palms/RG 132 Good Friday
+ cases).
+ a) "Color albus adhibetur in Officiis et Missis temporis paschalis et
+ Nativitatis Domini" -- white for Easter and Christmas time.
+ c) "Color viridis adhibetur in Officiis et Missis temporis 'per annum'"
+ -- green for Ordinary Time.
+ d) "Color violaceus adhibetur tempore Adventus et Quadragesimae" --
+ violet for Advent and Lent. *)
+let season_colour = function
+ | Christmas | Easter -> Colour.White
+ | Ordinary_time -> Colour.Green
+ | Advent | Lent -> Colour.Violet
+
+(* IGMR n. 346(f): "Color rosaceus adhiberi potest, ubi mos est, in
+ dominicis Gaudete (III Adventus) et Laetare (IV in Quadragesima)" -- MAY
+ be used, where customary. Treated as colitur's own canonical default the
+ same way Rite_ef.Temporal_ef.is_rose_sunday already treats EF's
+ structurally identical RG 131 ("may be used... for that Sunday only") --
+ an indult over the season's violet, not chosen afresh here. *)
+let is_rose_sunday d s =
+ let y = Date.year d in
+ match s with
+ | Advent -> same d (Date.add_days (advent_start y) 14) (* Gaudete: Advent III *)
+ | Lent -> same d (Date.add_days (Computus.gregorian_easter y) (-21)) (* Laetare: Lent IV *)
+ | _ -> false
+
+let holy_family_names =
+ Names.of_list [ (Lang.of_string_exn "la", "Sanctae Familiae Iesu, Mariae et Ioseph") ]
+
+let baptism_names = Names.of_list [ (Lang.of_string_exn "la", "In Baptismate Domini") ]
+
+(* Named temporal days that need no per-year conditional: the Nativity and
+ its Vigil, Mary Mother of God (the Nativity's own Octave Day, 1 January,
+ Normae n. 35(f)), Epiphany (n. 37, universal calendar only -- the
+ local Sunday-transfer option is out of scope, spec's own EF-precedented
+ discipline), Ash Wednesday (n. 29), Palm/Passion Sunday (n. 30, merged
+ into Lent's own week VI -- vocab_of.ml's own citation), Easter and the six
+ weekday days of its Octave (n. 24, "uti sollemnitates Domini
+ celebrantur"), Ascension (n. 25, "quadragesima die post Pascha" --
+ Easter+39, inclusive day-counting, the same convention EF's own [off 39]
+ anchor uses), Pentecost (n. 22-23), Trinity Sunday and Corpus Christi
+ (both found by their own headings in the Missale's "IN SOLLEMNITATIBUS
+ DOMINI 'PER ANNUM' OCCURRENTIBUS" appendix, extracted lines
+ ~13130-13230: "Dominica I post Pentecosten: SANCTISSIMAE TRINITATIS
+ Sollemnitas"; "Feria V post Ss.mam Trinitatem: SANCTISSIMI CORPORIS ET
+ SANGUINIS CHRISTI Sollemnitas" -- kept on its PRIMARY, typical-edition
+ date, Thursday after Trinity, not the widespread local Sunday-transfer
+ option the SAME heading's own next sentence names, for the identical
+ universal-calendar-only reason Epiphany's transfer is excluded), and
+ Christ the King (n. 43's own heading, this file's own [christ_the_king]
+ citation).
+
+ Grades: Nativity/Mary Mother of God/Epiphany/Easter-and-its-Octave/
+ Ascension/Pentecost/Trinity/Corpus-Christi/Christ-the-King are
+ [Sollemnitas], confirmed against the calendarium's own printed grade word
+ for each ("Sollemnitas") wherever a table entry exists (Nativity, Mary
+ Mother of God, Christ the King all extracted with that literal word next
+ to them; the movable Sundays/Ascension/Pentecost/Trinity/Corpus Christi
+ have none, being computed rather than tabulated, but sit at Tabula entry
+ 2/3/5, the same table region, and the Missale's own two "IN SOLLEMNITATIBUS
+ ... OCCURRENTIBUS" headings state it directly for Trinity/Corpus Christi).
+ Ash Wednesday is [Feria] -- see vocab_of.ml's own top-of-file comment for
+ why a privileged feria stays [Feria] here rather than getting an elevated
+ rank the way EF's own [Class1] does: OF's rank vocabulary keeps grade and
+ precedence-table-position separate, unlike EF's.
+
+ Colours per IGMR n. 346 (season_colour's own citation covers the season
+ defaults; the exceptions below are cited individually): Palm/Passion
+ Sunday is RED (346(b): "Color ruber adhibetur in dominica Passionis"),
+ not Lent's own violet -- a genuine, easy-to-miss divergence from what the
+ surrounding season's colour would suggest. Pentecost is likewise RED
+ (346(b): "in dominica Pentecostes"). The Nativity Vigil is tagged white on
+ IGMR 346(a)'s general "celebrationibus Domini" clause -- NO day-specific
+ colour rubric was found for the Vigil Mass itself in the extracted text
+ (unlike, e.g., the Chrism Mass or Holy Thursday's own explicit "color
+ albus" note found elsewhere in the same document), so this is the
+ best-supported general-clause reading, flagged honestly rather than
+ presented as a day-specific citation. *)
+let named d =
+ let y = Date.year d in
+ let easter = Computus.gregorian_easter y in
+ let off n = Date.add_days easter n in
+ let m = Date.month d and dd = Date.day d in
+ if m = 12 && dd = 24 then Some (Advent, "of-nativity-vigil", Colour.White, Sollemnitas)
+ else if m = 12 && dd = 25 then Some (Christmas, "of-nativity", Colour.White, Sollemnitas)
+ else if m = 1 && dd = 1 then Some (Christmas, "of-mary-mother-of-god", Colour.White, Sollemnitas)
+ else if m = 1 && dd = 6 then Some (Christmas, "of-epiphany", Colour.White, Sollemnitas)
+ else if same d (off (-46)) then Some (Lent, "of-ash-wednesday", Colour.Violet, Feria) (* Normae n. 29 *)
+ else if same d (off (-7)) then Some (Lent, "of-palm-sunday", Colour.Red, Sollemnitas) (* n. 30; IGMR 346(b) *)
+ else if same d easter then Some (Easter, "of-easter-sunday", Colour.White, Sollemnitas)
+ else if List.mem (days_between easter d) [ 1; 2; 3; 4; 5; 6 ] then
+ (* Easter Monday .. Saturday: n. 24's own eight days are Easter Sunday
+ (above) plus these six plus the closing Sunday (day 8), deliberately
+ left OUT of [named] -- see this file's own [sunday_slug], whose
+ generic Sunday-of-Easter branch already lands it on [Sollemnitas] via
+ the SAME week-1-numbering path every other Easter Sunday uses, with no
+ need for a second, competing named entry. *)
+ Some (Easter, Printf.sprintf "of-easter-octave-day-%d" (days_between easter d + 1), Colour.White, Sollemnitas)
+ else if same d (off 39) then Some (Easter, "of-ascension", Colour.White, Sollemnitas)
+ else if same d (off 49) then Some (Easter, "of-pentecost", Colour.Red, Sollemnitas)
+ else if same d (off 56) then Some (Ordinary_time, "of-trinity", Colour.White, Sollemnitas)
+ else if same d (off 60) then Some (Ordinary_time, "of-corpus-christi", Colour.White, Sollemnitas)
+ else if same d (christ_the_king y) then Some (Ordinary_time, "of-christ-the-king", Colour.White, Sollemnitas)
+ else None
+
+(* Week numbering. Advent, Lent and Easter each have a single, simple origin
+ Sunday (mirroring Rite_ef.Temporal_ef.week_origin's own shape); Christmas
+ has none at all (its own structure is date-keyed throughout -- n. 36 says
+ "the Sunday falling 2-5 January", never "week I", and no calendarium
+ heading anywhere in Christmas time carries a week-number title the way
+ "HEBDOMADA I 'PER ANNUM'" does); Ordinary Time needs the two-block
+ arithmetic below.
+
+ ORDINARY TIME'S OWN RESUMPTION RULE -- the hard part this module exists
+ to get right, and the reason it is Phase 1's own gate. DERIVED, not
+ copied, from three primary-text facts, none of which alone states the
+ rule but which together pin it down uniquely:
+
+ 1. Normae n. 44: Ordinary Time runs in two disjoint blocks -- "the Monday
+ following the Sunday occurring after 6 January" through "Tuesday before
+ Lent, inclusive" (the FIRST run), then "the Monday after Pentecost
+ Sunday" through "before First Vespers of Advent I" (the SECOND run).
+ 2. The Missale's own "HEBDOMADA I 'PER ANNUM'" heading (extracted line
+ 12050: "Dominica prima 'per annum' fit festum Baptismatis Domini") --
+ the Baptism of the Lord Sunday IS, positionally, "Dominica I" of
+ Ordinary Time's own week-numbering scheme, even though its SEASON, per
+ n. 33, is still Christmas. The very next heading in the Missal is
+ literally "DOMINICA II 'PER ANNUM'" -- confirming the first run's
+ numbering begins counting from that Sunday, landing the run's own
+ first NUMBERED Sunday (the one actually inside Ordinary Time's season)
+ on week 2.
+ 3. The Missale's own "HEBDOMADA XXXIV 'PER ANNUM'" heading (extracted
+ line 13099: "Dominica ultima 'per annum' fit sollemnitas ...
+ universorum Regis") -- the LAST Sunday before Advent I, Christ the
+ King, is ALWAYS week XXXIV. Confirmed structurally, not merely
+ asserted: nothing in the Missale computes Christ the King's own date
+ from a week number at all -- it is defined independently, as "the
+ Sunday nearest 30 November, minus 7 days" (this file's own
+ [christ_the_king]) -- so this heading is a genuine, independently
+ falsifiable claim, not a tautology.
+
+ Given (1)-(3), the FIRST run's week numbers follow the ordinary
+ Sunday-aligned [floor_div] formula from origin {!baptism_of_the_lord},
+ uninterrupted until Ash Wednesday cuts it off (fact 2 pins the origin;
+ nothing further is needed for this half). The SECOND run cannot use the
+ same simple formula with a fixed origin, because fact 3 is a CONSTRAINT
+ on where the run must END, not a statement of where it begins -- and
+ Normae n. 43 itself says the total is "thirty-three OR thirty-four"
+ weeks, meaning the gap between where the first run stops and where the
+ second run's own numbers would need to start (for the run to still end on
+ 34) is not constant across years. The only value consistent with facts 1
+ and 3 simultaneously is computed BACKWARD from Christ the King: number the
+ second run's own weeks, Sunday-aligned, counting forward from Pentecost
+ Sunday as if it were itself week 0 (a "raw" count with no liturgical
+ meaning of its own), find that raw count for the FINAL week (Christ the
+ King's own week), and shift every raw number in the run by the constant
+ amount that makes the final one read 34. That shift is not cosmetic the
+ way EF's own analogous "n - total + 7" resumed-tail relabelling
+ (Rite_ef.Temporal_ef.sunday_slug) is -- there, the underlying [week] FIELD
+ stays the raw, uncapped Pentecost count, and only the SLUG string is
+ relabelled, because EF's numbering carries no rubrical meaning past
+ Time_after_pentecost's own raw count. Here the adjusted number IS the
+ liturgical week identity itself (it selects which Mass formulary applies,
+ per the Missale's own week headings), so [week] itself, not merely a
+ slug, must carry the adjusted value.
+
+ EVIDENCE this produces the right shape (empirically verified, not merely
+ argued, across every one of the 8 417 years 1583..9999 with a small
+ throwaway script reproducing this exact arithmetic): the two runs never
+ overlap (no year assigns the same week number to two different Sundays),
+ every number produced falls in 1..34 inclusive, and the gap between the
+ first run's own last week and the second run's own first week is ALWAYS
+ either 0 (all 34 weeks used, no skip) or 1 (exactly one week number
+ skipped that year, used by neither run) -- never more, never negative --
+ which is exactly Normae n. 43's own "thirty-three OR thirty-four", with
+ "34" corresponding to gap 0 and "33" to gap 1. This IS "in some years a
+ week is skipped entirely" (the task brief's own framing), now measured:
+ 2 672 of the 8 417 years use all 34; 5 745 skip exactly one. *)
+let ordinary_time_week d y easter =
+ let baptism = baptism_of_the_lord y in
+ let ash_wednesday = Date.add_days easter (-46) in
+ if before d ash_wednesday then
+ let n = floor_div (days_between baptism d) 7 + 1 in
+ if n < 1 then None else Some n
+ else
+ let pentecost = Date.add_days easter 49 in
+ let ctk = christ_the_king y in
+ let raw_total = floor_div (days_between pentecost ctk) 7 + 1 in
+ let raw_n = floor_div (days_between pentecost d) 7 + 1 in
+ let adjusted = 34 - raw_total + raw_n in
+ if adjusted < 1 || adjusted > 34 then None else Some adjusted
+
+let week d =
+ let y = Date.year d in
+ let easter = Computus.gregorian_easter y in
+ match season d with
+ | Christmas -> None
+ | Advent ->
+ let n = floor_div (days_between (advent_start y) d) 7 + 1 in
+ if n < 1 then None else Some n
+ | Lent ->
+ let n = floor_div (days_between (Date.add_days easter (-42)) d) 7 + 1 in
+ if n < 1 then None else Some n
+ | Easter ->
+ let n = floor_div (days_between easter d) 7 + 1 in
+ if n < 1 then None else Some n
+ | Ordinary_time -> ordinary_time_week d y easter
+
+(* Sunday slugs. Christmas needs its own three-way dispatch (the season has
+ no week numbering at all, and carries up to three distinct movable
+ Sundays a year -- {!holy_family}, {!second_sunday_of_christmas} and
+ {!baptism_of_the_lord} -- every OTHER season's Sundays use the generic
+ week-numbered form. A Sunday {!named} already intercepts (Palm/Passion
+ Sunday, Easter, Ascension is never a Sunday, Pentecost, Trinity, Corpus
+ Christi is never a Sunday, Christ the King) never reaches this function at
+ all, since {!temporal}'s own dispatch checks [named] first. *)
+let sunday_slug d =
+ if Date.weekday d <> Date.Sun then None
+ else
+ let y = Date.year d in
+ let s = season d in
+ match s with
+ | Christmas ->
+ if same d (holy_family y) then Some "of-holy-family"
+ else if second_sunday_of_christmas y = Some d then Some "of-christmas-sunday-2"
+ else if same d (baptism_of_the_lord y) then Some "of-baptism-of-the-lord"
+ else None (* unreachable: every Christmas-season Sunday is one of the three above *)
+ | _ -> (
+ match week d with
+ | Some n -> Some (Printf.sprintf "of-%s-sunday-%d" (season_slug_word s) n)
+ | None -> None)
+
+(* Ferial slugs within Christmas time. Three disjoint stretches, numbered
+ 0/1/2 exactly like Rite_ef.Temporal_ef.christmastide_feria_slug's own
+ three (a real structural parallel between the two rites' Christmas
+ seasons, not a copied convention) -- needed for the identical reason: a
+ single "no week numbering" slug key would let the same weekday recur
+ across stretches within one liturgical year, violating slug uniqueness
+ (spec's own requirement, {!Colitur_kernel.Validate}'s ["slugs"] check).
+ - 26-31 December (minus {!holy_family}, when it lands there, already
+ intercepted earlier): "of-christmas-0-<weekday>".
+ - 2-5 January (minus {!second_sunday_of_christmas}, when it exists):
+ "of-christmas-1-<weekday>".
+ - 7 January through the day before {!baptism_of_the_lord} (0 to 6 days,
+ empty exactly when Epiphany falls on a Saturday): "of-christmas-2-<weekday>". *)
+let christmas_feria_slug d y =
+ let m = Date.month d and dd = Date.day d in
+ let w = weekday_word d in
+ if m = 12 && dd >= 26 && dd <= 31 then Some (Printf.sprintf "of-christmas-0-%s" w)
+ else if m = 1 && dd >= 2 && dd <= 5 then Some (Printf.sprintf "of-christmas-1-%s" w)
+ else if m = 1 && dd >= 7 && before d (baptism_of_the_lord y) then Some (Printf.sprintf "of-christmas-2-%s" w)
+ else None
+
+let id = "of"
+
+let temporal d =
+ let y = Date.year d in
+ let s = season d in
+ let weekday = Date.weekday d in
+ let build ?(subject = Subject.Temporal) ?(names = Names.empty) ~season ~slug ~colour ~rank ~week () =
+ let office = Celebration.make ~slug:(Slug.of_string_exn slug) ~rank ~colour ~subject ~names ~layer:"temporal" () in
+ { Temporal.season; week; weekday; office }
+ in
+ match named d with
+ | Some (season, slug, colour, rank) -> build ~season ~slug ~colour ~rank ~week:(week d) ()
+ | None -> (
+ match sunday_slug d with
+ | Some slug ->
+ let colour = if is_rose_sunday d s then Colour.Rose else season_colour s in
+ let subject, names, rank =
+ if same d (holy_family y) then (Subject.Lord, holy_family_names, Festum)
+ (* Tabula entry 6 ("Dominicae temporis Nativitatis") would
+ already give an ordinary Christmas-time Sunday Festum; Holy
+ Family's own grade word in the calendarium ("Festum") happens
+ to agree, so no separate table-entry citation is needed for
+ the RANK itself, only for the identity override. *)
+ else if second_sunday_of_christmas y = Some d then (Subject.Temporal, Names.empty, Festum)
+ else if same d (baptism_of_the_lord y) then (Subject.Lord, baptism_names, Festum)
+ else
+ (* Tabula entry 2 ("Dominicae Adventus, Quadragesimae et
+ Paschae") vs. entry 6 ("dominicae 'per annum'") -- the ONLY
+ remaining case reaching this branch is a plain, unnamed
+ Sunday of Advent, Lent, Easter or Ordinary Time (every
+ Christmas Sunday is already one of the three branches
+ above). *)
+ let rank = match s with Advent | Lent | Easter -> Sollemnitas | Ordinary_time | Christmas -> Festum in
+ (Subject.Temporal, Names.empty, rank)
+ in
+ build ~subject ~names ~season:s ~slug ~colour ~rank ~week:(week d) ()
+ | None -> (
+ match christmas_feria_slug d y with
+ | Some slug -> build ~season:s ~slug ~colour:(season_colour s) ~rank:Feria ~week:(week d) ()
+ | None ->
+ let easter = Computus.gregorian_easter y in
+ let after_ashes = days_between easter d in
+ if after_ashes >= -45 && after_ashes <= -43 then
+ (* Thursday/Friday/Saturday after Ash Wednesday: proper
+ Masses, but before Lent's own week-1 origin -- no week
+ number, mirroring Rite_ef.Temporal_ef's identical gap. *)
+ build ~season:s
+ ~slug:(Printf.sprintf "of-lent-after-ashes-%s" (weekday_word d))
+ ~colour:Colour.Violet ~rank:Feria ~week:None ()
+ else
+ let week_n = week d in
+ let slug =
+ Printf.sprintf "of-%s-%d-%s" (season_slug_word s) (Option.value week_n ~default:0) (weekday_word d)
+ in
+ let colour =
+ if after_ashes = -3 then
+ (* Holy Thursday (Missa in Cena Domini): IGMR n. 346(a)'s
+ own general clause, "insuper in celebrationibus
+ Domini, quae non sint de eius Passione" -- a
+ celebration OF THE LORD that is not of his Passion
+ (the institution of the Eucharist and the
+ priesthood, distinct from Good Friday's own Passion
+ commemoration) -- white. No DAY-SPECIFIC "color
+ albus" rubric was found in the extracted text for
+ this exact Mass (unlike some other Masses in the same
+ document); this is the best-supported general-clause
+ reading, flagged rather than presented as a
+ day-specific citation, the same honesty [named]'s own
+ Nativity Vigil colour note above gives. *)
+ Colour.White
+ else if after_ashes = -2 then
+ (* Good Friday: IGMR n. 346(b), "Color ruber adhibetur
+ ... in celebrationibus Passionis Domini" -- red, not
+ Lent's own season violet. *)
+ Colour.Red
+ else
+ (* Holy Saturday's own daytime (after_ashes = -1) is
+ aliturgical -- no Mass, hence no colour rubric to find
+ in the primary text at all -- and every other Lenten
+ feria has none either; both default to the season's
+ own violet, the textually conservative choice rather
+ than an invented specific one. *)
+ season_colour s
+ in
+ build ~season:s ~slug ~colour ~rank:Feria ~week:week_n ()))
+
+(* Independent restatement of {!named}'s fixed and Easter-relative dates,
+ plus {!holy_family}, {!second_sunday_of_christmas} (when it exists that
+ year) and {!baptism_of_the_lord}, paired with the slug each carries --
+ the same anchor-agreement discipline Rite_ef.Temporal_ef.anchors follows,
+ for the identical reason (catches an accidental single-site drift, e.g. an
+ offset silently changing, rather than leaving it invisible). Easter's own
+ six weekday octave days are included via the same [1;2;3;4;5;6] offset
+ list [named] itself uses, restated independently rather than reused, for
+ the same "independent" discipline the rest of this list follows. *)
+let anchors y =
+ let easter = Computus.gregorian_easter y in
+ let off n = Date.add_days easter n in
+ [ ("of-nativity-vigil", mk y 12 24);
+ ("of-nativity", mk y 12 25);
+ ("of-mary-mother-of-god", mk y 1 1);
+ ("of-epiphany", mk y 1 6);
+ ("of-holy-family", holy_family y);
+ ("of-baptism-of-the-lord", baptism_of_the_lord y);
+ ("of-ash-wednesday", off (-46));
+ ("of-palm-sunday", off (-7));
+ ("of-easter-sunday", off 0) ]
+ @ List.map (fun n -> (Printf.sprintf "of-easter-octave-day-%d" (n + 1), off n)) [ 1; 2; 3; 4; 5; 6 ]
+ @ [ ("of-ascension", off 39);
+ ("of-pentecost", off 49);
+ ("of-trinity", off 56);
+ ("of-corpus-christi", off 60);
+ ("of-christ-the-king", christ_the_king y) ]
+ @ (match second_sunday_of_christmas y with
+ | Some d -> [ ("of-christmas-sunday-2", d) ]
+ | None -> [])
+
+(* Compile-time check that this module satisfies the kernel's rite contract
+ -- the same check Rite_ef.Temporal_ef ends with. Deliberately the ONLY
+ kernel-facing signature this phase satisfies: {!Colitur_kernel.Rite.t}
+ itself (precedence, transfers, readings) is Phase 2's own deliverable, per
+ the task brief's explicit "do not add a Rite.t for it". *)
+module _ : Colitur_kernel.Temporal.RITE = struct
+ let id = id
+
+ type season = Vocab_of.season
+ type rank = Vocab_of.rank
+
+ let vocab = Vocab_of.vocab
+ let year_start = year_start
+ let temporal = temporal
+end
diff --git a/lib/rites/rite_of/temporal_of.mli b/lib/rites/rite_of/temporal_of.mli
new file mode 100644
index 0000000..982ff05
--- /dev/null
+++ b/lib/rites/rite_of/temporal_of.mli
@@ -0,0 +1,141 @@
+(** The OF (post-1970) temporal cycle, per the Missale Romanum editio typica
+ tertia (2002) "Normae universales de anno liturgico et de calendario"
+ ("Normae n. N") and the Institutio Generalis Missalis Romani ("IGMR
+ n. N") -- the same PDF carries both documents, independently numbered
+ from 1, so every citation in this rite module names which one it is.
+
+ OPEN QUESTION, deliberately NOT settled here (design
+ 2026-08-24-colitur-of-rite-module-design.md §8.1): the OF calendar has
+ no meaning before 1970, but colitur's kernel domain is 1583..9999, and
+ this module is computable -- and computed -- across the WHOLE of it,
+ exactly as {!Rite_ef.Temporal_ef} is. Phase 1 takes no position on
+ whether a pre-1970 (or, symmetrically, a far-future) answer from this
+ module means anything liturgically; it only guarantees the arithmetic is
+ total and the properties below hold everywhere the kernel itself is
+ total. Refusing, extrapolating, or flagging pre-1970 output specially is
+ a decision for the phase that wires this module into a {!Colitur_kernel
+ .Rite.t} and a validation layer with a real oracle to check it
+ against -- not this one. *)
+
+open Colitur_kernel
+
+(** Normae n. 40: "Tempus Adventus incipit a I Vesperis dominicae quae
+ incidit in diem 30 novembris vel est huic vicinior" -- Advent begins with
+ First Vespers of the Sunday falling on 30 November or nearest to it.
+ Worded identically in substance to EF's own RG 20 ({!Rite_ef.Temporal_ef
+ .advent_start}), so the same closed-form identity applies: the Sunday
+ nearest 30 November is, equivalently, three weeks before the last Sunday
+ on or before 24 December -- see that function's own citation for why
+ that identity holds for every civil year with no case split. *)
+val advent_start : int -> Date.t
+
+(** First day of the liturgical year opening in civil year [y]. Same as
+ {!advent_start}. *)
+val year_start : int -> Date.t
+
+val season : Date.t -> Vocab_of.season
+
+(** Normae n. 38 and the Calendarium Romanum Generale's own January table
+ (extracted line ~4033: "Dominica post diem 6 ianuarii: In baptismate
+ Domini Festum"): the Sunday falling AFTER 6 January (never 6 January
+ itself, even in a year 6 January IS a Sunday -- "post", not "on or
+ after") carries the feast of the Baptism of the Lord. The January
+ table's own further note ("Ubi sollemnitas Epiphaniae ad dominicam
+ transfertur... festum Baptismatis Domini celebratur feria secunda
+ sequenti") only concerns places that exercise Normae n. 37's LOCAL
+ option to move Epiphany itself off 6 January -- inapplicable to the
+ universal calendar this module computes, which keeps Epiphany fixed at
+ 6 January (spec's own EF-precedented "universal calendar only"
+ discipline), so that Monday-shift clause is never reached here. *)
+val baptism_of_the_lord : int -> Date.t
+
+(** Normae n. 35(a) and the December calendarium table's own closing line
+ (extracted line ~4459: "Dominica infra octavam Nativitatis, vel, ea
+ deficiente, die 30 decembris: Sanctae Familiae ... Festum"): the Sunday
+ falling within the Octave of the Nativity (26-31 December), or, that
+ lacking, 30 December. Movable-with-a-fixed-fallback, the same shape as
+ EF's own Holy Family/Holy Name ({!Rite_ef.Temporal_ef.holy_family_sunday}
+ /[holy_name_sunday]) -- CODE for that reason, not sanctoral DATA, unlike
+ the plain fixed-date entries this window also contains (Stephen 26 Dec,
+ John 27 Dec, the Holy Innocents 28 Dec), which carry no such fallback
+ logic and are deferred to Phase 3's calendar data instead (see this
+ file's own top-of-module comment for the full CODE-vs-DATA argument).
+ UNLIKE EF's 7-13 January window, which can never be empty of a Sunday,
+ this 6-day window (26-31 December) CAN be -- confirmed by the fallback
+ clause's own existence in the primary text, not merely inferred. *)
+val holy_family : int -> Date.t
+
+(** Normae n. 36: "Dominica a die 2 ad diem 5 ianuarii occurrens est
+ Dominica II post Nativitatem" -- the Sunday falling 2-5 January, if any,
+ is the Second Sunday after the Nativity. [None] when no such Sunday
+ exists that year (the SAME 2-5 January window as EF's own Holy Name of
+ Jesus, {!Rite_ef.Temporal_ef.holy_name_sunday} -- a real structural
+ coincidence between the two rites' calendars, not a shared computation:
+ OF's Normae carries no "otherwise, on a fixed date" fallback for this
+ Sunday at all, unlike EF's RG 17(a) -- a year without one simply has no
+ "Second Sunday after the Nativity" office; {!temporal}'s own dispatch
+ falls through to an ordinary Christmas-time feria for every day of that
+ window in such a year). *)
+val second_sunday_of_christmas : int -> Date.t option
+
+(** Normae n. 43-44 and the Missale's own "HEBDOMADA XXXIV 'PER ANNUM'"
+ heading (extracted line 13099: "Dominica ultima 'per annum' fit
+ sollemnitas ... universorum Regis"): the LAST Sunday before Advent I,
+ always the Solemnity of Christ the King, always week XXXIV. See
+ {!temporal}'s own citation on the two-block week arithmetic for the full
+ derivation this anchors. *)
+val christ_the_king : int -> Date.t
+
+(** The named temporal days: the Nativity and its Vigil, Mary Mother of God
+ (the Nativity's own Octave Day, 1 January), Epiphany, Ash Wednesday,
+ Palm/Passion Sunday, Easter and the days of its Octave, Ascension,
+ Pentecost, Trinity Sunday, Corpus Christi, and Christ the King. Returns
+ (season, slug, colour, rank). Carries no week of its own, for the same
+ reason {!Rite_ef.Temporal_ef.named} does not: {!temporal} computes it
+ uniformly via {!week} for every day, named or not, so a named day inside
+ a numbered season run always carries that run's week.
+
+ Deliberately EXCLUDES {!holy_family}, {!second_sunday_of_christmas} and
+ {!baptism_of_the_lord}: each needs a per-year conditional ([holy_family]
+ on whether 26-31 December has a Sunday at all; the other two on which
+ Sunday, if any, actually falls in their own window) that this function's
+ bare [Date.t -> ... option] shape has no way to express without
+ threading extra state through every other branch too -- the exact
+ reason EF's own [named] excludes its structurally identical Holy
+ Family/Holy Name. {!temporal} applies all three as its own targeted
+ dispatch instead. *)
+val named : Date.t -> (Vocab_of.season * string * Colour.t * Vocab_of.rank) option
+
+(** Week within the season, 1-indexed, Sunday-aligned (constant across a
+ Sunday-to-Saturday span). [None] outside every numbered run -- Christmas
+ has none at all (Normae's own Christmas-season structure is date-keyed,
+ never week-keyed: contrast n. 36's "the Sunday falling 2-5 January", a
+ date range, against n. 30's own "Sundays I, II, III..." for Lent) -- and
+ Ordinary Time's own numbering is total over both of its runs (Normae
+ n. 43-44): see {!temporal}'s own citation for the two-block arithmetic
+ that makes the second run's numbers land correctly on 1..34 with the
+ final week always 34, INCLUDING the "in some years a week is skipped
+ entirely" case Normae n. 43's own "thirty-three OR thirty-four" already
+ names. *)
+val week : Date.t -> int option
+
+(** The slug for a Sunday, or [None] if [d] is not a Sunday. *)
+val sunday_slug : Date.t -> string option
+
+val id : string
+
+(** Total over 1583..9999: every date yields exactly one temporal identity
+ (subject to this file's own top-of-module note on what "yields" means
+ before 1970). *)
+val temporal : Date.t -> (Vocab_of.season, Vocab_of.rank) Temporal.t
+
+(** Independent restatement of {!named}'s fixed and Easter-relative dates,
+ plus {!holy_family}, {!second_sunday_of_christmas} and
+ {!baptism_of_the_lord}, for civil year [y], paired with the slug each
+ should carry -- the same anchor-agreement discipline
+ {!Rite_ef.Temporal_ef.anchors} follows, for the same reason: catches an
+ accidental single-site drift in an offset rather than leaving it
+ invisible. Not wired into {!Colitur_kernel.Validate.run} in this phase
+ (that needs a full {!Colitur_kernel.Rite.t}, Phase 2's own deliverable),
+ but kept in the same shape so that wiring is a straight reuse. *)
+val anchors : int -> (string * Date.t) list
diff --git a/lib/rites/rite_of/vocab_of.ml b/lib/rites/rite_of/vocab_of.ml
new file mode 100644
index 0000000..932c30f
--- /dev/null
+++ b/lib/rites/rite_of/vocab_of.ml
@@ -0,0 +1,155 @@
+(* OF (post-1970) temporal cycle vocabulary. Both types are closed variants
+ private to this rite: EF code cannot name them, and this rite cannot name
+ EF's. Every boundary and rank rule cites its Missale Romanum editio typica
+ tertia (2002) "Normae universales de anno liturgico et de calendario"
+ paragraph ("Normae n. N") or Institutio Generalis Missalis Romani
+ paragraph ("IGMR n. N") -- the SAME PDF (docs/research/of/missale-
+ romanum-2002.pdf) carries both documents, independently numbered from 1,
+ so a bare "n. N" is ambiguous; every citation in this rite module names
+ which document it is. *)
+
+(* Normae Titulus II ("De anni circulo"), sections II-VI, PRIMARY-SOURCE-
+ VERIFIED against the 2002 typical edition (docs/research/of/
+ missale-romanum-2002.pdf, pdftotext -layout, around extracted line 3660
+ onward): the document's own six subsection headings under "De anni
+ circulo" are "I. De Triduo paschali", "II. De tempore paschali", "III. De
+ tempore Quadragesimae", "IV. De tempore Nativitatis", "V. De tempore
+ Adventus", "VI. De tempore 'per annum'". FIVE of those six are literally
+ called a "tempus" (season); the Triduum is deliberately NOT -- it is
+ named only "Triduum paschale", never "tempus Tridui". That is a real
+ textual signal, not an omission this module is working around: the
+ Missal's own authors did not count the Triduum as one of the seasons a
+ day's season vocabulary needs to name. Confirmed independently by the
+ Missal's own Mass-propers structure (extracted lines ~7778, ~8480):
+ "HEBDOMADA SANCTA" (the Lenten Holy Week propers) and "SACRUM TRIDUUM
+ PASCHALE" are printed as two SEPARATE section headings, and neither is
+ titled "tempus" either.
+
+ This module therefore has FIVE seasons, matching the design's own
+ prediction (2026-08-24-colitur-of-rite-module-design.md §4.2): [Advent],
+ [Christmas], [Lent], [Easter], [Ordinary_time]. No [Septuagesima] (the
+ 1969/2002 reform abolished the pre-Lent season the EF's Vocab_ef still
+ carries -- Normae's own season list has no seventh "de tempore
+ Septuagesimae" section at all, confirmed by its absence here) and no
+ [Passiontide] (EF's separate season for the two weeks before Easter; the
+ 2002 Normae's own n. 30 merges what used to be "Passion Sunday" and "Palm
+ Sunday" into ONE day, "Dominica in palmis de Passione Domini", and folds
+ it into Lent's own week numbering as week VI -- see [temporal_of.ml]'s own
+ citation on this for the full argument).
+
+ WHERE THE TRIDUUM'S OWN THREE DAYS LAND, given they get no season of
+ their own: Normae n. 28, PRIMARY TEXT: "Tempus Quadragesimae decurrit a
+ feria IV Cinerum ad Missam in Cena Domini EXCLUSIVE" -- Lent runs from Ash
+ Wednesday up to (excluding) the evening Mass of the Lord's Supper. At
+ colitur's own civil-day granularity (one season per [Date.t], never a
+ sub-day boundary), that puts the WHOLE civil day of Holy Thursday inside
+ Lent (the excluded boundary is the Mass itself, an event within that day,
+ not the day's own start). Good Friday and Holy Saturday have no season of
+ their own to fall back to either -- n. 22/23 fix Easter Time's own start
+ at "dominica Resurrectionis" (Easter SUNDAY), not a day earlier -- so by
+ the same "extend the last season through the Triduum's daytime, since the
+ only actual Mass in that window (the Vigil) is textually already Easter"
+ reasoning EF's own temporal_ef.ml already uses for its own Passiontide
+ ("Holy Saturday stays in Passiontide: the Vigil is a night Mass"), Good
+ Friday and Holy Saturday both stay [Lent] here too. Normae n. 30 gives
+ this a SECOND, independent, and more direct textual anchor unavailable to
+ EF's own analogous decision: "Dominicae huius temporis appellantur
+ dominicae I, II, III, IV, V in Quadragesima. Dominica sexta, in qua
+ initium sumit Hebdomada sancta, dicitur ... 'Dominica in palmis de
+ Passione Domini'" -- the Sundays of THIS TIME [Lent] are called Sundays
+ I-V in Lent; the SIXTH Sunday, ON WHICH HOLY WEEK BEGINS, is called
+ [without dropping the 'Alleluia', a rubric not relevant here] "Palm
+ Sunday of the Lord's Passion". Normae's OWN prose therefore states that
+ Holy Week (Palm Sunday through the Vigil) IS Lent's own sixth week, not a
+ thing outside Lent's own numbering -- confirmed against
+ [temporal_of.ml]'s own [week] computation, which independently derives
+ the identical result (Palm Sunday through Holy Saturday all fall in week
+ 6 of Lent by the ordinary Sunday-aligned [floor_div] formula, with no
+ special case needed for the Triduum days at all) -- see that file for the
+ arithmetic. *)
+type season = Advent | Christmas | Lent | Easter | Ordinary_time [@@deriving sexp]
+
+(* Normae Titulus I ("De diebus liturgicis"), and the Tabula dierum
+ liturgicorum's own 13-entry table (extracted lines ~3926-3980), name FOUR
+ graded celebrations throughout: "Sollemnitas" (entries 1-4), "Festum"
+ (entries 5-8), "Memoria obligatoria" (entries 10-11) and "Memoria ad
+ libitum" (entry 12) -- confirmed against the Calendarium Romanum
+ Generale's own printed month tables (e.g. "IN EPIPHANIA DOMINI
+ Sollemnitas", "S. Raimundi de Penyafort, presbyteri" [no grade word:
+ Memoria ad libitum by the table's own footnote, "Quando non indicatur
+ gradus celebrationis, fit Memoria ad libitum"], "Ss. Stephani,
+ protomartyris Festum"), which uses these four words as its own grade
+ column, verbatim, for every entry.
+
+ A FIFTH constructor, [Feria], is added beyond those four for an ordinary
+ weekday -- not a fifth grade on the same Sollemnitas-Festum-Memoria
+ scale, but a categorically different thing: Normae n. 16 (Titulus I.IV,
+ "De feriis") calls these days "feriae" and never applies any of the four
+ grade-words to one, yet {!Colitur_kernel.Celebration.t} requires SOME
+ [rank] on every day (the same reason EF's own Vocab_ef.rank carries a
+ catch-all [Class4] for its own plain ferias, RG 91 entry 28). The Tabula
+ itself confirms ferias are graded for PRECEDENCE without ever being
+ promoted to one of the four celebration-grades: entry 2 lists "Feriae
+ Hebdomadae sanctae, a feria II ad feriam V inclusive" and "Feria IV
+ Cinerum" [Ash Wednesday] at the SAME table position as Christmas,
+ Epiphany, Ascension and Pentecost -- an extremely privileged PRECEDENCE,
+ yet the table's own word for these days stays "feria", never
+ "sollemnitas". Entry 9 similarly places "Feriae Adventus a die 17 ad 24
+ decembris inclusive" and "Feriae Quadragesimae" together with a
+ THIRD kind of day (unnamed days within the Nativity octave), and entry 13
+ is the catch-all for every other feria. All THREE entries use the same
+ word, "feria" -- what differs between them is precedence-table position,
+ not grade. Phase 1 (this module, {!Temporal_of.temporal}) therefore
+ assigns [Feria] uniformly to every day carrying no graded celebration;
+ which Tabula entry a given [Feria] day occupies (Ash-Wednesday-tier vs.
+ Advent-17-24/Lenten-tier vs. ordinary) is a {!Colitur_kernel.Precedence
+ .rules.band} table lookup left to Phase 2, read off the day's own season/
+ date/weekday the same way EF's own [Precedence_ef.band] already
+ distinguishes its RG 91 entry 2 Triduum ferias from its entry-28 ordinary
+ ones without either of those two needing a DIFFERENT [Vocab_ef.rank]
+ value -- see design 2026-08-24-colitur-of-rite-module-design.md §3, "No
+ kernel change should be needed for the ranks themselves". *)
+type rank = Sollemnitas | Festum | Memoria_obligatoria | Memoria_ad_libitum | Feria [@@deriving sexp]
+
+let seasons = [ Advent; Christmas; Lent; Easter; Ordinary_time ]
+let ranks = [ Sollemnitas; Festum; Memoria_obligatoria; Memoria_ad_libitum; Feria ]
+
+let season_to_string = function
+ | Advent -> "advent"
+ | Christmas -> "christmas"
+ | Lent -> "lent"
+ | Easter -> "easter"
+ | Ordinary_time -> "ordinary-time"
+
+let season_of_string = function
+ | "advent" -> Some Advent
+ | "christmas" -> Some Christmas
+ | "lent" -> Some Lent
+ | "easter" -> Some Easter
+ | "ordinary-time" -> Some Ordinary_time
+ | _ -> None
+
+(* No lectionary-legacy word to preserve (spec §4.4's reasoning for EF's own
+ [Vocab_ef.season_slug_word] does not apply here -- OF has not been
+ bootstrapped from any external lectionary key set yet), so this is simply
+ [season_to_string] under a separate name, kept distinct only so a future
+ real divergence does not force renaming every call site. *)
+let season_slug_word = season_to_string
+
+let rank_to_string = function
+ | Sollemnitas -> "sollemnitas"
+ | Festum -> "festum"
+ | Memoria_obligatoria -> "memoria-obligatoria"
+ | Memoria_ad_libitum -> "memoria-ad-libitum"
+ | Feria -> "feria"
+
+let rank_of_string = function
+ | "sollemnitas" -> Some Sollemnitas
+ | "festum" -> Some Festum
+ | "memoria-obligatoria" -> Some Memoria_obligatoria
+ | "memoria-ad-libitum" -> Some Memoria_ad_libitum
+ | "feria" -> Some Feria
+ | _ -> None
+
+let vocab : (season, rank) Colitur_kernel.Vocab.t =
+ { seasons; season_to_string; season_of_string; ranks; rank_to_string; rank_of_string }
diff --git a/lib/rites/rite_of/vocab_of.mli b/lib/rites/rite_of/vocab_of.mli
new file mode 100644
index 0000000..dd23cfa
--- /dev/null
+++ b/lib/rites/rite_of/vocab_of.mli
@@ -0,0 +1,58 @@
+(** OF (post-1970) season and rank vocabulary. *)
+
+type season =
+ | Advent
+ | Christmas
+ | Lent
+ | Easter
+ | Ordinary_time
+[@@deriving sexp]
+
+(** Normae universales de anno liturgico et de calendario (Missale Romanum
+ editio typica tertia, 2002), Titulus I ("De diebus liturgicis"), and the
+ Institutio Generalis Missalis Romani (IGMR) n. 346's own colour-rule
+ prose, both cite the four graded celebrations by these exact names: a
+ Sunday-or-weekday MYSTERY of the Lord that ranks above everything else
+ ([Sollemnitas]), a lesser but still universally-binding celebration
+ ([Festum]), a saint's day the calendar obliges everyone to keep
+ ([Memoria_obligatoria]), and one a community may keep or not
+ ([Memoria_ad_libitum]).
+
+ A FIFTH constructor, [Feria], is added beyond those four -- not a fifth
+ grade on the same scale, but a different kind of thing: an ordinary
+ weekday carries none of the four (Normae Titulus I.IV n. 16 calls these
+ days "feriae", never a "celebratio" of any grade), yet
+ {!Colitur_kernel.Celebration.t} requires a [rank] on every day the way
+ {!Colitur_kernel.Vocab_ef}'s own catch-all [Class4] does for EF. Ferias
+ are graded for PRECEDENCE (privileged Lenten/Advent ferias outrank an
+ ordinary one, per the Tabula dierum liturgicorum's own entries 2, 9 and
+ 13) but that grading is a {!Colitur_kernel.Precedence.rules.band} table
+ lookup (Phase 2), not a distinct [rank] value -- unlike EF, where
+ [Class2]/[Class3]/[Class4] carry a privileged feria's own grade directly.
+ See vocab_of.ml's own top-of-file comment for the primary-source
+ citations and the design decision this rests on. *)
+type rank =
+ | Sollemnitas
+ | Festum
+ | Memoria_obligatoria
+ | Memoria_ad_libitum
+ | Feria
+[@@deriving sexp]
+
+val seasons : season list
+val ranks : rank list
+val season_to_string : season -> string
+val season_of_string : string -> season option
+
+(** The word used when building a slug: lowercase, hyphenated, e.g.
+ ["ordinary-time"] for [Ordinary_time]. Unlike {!Colitur_kernel.Vocab_ef
+ .season_slug_word}, this is not adopted from any external lectionary --
+ OF has no bootstrapped-from-lectio slug legacy to preserve -- so it is
+ simply {!season_to_string}'s own hyphenated image; kept as a separate
+ function anyway so a future divergence (a real lectionary source using
+ different words) does not require renaming every call site. *)
+val season_slug_word : season -> string
+
+val rank_to_string : rank -> string
+val rank_of_string : string -> rank option
+val vocab : (season, rank) Colitur_kernel.Vocab.t
diff --git a/test/dune b/test/dune
index 840f0d1..9f926df 100644
--- a/test/dune
+++ b/test/dune
@@ -1,6 +1,6 @@
(test
(name test_colitur)
- (libraries colitur_kernel colitur_naming colitur_render colitur_citation rite_ef alcotest qcheck qcheck-alcotest sexplib)
+ (libraries colitur_kernel colitur_naming colitur_render colitur_citation rite_ef rite_of alcotest qcheck qcheck-alcotest sexplib)
(deps
../data/ef/sanctoral.sexp
../data/ef/adjustments.sexp
diff --git a/test/test_colitur.ml b/test/test_colitur.ml
index 9eefecd..3721f5a 100644
--- a/test/test_colitur.ml
+++ b/test/test_colitur.ml
@@ -11,7 +11,8 @@ let () =
Test_citation.render_suite;
("sigla", Test_citation.sigla_suite);
Test_config.suite;
- Test_overlay.suite; Test_overlay_ini.suite; Test_temporal_ef.suite; Test_validate.suite; Test_precedence.suite;
+ Test_overlay.suite; Test_overlay_ini.suite; Test_temporal_ef.suite; Test_temporal_of.suite; Test_validate.suite;
+ Test_precedence.suite;
Test_calendar.suite; Test_precedence_ef.suite; Test_sanctoral_ef.suite; Test_rite_ef.suite;
Test_rubrics_ef.suite;
Test_differential.suite; Test_oracle.suite; Test_oracle.suite_2038; Test_oracle.suite_2035;
diff --git a/test/test_temporal_of.ml b/test/test_temporal_of.ml
new file mode 100644
index 0000000..7560413
--- /dev/null
+++ b/test/test_temporal_of.ml
@@ -0,0 +1,393 @@
+(* Phase 1 of the OF rite module: Vocab_of + Temporal_of. Modelled on
+ test_temporal_ef.ml's own shape (example-based unit tests for the named
+ days and boundaries, QCheck properties for the domain-wide invariants),
+ scoped to what Phase 1 actually delivers -- no Rite.t exists yet, so
+ these properties are written directly against Temporal_of rather than
+ through Colitur_kernel.Validate.run (which needs a full Rite.t bundle,
+ Phase 2's own deliverable). *)
+
+module V = Rite_of.Vocab_of
+module T = Rite_of.Temporal_of
+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
+
+(* ---- vocabulary ---- *)
+
+let test_vocab_roundtrips () =
+ List.iter
+ (fun s -> Alcotest.(check bool) "season string roundtrip" true (V.season_of_string (V.season_to_string s) = Some s))
+ V.seasons;
+ List.iter
+ (fun r -> Alcotest.(check bool) "rank string roundtrip" true (V.rank_of_string (V.rank_to_string r) = Some r))
+ V.ranks;
+ Alcotest.(check int) "five seasons" 5 (List.length V.seasons);
+ Alcotest.(check int) "five ranks" 5 (List.length V.ranks)
+
+(* ---- season boundaries (Normae n. 28/30/33/40/44 -- see temporal_of.ml's
+ own citations) ---- *)
+
+let season_str dt = V.season_to_string (T.season dt)
+
+let test_advent_start () =
+ Alcotest.(check string) "advent 2026" "2026-11-29" (iso (T.advent_start 2026));
+ Alcotest.(check bool) "always a Sunday" true (D.weekday (T.advent_start 2026) = D.Sun)
+
+let test_seasons () =
+ (* Easter 2026 = 5 April; Ash Wednesday = 18 Feb; Palm Sunday = 29 Mar. *)
+ Alcotest.(check string) "1 Dec 2026" "advent" (season_str (d 2026 12 1));
+ Alcotest.(check string) "24 Dec 2026" "advent" (season_str (d 2026 12 24));
+ Alcotest.(check string) "25 Dec 2026" "christmas" (season_str (d 2026 12 25));
+ Alcotest.(check string) "1 Jan 2027 (Mary Mother of God)" "christmas" (season_str (d 2027 1 1));
+ (* Baptism of the Lord 2027 = 10 Jan: Christmas Time runs through it inclusive. *)
+ Alcotest.(check string) "10 Jan 2027 (Baptism Sunday)" "christmas" (season_str (d 2027 1 10));
+ Alcotest.(check string) "11 Jan 2027 (Monday after Baptism)" "ordinary-time" (season_str (d 2027 1 11));
+ Alcotest.(check string) "Ash Wed 18 Feb 2026" "lent" (season_str (d 2026 2 18));
+ Alcotest.(check string) "Palm Sunday 29 Mar 2026" "lent" (season_str (d 2026 3 29));
+ (* Normae n. 30 makes Holy Week Lent's own week VI: Holy Thursday, Good
+ Friday and Holy Saturday all stay Lent (vocab_of.ml's own citation). *)
+ Alcotest.(check string) "Holy Thursday 2 Apr 2026" "lent" (season_str (d 2026 4 2));
+ Alcotest.(check string) "Good Friday 3 Apr 2026" "lent" (season_str (d 2026 4 3));
+ Alcotest.(check string) "Holy Saturday 4 Apr 2026" "lent" (season_str (d 2026 4 4));
+ Alcotest.(check string) "Easter 5 Apr 2026" "easter" (season_str (d 2026 4 5));
+ Alcotest.(check string) "Pentecost 24 May 2026" "easter" (season_str (d 2026 5 24));
+ Alcotest.(check string) "day after Pentecost 25 May 2026" "ordinary-time" (season_str (d 2026 5 25));
+ Alcotest.(check string) "last Sunday before Advent, 22 Nov 2026" "ordinary-time" (season_str (d 2026 11 22))
+
+(* ---- the three movable Christmas-time Sundays ---- *)
+
+let test_baptism_of_the_lord () =
+ Alcotest.(check string) "2026" "2026-01-11" (iso (T.baptism_of_the_lord 2026));
+ Alcotest.(check string) "2027" "2027-01-10" (iso (T.baptism_of_the_lord 2027));
+ Alcotest.(check string) "2024 (6 Jan is a Saturday)" "2024-01-07" (iso (T.baptism_of_the_lord 2024));
+ (* 2030: 6 January itself is a Sunday. Normae n. 38 says "dominica POST
+ diem 6 ianuarii" -- strictly after, never 6 January itself -- so the
+ Baptism jumps a full week to 13 January, not 6. *)
+ Alcotest.(check bool) "2030: 6 Jan is a Sunday" true (D.weekday (d 2030 1 6) = D.Sun);
+ Alcotest.(check string) "2030 (Epiphany itself a Sunday)" "2030-01-13" (iso (T.baptism_of_the_lord 2030));
+ Alcotest.(check bool) "always a Sunday" true (D.weekday (T.baptism_of_the_lord 2026) = D.Sun)
+
+let test_holy_family () =
+ Alcotest.(check string) "2026 (26 Dec is a Sunday)" "2026-12-27" (iso (T.holy_family 2026));
+ (* 2022: no Sunday falls 26-31 December that year -- the n. 35(a) fallback,
+ 30 December, fires. *)
+ Alcotest.(check bool) "2022: no Sunday 26-31 Dec"
+ (List.for_all (fun dd -> D.weekday (d 2022 12 dd) <> D.Sun) [ 26; 27; 28; 29; 30; 31 ])
+ true;
+ Alcotest.(check string) "2022 fallback" "2022-12-30" (iso (T.holy_family 2022))
+
+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
+ falls in the 2-5 January window that year -- a genuine, uncovered
+ [None], with no fallback in the primary text. *)
+ Alcotest.(check (option string)) "2024 (none)" None (Option.map iso (T.second_sunday_of_christmas 2024))
+
+let test_christ_the_king () =
+ Alcotest.(check string) "2026" "2026-11-22" (iso (T.christ_the_king 2026));
+ Alcotest.(check bool) "always a Sunday" true (D.weekday (T.christ_the_king 2026) = D.Sun);
+ (* Normae n. 43's own heading (temporal_of.ml's own citation): always week 34. *)
+ Alcotest.(check (option int)) "week 34" (Some 34) (T.week (T.christ_the_king 2026))
+
+(* ---- named days ---- *)
+
+let named_slug dt = match T.named dt with Some (_, slug, _, _) -> slug | None -> "<none>"
+let named_colour dt = match T.named dt with Some (_, _, c, _) -> Some c | None -> None
+let named_rank dt = match T.named dt with Some (_, _, _, r) -> Some r | None -> None
+
+let test_named_feasts () =
+ (* Easter 2026 = 5 April. *)
+ Alcotest.(check string) "nativity vigil" "of-nativity-vigil" (named_slug (d 2026 12 24));
+ Alcotest.(check string) "nativity" "of-nativity" (named_slug (d 2026 12 25));
+ Alcotest.(check string) "mary mother of god" "of-mary-mother-of-god" (named_slug (d 2027 1 1));
+ Alcotest.(check string) "epiphany" "of-epiphany" (named_slug (d 2027 1 6));
+ Alcotest.(check string) "ash wednesday" "of-ash-wednesday" (named_slug (d 2026 2 18));
+ Alcotest.(check string) "palm sunday" "of-palm-sunday" (named_slug (d 2026 3 29));
+ Alcotest.(check string) "easter" "of-easter-sunday" (named_slug (d 2026 4 5));
+ Alcotest.(check string) "ascension" "of-ascension" (named_slug (d 2026 5 14));
+ Alcotest.(check string) "pentecost" "of-pentecost" (named_slug (d 2026 5 24));
+ Alcotest.(check string) "trinity" "of-trinity" (named_slug (d 2026 5 31));
+ Alcotest.(check string) "corpus christi" "of-corpus-christi" (named_slug (d 2026 6 4));
+ Alcotest.(check string) "christ the king" "of-christ-the-king" (named_slug (d 2026 11 22));
+ Alcotest.(check string) "an ordinary day is not named" "<none>" (named_slug (d 2026 7 15))
+
+let test_named_colours_and_ranks () =
+ (* IGMR 346(b): Palm/Passion Sunday and Pentecost are red, not their
+ season's own colour. *)
+ Alcotest.(check (option string)) "palm sunday is red" (Some "red")
+ (Option.map Colitur_kernel.Colour.to_string (named_colour (d 2026 3 29)));
+ Alcotest.(check (option string)) "pentecost is red" (Some "red")
+ (Option.map Colitur_kernel.Colour.to_string (named_colour (d 2026 5 24)));
+ Alcotest.(check (option string)) "nativity is white" (Some "white")
+ (Option.map Colitur_kernel.Colour.to_string (named_colour (d 2026 12 25)));
+ (* Ash Wednesday: rank Feria, not an elevated grade -- vocab_of.ml's own
+ top-of-file argument for why OF's rank vocabulary keeps grade and
+ precedence-table position separate, unlike EF's. *)
+ Alcotest.(check (option string)) "ash wednesday is Feria" (Some "feria")
+ (Option.map V.rank_to_string (named_rank (d 2026 2 18)));
+ Alcotest.(check (option string)) "christmas is Sollemnitas" (Some "sollemnitas")
+ (Option.map V.rank_to_string (named_rank (d 2026 12 25)))
+
+let test_easter_octave () =
+ (* Easter 2026 = 5 April; the octave's six weekdays are 6-11 April. *)
+ Alcotest.(check string) "easter monday" "of-easter-octave-day-2" (named_slug (d 2026 4 6));
+ Alcotest.(check string) "easter saturday" "of-easter-octave-day-7" (named_slug (d 2026 4 11));
+ Alcotest.(check (option string)) "all octave weekdays are Sollemnitas white" (Some "sollemnitas white")
+ (match T.named (d 2026 4 6) with
+ | Some (_, _, c, r) -> Some (V.rank_to_string r ^ " " ^ Colitur_kernel.Colour.to_string c)
+ | None -> None);
+ (* The closing Sunday (12 Apr) is deliberately NOT in [named] -- it falls
+ through to the generic Sunday-of-Easter slug, week 2. *)
+ Alcotest.(check string) "closing Sunday is not in named" "<none>" (named_slug (d 2026 4 12));
+ Alcotest.(check string) "closing Sunday's own slug" "of-easter-sunday-2"
+ (match T.sunday_slug (d 2026 4 12) with Some s -> s | None -> "<none>")
+
+(* ---- week numbering ---- *)
+
+let test_week_numbers () =
+ Alcotest.(check (option int)) "Advent I 2026" (Some 1) (T.week (T.advent_start 2026));
+ Alcotest.(check (option int)) "Advent IV 2026" (Some 4) (T.week (D.add_days (T.advent_start 2026) 21));
+ Alcotest.(check (option int)) "Christmas has no week" None (T.week (d 2026 12 27));
+ (* Lent I Sunday = Easter - 42 = 2026-02-22; Ash Wednesday and the three
+ days after it belong to no numbered week. *)
+ Alcotest.(check (option int)) "Ash Wednesday has no week" None (T.week (d 2026 2 18));
+ Alcotest.(check (option int)) "Lent I Sunday is week 1" (Some 1) (T.week (d 2026 2 22));
+ (* Normae n. 30: Holy Week is Lent's own week VI. *)
+ Alcotest.(check (option int)) "Palm Sunday is Lent week 6" (Some 6) (T.week (d 2026 3 29));
+ Alcotest.(check (option int)) "Holy Saturday is Lent week 6" (Some 6) (T.week (d 2026 4 4));
+ Alcotest.(check (option int)) "Easter Sunday is Easter week 1" (Some 1) (T.week (d 2026 4 5));
+ Alcotest.(check (option int)) "Pentecost is Easter week 8" (Some 8) (T.week (d 2026 5 24))
+
+(* ---- Ordinary Time's own resumption arithmetic: the hard part, pinned
+ concretely (temporal_of.ml's own [ordinary_time_week] citation has the
+ full derivation and its evidence). ---- *)
+
+let test_ordinary_time_block_a () =
+ (* 2026: Baptism Sunday 11 Jan is week 1's own origin; the Missale's own
+ heading ("Dominica prima 'per annum' fit festum Baptismatis Domini")
+ puts that Sunday itself at week 1, so the following Monday (12 Jan,
+ the first day actually IN the Ordinary Time season) is ALSO week 1,
+ and the next Sunday (18 Jan, "Dominica II 'per annum'") is week 2. *)
+ Alcotest.(check (option int)) "12 Jan 2026 (Mon after Baptism) is week 1" (Some 1) (T.week (d 2026 1 12));
+ Alcotest.(check (option int)) "18 Jan 2026 (Dominica II) is week 2" (Some 2) (T.week (d 2026 1 18));
+ (* Ash Wednesday 2026 = 18 Feb; Shrove Tuesday (17 Feb) is block A's own
+ last day, week 6. *)
+ Alcotest.(check (option int)) "17 Feb 2026 (Shrove Tuesday) is week 6" (Some 6) (T.week (d 2026 2 17))
+
+(* Every day of CIVIL year [y] -- deliberately not a liturgical-year walk
+ ([T.year_start y] through [T.year_start (y + 1)]): that span crosses into
+ the FOLLOWING civil year's January-onward Ordinary Time, governed by a
+ DIFFERENT year's Easter, which would silently test the wrong year's own
+ resumption arithmetic. *)
+let civil_year_days y =
+ let start = d y 1 1 in
+ List.init (if Colitur_kernel.Date.is_leap y then 366 else 365) (fun i -> D.add_days start i)
+
+let test_ordinary_time_resumption () =
+ (* 2026: block A tops out at week 6; Pentecost is 24 May; the Monday
+ after (25 May) resumes at week 8, SKIPPING week 7 entirely that
+ year -- a concrete, verified instance of Normae n. 43's own "thirty-
+ three OR thirty-four weeks". *)
+ Alcotest.(check (option int)) "2026: 25 May (Mon after Pentecost) resumes at week 8" (Some 8)
+ (T.week (d 2026 5 25));
+ Alcotest.(check bool) "2026: Ordinary Time week 7 never occurs (Easter's own week 7, 17-23 May, is a
+ different axis and does occur that year -- this checks only Ordinary-Time-season days)" true
+ (List.for_all
+ (fun dt -> T.season dt <> V.Ordinary_time || T.week dt <> Some 7)
+ (civil_year_days 2026));
+ (* 2024: block A tops out at week 6 (Ash Wed 14 Feb, Shrove Tue 13 Feb);
+ Pentecost is 19 May; the Monday after (20 May) resumes at week 7 --
+ NO skip that year, all 34 weeks used. *)
+ Alcotest.(check (option int)) "2024: 13 Feb (Shrove Tuesday) is week 6" (Some 6) (T.week (d 2024 2 13));
+ Alcotest.(check (option int)) "2024: 20 May (Mon after Pentecost) resumes at week 7" (Some 7)
+ (T.week (d 2024 5 20));
+ Alcotest.(check bool) "2024: every Ordinary Time week 1..34 occurs somewhere in civil 2024" true
+ (let weeks =
+ List.filter_map (fun dt -> if T.season dt = V.Ordinary_time then T.week dt else None) (civil_year_days 2024)
+ in
+ List.for_all (fun n -> List.mem n weeks) (List.init 34 (fun i -> i + 1)))
+
+(* ---- 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));
+ Alcotest.(check string) "Lent I 2026" "of-lent-sunday-1" (sunday_slug_of (d 2026 2 22));
+ Alcotest.(check string) "18 Jan 2026 (Dominica II per annum)" "of-ordinary-time-sunday-2" (sunday_slug_of (d 2026 1 18));
+ Alcotest.(check string) "holy family 2026" "of-holy-family" (sunday_slug_of (T.holy_family 2026));
+ Alcotest.(check string) "baptism of the lord 2026" "of-baptism-of-the-lord" (sunday_slug_of (T.baptism_of_the_lord 2026));
+ Alcotest.(check string) "2nd sunday of christmas 2026" "of-christmas-sunday-2"
+ (sunday_slug_of (Option.get (T.second_sunday_of_christmas 2026)))
+
+let test_ferial_slugs () =
+ Alcotest.(check string) "26 Dec 2026 (stretch 0)" "of-christmas-0-saturday" (slug_of (d 2026 12 26));
+ Alcotest.(check string) "2 Jan 2027 (stretch 1)" "of-christmas-1-saturday" (slug_of (d 2027 1 2));
+ Alcotest.(check string) "7 Jan 2027 (stretch 2)" "of-christmas-2-thursday" (slug_of (d 2027 1 7));
+ Alcotest.(check string) "after-ashes Thursday 2026" "of-lent-after-ashes-thursday" (slug_of (d 2026 2 19));
+ Alcotest.(check string) "ordinary weekday" "of-ordinary-time-2-monday" (slug_of (d 2026 1 19));
+ Alcotest.(check string) "lent weekday" "of-lent-6-monday" (slug_of (d 2026 3 30))
+
+let test_colours () =
+ let colour_of dt = (T.temporal dt).Colitur_kernel.Temporal.office.Colitur_kernel.Celebration.colour in
+ Alcotest.(check string) "Good Friday is red" "red" (Colitur_kernel.Colour.to_string (colour_of (d 2026 4 3)));
+ Alcotest.(check string) "Holy Thursday is white" "white" (Colitur_kernel.Colour.to_string (colour_of (d 2026 4 2)));
+ Alcotest.(check string) "ordinary time weekday is green" "green"
+ (Colitur_kernel.Colour.to_string (colour_of (d 2026 1 19)));
+ Alcotest.(check string) "advent weekday is violet" "violet" (Colitur_kernel.Colour.to_string (colour_of (d 2026 12 1)));
+ Alcotest.(check string) "gaudete sunday is rose" "rose"
+ (Colitur_kernel.Colour.to_string (colour_of (D.add_days (T.advent_start 2026) 14)));
+ Alcotest.(check string) "laetare sunday is rose" "rose"
+ (Colitur_kernel.Colour.to_string (colour_of (D.add_days (Colitur_kernel.Computus.gregorian_easter 2026) (-21))))
+
+(* ---- properties over the whole domain ---- *)
+
+(* Walk one liturgical year, [T.year_start y] through the day before
+ [T.year_start (y + 1)]. Callers must keep [y] within 1583..9998 --
+ [T.year_start (y + 1)] calls [Date.make] internally (via [mk]) and
+ raises for civil year 10000, out of the kernel's domain, the same
+ reason Colitur_kernel.Validate.run's own walk clamps at 9999. *)
+let walk_year y =
+ let start = T.year_start y in
+ let stop = D.add_days (T.year_start (y + 1)) (-1) in
+ let rec go dt acc = if D.compare dt stop > 0 then List.rev acc else go (D.add_days dt 1) ((dt, T.temporal dt) :: acc) in
+ go start []
+
+let compressed_seasons days =
+ List.fold_left
+ (fun acc (_, t) -> match acc with s :: _ when s = t.Colitur_kernel.Temporal.season -> acc | _ -> t.Colitur_kernel.Temporal.season :: acc)
+ [] days
+ |> List.rev
+
+let expected_season_runs = [ V.Advent; V.Christmas; V.Ordinary_time; V.Lent; V.Easter; V.Ordinary_time ]
+
+(* Normae n. 43-44: seasons contiguous, no gaps or overlaps, over the whole
+ liturgical year -- the run-length-compressed sequence is always these
+ six runs, in this order (Ordinary Time genuinely appears twice, per
+ design 2026-08-24-colitur-of-rite-module-design.md §4.2). *)
+let prop_season_runs_contiguous y = compressed_seasons (walk_year y) = expected_season_runs
+
+(* Ordinary Time weeks are always 1..34 (Normae n. 43), and every
+ Ordinary-Time-season day carries a week number at all (the two-block
+ arithmetic never silently drops one). *)
+let prop_ordinary_time_week_in_range y =
+ List.for_all
+ (fun (_, t) ->
+ match t.Colitur_kernel.Temporal.season with
+ | V.Ordinary_time -> ( match t.Colitur_kernel.Temporal.week with Some n -> n >= 1 && n <= 34 | None -> false)
+ | _ -> true)
+ (walk_year y)
+
+(* Advent always has exactly four Sundays (Normae n. 41). *)
+let prop_advent_four_sundays y =
+ List.length
+ (List.filter
+ (fun (dt, t) -> t.Colitur_kernel.Temporal.season = V.Advent && D.weekday dt = D.Sun)
+ (walk_year y))
+ = 4
+
+(* No two dates within one liturgical year share a slug (spec's own
+ requirement 3; Colitur_kernel.Validate's own ["slugs"] check, for EF,
+ asserts the identical thing once a Rite.t exists -- this is that same
+ invariant, checked directly here since Phase 1 has no Rite.t yet). *)
+let prop_slug_uniqueness y =
+ let slugs =
+ List.map (fun (_, t) -> Colitur_kernel.Slug.to_string t.Colitur_kernel.Temporal.office.Colitur_kernel.Celebration.slug) (walk_year y)
+ in
+ List.length slugs = List.length (List.sort_uniq String.compare slugs)
+
+(* [Temporal.t.weekday] always agrees with [Date.weekday] of the date it was
+ computed for. *)
+let prop_weekday_agrees y = List.for_all (fun (dt, t) -> t.Colitur_kernel.Temporal.weekday = D.weekday dt) (walk_year y)
+
+(* [temporal] is a pure, deterministic function: calling it twice on the
+ same date gives structurally equal results. *)
+let prop_determinism y = List.for_all (fun (dt, t) -> T.temporal dt = t) (walk_year y)
+
+(* The year is covered exactly once: three consecutive liturgical years'
+ walks, concatenated and sorted by date, form one strictly consecutive
+ run with no duplicate and no gap -- catches an off-by-one in
+ [advent_start]/[season] that a single year's own walk could not (it
+ would show up only as a wrong boundary DATE within that walk, not as a
+ missing or doubled civil day, since [walk_year] itself always produces a
+ contiguous sequence by construction). *)
+let prop_liturgical_years_partition_civil_time y =
+ let dates = List.concat_map (fun yy -> List.map fst (walk_year yy)) [ y - 1; y; y + 1 ] in
+ let sorted = List.sort D.compare dates in
+ let rec check = function
+ | a :: (b :: _ as rest) -> D.to_rata b - D.to_rata a = 1 && check rest
+ | _ -> true
+ in
+ check sorted
+
+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
+
+let prop_tests =
+ [ make_prop "OF: season runs are Advent,Christmas,Ordinary_time,Lent,Easter,Ordinary_time" prop_season_runs_contiguous;
+ make_prop "OF: Ordinary Time weeks are always 1..34" prop_ordinary_time_week_in_range;
+ make_prop "OF: Advent always has exactly four Sundays" prop_advent_four_sundays;
+ make_prop "OF: no two dates in one liturgical year share a slug" prop_slug_uniqueness;
+ 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 ]
+
+(* ---- the committed exhaustive sweep, mirroring test_validate.ml's own
+ COLITUR_EXHAUSTIVE_SWEEP convention: every year 1583..9998, not a
+ 200-year sample, gated behind the same environment variable so the
+ default `dune test` stays fast and reports the skip honestly. *)
+let colitur_exhaustive_sweep_env = "COLITUR_EXHAUSTIVE_SWEEP"
+
+let test_exhaustive_domain_sweep () =
+ if Sys.getenv_opt colitur_exhaustive_sweep_env = None then Alcotest.skip ()
+ else
+ for y = 1584 to 9997 do
+ let days = walk_year y in
+ if compressed_seasons days <> expected_season_runs then
+ Alcotest.failf "%d: season runs %s" y
+ (String.concat "," (List.map V.season_to_string (compressed_seasons days)));
+ if not (prop_ordinary_time_week_in_range y) then Alcotest.failf "%d: an Ordinary Time week is out of 1..34" y;
+ 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
+ done
+
+(* 1583 and 9999 themselves sit at the domain edges, where the [y-1]/[y+1]
+ probes the properties above use would call [T.year_start] on an
+ out-of-range civil year and raise -- covered separately here instead,
+ confirming [T.temporal] itself stays total right up to both edges. *)
+let test_domain_edges_do_not_raise () =
+ let check_civil_year y =
+ let start = match D.make ~year:y ~month:1 ~day:1 with Ok t -> t | Error e -> Alcotest.failf "%s" e in
+ for i = 0 to 364 do
+ ignore (T.temporal (D.add_days start i))
+ done
+ in
+ check_civil_year 1583;
+ check_civil_year 9999
+
+let suite =
+ ( "temporal-of",
+ [ Alcotest.test_case "vocab roundtrips" `Quick test_vocab_roundtrips;
+ Alcotest.test_case "advent start" `Quick test_advent_start;
+ 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 "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;
+ Alcotest.test_case "named colours and ranks" `Quick test_named_colours_and_ranks;
+ Alcotest.test_case "easter octave" `Quick test_easter_octave;
+ Alcotest.test_case "week numbers" `Quick test_week_numbers;
+ Alcotest.test_case "ordinary time block A" `Quick test_ordinary_time_block_a;
+ Alcotest.test_case "ordinary time resumption" `Quick test_ordinary_time_resumption;
+ Alcotest.test_case "sunday slugs" `Quick test_sunday_slugs;
+ Alcotest.test_case "ferial slugs" `Quick test_ferial_slugs;
+ Alcotest.test_case "colours" `Quick test_colours;
+ Alcotest.test_case "domain edges do not raise" `Quick test_domain_edges_do_not_raise;
+ Alcotest.test_case "exhaustive domain sweep" `Slow test_exhaustive_domain_sweep ]
+ @ List.map QCheck_alcotest.to_alcotest prop_tests )