aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-21 15:07:44 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-21 15:07:44 +0200
commit35c11c7ccf95acfb35a7ec34edfa86d92da60e4a (patch)
tree63e2e0c8f9c6317f68a52961c7770f72c3ba79c0 /lib
parent0eae32f67145065b0e4960650ae06c293fd9f272 (diff)
parentf3ac69d0a679200a48716be065a0fa2e70a2c6da (diff)
downloadcolitur-35c11c7ccf95acfb35a7ec34edfa86d92da60e4a.tar.gz
colitur-35c11c7ccf95acfb35a7ec34edfa86d92da60e4a.zip
merge: RG 33's third omission trigger
The last unbuilt clause in the EF module. A II/III-class vigil is omitted entirely when the feast it precedes fails to keep its own day -- 1 744 days across 1583-9999, three verified shapes, zero unexplained. Rule and vigil inventory independently confirmed by O'Connell 1964, outside the Divinum Officium -> missalemeum -> lectio lineage. The 10 affected days in 2005-2050 are allow-listed as C39; no external day-level witness exists, which the note states rather than glosses.
Diffstat (limited to 'lib')
-rw-r--r--lib/kernel/calendar.ml133
-rw-r--r--lib/kernel/precedence.ml6
-rw-r--r--lib/kernel/precedence.mli40
-rw-r--r--lib/rites/rite_ef/precedence_ef.ml55
-rw-r--r--lib/rites/rite_ef/precedence_ef.mli35
-rw-r--r--lib/rites/rite_ef/rite_ef.ml3
6 files changed, 266 insertions, 6 deletions
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-<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 }