diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-21 14:42:30 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-21 14:42:30 +0200 |
| commit | 12b97761019cfa02ca0da8a5fb50ef815d07685c (patch) | |
| tree | 11e7306a1a5de2f5226eea06282515ff38b226d5 /lib/rites/rite_ef | |
| parent | 0eae32f67145065b0e4960650ae06c293fd9f272 (diff) | |
| download | colitur-12b97761019cfa02ca0da8a5fb50ef815d07685c.tar.gz colitur-12b97761019cfa02ca0da8a5fb50ef815d07685c.zip | |
feat(ef): implement RG 33's third omission trigger
RG 33 omits a II/III-class vigil in three cases: it falls on a Sunday, it
falls on a I-class feast, "vel si festum cui praemittitur in alium diem
transferri aut ad commemorationem reduci contingat". Only the first two
were built; the third was recorded in precedence_ef.ml as unimplemented on
the grounds that no witness existed in the shipped data. That reasoning was
wrong, and the rule fires on 1 744 days across 1583-9999.
Both halves of the clause reduce to one observable question -- is the feast
the OBSERVED office on the following day (RG 34 puts it there) -- so the
kernel asks it once per candidate, after place_transfers has settled the
year. No fixed point is needed: a vigil is a candidate only on its own day,
never on its feast's, so suppressing it cannot change what the next day
observes. Precedence.rules gains vigil_feast, which returns the slug of the
feast a vigil precedes; the kernel cannot infer that itself, because only
two of the five vigil/feast pairs share a slug stem.
Blast radius, measured pre-change binary vs HEAD over the whole domain and
classified: 1 744 days, three shapes, zero unexplained. 1 199 are the feast
reduced to a commemoration (10 August on a Sunday, St Lawrence); 478 and 67
are the feast transferred under RG 96 after the Sacred Heart or Corpus
Christi takes its day. The Assumption's and the Ascension's vigils never
qualify -- their I-class feasts always keep their own day.
Independently witnessed, which is unusual here. The published Ordo -- the
only witness outside the Divinum Officium -> missalemeum -> lectio lineage
-- omits St Lawrence's vigil on 2025-08-09, agreeing with colitur against
both engines. That date had been read earlier as an Ordo gap; the Ordo was
right, and correcting the misreading is what surfaced this clause. On
2027-08-09 the feast does keep its day and the Ordo omits a vigil colitur
correctly keeps, which is a genuine Ordo gap.
Allow-lists: C39 (lectio, 10 rows) and a 2038 oracle class citing the
register, the 2026-2027 window having no instance. The golden pin asserting
St Lawrence's vigil is violet moved 2025 -> 2027; its own comment had
reasoned about the vigil's weekday and missed that RG 33 also looks at the
feast's. Two new pins cover both shapes of the clause.
The vigil/feast table is built with Slug.of_string_exn: mutation testing
showed that of_string plus Result.to_option turns a typo into None, which
this hook's contract reads as "not a vigil", switching the rule off in
silence. Two tests assert the table against the shipped data in both
directions.
Diffstat (limited to 'lib/rites/rite_ef')
| -rw-r--r-- | lib/rites/rite_ef/precedence_ef.ml | 55 | ||||
| -rw-r--r-- | lib/rites/rite_ef/precedence_ef.mli | 35 | ||||
| -rw-r--r-- | lib/rites/rite_ef/rite_ef.ml | 3 |
3 files changed, 92 insertions, 1 deletions
diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml index b69ee52..36aea83 100644 --- a/lib/rites/rite_ef/precedence_ef.ml +++ b/lib/rites/rite_ef/precedence_ef.ml @@ -600,6 +600,61 @@ let is_bvm_office (c : Vocab_ef.rank Precedence.candidate) = (ยง6) rather than guessed. *) let is_omissible_vigil (rank : Vocab_ef.rank) = rank = Vocab_ef.Class2 || rank = Vocab_ef.Class3 +(* RG 33's THIRD omission trigger, the clause [is_omissible_vigil]'s own + comment above records as unimplemented: "vel si festum cui praemittitur in + alium diem transferri aut ad commemorationem reduci contingat" -- "or if + the feast it precedes happens to be transferred to another day or reduced + to a commemoration". + + Each pair is (vigil slug, the slug of the feast it precedes). RG 34 fixes + the feast on the day AFTER the vigil ("Vigiliae... celebrantur die + praecedenti festum"), so the kernel needs no date here, only the identity + -- and it must be given the identity rather than deriving it, because + these slugs do not derive from one another: two of the five share a stem + with their feast and three do not (see {!Precedence.rules.vigil_feast}). + + The five are exhaustive for the 1962 universal calendar. The four + sanctoral ones are the only entries in data/ef/sanctoral.sexp matching + [is_vigil]; the fifth is temporal_ef's own Ascension vigil. The two + I-class vigils (Nativity Eve, Pentecost Vigil) are deliberately ABSENT: + RG 33 governs only "Vigilia II aut III classis", and RG 30 puts a I-class + vigil beyond losing in the first place -- the same argument + [is_omissible_vigil] already makes for the other two triggers. + + A user overlay adding a diocesan vigil is NOT covered by this table and + its vigil will not be omitted. That is a data limit, not an architectural + one -- the kernel hook takes any candidate -- and it is stated here rather + than papered over with a slug-shape heuristic that would be wrong for + three of the five universal cases it can already be checked against. *) +(* Built with [Slug.of_string_exn], deliberately, and at module initialisation + rather than per call. A malformed literal here is a PROGRAMMING error in a + static table, not untrusted input, and the alternative found by mutation + testing is worse than a crash: with [Slug.of_string] and [Result.to_option] + a typo (an uppercase letter is enough -- slugs are lowercase-only) collapses + to [None], which this function's own contract reads as "not a vigil", and + the whole rule switches itself off for that entry in silence. That failure + was reproduced: mutating "lawrence" to "lawrence-WRONG" reddened FOUR tests, + all of them the ones that notice the rule missing, and none that notice it + pointing at the wrong feast -- indistinguishable from deleting the row. + The same mutation with a well-formed "lawrence-wrong" reddens eighteen. + Raising at startup keeps a typo loud; [Rite_ef]'s own bundle is constructed + at initialisation too, so the failure surfaces before any calendar is + resolved. *) +let vigil_feast_table = + List.map + (fun (vigil, feast) -> (vigil, Slug.of_string_exn feast)) + [ ("ef-ascension-vigil", "ef-ascension"); + ("vigil-of-the-nativity-of-st-john-the-baptist", "nativity-of-st-john-the-baptist"); + ("vigil-of-sts-peter-paul", "sts-peter-paul"); + ("vigil-of-st-lawrence", "lawrence"); + ("vigil-of-the-assumption", "assumption-of-the-blessed-virgin-mary") ] + +let vigil_feast (c : Vocab_ef.rank Precedence.candidate) : Slug.t option = + let cel = c.Precedence.cel in + if not (is_omissible_vigil cel.Celebration.rank) then None + else List.assoc_opt (Slug.to_string cel.Celebration.slug) vigil_feast_table + + (* 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 diff --git a/lib/rites/rite_ef/precedence_ef.mli b/lib/rites/rite_ef/precedence_ef.mli index 419613a..4c2523a 100644 --- a/lib/rites/rite_ef/precedence_ef.mli +++ b/lib/rites/rite_ef/precedence_ef.mli @@ -217,6 +217,41 @@ val disposition : loser:Vocab_ef.rank Precedence.candidate -> Precedence.disposition +(** RG 33's third omission trigger. Given a candidate, the feast it is a + VIGIL of, when that vigil is one this rule can omit; [None] otherwise -- + which is every candidate that is not one of the five II/III-class vigils + in the 1962 universal calendar. + + Answers only the identity question. Whether the feast actually kept its + own day is {!Colitur_kernel.Calendar}'s to determine, because only the + kernel holds the settled post-transfer placement of the whole year; see + {!Colitur_kernel.Precedence.rules.vigil_feast} for the division of labour + and for why the feast is NAMED here rather than inferred there. + + The I-class vigils (Nativity Eve, Pentecost Vigil) are outside this rule + by RG 33's own wording and outside losing at all by RG 30, so they are + absent from the table and this returns [None] for them -- the same + argument {!is_omissible_vigil} already makes for the rule's other two + triggers. *) +val vigil_feast : Vocab_ef.rank Precedence.candidate -> Slug.t option + +(** The (vigil slug, feast slug) pairs {!vigil_feast} answers from, exposed so + the test suite can assert both directions against the shipped sanctoral + data: that every slug named here exists, and that no II/III-class vigil in + the data is missing from it. Neither is checkable by a type, and both fail + SILENTLY -- an unmatched slug simply makes RG 33 inert for that entry. *) +val vigil_feast_table : (string * Slug.t) list + +(** Whether a slug names a vigil, by either of the two conventions this + calendar's data uses (a "-vigil" suffix from the temporal cycle, a + "vigil-of-" prefix from the sanctoral bootstrap). Exposed for the same + table-drift assertions as {!vigil_feast_table}. *) +val is_vigil : string -> bool + +(** Whether a rank is one RG 33 can omit -- II or III class. I-class vigils are + outside the rule (RG 30). Exposed alongside {!is_vigil}. *) +val is_omissible_vigil : Vocab_ef.rank -> bool + (** 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 diff --git a/lib/rites/rite_ef/rite_ef.ml b/lib/rites/rite_ef/rite_ef.ml index a8a9703..ec6d2b9 100644 --- a/lib/rites/rite_ef/rite_ef.ml +++ b/lib/rites/rite_ef/rite_ef.ml @@ -41,7 +41,8 @@ let context ~lectionary ~commons : (Vocab_ef.season, Vocab_ef.rank) Rite.t = rules = { Precedence.band = Precedence_ef.band; disposition = Precedence_ef.disposition; - admit = Precedence_ef.admit }; + admit = Precedence_ef.admit; + vigil_feast = Precedence_ef.vigil_feast }; season_runs = Vocab_ef.seasons; transfer_target = Precedence_ef.transfer_target; readings = Lectionary_ef.readings ~lectionary ~commons } |
