From 12b97761019cfa02ca0da8a5fb50ef815d07685c Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Fri, 21 Aug 2026 14:42:30 +0200 Subject: 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. --- lib/kernel/calendar.ml | 133 ++++++++++++++++++++++++++++++++++-- lib/kernel/precedence.ml | 6 ++ lib/kernel/precedence.mli | 40 +++++++++++ lib/rites/rite_ef/precedence_ef.ml | 55 +++++++++++++++ lib/rites/rite_ef/precedence_ef.mli | 35 ++++++++++ lib/rites/rite_ef/rite_ef.ml | 3 +- 6 files changed, 266 insertions(+), 6 deletions(-) (limited to 'lib') diff --git a/lib/kernel/calendar.ml b/lib/kernel/calendar.ml index 99992b3..98e9032 100644 --- a/lib/kernel/calendar.ml +++ b/lib/kernel/calendar.ml @@ -58,7 +58,20 @@ let year_bounds (rite : ('s, 'r) Rite.t) (y : int) : Date.t * Date.t = [injected] is keyed by [Date.to_rata] rather than [Date.t] directly: [Date.t] carries no [compare]-respecting hash, and rata-die is already the canonical total order this module uses for date arithmetic. *) -let resolve_with_injected (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) +(* RG 33's third trigger, third stage: the set of (date, slug) pairs whose + vigil the rule suppresses, keyed by rata die. Empty for a rite whose + [vigil_feast] is the constant [None], and empty on the EF's own shipped + data -- see {!rg33_suppressed} for both. *) +let no_suppression : (int, string list) Hashtbl.t = Hashtbl.create 1 + +let is_suppressed (suppressed : (int, string list) Hashtbl.t) (date : Date.t) + (c : 'r Precedence.candidate) = + match Hashtbl.find_opt suppressed (Date.to_rata date) with + | None -> false + | Some slugs -> List.mem (Slug.to_string c.Precedence.cel.Celebration.slug) slugs + +let resolve_with_injected ?(suppressed = no_suppression) (rite : ('s, 'r) Rite.t) + (idx : 'r Layer.index) (injected : (int, 'r Precedence.candidate list) Hashtbl.t) (date : Date.t) : ('s, 'r) Temporal.t * 's Precedence.context * 'r Precedence.resolution = let temporal = rite.Rite.temporal date in @@ -72,8 +85,22 @@ let resolve_with_injected (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) in let arrived = try Hashtbl.find injected (Date.to_rata date) with Not_found -> [] in let ctx = { Precedence.date; season = temporal.Temporal.season; weekday = temporal.Temporal.weekday } in + (* RG 33: a suppressed vigil is not a losing candidate, it is not a + candidate at all -- "penitus omittitur". Filtering here rather than + leaving it to [disposition] is deliberate and is what the rubric says: + were it left in the contest it could still claim the day's single + commemoration slot (RG 111) ahead of a saint genuinely entitled to it, + which is precisely the defect the SECOND trigger's own fix corrected for + the Sunday case. Note this drops it from [omitted] too -- the + suppression is reported by {!build_day} instead, with its own reason, so + nothing vanishes unaccounted for. *) + let sanctoral = + match Hashtbl.length suppressed with + | 0 -> natural @ arrived + | _ -> List.filter (fun c -> not (is_suppressed suppressed date c)) (natural @ arrived) + in let resolution = - Precedence.resolve rite.Rite.rules ctx ~temporal:temporal_candidate ~sanctoral:(natural @ arrived) + Precedence.resolve rite.Rite.rules ctx ~temporal:temporal_candidate ~sanctoral in (temporal, ctx, resolution) @@ -115,6 +142,13 @@ let unconverged_reason = silently dropped". This reason makes that failure mode visible instead. *) let out_of_range_reason = "omitted: transfer target falls outside the liturgical year (RG 96)" +(* RG 33: "Vigilia II aut III classis penitus omittitur... vel si festum cui + praemittitur in alium diem transferri aut ad commemorationem reduci + contingat." The vigil is not demoted or commemorated -- it is dropped + whole, which is what "penitus" says. *) +let rg33_vigil_reason = + "omitted: the feast this vigil precedes does not keep its own day (RG 33)" + (* Rebuilds the per-date injection index from [assignment] (slug -> (origin, target)) fresh each round, rather than accumulating it incrementally as candidates are placed. A candidate re-deferred in a later round (its first @@ -273,13 +307,77 @@ let place_transfers (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) ~(start : Da went on to win) and [transferred_out] (whichever candidates' settled placements originated here -- RG 97-98 lets that be more than one; see [Liturgical_day.transferred_out]). *) +(* RG 33's third omission trigger -- "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"). See {!Precedence.rules.vigil_feast} for the contract and + for why the RITE names the feast instead of the kernel inferring it. + + ONE PASS, NO FIXED POINT. Both halves of the clause reduce to the same + observable question -- is the named feast the OBSERVED office on the + following day? -- and the answer cannot depend on the vigil, because a + vigil is a candidate only on its own day and never on its feast's. So + resolving D+1 here WITHOUT applying this rule is exact, not an + approximation, and the recursion an eager reading would suggest (D asks + D+1, which asks D+2...) never arises. Contrast RG 96's transfers, which + genuinely do need [place_transfers]' iteration. + + RUNS AFTER [place_transfers], and must: the whole point is to see the + post-transfer placement, so the [injected] table this receives is the + settled one. + + D+1 MAY FALL OUTSIDE THE LITURGICAL YEAR -- the last day of the year is + resolved against the first day of the next, which [Layer.index] already + covers (it indexes [y-1; y; y+1]) and {!Rite.temporal} answers for any + in-domain date. Only the domain edge itself is refused, where [Date.add_days] + would leave the representable range; a vigil there keeps its office, the + same conservative direction the rest of this module takes at the boundary. *) +let rg33_suppressed (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) + (injected : (int, 'r Precedence.candidate list) Hashtbl.t) (dates : Date.t array) : + (int, string list) Hashtbl.t = + let suppressed : (int, string list) Hashtbl.t = Hashtbl.create 8 in + Array.iter + (fun date -> + let candidates = + Layer.on_date idx date + |> List.map (fun (e : 'r Layer.entry) -> + { Precedence.cel = e.Layer.cel; origin = Precedence.Sanctoral }) + in + let arrived = try Hashtbl.find injected (Date.to_rata date) with Not_found -> [] in + let temporal = rite.Rite.temporal date in + let temporal_candidate = + { Precedence.cel = temporal.Temporal.office; origin = Precedence.Temporal } + in + (* The temporal cycle produces vigils too (the Ascension vigil is one), + so it is checked alongside the sanctoral candidates. *) + List.iter + (fun c -> + match rite.Rite.rules.Precedence.vigil_feast c with + | None -> () + | Some feast -> + if Date.compare date domain_max_date < 0 then begin + let morrow = Date.add_days date 1 in + let _, _, r = resolve_with_injected rite idx injected morrow in + let observed = r.Precedence.observed.Precedence.cel.Celebration.slug in + if not (Slug.equal observed feast) then begin + let key = Date.to_rata date in + let slug = Slug.to_string c.Precedence.cel.Celebration.slug in + let prior = try Hashtbl.find suppressed key with Not_found -> [] in + Hashtbl.replace suppressed key (slug :: prior) + end + end) + (temporal_candidate :: (candidates @ arrived))) + dates; + suppressed + let build_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) (assignment : (string, Date.t * Date.t) Hashtbl.t) (out_of_range : (string, Date.t * Date.t) Hashtbl.t) (injected : (int, 'r Precedence.candidate list) Hashtbl.t) + (suppressed : (int, string list) Hashtbl.t) (transferred_out_of : (int, ('r Celebration.t * Date.t) list) Hashtbl.t) (date : Date.t) : ('s, 'r) Liturgical_day.t = - let temporal, _ctx, resolution = resolve_with_injected rite idx injected date in + let temporal, _ctx, resolution = resolve_with_injected ~suppressed rite idx injected date in let arrived = try Hashtbl.find injected (Date.to_rata date) with Not_found -> [] in let transferred_in = arrived @@ -399,7 +497,7 @@ let build_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) Major Litanies transfer as [Commemoration_only], and RG 96's search guarantees a transferred FEAST an unblocked target. *) let settled_at target slug = - let _, _, target_resolution = resolve_with_injected rite idx injected target in + let _, _, target_resolution = resolve_with_injected ~suppressed rite idx injected target in let matches (c : 'r Precedence.candidate) = Slug.equal c.Precedence.cel.Celebration.slug slug in @@ -420,10 +518,30 @@ let build_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) out_of_range_reason else unconverged_reason in + (* RG 33's third trigger removes the vigil from the contest entirely + ({!resolve_with_injected} filters it before {!Precedence.resolve} ever + sees it), so it cannot appear in [resolution.omitted] the way an + ordinary loser does. Re-derived here from the same table, with its own + reason, so that the day's accounting stays complete: every candidate the + date carries is still reported somewhere. *) + let rg33_omitted = + match Hashtbl.find_opt suppressed (Date.to_rata date) with + | None -> [] + | Some slugs -> + let on_date = + Layer.on_date idx date |> List.map (fun (e : 'r Layer.entry) -> e.Layer.cel) + in + let temporal_office = temporal.Temporal.office in + (temporal_office :: (on_date @ List.map (fun c -> c.Precedence.cel) arrived)) + |> List.filter (fun (cel : 'r Celebration.t) -> + List.mem (Slug.to_string cel.Celebration.slug) slugs) + |> List.map (fun cel -> (cel, rg33_vigil_reason)) + in let omitted = List.map (fun (c, reason) -> (c.Precedence.cel, reason)) resolution.Precedence.omitted @ (resolution.Precedence.deferred |> List.filter unresolved |> List.map (fun c -> (c.Precedence.cel, reason_for c))) + @ rg33_omitted in { Liturgical_day.date; @@ -489,7 +607,12 @@ let year (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) (y : int) : Hashtbl.iter (fun k v -> Hashtbl.replace transferred_out_of k (List.sort by_target_then_slug v)) transferred_out_of; - Array.map (build_day rite idx assignment out_of_range injected transferred_out_of) dates + (* RG 33's third trigger, computed once for the whole year and AFTER + [place_transfers], because the question it asks -- did the vigil's feast + keep its own day? -- is only answerable against the settled placement. + See {!rg33_suppressed}. *) + let suppressed = rg33_suppressed rite idx injected dates in + Array.map (build_day rite idx assignment out_of_range injected suppressed transferred_out_of) dates let day (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) (date : Date.t) : ('s, 'r) Liturgical_day.t = diff --git a/lib/kernel/precedence.ml b/lib/kernel/precedence.ml index 2c55817..c207117 100644 --- a/lib/kernel/precedence.ml +++ b/lib/kernel/precedence.ml @@ -25,6 +25,12 @@ type ('s, 'r) rules = { has no [context] of its own to compute it with) -- see [resolve]'s own comment for why this is a KERNEL-level policy, not a rite- specific rule threaded in as data. *) + vigil_feast : 'r candidate -> Slug.t option; + (** RG 33's third omission trigger: the slug of the feast this candidate + is a vigil OF, when the rite subjects that vigil to omission; + [None] for every other candidate. See the .mli for the contract and + for why the kernel asks the rite to NAME the feast rather than infer + it. *) } type 'r resolution = { diff --git a/lib/kernel/precedence.mli b/lib/kernel/precedence.mli index 09376d7..a0b4b1a 100644 --- a/lib/kernel/precedence.mli +++ b/lib/kernel/precedence.mli @@ -86,6 +86,46 @@ type ('s, 'r) rules = { own module documentation (Rite_ef.Precedence_ef.admit); stated here because this signature -- not any one rite's implementation of it -- is what an author of the next rite reads. *) + vigil_feast : 'r candidate -> Slug.t option; + (** The feast this candidate is a VIGIL OF, when the rite subjects that + vigil to omission because its feast did not keep its own day; + [None] for every other candidate, which is what a rite with no such + rule returns unconditionally. + + Exists for RG 33's third omission trigger -- "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". Both halves of that + clause reduce to ONE observable question, which is why this hook + returns a slug rather than a verdict: is the named feast the + OBSERVED office on the following day? {!Calendar} asks it and + suppresses the vigil when the answer is no. A feast transferred away + (RG 96) and a feast outranked into a bare commemoration (RG 94) both + fail that test; so does a feast omitted outright, which RG 33 does + not enumerate but which is strictly the stronger case. + + WHY THE RITE NAMES THE FEAST. The kernel could not infer it. RG 34 + fixes the vigil on the day BEFORE its feast, so the date is known, + but nothing in {!Celebration.t} links the two and the slugs do not + reliably derive from one another -- in the EF's own shipped data + only two of five vigils ("ef-ascension-vigil"/"ef-ascension", + "vigil-of-sts-peter-paul"/"sts-peter-paul") share a stem, while + "vigil-of-st-lawrence" precedes "lawrence" and + "vigil-of-the-assumption" precedes + "assumption-of-the-blessed-virgin-mary". Deriving the feast by + string surgery would be wrong for three of the five. Asking "is a + Class1 sanctoral office observed tomorrow?" would be a PROXY, and + would fire on a day where some UNRELATED I-class feast had + transferred in on top of the real one -- the vigil's feast would be + absent and the vigil wrongly kept. + + CALLED ONCE PER CANDIDATE PER DAY, and the resolution of the + following day that {!Calendar} performs to answer it does NOT + itself apply this rule. That is not an approximation: a vigil is a + candidate only on its own day, never on its feast's, so suppressing + it cannot change what is observed the day after. The check is + therefore a single pass with no fixed point and no recursion -- + unlike RG 96's transfers, which genuinely need one. *) } (** The outcome of resolving one day's candidates. *) 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--sunday-" 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 } -- cgit v1.3