diff options
Diffstat (limited to 'lib/rites')
| -rw-r--r-- | lib/rites/rite_ef/precedence_ef.ml | 893 | ||||
| -rw-r--r-- | lib/rites/rite_ef/precedence_ef.mli | 260 | ||||
| -rw-r--r-- | lib/rites/rite_ef/rite_ef.ml | 24 | ||||
| -rw-r--r-- | lib/rites/rite_ef/rite_ef.mli | 35 | ||||
| -rw-r--r-- | lib/rites/rite_ef/temporal_ef.ml | 176 | ||||
| -rw-r--r-- | lib/rites/rite_ef/vocab_ef.ml | 5 |
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" |
