aboutsummaryrefslogtreecommitdiff
path: root/lib/rites
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rites')
-rw-r--r--lib/rites/rite_ef/precedence_ef.ml893
-rw-r--r--lib/rites/rite_ef/precedence_ef.mli260
-rw-r--r--lib/rites/rite_ef/rite_ef.ml24
-rw-r--r--lib/rites/rite_ef/rite_ef.mli35
-rw-r--r--lib/rites/rite_ef/temporal_ef.ml176
-rw-r--r--lib/rites/rite_ef/vocab_ef.ml5
6 files changed, 1349 insertions, 44 deletions
diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml
new file mode 100644
index 0000000..db7e708
--- /dev/null
+++ b/lib/rites/rite_ef/precedence_ef.ml
@@ -0,0 +1,893 @@
+(* RG 91's Table of Precedence (docs/research/rules-register.md §4). Each
+ branch below is one of the table's 28 entries, checked in the table's own
+ numeric order -- lower wins, and because occasional entries are true
+ exceptions to a later, broader one (RG 91 entry 18's Ember days are an
+ exception carved out of entry 22's Lent ferias; entry 21/26's vigils are
+ an exception carved out of the generic Class2/Class3 sanctoral-feast
+ entries that would otherwise also match), checking in table order and
+ returning on the first match is what makes the exception actually win
+ without a separate exclusion for every later entry it pre-empts.
+
+ Two kinds of evidence decide an entry:
+ - The temporal cycle's own office (Nativity, a Sunday, a feria, All Souls)
+ is identified structurally, from the context's date/season/weekday and
+ the day's Easter offset -- never from its slug, which is just a label.
+ [origin = Temporal] gates every such entry so a sanctoral candidate that
+ happens to share a date (Immaculate Conception can never coincide with
+ the movable cycle, but nothing stops a future rite bug from producing
+ one) cannot be mistaken for the office itself. All Souls (entry 8, the
+ one non-temporal-origin member of this group) additionally reads the
+ context's weekday for its own register-stated exception -- see entry 8
+ below.
+ - A sanctoral feast's entry is decided by its [rank], and -- except at
+ entry 14 (see its own comment below, where the register draws no such
+ line) -- per the brief's structural insight, also by its
+ {!Celebration.t}.layer: a celebration whose layer is not the universal
+ base is an overlay, hence "proper" or "indult" rather than the
+ universal entry (11-13 I class; 16/19/20 II class; 23/24 III class; see
+ precedence_ef.mli). [origin = Sanctoral] gates these for the same
+ reason: temporal-origin celebrations carry the literal layer id
+ "temporal" (rite_ef/temporal_ef.ml's [build]), which is not
+ [universal_layer] either, and would otherwise be misread as "proper" by
+ the layer test alone.
+
+ Vigils (21, 26) are the one shape neither of those two kinds fully
+ describes on their own: a II/III-class vigil can be temporal-origin (the
+ Ascension Vigil, produced by temporal_ef today) or sanctoral-origin (a
+ saint's vigil, not yet loaded by any task), so its entry cannot be gated
+ on [origin] at all. Nothing in the day's other fields marks "this is a
+ vigil, not an ordinary office of the same rank" either, so this reads it
+ off the temporal cycle's own slug convention (a "-vigil" suffix -- see
+ [named] in temporal_ef.ml) rather than guessing a new one. *)
+
+open Colitur_kernel
+
+(* Not an RG citation -- RG 91 ranks proper and indult feasts, it does not
+ encode how a computer tells them apart. See precedence_ef.mli. *)
+let universal_layer = "ef-universal"
+let indult_prefix = "indult:"
+let unclassified = max_int
+
+let is_indult layer = String.starts_with ~prefix:indult_prefix layer
+let is_universal layer = String.equal layer universal_layer
+
+(* Not an RG citation either -- see [universal_layer] above. Nothing in
+ {!Celebration.t} otherwise marks "this is a vigil, not an ordinary office
+ of the same rank" (see the file's top comment), so entries 21/26 read it
+ off the temporal cycle's own slug suffix (rite_ef/temporal_ef.ml's
+ [named], e.g. "ef-ascension-vigil"). *)
+let vigil_suffix = "-vigil"
+
+(* Not an RG citation -- see [universal_layer]. Task 10's sanctoral bootstrap
+ turned out to name its four real vigils with lectio's OWN convention, a
+ "vigil-of-X" PREFIX (data/ef/sanctoral.sexp: vigil-of-st-lawrence,
+ vigil-of-sts-peter-paul, vigil-of-the-assumption, vigil-of-the-nativity-
+ of-st-john-the-baptist), not [vigil_suffix] -- exactly the mismatch Task
+ 7's review predicted when it asked for [vigil_suffix] to be exposed.
+ [is_vigil] below checks both conventions, so a celebration is a "vigil"
+ for RG 91/33's purposes regardless of which layer (temporal or sanctoral)
+ produced it. *)
+let vigil_prefix = "vigil-of-"
+
+let is_vigil slug =
+ String.ends_with ~suffix:vigil_suffix slug || String.starts_with ~prefix:vigil_prefix slug
+
+(* Not an RG citation -- see [universal_layer]. Entry 18's Ember days are
+ identified by the temporal cycle's own slug convention (rite_ef/
+ temporal_ef.ml's [ember]: "ef-<set>-ember-<day>"), not re-derived here:
+ the September anchor in particular is one of the more contested dates in
+ the 1962 calendar (temporal_ef.ml's own comment on
+ [third_sunday_of_september]), and re-deriving it a second time would only
+ create a second place for that same uncertainty to drift. Only the
+ Advent, Lent and September sets are listed: RG 91 entry 18 names exactly
+ those three; the Whitsun (Pentecost) set is I class and falls inside the
+ Pentecost octave, entry 10, matched below before this is ever reached.
+ Exposed for the same reason as [vigil_suffix]: a rename of temporal_ef's
+ format has somewhere to be caught other than a silently-wrong entry 18.
+
+ [september_ember_prefix] is broken out as its own name (rather than an
+ anonymous list literal) because Task 9's [privilege_of] needs to test the
+ September set alone: RG 109(e)'s three named seasons (Advent, Lent,
+ Passiontide, §4 "Commemorations") never include September, which sits
+ entirely in time after Pentecost under any reading -- so September Ember
+ days need their own separate privilege category, (d), regardless of how
+ (e) itself is read. CORRECTED (fix round 1, F1/F2): this comment
+ previously justified the split the other way round, claiming RG 109(e)
+ privileges September specifically "while leaving the Advent and Lent
+ sets ordinary" -- WRONG; see [privilege_of]'s own (e) comment below for
+ the full argument. The Advent and Lent Ember sets ARE privileged under
+ (e), the same as any other Advent/Lent feria; building [ember_prefixes]
+ from this constant rather than duplicating the literal keeps the two
+ from silently drifting apart. *)
+let advent_ember_prefix = "ef-advent-ember-"
+let lent_ember_prefix = "ef-lent-ember-"
+let september_ember_prefix = "ef-september-ember-"
+let ember_prefixes = [ advent_ember_prefix; lent_ember_prefix; september_ember_prefix ]
+
+let is_ember_18 slug = List.exists (fun prefix -> String.starts_with ~prefix slug) ember_prefixes
+
+let band (ctx : Vocab_ef.season Precedence.context) (c : Vocab_ef.rank Precedence.candidate) :
+ int =
+ let cel = c.Precedence.cel in
+ let rank = cel.Celebration.rank in
+ let subject = cel.Celebration.subject in
+ let layer = cel.Celebration.layer in
+ let slug = Slug.to_string cel.Celebration.slug in
+ let is_temporal = c.Precedence.origin = Precedence.Temporal in
+ let is_vigil = is_vigil slug in
+ let date = ctx.Precedence.date in
+ let season = ctx.Precedence.season in
+ let weekday = ctx.Precedence.weekday in
+ let is_sunday = weekday = Date.Sun in
+ let m = Date.month date and d = Date.day date in
+ (* Easter offset, the same convention as temporal_ef.ml's [days_between
+ easter d]: 0 is Easter itself, negative before, positive after. *)
+ let off = Date.to_rata date - Date.to_rata (Computus.gregorian_easter (Date.year date)) in
+ (* Named so entry 8's Sunday exception below can read "one worse than the
+ Sunday it must yield to" rather than a bare integer that happens to
+ equal entry 15's own value; entry 15's own branch returns this same
+ binding, not a second literal, so the two can never drift apart. *)
+ let entry_15_band = 15 in
+ let open Vocab_ef in
+ (* 1: Nativity, Easter Sunday, Pentecost Sunday (I class w/ octave). *)
+ if is_temporal && rank = Class1 && ((m = 12 && d = 25) || off = 0 || off = 49) then 1
+ (* 2: Sacred Triduum (Thu-Sat of Holy Week). *)
+ else if is_temporal && rank = Class1 && off >= -3 && off <= -1 then 2
+ (* 3: Epiphany, Ascension, Holy Trinity, Corpus Christi, Sacred Heart,
+ Christ the King. *)
+ else if is_temporal && rank = Class1
+ && ((m = 1 && d = 6) (* Epiphany *)
+ || off = 39 (* Ascension *) || off = 56 (* Trinity *)
+ || off = 60 (* Corpus Christi *) || off = 68 (* Sacred Heart *)
+ || Date.compare date (Temporal_ef.christ_the_king (Date.year date)) = 0)
+ then 3
+ (* 4: Immaculate Conception, Assumption BVM. *)
+ else if (not is_temporal) && rank = Class1 && ((m = 12 && d = 8) || (m = 8 && d = 15)) then 4
+ (* 5: Vigil & Octave day of the Nativity. *)
+ else if is_temporal && rank = Class1 && ((m = 12 && d = 24) || (m = 1 && d = 1)) then 5
+ (* 6: Sundays of Advent, Lent, Passiontide, and Low Sunday. *)
+ else if is_temporal && rank = Class1 && is_sunday
+ && (season = Advent || season = Lent || season = Passiontide || off = 7)
+ then 6
+ (* 7: I-class ferias not above -- Ash Wednesday; Mon/Tue/Wed of Holy Week.
+ Thu-Sat of Holy Week are the Triduum, entry 2 above, not this entry. *)
+ else if is_temporal && rank = Class1 && (off = -46 || (off >= -6 && off <= -4)) then 7
+ (* 8: All Souls -- RG 91 entry 8's own text (§4) carries a qualifier this
+ transcription must honour: "yields to an occurring Sunday". 2 November
+ is always Time_after_pentecost (well clear of Advent/Lent/Passiontide
+ and of every other entry's own Easter-relative or fixed date), so a
+ Sunday landing on it is always an ordinary entry-15 II-class Sunday --
+ the one and only rival this exception ever has to lose to. On such a
+ Sunday this returns [entry_15_band + 1]: strictly worse than 15 (an
+ exact tie would fall to Precedence.resolve's slug tie-break, which
+ for "ef-all-souls" against a "ef-time-after-pentecost-sunday-*" slug
+ would make All Souls WIN -- the precise bug this guards against), but
+ otherwise not a citation to any other RG 91 row -- nothing else can
+ ever occur on 2 November to be confused with it. Entry 8's own [rank]
+ is untouched by this, so Task 8's disposition (RG 95: only I-class
+ feasts transfer) still sees the true I-class candidate it needs to
+ move to 3 November. *)
+ else if (not is_temporal) && rank = Class1 && m = 11 && d = 2 then
+ if is_sunday then entry_15_band + 1 else 8
+ (* 9: Vigil of Pentecost. *)
+ else if is_temporal && rank = Class1 && off = 48 then 9
+ (* 10: Days within the Octaves of Easter and Pentecost. *)
+ else if is_temporal && rank = Class1 && ((off >= 1 && off <= 6) || (off >= 50 && off <= 55))
+ then 10
+ (* 11: I-class feasts of the universal Church not above. *)
+ else if (not is_temporal) && (not is_vigil) && rank = Class1 && is_universal layer then 11
+ (* 12: Proper I-class feasts. *)
+ else if (not is_temporal) && (not is_vigil) && rank = Class1 && not (is_indult layer) then 12
+ (* 13: Indult I-class feasts. By elimination once 11 and 12 have failed:
+ not the universal layer (11), and marked as an indult overlay (12's
+ "not indult" test having just failed). *)
+ else if (not is_temporal) && (not is_vigil) && rank = Class1 then 13
+ (* 14: Feasts of the Lord, II class -- RG 91 entry 14, deliberately
+ UNQUALIFIED (contrast entry 16, which explicitly says "not
+ of the Lord"; RG 37c (§4, "Sundays") speaks of "II-class feasts of
+ the Lord" replacing an occurring II-class Sunday with no universal
+ qualifier either). No layer test here, unlike 11/12/13 and 16/19/20:
+ the register does not split this entry into universal/proper/indult,
+ so a proper or indult feast of the Lord still bands 14, not 19/20. *)
+ else if (not is_temporal) && (not is_vigil) && rank = Class2 && subject = Subject.Lord then 14
+ (* 15: Sundays, II class (every Sunday not already named at 6). *)
+ else if is_temporal && rank = Class2 && is_sunday then entry_15_band
+ (* 16: II-class feasts of the universal Church, not of the Lord. *)
+ else if (not is_temporal) && (not is_vigil) && rank = Class2 && is_universal layer then 16
+ (* 17: Days within the Octave of the Nativity (26-28 Dec are Stephen,
+ John, the Innocents -- sanctoral, not this entry). *)
+ else if is_temporal && rank = Class2 && m = 12 && (d = 29 || d = 30 || d = 31) then 17
+ (* 18: II-class ferias -- Advent 17-23 Dec; Ember days of Advent, Lent,
+ September. *)
+ else if is_temporal && rank = Class2
+ && ((season = Advent && m = 12 && d >= 17 && d <= 23) || is_ember_18 slug)
+ then 18
+ (* 19: Proper II-class feasts. *)
+ else if (not is_temporal) && (not is_vigil) && rank = Class2 && not (is_indult layer) then 19
+ (* 20: Indult II-class feasts. By elimination, as at 13. *)
+ else if (not is_temporal) && (not is_vigil) && rank = Class2 then 20
+ (* 21: II-class vigils (Ascension, Assumption, John Baptist, Peter & Paul
+ -- can be temporal- or sanctoral-origin, see the file comment above). *)
+ else if rank = Class2 && is_vigil then 21
+ (* 22: Ferias of Lent and Passiontide (Thursday after Ash Wednesday to the
+ Saturday before Palm Sunday), except the Ember days (18 above). *)
+ else if is_temporal && rank = Class3 && (season = Lent || season = Passiontide) then 22
+ (* 23: III-class feasts in particular calendars. Unlike 11/12 and 14/16
+ above, the universal entry (24) is the HIGHER number here -- RG 91's
+ own table ranks a particular-calendar III-class feast ahead of a
+ universal one, the reverse of the I/II-class ordering. Transcribed as
+ the register states it, not "corrected" into the other classes'
+ pattern. RG 91 has no indult sub-rank at III class, so every non-base
+ layer lands here, not split further. *)
+ else if (not is_temporal) && (not is_vigil) && rank = Class3 && not (is_universal layer) then 23
+ (* 24: III-class feasts in the universal calendar. *)
+ else if (not is_temporal) && (not is_vigil) && rank = Class3 then 24
+ (* 25: Ferias of Advent to 16 Dec, except the Ember days (18 above). *)
+ else if is_temporal && rank = Class3 && season = Advent then 25
+ (* 26: III-class vigils (St Lawrence). *)
+ else if rank = Class3 && is_vigil then 26
+ (* 27: Office of the BVM on Saturday -- every otherwise-unoccupied IV-class
+ Saturday, per the historical default that fills it; ordinary Mass
+ propers still make Rogation Mon/Tue/Wed proper without changing the
+ Office (RG 88, see temporal_ef.ml's [temporal]), so those never carry
+ this entry unless they happen to fall on the Saturday itself. Excludes
+ vigils for the same reason 11-13/14/16/19/20/23/24 do: RG 91 has no
+ IV-class vigil at all (RG 91's own vigil list, §4 "Vigils",
+ stops at III class), so one would be an anomaly, not this entry. *)
+ else if is_temporal && (not is_vigil) && rank = Class4 && weekday = Date.Sat then 27
+ (* 28: IV-class ferias -- the unqualified catch-all (temporal_ef.ml's own
+ comment on [ferial_rank] cites the same primary text, "Feriae IV
+ classis"). Excludes vigils for the same reason as 27 above: a IV-class
+ "feria" that is also a vigil is not a feria RG 91 describes. *)
+ else if (not is_vigil) && rank = Class4 then 28
+ else unclassified
+
+(* Task 8: what happens to the day's LOSING candidate (docs/research/
+ rules-register.md §4, "Occurrence" RG 92-95 and "Vigils" RG 33, plus RG
+ 94). [band] above decides who wins; this decides the loser's fate, which
+ turns on the LOSER's own rank and status (RG 95), except RG 33's vigil
+ omission, which also has to read the winner. Nothing here ever returns
+ [Precedence.Repose]: that disposition denotes RG 100-102's *repositio*
+ (perpetual impediment from a proper/diocesan calendar), out of this
+ plan's scope -- see calendar.mli's own note that nothing in the EF
+ ruleset currently emits it. *)
+
+(* RG 33 -- CORRECTED 2026-08-12 (Task 16, primary-source-verified against
+ docs/research/1962-06-23,_SS_Ioannes_XXIII,_Missale_Romanum,_LT.pdf, the
+ General Rubrics' own Chapter XI "De Vigiliis"). The register previously
+ transcribed this as "a I/II-class vigil is entirely omitted"; the
+ PRIMARY TEXT reads the other way round:
+
+ "33. Vigilia II aut III classis penitus omittitur, si occurrat in
+ dominica quavis, aut in festo I classis, vel si festum cui
+ præmittitur in alium diem transferri aut ad commemorationem reduci
+ contingat."
+
+ -- "A vigil of the II OR III class is entirely omitted, if it occurs on
+ ANY Sunday whatsoever, or on a feast of the I class, or if the feast it
+ precedes happens to be transferred to another day or reduced to a
+ commemoration." I-class vigils (Nativity, Pentecost, RG 30) are outside
+ this rule entirely -- RG 30's own text says they "festis quibuslibet
+ præferunt, et nullam admittunt commemorationem" (are preferred to ANY
+ feast whatsoever, and admit no commemoration at all), i.e. they can never
+ lose in the first place: {!band} entries 1/5/9 already rank Nativity Eve
+ and the Pentecost Vigil above every Sunday and every other I-class row
+ that could coincide with their fixed/Easter-relative dates (verified: no
+ date collision is even representable), so no I-class vigil can ever reach
+ this function as a [loser] -- the branch below never needs to test for
+ [Class1] and, before this fix, its stray inclusion of [Class1] here was
+ simply dead code, not a second bug (see the task report for the
+ argument). The bug was the OTHER half: [Class3] (the sole III-class
+ vigil, St Lawrence, RG 32) was MISSING from this branch, so it fell
+ through to the generic "commemorated or omitted" branch below instead of
+ RG 33's mandatory omission -- confirmed wrong for real data: 9 August
+ 2026 is a Sunday, and before this fix "vigil-of-st-lawrence" competed for
+ (and could in principle win) that Sunday's single commemoration slot,
+ when RG 33 says it must not even be a candidate. The oracle comparison
+ (missalemeum, Task 16) independently confirms: 9 Aug 2026 shows no trace
+ of the vigil surviving as a commemoration.
+
+ The third omission trigger in RG 33's own text -- "or if the feast it
+ precedes is transferred to another day or reduced to a commemoration" --
+ is NOT implemented: no II/III-class vigil's own feast (Ascension,
+ Assumption, John Baptist, Sts Peter & Paul, Lawrence) is ever
+ transferred or reduced to a commemoration anywhere in this codebase's
+ current data (all fixed I-class, none coincide with anything of equal or
+ higher rank within any year this project has sampled), so no witness
+ exists to build or test this clause against; flagged in the register
+ (§6) rather than guessed. *)
+let is_omissible_vigil (rank : Vocab_ef.rank) = rank = Vocab_ef.Class2 || rank = Vocab_ef.Class3
+
+(* Every Sunday slug this rite's temporal cycle produces -- named
+ (temporal_ef.ml's [named], e.g. "ef-easter-sunday") or the generic
+ "ef-<season>-sunday-<n>" fallback ([sunday_slug]) -- contains this
+ marker; nothing else [band] classifies does. Not an RG citation itself --
+ see [universal_layer]'s note on this file's own naming conventions --
+ exposed for the same reason as {!vigil_suffix}: a future rename of
+ temporal_ef's Sunday-slug format has somewhere to be caught other than a
+ silently-wrong RG 33 disposition. *)
+let sunday_marker = "-sunday"
+
+let contains_substring s ~needle =
+ let ls = String.length s and ln = String.length needle in
+ let rec at i = i + ln <= ls && (String.sub s i ln = needle || at (i + 1)) in
+ ln = 0 || at 0
+
+let is_sunday_slug slug = contains_substring slug ~needle:sunday_marker
+
+(* RG 33's own two conditions, taken directly from its text ("any Sunday or
+ a I-class feast") -- NOT derived from anything about which RG 91 entries
+ can numerically outrank a vigil. [rank = Class1] is the "I-class feast"
+ half. [is_sunday_slug] is the "any Sunday" half, and it is not redundant
+ with the rank check: RG 91 entries 14 and 16-20 (Feasts of the Lord II
+ class, universal/proper/indult II-class feasts, days within the Nativity
+ octave) are all [Class2], all outrank a II-class vigil (entry 21), and
+ none of them is a Sunday -- a winner of that shape satisfies neither
+ condition here, so [impedes_vigil] correctly returns [false] and such a
+ vigil falls through to RG 95's ordinary commemorate-or-omit branch
+ instead of RG 33's omission, exactly as the rubric requires. *)
+let impedes_vigil (winner : Vocab_ef.rank Precedence.candidate) =
+ let cel = winner.Precedence.cel in
+ cel.Celebration.rank = Vocab_ef.Class1
+ || is_sunday_slug (Slug.to_string cel.Celebration.slug)
+
+(* RG 91 entry 17's own slug convention (rite_ef/temporal_ef.ml's [named]:
+ "ef-nativity-octave-day-%d" for 29-31 Dec -- 26-28 Dec are Stephen, John,
+ the Innocents, sanctoral, and never carry this prefix, see [band]'s entry
+ 17 comment). Not an RG citation itself -- see [universal_layer] -- reused
+ below by [privilege_of] for RG 109(c). *)
+let nativity_octave_prefix = "ef-nativity-octave-day-"
+
+(* RG 109's own three named seasons for (e), "of ferias of Advent, Lent and
+ Passiontide" (§4, "Commemorations") -- temporal_ef.ml's generic
+ <season>-<week>-<weekday> ferial fallback slugs, whose season word is
+ [season_slug_word]'s output for exactly these three (vocab_ef.ml: Advent
+ and Passiontide are unmodified [season_to_string]; Lent likewise). Also
+ matches the Lent "after Ashes" sub-case ("ef-lent-after-ashes-<weekday>",
+ temporal_ef.ml's own [christmastide_feria_slug]-adjacent branch), which
+ is still a Lent feria under this same prefix. Not an RG citation -- see
+ [universal_layer] -- private: nothing outside [privilege_of] needs it. *)
+let alp_feria_prefixes = [ "ef-advent-"; "ef-lent-"; "ef-passiontide-" ]
+
+(* RG 109 (docs/research/rules-register.md §4, "Commemorations"): the
+ closed list of privileged commemorations, checked in the register's own
+ lettered order. A candidate matching none of (a)-(f) is ordinary, per the
+ register's own closing sentence, "All others are ordinary." Read entirely
+ off the candidate's own fields (rank, slug, origin) -- no [context]
+ (date/season/weekday) is available or needed: every category names a
+ property of the commemorated OFFICE ITSELF ("a commemoration OF a
+ Sunday", "OF a I-class day", ...), not of the day it happens to fall on,
+ and each of (a)-(e) already has a candidate-only marker this file's own
+ conventions establish ([sunday_marker], rank, [nativity_octave_prefix],
+ [september_ember_prefix]/[alp_feria_prefixes]) -- see the task report for
+ the full reasoning.
+
+ [disposition] below is this function's only caller, at both of its
+ [Commemorate] sites -- replacing Task 8's [interim_privilege] placeholder,
+ which always returned [Ordinary] regardless of the loser's real shape.
+ [admit] (RG 108-111's admission counts, below) trusts the privilege value
+ [disposition] has already attached rather than recomputing it here a
+ second time. *)
+let privilege_of (c : Vocab_ef.rank Precedence.candidate) : Precedence.privilege =
+ let cel = c.Precedence.cel in
+ let rank = cel.Celebration.rank in
+ let slug = Slug.to_string cel.Celebration.slug in
+ let is_temporal = c.Precedence.origin = Precedence.Temporal in
+ let open Vocab_ef in
+ (* (a) RG 109(a) (§4): "of a Sunday" -- the same slug marker RG 33's
+ [impedes_vigil] already reads to answer "is this candidate a Sunday". *)
+ if is_sunday_slug slug then Precedence.Privileged
+ (* (b) RG 109(b) (§4): "of a I-class day" -- the candidate's own
+ rank. In this codebase's current disposition rules the ONLY way a
+ [Class1] candidate ever reaches [Commemorate] at all is via
+ [Celebration.status = Commemoration_only] (a plain [Feast]-status
+ [Class1] loser always [Transfer]s instead, RG 95, below) -- so this
+ branch is real but its only reachable witness today is that shape; see
+ the task report. *)
+ else if rank = Class1 then Precedence.Privileged
+ (* (c) RG 109(c) (§4): "of days within the Octave of the Nativity". *)
+ else if is_temporal && String.starts_with ~prefix:nativity_octave_prefix slug then
+ Precedence.Privileged
+ (* (d) RG 109(d) (§4): "of September Ember days" -- named on its
+ own because September falls entirely outside (e)'s three seasons
+ (Advent/Lent/Passiontide) under ANY reading, not because it needs
+ excluding FROM (e) the way review round 1's F1/F2 finding corrected
+ the Advent/Lent Ember sets below to no longer need. *)
+ else if is_temporal && String.starts_with ~prefix:september_ember_prefix slug then
+ Precedence.Privileged
+ (* (e) RG 109(e) (§4): "of ferias of Advent, Lent and Passiontide" --
+ CORRECTED, fix round 1 (F1/F2): this branch previously excluded the
+ Advent and Lent Ember sets via [not (is_ember_18 slug)], reading RG
+ 109(e)'s bare "feriis Adventus, Quadragesimae" as tacitly narrower than
+ the ordinary ferias of those seasons, on the theory that (d)'s separate
+ September carve-out implied Ember days needed excluding from (e) too.
+ That reading does not survive comparing (e)'s text against RG 91's own
+ TABLE entries for the same seasons (register §4, "Ferias of Lent and
+ Passiontide... EXCEPTIS feriis Quatuor Temporum" at entry 22; "Ferias
+ of Advent... EXCEPTIS feriis Quatuor Temporum" at entry 25): the table
+ needs an explicit "exceptis" to keep Ember days from being double-
+ listed at both their own entry 18 AND entries 22/25 -- and an explicit
+ exception is only necessary because, ABSENT one, "feriae Adventus"/
+ "feriae Quadragesimae" already DO include their Ember sub-days by
+ default (an unnecessary exception is not how a rubrical text is
+ drafted). RG 109(e) carries no such "exceptis" clause, so its bare
+ "feriis Adventus, Quadragesimae" is read at that same default,
+ INCLUSIVE scope: the Advent and Lent Ember ferias ARE "ferias of
+ Advent"/"of Lent" in RG 109(e)'s sense, hence privileged, not merely
+ ordinary. (d)'s own separate existence is unaffected by this reading
+ either way -- September Ember days sit in "time after Pentecost",
+ never within Advent/Lent/Passiontide under any reading, so (d) remains
+ necessary regardless; it is not evidence for excluding Advent/Lent
+ Ember from (e), only for including September at all.) Consequently
+ [is_ember_18] is no longer tested here -- an Advent/Lent Ember slug
+ matches this branch exactly like an ordinary Advent/Lent feria slug
+ does, via the same [alp_feria_prefixes] prefix test; only a September
+ Ember slug is structurally excluded, because "ef-september-ember-*"
+ never starts with any of [alp_feria_prefixes] ("ef-advent-"/"ef-lent-"/
+ "ef-passiontide-") in the first place -- (d) above already privileges
+ it under its own name. *)
+ else if is_temporal && List.exists (fun p -> String.starts_with ~prefix:p slug) alp_feria_prefixes then
+ Precedence.Privileged
+ (* (f) RG 109(f) (§4): "of the Major Rogations, in Mass" -- the
+ Major Litanies (25 April, RG 80) are STILL not computed anywhere in
+ this codebase (temporal_ef.ml's own comment on [temporal]'s Rogation
+ branch, CORRECTED final fix wave item 7: they did not, in fact,
+ "arrive with Plan 3's sanctoral" -- Plan 3 shipped without them,
+ register §6 tracks this as an open item with no plan yet committed to
+ build it), so no candidate this engine can currently construct
+ represents one. There is no existing slug
+ convention to anchor a check to, and guessing one risks silently
+ misclassifying whatever a future task does name it -- a wrong citation
+ is worse than a missing one, so this is left unimplemented and flagged
+ in the task report rather than guessed. Deliberately NOT matched by
+ anything above: the Minor Litanies/Rogations ("ef-rogation-monday"/
+ "-tuesday", RG 87) temporal_ef.ml DOES compute are a different
+ observance RG 109(f) does not name (RG 88: the Minor Rogations change
+ nothing in the Office at all), so they correctly fall through to
+ "ordinary" below, not this category. *)
+ else Precedence.Ordinary
+
+let disposition ~(winner : Vocab_ef.rank Precedence.candidate)
+ ~(loser : Vocab_ef.rank Precedence.candidate) : Precedence.disposition =
+ let open Vocab_ef in
+ let cel = loser.Precedence.cel in
+ let is_temporal = loser.Precedence.origin = Precedence.Temporal in
+ if cel.Celebration.status = Celebration.Commemoration_only then
+ (* Always -- checked before RG 33's omission and RG 95's transfer so
+ neither can override it: a Commemoration_only entry can never win
+ (Precedence.resolve holds it out of the band contest entirely, see
+ that module's [resolve]) and, per the brief, can never transfer
+ either. Its privilege is [privilege_of loser] like every other
+ [Commemorate] below -- Commemoration_only carries a real [rank] for
+ exactly this purpose (Celebration.mli: "RG 111 orders admitted
+ commemorations by dignity"), so RG 109(b) applies to it precisely as
+ it would to any other candidate. *)
+ Precedence.Commemorate (privilege_of loser)
+ else if
+ is_omissible_vigil cel.Celebration.rank
+ && is_vigil (Slug.to_string cel.Celebration.slug)
+ && impedes_vigil winner
+ then
+ (* RG 33, corrected (see {!is_omissible_vigil}'s own comment): II- or
+ III-class vigils only -- a real I-class vigil can never reach this
+ function as a loser at all (see that comment), so this branch would
+ never have fired for [Class1] even before the fix; what changed is
+ that [Class3] (St Lawrence) now correctly reaches RG 33's omission
+ instead of falling through to the generic "commemorated or omitted"
+ branch below. *)
+ Precedence.Omit
+ else if
+ cel.Celebration.rank = Class1
+ && not (is_sunday_slug (Slug.to_string cel.Celebration.slug))
+ then
+ (* RG 95 (§4, "Occurrence" and "Transfer/translation"): only I-class FEASTS have the right
+ of translation -- RG 91's own table lists Sundays as a separate row
+ (entry 6) from feasts (entries 11-13), so a Sunday is never a "feast" in RG 95's sense, and
+ [is_sunday_slug] (the same marker RG 33's [impedes_vigil] and RG
+ 109(a)'s [privilege_of] already use) excludes it here. This is the
+ branch that completes Task 7's All Souls fix (RG
+ 91 entry 8): All Souls is I class, not a vigil, and not a Sunday
+ slug, so once it loses to an occurring Sunday it still reaches here
+ and transfers -- to 3 November, now DIRECTLY authorised by RG 96
+ Attamen (b) (primary-source-verified 2026-08-12): "Commemoratio
+ omnium Fidelium defunctorum, quando occurrit cum dominica,
+ transfertur, tamquam in sedem propriam, in feriam II sequentem" --
+ when it coincides with a Sunday, transferred, as to its own proper
+ seat, to the following Monday. Previously this rested only on entry
+ 8's own parenthetical plus the general RG 96 walk, which happened to
+ produce the right date; WHERE it lands either way is
+ Rite.transfer_target's job, not this function's -- disposition only
+ says THAT it moves. *)
+ Precedence.Transfer
+ else if
+ is_temporal
+ && (not (is_vigil (Slug.to_string cel.Celebration.slug)))
+ && cel.Celebration.rank = Class4
+ then
+ (* CORRECTED, fix round 1 (F1/F2 -- both real, the second the direct
+ cause of the first): the branch this replaces gated on
+ [privilege_of loser = Ordinary], justified by treating RG 109 as an
+ "exhaustive, closed list of the only temporal-origin circumstances
+ that ever generate a commemoration". That justification does not
+ survive reading RG 109 itself: it is headed "Commemorationes
+ PRIVILEGIATAE sunt commemorationes" and closes "Omnes aliae
+ commemorationes sunt commemorationes ORDINARIAE" -- it sorts
+ commemorations that ALREADY exist into two HONOUR classes
+ (privileged vs ordinary, RG 108's differing liturgical hours), and
+ says nothing about which offices have the RIGHT to be commemorated
+ in the first place. Testing [privilege_of = Ordinary] as an
+ ELIGIBILITY gate therefore happened to reach the right answer for
+ IV-class ferias (they are never commemorated, but for a reason
+ RG 109 does not state) and the WRONG answer for II- and III-class
+ ferias impeded during a season RG 109(e) does not privilege by name
+ (Advent 17-23 Dec's own ordinary-non-Ember ferias were fine, already
+ matching (e)'s slug prefix; the Advent and Lent EMBER ferias were
+ not, since the pre-fix (e) excluded them -- see [privilege_of]'s own
+ fix-round-1 comment above, which independently corrects THAT half
+ too). Confirmed wrong by direct reproduction (fix-round-1 review):
+ 1900-12-21 (an Advent Ember Friday, RG 91 entry 18, II class) lost
+ its own commemoration entirely under the pre-fix code, while an
+ ORDINARY (non-Ember, lower-solemnity) Advent feria the same week
+ kept its commemoration -- backwards on any reading.
+
+ The actual rule is Caput IV, "De feriis" (RG 21-27), which the
+ original Task 16 pass never opened -- a FERIAL-CLASS-keyed rule,
+ entirely separate from RG 109's HONOUR-class one:
+ - RG 23 (I-class ferias -- Ash Wednesday, Holy Week): "nullam
+ admittunt commemorationem, nisi unam privilegiatam" -- admit no
+ commemoration except one privileged one. Never actually reaches
+ this function as a loser (these ferias structurally always
+ outrank anything that could coincide with their dates -- {!band}
+ entries 2/7, see that function's own file comment and the Task 11
+ Easter-window invariant), so this clause has no live witness, the
+ same as before.
+ - RG 24 (II-class ferias -- Advent 17-23 Dec, the Advent/Lent/
+ September Ember ferias, RG 91 entry 18): "si vero impediuntur,
+ COMMEMORARI DEBENT" -- if indeed impeded, they MUST be
+ commemorated. Not optional, not conditioned on RG 109's list.
+ - RG 25 (III-class ferias -- ordinary Lent/Passiontide ferias, RG 91
+ entry 22; ordinary Advent ferias to 16 Dec, entry 25): "Hae
+ feriae, si impediuntur, commemorari debent" -- same mandate.
+ - RG 26: "Omnes feriae, numeris 23-25 non nominatae, sunt feriae IV
+ classis; quae NUNQUAM COMMEMORANTUR" -- every feria not named in
+ 23-25 is IV class, and IV-class ferias are NEVER commemorated.
+ This is [ferial_rank]'s own unqualified IV-class catch-all
+ (temporal_ef.ml), covering the ordinary green-season ferias of
+ Time after Epiphany/Pentecost, Septuagesima, Paschaltide outside
+ its privileged octave, and the Minor Rogation days (RG 87/88 --
+ they change nothing in the Office, so they take their season's
+ plain ferial class, which for Paschaltide-adjacent dates is
+ IV, not a special one).
+
+ So this branch is now gated directly on RG 26's own condition
+ ([rank = Class4]), which is the ONLY ferial class RG 21-27 excludes
+ from commemoration -- Class1 is structurally unreachable here (RG
+ 23, above); Class2 and Class3 both fall through to the final
+ [Commemorate] branch below (RG 24/25's mandate), tagged with
+ whatever HONOUR class [privilege_of] separately computes for them
+ under RG 109 -- a question this branch no longer conflates with
+ eligibility. SANCTORAL losers are entirely unaffected (the
+ [is_temporal] guard): Caput IV governs FERIAE, RG 21's own opening
+ definition ("Nomine feriae intelleguntur singuli dies hebdomadae,
+ praeter dominicam"), never a saint's day; RG 111(c)/(d) admit an
+ "ordinary" commemoration of a losing SAINT freely, with no such
+ class-keyed gate.
+
+ Empirically confirmed against the missalemeum oracle (Task 16,
+ 2026-2027, both years): every one of ~190 days where a saint's feast
+ impedes an ordinary (IV-class, non-privileged) temporal feria shows
+ ZERO commemorations in the oracle (e.g. "St. Marcellus I" impeding
+ the plain "Friday after Epiphany"), and the SAME rank-4 gate,
+ independently, correctly still omits the Minor Rogation days (RG 87)
+ losing to a saint -- both consequences of RG 26 alone now, not of a
+ reading of RG 109 that RG 109's own text does not support.
+
+ [is_vigil] is EXCLUDED from this branch for the same reason as
+ before, restated under the corrected citation: a II/III-class vigil
+ is temporal-origin too (the Ascension/Pentecost-adjacent case
+ {!of_temporal} produces) and typically Class2, so it would already
+ fall through this branch's [rank = Class4] test harmlessly on its
+ own -- RG 91 has no IV-class vigil at all (this file's own entry-27/
+ 28 comments), so [is_vigil && rank = Class4] should never occur on
+ real data. Kept as an explicit, defensive guard (not load-bearing
+ for real data, but total over every candidate {!Precedence.resolve}
+ or {!Calendar} can construct, including shapes RG 91's table itself
+ does not describe) rather than relying on that absence silently: a
+ vigil, per RG 31 (II class, "si impediuntur, commemorantur") / RG 32
+ (III class, "si impeditur, commemoratur"), is ALWAYS commemorated
+ once RG 33 does not omit it outright, regardless of ferial class --
+ a rule Caput IV does not speak to at all (vigils are Caput V, RG
+ 28-34, not "feriae"). RG 32's own full sentence, primary-source-
+ verified (final fix wave): "Vigilia III classis est vigilia S.
+ Laurentii. Haec vigilia praefertur diebus liturgicis IV classis; et,
+ si impeditur, commemoratur, iuxta rubricas" -- confirmed word for
+ word against the scan, not constructed by analogy with RG 31 (the
+ register's own §4 "Vigils" entry states RG 32 only as "same pattern
+ [as RG 31]", not verbatim -- now closed here). *)
+ Precedence.Omit
+ else
+ (* RG 95's other branch: "aut commemorantur aut penitus omittuntur" --
+ commemorated or wholly omitted. Reached by every SANCTORAL loser
+ below I class (RG 111(c)/(d)'s "ordinary" commemoration, no closed
+ list the way the temporal branch above has), AND by an impeded
+ I-class Sunday (excluded from the [Transfer] branch above, and from
+ the temporal Class4 [Omit] branch above because a Sunday is never
+ IV class -- RG 11-12/91 entry 6/15 make every Sunday I or II class,
+ never a "feria" at all in Caput IV's own sense, RG 21): RG 109(a)
+ (§4) lists "of a Sunday" as a privileged commemoration
+ category, which presupposes an impeded Sunday stays put rather than
+ moving to another day the way a feast does -- [privilege_of] tags it
+ [Privileged] via the same [is_sunday_slug] marker, with no further
+ code needed here. Which of commemorate/omit survives is RG 108-111's
+ admission count ([admit], below), not this function's decision; this
+ only opens the commemoration, tagged with its real RG 109 privilege
+ via [privilege_of].
+
+ RG 94 (a fixed-day commemoration is not carried along with a
+ transferred feast) needs no code here: [Precedence.resolve] calls
+ this function once per loser, always against the day's actual
+ [observed] winner -- never against a fellow loser that itself
+ transferred away -- so no mechanism exists by which a commemoration
+ could ride along with a departing feast in the first place; there is
+ nothing to suppress. *)
+ Precedence.Commemorate (privilege_of loser)
+
+(* Task 9: how many of the day's commemorations RG 111 admits, and which
+ (docs/research/rules-register.md §4, "Commemorations",
+ RG 111). [band] decides who wins the day; [disposition] decides who is
+ even eligible to be commemorated, and tags each with its RG 109 privilege
+ via [privilege_of]; this decides how many of THOSE survive.
+
+ RG 111 keys its four admission rules off the CLASS OF THE DAY ("diebus I
+ classis", "dominicis II classis", "aliis diebus II classis", "diebus III
+ et IV classis") -- read here off [observed]'s own [rank] and, for the
+ Sunday/non-Sunday II-class split, the same slug marker [privilege_of] and
+ RG 33's [impedes_vigil] already use ([is_sunday_slug]). No [context]
+ (date/season/weekday) is available to [admit] (see precedence.mli's
+ [rules.admit]) or needed: [observed] IS the day's own celebration, so its
+ rank and slug already carry everything RG 111's own four categories test. *)
+
+(* RG 8's four-class dignity order, Class1 highest. Deliberately NOT [band]
+ (RG 91's much finer 28-entry table): [band] needs a [context] [admit]
+ does not have (see above), and Celebration.mli's own comment on [status]
+ -- "RG 111 orders admitted commemorations by dignity" -- names [rank]
+ itself as that dignity, not the finer occurrence-table entry. *)
+let dignity = function
+ | Vocab_ef.Class1 -> 1
+ | Vocab_ef.Class2 -> 2
+ | Vocab_ef.Class3 -> 3
+ | Vocab_ef.Class4 -> 4
+
+(* Deterministic selection order for RG 111: dignity first, then slug --
+ the same tie-break {!Precedence.compare_by} uses for [band] itself (the
+ brief: "break ties on slug"), so which candidate wins a shared rank never
+ depends on the order [comms] arrives in. *)
+let compare_dignity (a, _) (b, _) =
+ let da = dignity a.Precedence.cel.Celebration.rank
+ and db = dignity b.Precedence.cel.Celebration.rank in
+ if da <> db then Int.compare da db
+ else Slug.compare a.Precedence.cel.Celebration.slug b.Precedence.cel.Celebration.slug
+
+let rec take n = function
+ | [] -> []
+ | x :: xs -> if n <= 0 then [] else x :: take (n - 1) xs
+
+let admit ~(observed : Vocab_ef.rank Precedence.candidate)
+ (comms : (Vocab_ef.rank Precedence.candidate * Precedence.privilege) list) :
+ (Vocab_ef.rank Precedence.candidate * Precedence.privilege) list =
+ (* Sorted once, by dignity then slug (see [compare_dignity]); every branch
+ below either takes a prefix of this list or filters it, so the RESULT
+ is always a sub-list of [comms] with its elements untouched -- never
+ rebuilt -- which matters beyond determinism: {!Precedence.resolve}'s
+ own [dropped] computation tells an admitted candidate from a dropped
+ one by physical equality (==) on the candidate value (Task 2's own
+ deferred note: "assuming admit returns the same candidate values rather
+ than rebuilt ones; undocumented for rite authors" -- documented here,
+ now that this is the function that note was about). Building a fresh
+ [{ c with ... }] record anywhere below would silently defeat that
+ accounting: the original would then match nothing in [admitted], so the
+ celebration would surface TWICE in the same day -- once in
+ [commemorations] (the rebuilt copy) and once in [omitted] (the original,
+ which nothing admitted matches). One admission, double-reported, and no
+ crash to announce it, which is exactly why this comment exists. *)
+ let sorted = List.stable_sort compare_dignity comms in
+ let is_privileged (_, p) = p = Precedence.Privileged in
+ let observed_rank = observed.Precedence.cel.Celebration.rank in
+ let observed_is_sunday =
+ is_sunday_slug (Slug.to_string observed.Precedence.cel.Celebration.slug)
+ in
+ let open Vocab_ef in
+ match (observed_rank, observed_is_sunday) with
+ | Class1, _ ->
+ (* RG 111: "I class: none save one privileged." Ordinary commemorations
+ never get a slot at all on a I-class day, no matter how many are
+ due; at most one privileged one does, the highest-dignity one if
+ several are. *)
+ (match List.filter is_privileged sorted with [] -> [] | best :: _ -> [ best ])
+ | Class2, true ->
+ (* RG 111(b), primary text, RE-VERIFIED word for word against the scan
+ (final fix wave; this sentence is the sole textual basis for the
+ shipped rank-floor fix below, and the register's own §4 "RG 111"
+ entry previously carried only the fragment "de festo II classis",
+ not the full sentence -- now added there too): "in dominicis II
+ classis, una tantum admittitur commemoratio, SCILICET DE FESTO II
+ CLASSIS, quæ tamen omittitur si commemoratio privilegiata facienda
+ sit" -- "on Sundays of the II class, only ONE commemoration is
+ admitted, NAMELY OF A FEAST OF THE II CLASS, which however is
+ dropped if a privileged commemoration is due." Two clauses, not
+ one: (i) a privileged
+ commemoration, whenever due, categorically takes the day's one slot
+ -- not by comparing its dignity against the ordinary contender's,
+ so an ordinary commemoration that would otherwise win on raw
+ dignity is still dropped once any privileged one is also due (the
+ asymmetric clause the brief and task report flag as deliberate, not
+ present at "other II class" below); (ii) failing that, the slot is
+ reserved SPECIFICALLY for a [Class2] candidate -- "de festo II
+ classis" is a RANK restriction, not merely "whichever ordinary
+ candidate has the best dignity": a III- or IV-class ordinary loser
+ (a plain commemoration-only saint with no privilege of its own) has
+ NO standing for this slot at all and must be entirely omitted, even
+ when it is the only candidate present.
+
+ Fix, Task 16 (primary-source-verified + missalemeum-confirmed):
+ previously this fell back to "the best of [sorted], whatever its
+ rank" once no privileged candidate was due, silently admitting a
+ III/IV-class ordinary saint that RG 111(b)'s own wording excludes.
+ Confirmed wrong for real data by the oracle comparison: e.g. 11 Jan
+ 2026 (Holy Family, a II-class Sunday) has St Hyginus (Class3,
+ commemoration-only) as its only competing candidate -- missalemeum
+ shows him "displaced" (omitted), never commemorated; the
+ pre-fix code admitted him regardless. *)
+ (match List.filter is_privileged sorted with
+ | best :: _ -> [ best ]
+ | [] -> (
+ match List.filter (fun (c, _) -> c.Precedence.cel.Celebration.rank = Class2) sorted with
+ | [] -> []
+ | best :: _ -> [ best ]))
+ | Class2, false ->
+ (* RG 111: "other II class: one" -- no privilege-override clause here,
+ unlike the Sunday case immediately above, so the day's one slot
+ goes to whichever candidate outranks the rest by dignity alone,
+ privileged or not. *)
+ (match sorted with [] -> [] | best :: _ -> [ best ])
+ | (Class3 | Class4), _ ->
+ (* RG 111: "III-IV class: at most two" -- by dignity, same as the
+ non-Sunday II-class case, just with room for two. *)
+ take 2 sorted
+
+(* Task 11: RG 96 -- where an impeded I-class feast lands (docs/research/
+ rules-register.md §4, "Transfer/translation"). [band] decides who is
+ impeded; [disposition] decides that an impeded I-class FEAST (not a
+ Sunday, not omitted by RG 33) is [Transfer]-disposed; this is the third
+ and final question RG 96 poses -- WHERE the translation lands -- and is
+ {!Rite.t.transfer_target} itself, called by {!Calendar}'s placement pass
+ once per deferred candidate, never re-run once a target is accepted
+ (calendar.ml's own comment on [~start ~stop]).
+
+ RG 96's own text, register-transcribed: "the next following day that is
+ not I or II class." [is_blocking] reads that off [Vocab_ef.rank] --
+ RG 96 speaks of the day's CLASS (RG 8's four-way dignity), not [band]'s
+ finer 28-entry occurrence-table row, the same distinction {!admit} above
+ already draws for RG 111 ({!dignity}, not [band]). *)
+let is_blocking (rank : Vocab_ef.rank) = rank = Vocab_ef.Class1 || rank = Vocab_ef.Class2
+
+(* RG 96's own named exception (docs/research/rules-register.md §4,
+ "Transfer/translation", RG 96 Attamen (a) -- primary-source-verified
+ 2026-08-12, corrected from an earlier unconditional transcription; see
+ the register's own correction note). Verbatim: "festum Annuntiationis
+ B. Mariae Virg., quando est transferendum post Pascha, transfertur,
+ tamquam in sedem propriam, in feriam II post dominicam in albis" -- when
+ [the feast] is to be transferred PAST EASTER, [it] is transferred, as to
+ its own proper seat, to the Monday after Low Sunday. The exception is
+ CONDITIONAL on that "past Easter" clause -- {!transfer_target} tests it
+ by comparing the GENERAL RG 96 target against Easter itself, not by
+ testing the date here. Identified by slug -- the same convention this
+ file already uses to pick out one specific celebration from a rank/
+ status shape shared by many others ({!nativity_octave_prefix},
+ [is_ember_18]'s date anchors) -- not an RG citation itself: RG 96 does
+ not encode how a computer recognises "the Annunciation", only what
+ happens to it once recognised. data/ef/sanctoral.sexp's own bootstrapped
+ slug (Task 10), reused verbatim rather than guessed. *)
+let annunciation_slug = "annunciation-of-the-blessed-virgin-mary"
+
+(* Not an RG citation -- a defensive engineering ceiling, the same role
+ Calendar's own [max_transfer_rounds] plays for the OUTER round loop
+ (calendar.ml). That guard bounds how many ROUNDS the whole-year placement
+ pass takes; it does nothing for the walk a single call to this function
+ makes internally, which is this module's own responsibility (rite.mli
+ documents the obligation this constant exists to satisfy). Comfortably
+ longer than the longest real run of consecutive I/II-class days the 1962
+ calendar produces -- 24 Dec to 1 Jan (the Nativity vigil through the
+ Circumcision, both I class, with the intervening octave days II class) is
+ 9 days; Easter through Low Sunday (the Easter octave, I class, entry 10)
+ is 8 -- RG 91 entry 28's own unqualified IV-class catch-all guarantees a
+ non-blocking feria follows any such run in real data. Not tuned to that
+ bound any more than 64 is tuned to RG 97-98's real collision count: a
+ ceiling nothing in the 1962 calendar comes close to, so a rite/data shape
+ this module has not anticipated fails FINITELY (see [search_from]) rather
+ than hanging the CLI. *)
+let max_search_days = 400
+
+(* The domain's own ceiling ({!Date.make}'s documented 1583..9999 bound,
+ also duplicated by calendar.ml's own [domain_max_date] for the same
+ reason: neither module exposes it to the other, and this is a three-line
+ constant, not worth a new signature just to share it). [search_from]
+ below must never call [occupant] on a date past this: [occupant] chains
+ through the rite's own [temporal] (calendar.ml's [resolve_with_injected]),
+ which for the real EF rite calls [Computus.gregorian_easter], which is
+ NOT total outside 1583..9999 -- it constructs a [Date.t] via [Date.make]
+ and [failwith]s on [Error]. [Date.add_days] itself has no such limit (it
+ is documented "unbounded total arithmetic"), so [search_from] CAN walk
+ [d] past 31 December 9999 without raising by itself -- the raise would
+ only happen on the NEXT [occupant d] call, which is exactly the bug this
+ guards against: an I-class feast impeded late enough in civil year 9999
+ that every remaining day of the year is also I or II class (reachable
+ through the project's own overlay mechanism, confirmed by review: an
+ Add-ed I-class feast on 25 December leaves only Class2 Nativity-octave
+ days for the rest of 9999, so the unguarded walk reached 1 January 10000
+ and crashed there). *)
+let domain_max_date =
+ match Date.make ~year:9999 ~month:12 ~day:31 with Ok d -> d | Error e -> failwith e
+
+(* Walks forward from [d], returning the first date [occupant] reports as
+ NOT [is_blocking]. [steps] is a strictly increasing structural bound on
+ the recursion, capped at [max_search_days]: the function decreases
+ [max_search_days - steps] by exactly one on every call and returns as
+ soon as that reaches zero (whether or not an admissible day was ever
+ found), so THIS loop terminates by construction, regardless of what
+ [occupant] reports -- it does not rely on the real EF calendar's own
+ structure to guarantee termination the way the comment above explains
+ why the bound is never actually reached in practice. Also stops, without
+ calling [occupant] again, once [d] passes {!domain_max_date} -- see that
+ constant's own comment for why probing [occupant] beyond it can raise.
+ Either way the last date visited is returned WITHOUT a further
+ [occupant] probe -- one more finite (not necessarily admissible) date,
+ not a further search -- because the value the caller ([transfer_target])
+ is still owed is "a date", never an exception; {!Calendar}'s own
+ [~start ~stop] bound (calendar.ml's [place_transfers]) is what turns an
+ implausible non-terminating real search into a recorded [omitted], not
+ this function pretending to have found something admissible. *)
+let rec search_from (occupant : Date.t -> Vocab_ef.rank Celebration.t) (steps : int) (d : Date.t) :
+ Date.t =
+ if steps >= max_search_days || Date.compare d domain_max_date > 0 then d
+ else if is_blocking (occupant d).Celebration.rank then search_from occupant (steps + 1) (Date.add_days d 1)
+ else d
+
+(* [transfer_target]'s contract (rite.mli): total, terminating, and its
+ result is always strictly after [origin]. Terminating: [search_from]'s
+ own structural bound, above. Strictly after [origin]: the general branch
+ is exactly [search_from]'s own result starting at [Date.add_days origin
+ 1], which only ever advances forward from there, so it is always >=
+ origin + 1. The Annunciation branch, when it fires, instead searches from
+ the Monday after Low Sunday for [origin]'s own civil year -- NOT provably
+ later than [origin] by the code alone, but true of every representable
+ year: the Annunciation's [origin] is always 25 March (Date_spec.Fixed in
+ data/ef/sanctoral.sexp), Easter always falls within that SAME civil year
+ in [22 March, 25 April] (Computus's own documented range, register §0),
+ so Low Sunday (Easter + 7) falls in [29 March, 2 May] and the Monday
+ after it in [30 March, 3 May] -- always after 25 March.
+
+ RG 96 Attamen (a) (see {!annunciation_slug}'s own comment) makes the
+ Annunciation exception CONDITIONAL on the general walk carrying the
+ feast past Easter -- so the general target is always computed FIRST,
+ for every candidate, and only overridden for the Annunciation when that
+ target itself falls after Easter Sunday. A version of this function that
+ tested the DATE of [origin] instead (e.g. "is 25 March within some fixed
+ window of Easter") would be re-deriving the register's own "quando est
+ transferendum post Pascha" condition from first principles, exactly the
+ kind of guess this project's "a wrong citation is worse than a missing
+ one" rule warns against; comparing the general target against Easter
+ directly tests the rubric's own words. *)
+let transfer_target (c : Vocab_ef.rank Precedence.candidate) (origin : Date.t)
+ (occupant : Date.t -> Vocab_ef.rank Celebration.t) : Date.t =
+ let general_target = search_from occupant 0 (Date.add_days origin 1) in
+ let is_annunciation = Slug.to_string c.Precedence.cel.Celebration.slug = annunciation_slug in
+ let easter = Computus.gregorian_easter (Date.year origin) in
+ if is_annunciation && Date.compare general_target easter > 0 then
+ (* Low Sunday = Easter + 7 (register §0, temporal_ef.ml's [off 7]); the
+ Monday after it = Easter + 8. Searched onward from there exactly
+ like the general case searches from [origin + 1] -- "only if that
+ day is itself blocked" (rite.mli) is [search_from]'s ordinary
+ behaviour, not a second mechanism. *)
+ search_from occupant 0 (Date.add_days easter 8)
+ else general_target
diff --git a/lib/rites/rite_ef/precedence_ef.mli b/lib/rites/rite_ef/precedence_ef.mli
new file mode 100644
index 0000000..55f947e
--- /dev/null
+++ b/lib/rites/rite_ef/precedence_ef.mli
@@ -0,0 +1,260 @@
+(** RG 91's Table of Precedence for the EF (1962) rite: ranks any candidate
+ for a given day by its RG 91 entry number. See
+ docs/research/rules-register.md §4, whose 28-entry transcription this
+ module follows line by line. *)
+
+open Colitur_kernel
+
+(** The universal (base) sanctoral layer's {!Celebration.t}.layer id. A
+ Sanctoral-origin candidate whose layer is anything else is an overlay:
+ "proper" (RG 91 entries 12, 19, 23) unless its layer id also carries
+ {!indult_prefix} ("indult", entries 13, 20). This id and the prefix are
+ colitur's own data-modelling convention, not an RG citation -- RG 91
+ prescribes the ranking, not a machine encoding for it. Whichever task
+ loads the real EF sanctoral base layer and its overlays must either
+ reuse these two constants or this classifier will misfile them. *)
+val universal_layer : string
+
+(** See {!universal_layer}. *)
+val indult_prefix : string
+
+(** Slug suffix marking a celebration as a vigil (RG 91 entries 21, 26),
+ e.g. "ef-ascension-vigil" -- colitur's own temporal-cycle convention
+ (rite_ef/temporal_ef.ml's [named]). Also colitur's own convention, not
+ an RG citation, exposed for the same reason as {!universal_layer}. See
+ {!vigil_prefix} for the sanctoral data's own, different convention: a
+ vigil can arrive named either way, and {!band}/{!disposition} must
+ recognise both. *)
+val vigil_suffix : string
+
+(** Slug prefix marking a celebration as a vigil, e.g. "vigil-of-st-lawrence"
+ -- the sanctoral data's own convention (data/ef/sanctoral.sexp, adopted
+ verbatim from lectio's naming, per spec §4.4's "slugs are lectionary keys,
+ not re-derived"). Also colitur's own convention, not an RG citation --
+ see {!universal_layer}. Task 10 bootstrapped four real sanctoral vigils
+ named this way (St Lawrence 08-09, Sts Peter & Paul 06-28, the Assumption
+ 08-14, the Nativity of St John the Baptist 06-23; a fifth, Christmas, is
+ suppressed as a duplicate of the temporal cycle's own "ef-nativity-vigil"
+ -- see data/ef/adjustments.sexp), none of which end in {!vigil_suffix}:
+ without this prefix also being checked, {!band} would misfile all four at
+ 16/24 (an ordinary feast of the same rank) instead of RG 91's 21/26, and
+ RG 33's vigil omission ({!disposition}'s [is_vigil] test, the same
+ predicate) would never fire for them either -- two rubrics silently
+ broken for four celebrations, exactly what Task 7's review predicted
+ when it asked for {!vigil_suffix} to be exposed. *)
+val vigil_prefix : string
+
+(** Slug prefixes marking a celebration as one of RG 91 entry 18's three
+ Ember-day sets (Advent, Lent, September -- the Pentecost/Whitsun set is
+ I class and matched by entry 10 before this is ever consulted). Also
+ colitur's own convention mirroring rite_ef/temporal_ef.ml's own "ef-<set>
+ -ember-<day>" slug format, not re-derived from first principles; exposed
+ so a rename of that format has somewhere to be caught other than a
+ silently-wrong entry 18. *)
+val ember_prefixes : string list
+
+(** Returned for a candidate shape RG 91's 28-entry table has no row for --
+ e.g. a [Class1] vigil that is not the Nativity or Pentecost (entries 5,
+ 9 are the only I-class vigils the table names), or a [Class4] candidate
+ also marked as a vigil. Deliberately outside 1..28 and larger than any
+ real entry, so an unclassified candidate can never win an occurrence
+ contest by accident; a caller that sees it back knows the shape needs a
+ new rule, not a silently wrong one. *)
+val unclassified : int
+
+(** [band ctx c]: RG 91's Table of Precedence. Returns the table's own entry
+ number -- I class 1-13, II class 14-21, III class 22-26, IV class 27-28;
+ lower wins (see {!Precedence.rules.band}) -- EXCEPT where the table's own
+ text states an exception: entry 8 (All Souls) reads "yields to an
+ occurring Sunday", so on a Sunday this returns a value that
+ loses to entry 15 rather than the literal integer 8 (see the comment on
+ entry 8 in precedence_ef.ml for the exact value and why). Total over
+ every candidate {!Precedence.resolve} or {!Calendar} can construct,
+ including shapes the 1962 table itself does not describe (see
+ {!unclassified}). *)
+val band : Vocab_ef.season Precedence.context -> Vocab_ef.rank Precedence.candidate -> int
+
+(** RG 33's marker: every Sunday slug this rite's temporal cycle produces
+ (temporal_ef.ml's [named] and [sunday_slug]) contains this substring;
+ nothing else {!band} classifies does. Also colitur's own convention, not
+ an RG citation -- see {!universal_layer} -- exposed for the same reason
+ as {!vigil_suffix}: a rename of temporal_ef's Sunday-slug format has
+ somewhere to be caught other than a silently-wrong RG 33 disposition. *)
+val sunday_marker : string
+
+(** [disposition ~winner ~loser]: RG 92-95, 33, 21-27, 94 (docs/research/
+ rules-register.md §4, "Occurrence", "Vigils" and "Caput IV, 'De
+ feriis'"). What becomes of a losing candidate, decided by the LOSER's
+ own rank and status (RG 95), except RG 33's vigil omission, which also
+ reads the winner:
+ - a {!Celebration.status} of [Commemoration_only] is always
+ [Commemorate] (checked first: it can never win -- see
+ {!Precedence.resolve} -- and, by that same status's own definition,
+ already denotes an office with nothing left to translate, so it never
+ transfers either; not itself a further RG citation beyond RG 93's
+ general four-mechanism statement above);
+ - a [Class2] or [Class3] loser whose slug marks it a vigil
+ ({!vigil_suffix} OR {!vigil_prefix} -- both conventions this
+ codebase's data uses, see {!vigil_prefix}'s own comment) is [Omit]
+ when the winner is any Sunday ({!sunday_marker}) or itself [Class1]
+ (RG 33 -- entirely omitted, not merely commemorated). A [Class1]
+ vigil (Nativity, Pentecost) is outside RG 33 entirely -- RG 30 makes
+ it preferred to any feast whatsoever, so a real one can never reach
+ this function as a [loser] in the first place (see the .ml's own
+ comment on [is_omissible_vigil] for the full argument);
+ - any other [Class1] loser that is NOT a Sunday ({!sunday_marker}) is
+ [Transfer] (RG 95 -- only I-class FEASTS have the right of
+ translation; RG 91's own table lists Sundays as a separate row, entry
+ 6, from feasts, entries 11-13, so a Sunday is never a "feast" in RG
+ 95's sense and does not transfer even when impeded by a higher
+ I-class day. This is also what moves All Souls, RG 91 entry 8, once
+ it loses to an occurring Sunday -- WHERE it lands is
+ {!Rite.t.transfer_target}'s job, not this function's);
+ - a TEMPORAL-origin, non-vigil loser of [Class4] is [Omit] (RG 26,
+ "Caput IV, De feriis" -- "every feria not named in [RG 23-25] is IV
+ class ... and IV-class ferias are NEVER commemorated." A SEPARATE
+ rule from RG 109's honour-class one immediately below, keyed on
+ ferial CLASS rather than on RG 109's privilege letters: RG 109 sorts
+ commemorations that already exist into honour classes (RG 108's
+ differing liturgical hours), it does not itself decide which offices
+ have the right to be commemorated at all -- that is Caput IV's own
+ business. A SANCTORAL loser of the same rank is unaffected by this
+ branch (the [is_temporal] guard): RG 21 defines "feria" as any
+ weekday, never a saint's day, and RG 111(c)/(d) admit an "ordinary"
+ commemoration of a losing SAINT freely, with no such class-keyed
+ gate);
+ - everything else -- including an impeded I-class Sunday, and a
+ SANCTORAL loser of any rank below I class -- is [Commemorate],
+ carrying its real RG 109 privilege (see {!admit} below); RG 109(a)
+ lists "of a Sunday" as a privileged commemoration category precisely
+ because an impeded Sunday stays put rather than moving to another
+ day, and RG 24/25 make a losing II- or III-class FERIA's own
+ commemoration mandatory when impeded, not merely eligible.
+
+ Total over every winner/loser pair {!Precedence.resolve} or {!Calendar}
+ can construct: [Vocab_ef.rank] (RG 8) and {!Celebration.status} are both
+ closed variants, and the five cases above -- an if/else-if chain ending
+ in the unconditional [Commemorate] catch-all -- exhaust every value
+ those two fields can take between them; there is no sixth,
+ "unclassified" case the way {!band} needs one, because this function's
+ own return type has no such slot to fall into by accident. *)
+val disposition :
+ winner:Vocab_ef.rank Precedence.candidate ->
+ loser:Vocab_ef.rank Precedence.candidate ->
+ Precedence.disposition
+
+(** Slug prefix marking a celebration as one of RG 91 entry 17's days within
+ the Octave of the Nativity (29-31 Dec -- 26-28 Dec are Stephen, John, the
+ Innocents, sanctoral, never this prefix). Also colitur's own convention
+ mirroring rite_ef/temporal_ef.ml's own "ef-nativity-octave-day-%d" slug
+ format, not an RG citation -- see {!universal_layer} -- exposed for the
+ same reason as {!vigil_suffix}: a rename of that format has somewhere to
+ be caught other than a silently-wrong RG 109(c) privilege. *)
+val nativity_octave_prefix : string
+
+(** The September set of {!ember_prefixes}, broken out on its own because RG
+ 109(d) (§4, "Commemorations") privileges September Ember days under its
+ own name, and September sits outside RG 109(e)'s three named seasons
+ (Advent, Lent, Passiontide) under any reading of that clause -- NOT
+ because the Advent and Lent Ember sets need excluding from (e), which
+ they do not: (e)'s own bare text privileges them too, the same as any
+ other Advent/Lent feria (see {!Precedence_ef.privilege_of}'s own (e)
+ comment in the .ml for the full argument, corrected fix round 1).
+ {!ember_prefixes} is built from this constant, not a duplicated literal,
+ so the two cannot silently drift apart. *)
+val september_ember_prefix : string
+
+(** [admit ~observed comms]: RG 108-111 (docs/research/rules-register.md §4,
+ "Commemorations"). How many of [comms] -- each already tagged with its
+ real RG 109 privilege by {!disposition} -- RG 111 admits, and which,
+ given the day actually observed:
+ - [observed] a [Class1] day: none, except at most one privileged
+ commemoration (the highest-dignity one, if several are due) -- an
+ ordinary one is never admitted here, no matter how many are due;
+ - [observed] a [Class2] Sunday (its slug carries {!sunday_marker}): one,
+ subject to TWO conditions, not one -- (i) a privileged commemoration,
+ whenever due, categorically takes the day's one slot over any
+ ordinary one, not by comparing dignity, so an ordinary commemoration
+ that would otherwise win on dignity is still dropped; (ii) failing
+ that, the slot is reserved for a [Class2] candidate SPECIFICALLY
+ ("de festo II classis", RG 111(b)'s own wording -- a RANK FLOOR, not
+ "whichever ordinary candidate has the best dignity"): a III- or
+ IV-class ordinary loser has no standing for this slot at all and is
+ admitted nothing, even when it is the only candidate due;
+ - [observed] any other [Class2] day: one, by dignity alone -- no
+ privilege override and no rank floor, unlike the Sunday case
+ immediately above;
+ - [observed] a [Class3] or [Class4] day: at most two, by dignity alone.
+
+ "Dignity" here is [Vocab_ef.rank] (RG 8's four classes), NOT {!band}'s
+ finer RG 91 entry number -- {!band} needs a [context] (date/season/
+ weekday) this function does not receive (see {!Precedence.rules.admit}).
+ Ties break on slug, matching {!Precedence.compare_by}, so the result
+ never depends on the order [comms] arrives in.
+
+ Every candidate this returns is a value taken unchanged from [comms],
+ never rebuilt: {!Precedence.resolve}'s own [dropped]/[omitted]
+ accounting tells an admitted candidate from a dropped one by physical
+ equality on the candidate value, so anything this function admitted
+ stays admitted, and anything it did not is reported in
+ {!Precedence.resolution.omitted}, never silently lost. Total: every
+ [Vocab_ef.rank] is one of the four cases above, and every branch is
+ itself total over an empty or arbitrarily long [comms]. *)
+val admit :
+ observed:Vocab_ef.rank Precedence.candidate ->
+ (Vocab_ef.rank Precedence.candidate * Precedence.privilege) list ->
+ (Vocab_ef.rank Precedence.candidate * Precedence.privilege) list
+
+(** The Annunciation's own bootstrapped slug (data/ef/sanctoral.sexp, Task
+ 10), reused verbatim by {!transfer_target} to recognise RG 96's named
+ exception. Not an RG citation -- see {!universal_layer} -- exposed so a
+ future re-bootstrap that renames the slug has somewhere to be caught
+ other than a silently-wrong transfer target. *)
+val annunciation_slug : string
+
+(** [transfer_target c origin occupant]: RG 96 (docs/research/rules-register
+ .md §4, "Transfer/translation") -- where an impeded I-class feast, once
+ {!disposition} has decided it is [Transfer]-disposed, is placed. This
+ *is* {!Colitur_kernel.Rite.t}.transfer_target; see that field's own
+ fuller rationale for why the search has to be rite-supplied at all.
+
+ RG 96's own rule: the next following day whose currently-resolved
+ occupant is not I or II class (read off [Vocab_ef.rank], RG 8's dignity
+ -- not {!band}'s finer occurrence-table entry, the same distinction
+ {!admit} draws for RG 111). This general target is computed for EVERY
+ candidate, always, first.
+
+ RG 96's own named exception (Attamen (a), primary-source-verified --
+ see {!annunciation_slug}'s comment for the Latin and the register's own
+ correction note): for the Annunciation specifically, IF that general
+ target would fall after Easter Sunday itself ("quando est transferendum
+ post Pascha" -- when it is to be transferred past Easter), the
+ Annunciation is placed instead at the Monday after Low Sunday (its
+ [sedes propria]), searching onward from there only if that day is
+ itself occupied by a blocking class. The exception is CONDITIONAL, not
+ unconditional: an Annunciation impeded for a reason that resolves
+ BEFORE Easter (e.g. an ordinary Lent Sunday with a free feria the next
+ day) takes the general target like any other I-class feast. Operationally
+ the condition holds exactly when 25 March falls close enough to Easter
+ that the general walk crosses it -- concretely, when 25 March itself
+ falls within Holy Week or Easter Week.
+
+ Total, terminating, and its result is always strictly later than
+ [origin] -- {!Colitur_kernel.Rite.t}.transfer_target's own obligations,
+ which {!Colitur_kernel.Calendar}'s placement pass relies on and its own
+ round guard does not itself enforce (calendar.ml's [place_transfers]
+ bounds ROUNDS across a whole year, not one call's internal walk).
+ Terminating by a structural bound on the internal walk (max 400 days,
+ an engineering ceiling, not an RG citation -- see the .ml) AND a guard
+ at {!Colitur_kernel.Date}'s own domain ceiling (31 December 9999,
+ beyond which probing [occupant] can itself raise -- see the .ml's
+ [domain_max_date]), not by an argument about the real 1962 calendar's
+ own structure, so a rite/data shape this function has not anticipated
+ fails FINITELY rather than hanging or crashing the caller. Strictly
+ later than [origin]: the general search starts at [origin + 1] and only
+ ever advances forward from there; the Annunciation's own alternate
+ starting point is provably later than 25 March for every representable
+ year (Easter's documented range, register §0) -- see the .ml for the
+ full argument. *)
+val transfer_target :
+ Vocab_ef.rank Precedence.candidate -> Date.t -> (Date.t -> Vocab_ef.rank Celebration.t) -> Date.t
diff --git a/lib/rites/rite_ef/rite_ef.ml b/lib/rites/rite_ef/rite_ef.ml
new file mode 100644
index 0000000..7a29421
--- /dev/null
+++ b/lib/rites/rite_ef/rite_ef.ml
@@ -0,0 +1,24 @@
+(* This module's name matches the library's own name ("rite_ef"), so dune
+ treats it as the library's top-level module instead of generating one
+ automatically -- every sibling module this library defines must be
+ re-exported here explicitly, or external references to e.g.
+ [Rite_ef.Temporal_ef] (bin/main.ml, every test/ file that opens this
+ rite) stop resolving. *)
+module Vocab_ef = Vocab_ef
+module Temporal_ef = Temporal_ef
+module Precedence_ef = Precedence_ef
+
+open Colitur_kernel
+
+let context : (Vocab_ef.season, Vocab_ef.rank) Rite.t =
+ { Rite.id = Temporal_ef.id;
+ vocab = Vocab_ef.vocab;
+ year_start = Temporal_ef.year_start;
+ temporal = Temporal_ef.temporal;
+ anchors = Temporal_ef.anchors;
+ rules =
+ { Precedence.band = Precedence_ef.band;
+ disposition = Precedence_ef.disposition;
+ admit = Precedence_ef.admit };
+ season_runs = Vocab_ef.seasons;
+ transfer_target = Precedence_ef.transfer_target }
diff --git a/lib/rites/rite_ef/rite_ef.mli b/lib/rites/rite_ef/rite_ef.mli
new file mode 100644
index 0000000..e2b3e6d
--- /dev/null
+++ b/lib/rites/rite_ef/rite_ef.mli
@@ -0,0 +1,35 @@
+(** The EF (1962) rite module: this library's top-level module (its filename
+ matches the library's own name "rite_ef", so dune uses it as the
+ library's entry point directly rather than generating one -- see the
+ .ml's own comment). Re-exports every sibling module this library
+ defines, so [Rite_ef.Vocab_ef], [Rite_ef.Temporal_ef] and
+ [Rite_ef.Precedence_ef] keep resolving exactly as they did before this
+ module existed. *)
+
+module Vocab_ef = Vocab_ef
+module Temporal_ef = Temporal_ef
+module Precedence_ef = Precedence_ef
+
+(** The EF rite, bundled (design spec's [RITE] signature, realised as a
+ {!Colitur_kernel.Rite.t} value rather than a functor -- see rite.mli):
+ - [id], [vocab], [year_start], [temporal], [anchors]: {!Temporal_ef}
+ unchanged (RG 71-77 seasons, RG 91's named movable days).
+ - [rules]: {!Precedence_ef}'s three RG 91/92-95/108-111 functions,
+ wrapped as one {!Colitur_kernel.Precedence.rules} record.
+ - [season_runs]: {!Vocab_ef.seasons} itself -- the EF liturgical year
+ visits each of its eight seasons exactly once, in that same order
+ (Advent-anchored, matching [year_start]), so the expected
+ run-length-compressed sequence {!Colitur_kernel.Rite.t.season_runs}
+ wants IS the vocabulary's own canonical list, not a separate one.
+ - [transfer_target]: {!Precedence_ef.transfer_target}, RG 96 (see that
+ value's own documentation for the termination and forward-progress
+ argument {!Colitur_kernel.Rite.t.transfer_target}'s contract requires).
+
+ Deliberately carries no [sanctoral]/[lectionary] fields the way the
+ original design-doc sketch of [RITE] does: {!Colitur_kernel.Rite.t} (the
+ type actually shipped, Plan 2) keeps the sanctoral {!Colitur_kernel.Layer.t}
+ a separate argument to {!Colitur_kernel.Calendar.year}/[day] rather than
+ embedding it here, so a caller can load data/ef/sanctoral.sexp (plus
+ data/ef/adjustments.sexp's overlay) however suits it -- bin/main.ml's
+ [load_ef_layer] is the one this module ships with. *)
+val context : (Vocab_ef.season, Vocab_ef.rank) Colitur_kernel.Rite.t
diff --git a/lib/rites/rite_ef/temporal_ef.ml b/lib/rites/rite_ef/temporal_ef.ml
index 52a9adb..7782876 100644
--- a/lib/rites/rite_ef/temporal_ef.ml
+++ b/lib/rites/rite_ef/temporal_ef.ml
@@ -17,9 +17,17 @@ let weekday_index d =
(* The Sunday on or before [d]. *)
let sunday_on_or_before d = Date.add_days d (-(weekday_index d))
-(* RG 71: Advent I is the Sunday nearest 30 November -- equivalently the fourth
- Sunday before Christmas, i.e. three weeks before the last Sunday on or before
- 24 December. *)
+(* RG 20 (Caput III, "De Dominicis"), primary-source-verified (final fix
+ wave): "Dominica I Adventus ea est, quae cadit die 30 novembris vel est
+ ipsi proximior" -- Advent I Sunday is that which falls on 30 November or
+ is nearest to it. CORRECTED citation: this comment previously cited RG
+ 71 for this placement rule -- WRONG, RG 71 (cited on [season] below)
+ states only Advent's own season BOUNDARY ("a I Vesperis dominicae I
+ Adventus..."), not which Sunday opens it; the register's own RG 71 entry
+ is a boundary citation, and the only "nearest 30 November" text there
+ before this fix was UNLYC nn. 39-42, the MODERN form's rule, not this
+ one's. Equivalently the fourth Sunday before Christmas, i.e. three weeks
+ before the last Sunday on or before 24 December. *)
let advent_start y = Date.add_days (sunday_on_or_before (mk y 12 24)) (-21)
let year_start = advent_start
@@ -47,8 +55,13 @@ let season d =
else if Date.compare d paschal_end <= 0 then Paschaltide (* RG 76 *)
else Time_after_pentecost (* RG 77 *)
-(* Last Sunday of October, per the 1960 calendar -- NOT the OF's last Sunday
- before Advent. Register §6 flags this for primary-source confirmation. *)
+(* RG 17(d) (Caput III, "De Dominicis"), PRIMARY-SOURCE-VERIFIED (final fix
+ wave, closing the item register §6 previously carried as "oracle-backed,
+ not yet primary-verified"): "festum D. N. Iesu Christi Regis, celebrandum
+ dominica ultima mensis octobris" -- the feast of Our Lord Jesus Christ
+ the King is to be celebrated on the LAST SUNDAY OF OCTOBER. NOT the OF's
+ last Sunday before Advent -- a genuine EF/OF divergence, not merely a
+ citation gap. *)
let christ_the_king y = sunday_on_or_before (mk y 10 31)
let same a b = Date.compare a b = 0
@@ -77,7 +90,10 @@ let named d =
if m = 12 && dd = 25 then Some (Christmastide, "ef-nativity", Colour.White, Class1)
else if m = 12 && dd = 24 then
(* RG 91 entry 5: the Vigil of the Nativity is I class. lectio has no slug
- for it, so this key has no lectionary entry until Plan 3 fills it. *)
+ for it, so this key has no lectionary entry until Plan 4 fills it
+ (CORRECTED, final fix wave, item 7 -- this is the lectionary/reading-
+ citations bootstrap, Plan 4, not the sanctoral one, Plan 3, which
+ already shipped in this branch). *)
Some (Advent, "ef-nativity-vigil", Colour.Violet, Class1)
else if m = 12 && (dd = 29 || dd = 30 || dd = 31) then
(* Days within the Octave of the Nativity; 26-28 Dec are Stephen, John and
@@ -98,9 +114,15 @@ let named d =
else if same d (off 7) then
Some (Paschaltide, "ef-low-sunday", Colour.White, Class1) (* RG 91 entry 6 *)
else if same d (off 38) then
- (* RG 91 entry 21: II-class vigil. It is also Rogation Wednesday; with no
- precedence framework until Plan 3, temporal emits the higher-ranked
- vigil and the Rogation commemoration waits for RG 108-111. *)
+ (* RG 91 entry 21: II-class vigil. It is also Rogation Wednesday; [named]
+ emits the higher-ranked vigil (entry 21 outranks any Rogation-day
+ ferial rank). CORRECTED (final fix wave, item 7): this comment
+ previously said the Rogation commemoration itself "waits for RG
+ 108-111" -- the precedence framework and RG 108-111 both exist now
+ (this branch), but no candidate for the Rogation Wednesday's own
+ observance is constructed here or anywhere else, so there is nothing
+ for RG 108-111 to admit; see the fuller comment on the Rogation
+ branch further down in [temporal] for the current, still-real gap. *)
Some (Paschaltide, "ef-ascension-vigil", Colour.White, Class2)
else if same d (off 39) then Some (Paschaltide, "ef-ascension", Colour.White, Class1)
else if same d (off 48) then Some (Paschaltide, "ef-pentecost-vigil", Colour.Red, Class1) (* RG 91 entry 9 *)
@@ -170,8 +192,10 @@ let week d =
this to "ef-christmas-0-<weekday>", indistinguishable from the stretch
above in lectio's own data. colitur cannot preserve a distinction lectio
doesn't make, so this becomes "ef-christmas-1-<weekday>" -- a
- colitur-only key and a lectionary gap for the Plan 3 bootstrap to fill,
- exactly like the Nativity vigil and octave-day keys above.
+ colitur-only key and a lectionary gap for the Plan 4 bootstrap to fill
+ (CORRECTED, final fix wave, item 7: the lectionary bootstrap is Plan 4,
+ not Plan 3 -- see Slug.ml's own corrected comment), exactly like the
+ Nativity vigil and octave-day keys above.
- 7-13 Jan, split in two by the *actual* first-Sunday-after-Epiphany
origin ([week_origin Time_after_epiphany], which by construction always
falls somewhere in this window -- see that function's own comment):
@@ -253,18 +277,30 @@ let id = "ef"
(* The third Sunday of September: the Ember week's anchor.
- This specific date-derivation rule is one of the more contested points in
- the 1962 calendar: pre-1955 practice tied the September Ember days to the
- week following the Exaltation of the Holy Cross (14 Sept) instead. The two
- rules only disagree when 1 September is a Monday -- 2025 is such a year --
- and the primary-source scan available to this project does not contain an
- explicit numbered-paragraph statement of either rule (searched; see
- register §3 "Ember days"), so this citation is deliberately left at the
- rank rules only (RG 91 entries 18/22, cited on [ember] below), not the
- date-derivation rule itself: a wrong citation is worse than none.
- Empirically: for 2025 this rule gives 24/26/27 September, confirmed
- against an independent oracle; the Holy-Cross rule would give 17/19/20
- September instead. See register §3 for the full note. *)
+ [cited] PRIMARY-SOURCE-VERIFIED (register §3a): MR1962, "De anno et eius
+ partibus", under the heading "Quatuor Tempora" (not a numbered RG
+ paragraph, which is why an earlier paragraph-number search missed it):
+
+ "Quatuor Tempora celebrantur quarta et sexta feria ac sabbato post
+ tertiam dominicam Adventus, post primam dominicam Quadragesimae, post
+ dominicam Pentecostes, post dominicam tertiam septembris."
+
+ -- the Ember Days are kept on the Wednesday, Friday and Saturday after
+ Advent III, after Lent I, after Pentecost, [and] after the third Sunday
+ of September -- confirming all four of this module's anchors, including
+ this specific contested one. This specific date-derivation rule was one
+ of the more contested points in the 1962 calendar: pre-1955 practice
+ tied the September Ember days to the week following the Exaltation of
+ the Holy Cross (14 Sept) instead. The two rules only disagree when 1
+ September is a Monday -- 2025 is such a year, and confirms the
+ third-Sunday reading empirically too (24/26/27 September against an
+ independent oracle, vs the Holy-Cross rule's 17/19/20). An earlier
+ version of this comment said the scan contained no numbered-paragraph
+ statement of either rule and left the citation at the rank rules only
+ (RG 91 entries 18/22) -- WRONG, corrected once the nominative heading
+ "Quatuor Tempora" was found rather than the genitive "Quatuor Temporum"
+ the original search used; register §3a records the correction, because a
+ false "not in the source" note is worse than no note. *)
let third_sunday_of_september y =
let sep1 = mk y 9 1 in
let first_sunday = Date.add_days sep1 ((7 - weekday_index sep1) mod 7) in
@@ -278,8 +314,10 @@ let third_sunday_of_september y =
The September and Advent sets match lectio's own Ember slugs. The Lent and
Whitsun (Pentecost) sets do not -- lectio has no Ember slug for either, so
"ef-lent-ember-*" and "ef-pentecost-ember-*" are colitur-only keys and a
- lectionary gap for the Plan 3 bootstrap to fill (spec §4.4), the same
- status as the Nativity vigil and the Rogation days below. *)
+ lectionary gap for the Plan 4 bootstrap to fill (spec §4.4; CORRECTED,
+ final fix wave, item 7 -- Plan 4, not Plan 3, is the lectionary
+ bootstrap), the same status as the Nativity vigil and the Rogation days
+ below. *)
let ember d =
let y = Date.year d in
let easter = Computus.gregorian_easter y in
@@ -302,25 +340,38 @@ let ember d =
(* RG 91 entry 7: Ash Wednesday (named above) and Monday-Wednesday of Holy
Week are I-class ferias -- the primary text reads "feria IV cinerum et II,
- III et IV Hebdomadae sanctae", i.e. explicitly stops at Wednesday. Thursday
- to Saturday of Holy Week are the Sacred Triduum, RG 91 entry 2 -- ranked
- even above entry 7, not a mere feria -- but their own named offices are a
- Plan 3 sanctoral addition; until then this gives them the same I-class rank
- via the generic ferial path. RG 91 entry 10: the weekdays within the
- privileged Octaves of Easter and Pentecost are I class too. *)
+ III et IV Hebdomadae sanctae", i.e. explicitly stops at Wednesday
+ (PRIMARY-SOURCE-VERIFIED, final fix wave: confirmed word for word
+ against the scan). Thursday to Saturday of Holy Week are the Sacred
+ Triduum, RG 91 entry 2 -- ranked even above entry 7, not a mere feria --
+ but the Sacred Triduum has NO PROPER OFFICE of its own in this codebase
+ (CORRECTED, final fix wave, item 7: this comment previously said their
+ "own named offices are a Plan 3 sanctoral addition"; WRONG on two
+ counts -- Plan 3 shipped, in this branch, without adding them, AND a
+ proper office for I-class FERIAS was never a sanctoral matter to begin
+ with, RG 21's own definition of "feria" excludes Sundays/feasts, not the
+ other way round). `Temporal_ef.temporal 2026-04-02/03/04` (Holy
+ Thursday/Good Friday/Holy Saturday) still resolve today to the ordinary
+ Passiontide ferial fallback's own generic slugs,
+ "ef-passiontide-2-{thursday,friday,saturday}" -- register §6 records
+ this as its own open item now. This gives them the same I-class rank
+ via the generic ferial path regardless. RG 91 entry 10: the weekdays
+ within the privileged Octaves of Easter and Pentecost are I class too. *)
let privileged_feria d =
let easter = Computus.gregorian_easter (Date.year d) in
let n = days_between easter d in
(n >= -6 && n <= -1) || (n >= 1 && n <= 6) || (n >= 50 && n <= 55)
(* RG 117 enumerates the five colours (white, red, green, violet, black);
- RG 127 assigns green and RG 128 violet to the seasons de Tempore below.
- White's own specific paragraph (the "B) De colore albo" section, between
- 117 and 123) was not pinned by the primary-source search available here --
- left uncited rather than guessed; see register §3 "Colours". *)
+ RG 127 assigns green and RG 128 violet to the seasons de Tempore below. RG
+ 119 (register §3b, primary-source-verified 2026-08-11 -- this comment was
+ stale until Task 16 noticed the correction had not been copied down here):
+ white "a festo Nativitatis Domini usque ad expletum tempus Epiphaniae"
+ and "a Missa Vigiliae paschalis usque ad Missam vigiliae Pentecostis
+ exclusive" -- exactly Christmastide and Paschaltide below. *)
let season_colour = function
| Advent | Septuagesima | Lent | Passiontide -> Colour.Violet (* RG 128 *)
- | Christmastide | Paschaltide -> Colour.White
+ | Christmastide | Paschaltide -> Colour.White (* RG 119 *)
| Time_after_epiphany | Time_after_pentecost -> Colour.Green (* RG 127 *)
(* Gaudete (Advent III) and Laetare (Lent IV) are rose: RG 131, "may be used...
@@ -365,16 +416,27 @@ let temporal d =
| None -> (
(* Rogations (the Minor Litanies only -- RG 87, Monday and Tuesday
before Ascension). The Major Litanies (25 April, RG 80) are a fixed
- date and are not yet computed; they arrive with Plan 3's sanctoral
- (register §6). The Wednesday here is the Ascension vigil (see Task
- 11). RG 88: "de Litaniis minoribus nihil fit in Officio" -- the
- Office (hence the day's rank) is unchanged by the Rogation; only the
- Mass is proper. No RG 91 table entry elevates these days, so they
+ date and are STILL not computed (CORRECTED, final fix wave, item 7:
+ this comment previously said "they arrive with Plan 3's sanctoral"
+ -- Plan 3 shipped, in this branch, without them; register §6 tracks
+ this as a plain open item, with no plan committed to build it yet).
+ The Wednesday here is the Ascension vigil (see Task 11), which
+ happens to also fall on Rogation Wednesday -- the vigil (higher
+ RG 91 entry) is what [named] emits for that date; the Rogation
+ Wednesday's own commemoration is not separately constructed (a real
+ gap, not a forward dependency: the precedence framework and RG
+ 108-111 both exist now, but nothing wires a Rogation-Wednesday
+ candidate into the contest for this specific date the way Monday
+ and Tuesday get one below). RG 88: "de Litaniis minoribus nihil fit
+ in Officio" -- the Office (hence the day's rank) is unchanged by
+ the Rogation; only the Mass is proper. No RG 91 table entry
+ elevates these days, so they
take the ordinary ferial rank of their season via [ferial_rank]
rather than a fixed class. lectio has no Rogation slug at all, so
"ef-rogation-monday"/"-tuesday" are colitur-only keys and a
- lectionary gap for Plan 3, like the Ember and Nativity-vigil keys
- above. *)
+ lectionary gap for Plan 4 (CORRECTED, final fix wave, item 7 --
+ the lectionary bootstrap is Plan 4, not Plan 3), like the Ember
+ and Nativity-vigil keys above. *)
let rogation = days_between easter d in
if rogation = 36 || rogation = 37 then
build ~season:s
@@ -411,6 +473,34 @@ let temporal d =
let colour =
(* The Pentecost octave weekdays are red, not Paschaltide's white. *)
if days_between easter d >= 50 && days_between easter d <= 55 then Colour.Red
+ (* RG 128(b) (docs/research/rules-register.md §3b), primary
+ text: "...a dominica in Septuagesima usque ad Vigiliam
+ paschalem, EXCEPTIS: ... MISSA SIVE CHRISMATIS SIVE IN
+ CENA DOMINI FERIA V HEBDOMADAE SANCTAE; ..." -- violet
+ runs Septuagesima to the Easter Vigil EXCEPT (among
+ others) "the Mass, whether of the Chrism or in Cena
+ Domini [Holy Thursday], on Thursday of Holy Week" --
+ named as a WHOLE-MASS exception (unlike Palm Sunday's
+ "blessing and procession of palms", which the SAME
+ sentence carves out as only PART of that day, register
+ §3b's own RG126 note on the not-yet-modelled per-action
+ nuance), so this is a clean whole-day colour fact, not
+ a per-action one the day/colour model cannot express.
+ RG 122, fix round 1 (F9), states the same fact
+ affirmatively rather than as an exception to RG 128's
+ violet: "Demum adhibetur color albus, feria V
+ Hebdomadae sanctae, in Missa Chrismatis et in Missa in
+ Cena Domini" -- white is used, finally [among the
+ White section's own list], on Thursday of Holy Week,
+ in the Mass of Chrism and in the Mass in Cena Domini.
+ Task 16, found via the missalemeum oracle comparison:
+ every other Triduum day's oracle colour SET includes
+ violet as one option (Good Friday "bv", Holy Saturday
+ "vw" -- RG 132's black is a separate, ALREADY-flagged
+ gap, register §3b, not touched here), but Holy
+ Thursday's is white ALONE -- confirming this specific
+ day, and only this one, needs the exception coded. *)
+ else if days_between easter d = -3 then Colour.White
else season_colour s
in
let week_n = week d in
diff --git a/lib/rites/rite_ef/vocab_ef.ml b/lib/rites/rite_ef/vocab_ef.ml
index 13e7309..e5726e8 100644
--- a/lib/rites/rite_ef/vocab_ef.ml
+++ b/lib/rites/rite_ef/vocab_ef.ml
@@ -45,7 +45,10 @@ let season_of_string = function
(* Deliberately NOT season_to_string: slugs are lectionary keys adopted verbatim
from lectio, which names these two seasons differently. Changing these words
- would silently break the Plan 3 lectionary bootstrap. *)
+ would silently break the Plan 4 lectionary bootstrap (CORRECTED, final fix
+ wave, item 7 -- see Slug.ml's own corrected comment for the Plan 3/4
+ distinction: the sanctoral bootstrap these slugs already serve is Plan 3
+ and shipped; the lectionary/reading-citations bootstrap is Plan 4). *)
let season_slug_word = function
| Christmastide -> "christmas"
| Paschaltide -> "easter"