summaryrefslogtreecommitdiff
path: root/lib/rites
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 11:38:35 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 11:38:35 +0200
commite19adb9c7ea369cafe76e7eff50e9248a4a954d0 (patch)
tree57824e121606286ebdf8fff68439986dd3300eae /lib/rites
parent0506388da160a15ceddb0cea1a697d737103d5c4 (diff)
parent36df2bd0af9a555a09334e14b0d89118be1dbbc0 (diff)
downloadcolitur-e19adb9c7ea369cafe76e7eff50e9248a4a954d0.tar.gz
colitur-e19adb9c7ea369cafe76e7eff50e9248a4a954d0.zip
Merge branch 'ef-plan3': Plan 3, the EF resolution engine
Adds the rite-parameterised Precedence resolver, Liturgical_day, Rite, and Calendar (year as the primitive, because transfers need whole-year knowledge), the full EF precedence ruleset (RG 91's 28-entry table, occurrence RG 92-95, commemorations RG 108-111, transfers RG 96-98), 322 bootstrapped sanctoral entries, colitur day <year>, and validation layers 3-5. Layer 3 diffs 16801 days against lectio; layer 4 diffs 730 days against missalemeum; layer 5 pins ~30 dates on the known-tricky years. Both comparison layers carry cited allow-lists that name the governing RG paragraph and which engine is right. The oracle layer earned its place immediately: Holy Thursday was violet in colitur and lectio alike, because colitur's data was bootstrapped from lectio and both carried the same error. Only an independent source could see it. RG 128(b) and RG 122 name it white.
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"