summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 11:38:35 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 11:38:35 +0200
commite19adb9c7ea369cafe76e7eff50e9248a4a954d0 (patch)
tree57824e121606286ebdf8fff68439986dd3300eae /lib
parent0506388da160a15ceddb0cea1a697d737103d5c4 (diff)
parent36df2bd0af9a555a09334e14b0d89118be1dbbc0 (diff)
downloadcolitur-e19adb9c7ea369cafe76e7eff50e9248a4a954d0.tar.gz
colitur-e19adb9c7ea369cafe76e7eff50e9248a4a954d0.zip
Merge branch 'ef-plan3': Plan 3, the EF resolution engine
Adds the rite-parameterised Precedence resolver, Liturgical_day, Rite, and Calendar (year as the primitive, because transfers need whole-year knowledge), the full EF precedence ruleset (RG 91's 28-entry table, occurrence RG 92-95, commemorations RG 108-111, transfers RG 96-98), 322 bootstrapped sanctoral entries, colitur day <year>, and validation layers 3-5. Layer 3 diffs 16801 days against lectio; layer 4 diffs 730 days against missalemeum; layer 5 pins ~30 dates on the known-tricky years. Both comparison layers carry cited allow-lists that name the governing RG paragraph and which engine is right. The oracle layer earned its place immediately: Holy Thursday was violet in colitur and lectio alike, because colitur's data was bootstrapped from lectio and both carried the same error. Only an independent source could see it. RG 128(b) and RG 122 name it white.
Diffstat (limited to 'lib')
-rw-r--r--lib/kernel/calendar.ml387
-rw-r--r--lib/kernel/calendar.mli72
-rw-r--r--lib/kernel/celebration.ml13
-rw-r--r--lib/kernel/celebration.mli12
-rw-r--r--lib/kernel/liturgical_day.ml27
-rw-r--r--lib/kernel/liturgical_day.mli25
-rw-r--r--lib/kernel/precedence.ml69
-rw-r--r--lib/kernel/precedence.mli78
-rw-r--r--lib/kernel/rite.ml14
-rw-r--r--lib/kernel/rite.mli62
-rw-r--r--lib/kernel/slug.ml8
-rw-r--r--lib/kernel/validate.ml194
-rw-r--r--lib/kernel/validate.mli79
-rw-r--r--lib/kernel/vocab.ml25
-rw-r--r--lib/kernel/vocab.mli25
-rw-r--r--lib/rites/rite_ef/precedence_ef.ml893
-rw-r--r--lib/rites/rite_ef/precedence_ef.mli260
-rw-r--r--lib/rites/rite_ef/rite_ef.ml24
-rw-r--r--lib/rites/rite_ef/rite_ef.mli35
-rw-r--r--lib/rites/rite_ef/temporal_ef.ml176
-rw-r--r--lib/rites/rite_ef/vocab_ef.ml5
21 files changed, 2393 insertions, 90 deletions
diff --git a/lib/kernel/calendar.ml b/lib/kernel/calendar.ml
new file mode 100644
index 0000000..5d3572c
--- /dev/null
+++ b/lib/kernel/calendar.ml
@@ -0,0 +1,387 @@
+(* Resolution across a whole liturgical year. See calendar.mli for the
+ architectural rationale (why [year] is the primitive and [day] derived). *)
+
+(* The kernel's domain floor and ceiling (Date.make's documented 1583..9999
+ bound). Both are always constructible -- in-range by definition -- so
+ neither of these can itself raise. *)
+let domain_min_date =
+ match Date.make ~year:1583 ~month:1 ~day:1 with Ok d -> d | Error e -> failwith e
+
+let domain_max_date =
+ match Date.make ~year:9999 ~month:12 ~day:31 with Ok d -> d | Error e -> failwith e
+
+(* [start, stop] for the liturgical year opening in civil year [y], clamped at
+ both ends of the domain rather than calling [rite.year_start] on a civil
+ year outside 1583..9999.
+
+ Top: at [y] = 9999, [rite.year_start (y + 1)] would ask for civil year
+ 10000 -- out of Date's domain (Plan 2 shipped exactly this bug in
+ Validate). Clamp [stop] to 31 December 9999 instead: the final liturgical
+ year comes back truncated, not un-computable.
+
+ Bottom: symmetric case, reachable only through [day] below. A date in
+ civil year 1583 before that year's own [rite.year_start] genuinely belongs
+ to the liturgical year that opened in civil year 1582 for an
+ Advent-anchored rite -- but [rite.year_start 1582] is equally out of
+ domain. [day] only ever decrements a valid date's own (in-domain) civil
+ year by at most one, so [y] = 1582 is the sole way this branch is reached.
+ Clamp [start] to 1 January 1583: "year 1582" becomes the truncated
+ stretch from the domain floor up to the day before [rite.year_start 1583],
+ which is exactly the sliver a date there needs.
+
+ [y] itself is clamped once, up front, to [1582, 9999] -- not left to each
+ branch's own guard. Task 5's review found that guarding [start] and [stop]
+ independently protected only one of their two [rite.year_start] calls
+ each: [start]'s guard (["y < 1583"]) leaves [stop]'s "y + 1" call
+ unguarded at the bottom (["year 999"] still called [year_start 1000], out
+ of domain), and [stop]'s guard (["y >= 9999"]) leaves [start]'s call
+ unguarded at the top (["year 100000"] still called [year_start 100000]).
+ Neither is reachable through [day] (see calendar.mli), but [year] is
+ public, and a direct out-of-contract call must not raise either. Clamping
+ [y] once closes both gaps with one check instead of two. *)
+let year_bounds (rite : ('s, 'r) Rite.t) (y : int) : Date.t * Date.t =
+ let y = max 1582 (min 9999 y) in
+ let start = if y < 1583 then domain_min_date else rite.Rite.year_start y in
+ let stop =
+ if y >= 9999 then domain_max_date else Date.add_days (rite.Rite.year_start (y + 1)) (-1)
+ in
+ (start, stop)
+
+(* RG 91's contest for one date: the temporal office against every sanctoral
+ entry whose Date_spec resolves to it, plus whatever the placement pass
+ below has [injected] there so far (a celebration transferred in from an
+ impeded day elsewhere). [Layer.on_date] is keyed on exactly (month, day),
+ which for a [Fixed] spec -- the only form Plan 2 ships -- is the same test
+ as resolving the spec against [date]'s own year and comparing, so no
+ separate filter is needed here.
+
+ [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.by_date)
+ (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
+ let temporal_candidate =
+ { Precedence.cel = temporal.Temporal.office; origin = Precedence.Temporal }
+ in
+ let natural =
+ Layer.on_date idx ~month:(Date.month date) ~day:(Date.day 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 ctx = { Precedence.date; season = temporal.Temporal.season; weekday = temporal.Temporal.weekday } in
+ let resolution =
+ Precedence.resolve rite.Rite.rules ctx ~temporal:temporal_candidate ~sanctoral:(natural @ arrived)
+ in
+ (temporal, ctx, resolution)
+
+(* What Precedence.resolve currently reports as observed on [date], given the
+ placements decided so far -- this is exactly the [occupant] callback
+ Rite.transfer_target's search walks forward with (rite.mli explains why
+ that judgement has to come from the rite, not from here). *)
+let occupant_of (rite : ('s, 'r) Rite.t) (idx : 'r Layer.by_date)
+ (injected : (int, 'r Precedence.candidate list) Hashtbl.t) (date : Date.t) : 'r Celebration.t =
+ let _, _, resolution = resolve_with_injected rite idx injected date in
+ resolution.Precedence.observed.Precedence.cel
+
+(* Hard guard on the placement fixed point (spec §2.4): every genuine
+ transfer moves a celebration strictly forward and the celebration set is
+ finite, so the round below always empties [deferred] within a handful of
+ rounds in practice (an RG 97-98 collision of N feasts on one date costs at
+ most N-1 extra rounds -- each round resolves the winner of whatever pile-up
+ occurred and re-defers the rest, one fewer each time). 64 is not tuned to
+ that bound; it is a defensive ceiling nothing in the 1962 calendar comes
+ close to, so that a rite/data combination this module has not anticipated
+ fails as a recorded, inspectable [omitted] reason (below) instead of
+ hanging the CLI. *)
+let max_transfer_rounds = 64
+
+let unconverged_reason =
+ "omitted: transfer placement did not converge within max_transfer_rounds (RG 96-98)"
+
+(* A rite-supplied [transfer_target] is trusted to search strictly forward
+ (rite.mli), but nothing stops it naming a date past the end of the
+ liturgical year it was asked about -- e.g. an I-class feast impeded in
+ the last days before Advent I, whose first admissible day genuinely
+ falls in the following liturgical year's own territory (unproven to
+ occur in the real EF calendar, but not something this module can rule
+ out by construction). [place_transfers] never injects such a target: the
+ [dates] array is exactly what [year]/[build_day] walk to produce the
+ result, so a candidate placed outside it would be [observed]/
+ [transferred_in] nowhere in the output at all -- gone, not merely
+ mis-filed, and silently so, contradicting [calendar.mli]'s "never
+ 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)"
+
+(* 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
+ target turned out to already be claimed by a higher-band rival, see
+ [place_transfers]) must vacate its old target date entirely, not merely
+ gain a second one; rebuilding from a slug-keyed map, which holds exactly
+ one entry per candidate, gives that for free. An append-only structure
+ would instead leave the stale placement behind forever, and the round
+ loop would never see [deferred] empty out. *)
+let injected_index_of_assignment (assignment : (string, Date.t * Date.t) Hashtbl.t)
+ (candidate_by_slug : (string, 'r Precedence.candidate) Hashtbl.t) :
+ (int, 'r Precedence.candidate list) Hashtbl.t =
+ let tbl : (int, 'r Precedence.candidate list) Hashtbl.t = Hashtbl.create 16 in
+ Hashtbl.iter
+ (fun slug (_origin, target) ->
+ let key = Date.to_rata target in
+ let c = Hashtbl.find candidate_by_slug slug in
+ Hashtbl.replace tbl key (c :: (try Hashtbl.find tbl key with Not_found -> [])))
+ assignment;
+ tbl
+
+(* The placement pass itself (spec §2.4 steps 1-4; step 5, recording
+ transferred_in/out, is [year]'s job once this reaches a fixed point).
+
+ Every [Precedence.Transfer]-*and*-[Precedence.Repose]-disposed loser lands
+ in [resolution.deferred] together -- [Precedence.resolve]'s own fold
+ matches them as one case, [Transfer | Repose -> ... :: defs ...] -- and
+ everything gathered below is routed through
+ [rite.transfer_target], i.e. RG 96's next-admissible-day search. That is
+ only correct for [Transfer]. [Repose] denotes RG 100-102's *repositio*
+ (perpetual impediment, reassigned to the next appropriate day and treated
+ as proper) -- a distinct rubric this module does not implement. It is
+ documented here rather than split into a second mechanism because nothing
+ currently produces [Repose]: the design spec records it as "declared, not
+ exercised" (§1.3) -- the EF ruleset (Tasks 7-9) returns it for nothing;
+ perpetual impediment arises from proper/diocesan calendars, which are
+ overlay content, out of this plan's scope. If a future rite's rules ever
+ do return [Repose], it would silently take the RG 96 path here, which
+ would be wrong -- worth knowing before that day, not discovering it then.
+
+ Each round: gather every currently-deferred candidate across the whole
+ year (fresh, against this round's [injected] state -- a candidate already
+ placed and now winning its target is no longer a loser anywhere and so
+ will not reappear here); if none, the fixed point is reached. Otherwise
+ sort ALL of them by band -- RG 97-98: this is the global ordering that
+ decides who transfers first when I-class feasts coincide -- ties break on
+ slug, same convention as Precedence.compare_by, so placement never depends
+ on the layer's own entry order. Then place each in turn, in that order.
+
+ [claimed_this_round] is what makes the sort actually decide anything: it
+ starts empty every round and gains one entry per candidate placed so far
+ THIS round, and [occupant_with_claims] reports a claimed date as occupied
+ by whoever claimed it, layered on top of [injected] (last round's settled
+ state, frozen for the round -- see [injected_index_of_assignment] for why
+ that has to stay frozen rather than being updated in place). Without it,
+ every candidate in a round would search against the exact same snapshot
+ and a same-date collision would only be caught (and only one side of it
+ corrected) on re-resolution next round, one collision layer per round --
+ RG 97-98's own ordering would still come out right in the end, but only
+ by accident of Precedence.resolve's own internal tie-break repeating this
+ module's, not because this module's sort ever decided anything. Layering
+ the claims instead means a same-round collision is resolved in the one
+ round it is found, in the sorted order, and the earlier RG 97-98 test
+ pins exactly that: it fails on "claims 2 Feb first" without this.
+
+ [~start ~stop] bound the [transfer_target] a placement is allowed to
+ settle on: outside that range it goes into [out_of_range] instead of
+ [assignment], permanently (never retried -- [transfer_target] is a pure
+ function of a candidate's own permanent origin and the occupancy state,
+ so asking it again would only recompute the same out-of-range answer). *)
+let place_transfers (rite : ('s, 'r) Rite.t) (idx : 'r Layer.by_date) ~(start : Date.t)
+ ~(stop : Date.t) (dates : Date.t array) :
+ (string, Date.t * Date.t) Hashtbl.t
+ * (string, 'r Precedence.candidate) Hashtbl.t
+ * (string, Date.t * Date.t) Hashtbl.t =
+ let assignment : (string, Date.t * Date.t) Hashtbl.t = Hashtbl.create 16 in
+ let candidate_by_slug : (string, 'r Precedence.candidate) Hashtbl.t = Hashtbl.create 16 in
+ let out_of_range : (string, Date.t * Date.t) Hashtbl.t = Hashtbl.create 4 in
+ let compare_deferred (_, ctx1, c1) (_, ctx2, c2) =
+ let b1 = rite.Rite.rules.Precedence.band ctx1 c1 in
+ let b2 = rite.Rite.rules.Precedence.band ctx2 c2 in
+ if b1 <> b2 then Int.compare b1 b2
+ else Slug.compare c1.Precedence.cel.Celebration.slug c2.Precedence.cel.Celebration.slug
+ in
+ let round = ref 0 in
+ let converged = ref false in
+ let guard_hit = ref false in
+ while (not !converged) && not !guard_hit do
+ incr round;
+ if !round > max_transfer_rounds then guard_hit := true
+ else begin
+ let injected = injected_index_of_assignment assignment candidate_by_slug in
+ let raw =
+ Array.to_list dates
+ |> List.concat_map (fun date ->
+ let _, ctx, resolution = resolve_with_injected rite idx injected date in
+ List.map (fun c -> (date, ctx, c)) resolution.Precedence.deferred)
+ in
+ (* [raw] rediscovers every candidate's *permanent* natural loss at its
+ origin every round -- the layer entry never moves, so a candidate
+ already settled elsewhere still shows up losing at the date it was
+ always going to lose at. Left unfiltered, that stale sighting gets
+ placed again right next to the candidate's own already-settled
+ self, which -- because a placed candidate's own rank makes it look
+ "occupied" to a fresh search starting from its original origin --
+ oscillates between two dates forever, never reaching [deferred =
+ []] (confirmed by removing this filter: "transferable" lands on 14
+ Jan instead of 13 in test_transfer_moves_and_does_not_duplicate,
+ not merely "doesn't converge" -- the bug is a wrong answer, not
+ only a hang). A sighting is genuinely actionable only if the
+ candidate has never been placed yet (first time seen, and not
+ already known unplaceable -- [out_of_range] gets the same
+ permanent exclusion [assignment] does, for the same reason), or if
+ it is losing exactly at the date it is *currently* assigned to (a
+ fresh RG 97-98 bump: something else also landed there and
+ out-ranked it) -- any other date is the stale, permanent one and is
+ dropped. *)
+ let deferred =
+ List.filter
+ (fun (date, _ctx, c) ->
+ let slug = Slug.to_string c.Precedence.cel.Celebration.slug in
+ if Hashtbl.mem out_of_range slug then false
+ else
+ match Hashtbl.find_opt assignment slug with
+ | None -> true
+ | Some (_, target) -> Date.compare date target = 0)
+ raw
+ in
+ if deferred = [] then converged := true
+ else begin
+ let claimed_this_round : (int, 'r Precedence.candidate) Hashtbl.t = Hashtbl.create 4 in
+ let occupant_with_claims d =
+ match Hashtbl.find_opt claimed_this_round (Date.to_rata d) with
+ | Some c -> c.Precedence.cel
+ | None -> occupant_of rite idx injected d
+ in
+ List.stable_sort compare_deferred deferred
+ |> List.iter (fun (origin, _ctx, c) ->
+ let target = rite.Rite.transfer_target c origin occupant_with_claims in
+ let slug = Slug.to_string c.Precedence.cel.Celebration.slug in
+ if Date.compare target start < 0 || Date.compare target stop > 0 then
+ Hashtbl.replace out_of_range slug (origin, target)
+ else begin
+ Hashtbl.replace claimed_this_round (Date.to_rata target) c;
+ Hashtbl.replace assignment slug (origin, target);
+ Hashtbl.replace candidate_by_slug slug c
+ end)
+ end
+ end
+ done;
+ (assignment, candidate_by_slug, out_of_range)
+
+(* The final build of one day, once placement has reached its fixed point (or
+ exhausted the guard): resolve against the settled [injected] state, then
+ layer on [transferred_in] (this date received an injected candidate that
+ 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]). *)
+let build_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.by_date)
+ (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)
+ (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 arrived = try Hashtbl.find injected (Date.to_rata date) with Not_found -> [] in
+ let transferred_in =
+ arrived
+ |> List.find_opt (fun c ->
+ Slug.equal c.Precedence.cel.Celebration.slug
+ resolution.Precedence.observed.Precedence.cel.Celebration.slug)
+ |> Option.map (fun c -> c.Precedence.cel)
+ in
+ let transferred_out =
+ try Hashtbl.find transferred_out_of (Date.to_rata date) with Not_found -> []
+ in
+ (* [resolution.deferred] here is NOT "the placement pass never got to
+ these": it is the origin day's own permanent, structural loss -- the
+ layer entry that lost the RG 91 contest here never moves, so a
+ candidate successfully placed somewhere else still shows up losing at
+ the exact date it was always going to lose at (this is the same fact
+ [place_transfers]'s round loop has to filter around, see its comment).
+ A [deferred] sighting only belongs in [omitted] if it was never
+ actually settled anywhere -- i.e. it is stuck in [out_of_range], or the
+ guard above was hit before it reached a day it wins. Settled elsewhere
+ means genuinely accounted for via [observed]/[transferred_in] on the
+ day it landed and [transferred_out] here, not via [omitted] too --
+ double-booking it in both would fail Task 12's "appears exactly once"
+ reading of this day alone. *)
+ let unresolved c =
+ let slug = Slug.to_string c.Precedence.cel.Celebration.slug in
+ if Hashtbl.mem out_of_range slug then true
+ else
+ match Hashtbl.find_opt assignment slug with
+ | None -> true
+ | Some (_, target) ->
+ not (Slug.equal (occupant_of rite idx injected target).Celebration.slug c.Precedence.cel.Celebration.slug)
+ in
+ let reason_for c =
+ if Hashtbl.mem out_of_range (Slug.to_string c.Precedence.cel.Celebration.slug) then
+ out_of_range_reason
+ else unconverged_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)))
+ in
+ {
+ Liturgical_day.date;
+ rite = rite.Rite.id;
+ temporal;
+ observed = resolution.Precedence.observed.Precedence.cel;
+ commemorations =
+ List.map (fun (c, p) -> (c.Precedence.cel, p)) resolution.Precedence.commemorations;
+ transferred_in;
+ transferred_out;
+ omitted;
+ citations = [];
+ }
+
+let year (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) (y : int) :
+ ('s, 'r) Liturgical_day.t array =
+ let idx = Layer.index_by_date layer in
+ let start, stop = year_bounds rite y in
+ (* [max 0]: defends [Array.init] against a negative length, which would
+ otherwise arise for a rite whose [year_start] lands exactly on the
+ domain floor (start clamps to the same date, giving [stop] a day
+ before it). Not reachable through [day] -- see calendar.mli -- but
+ [year] is public, and a direct out-of-contract call must not raise
+ either. *)
+ let n = max 0 (Date.to_rata stop - Date.to_rata start + 1) in
+ let dates = Array.init n (fun i -> Date.add_days start i) in
+ let assignment, candidate_by_slug, out_of_range = place_transfers rite idx ~start ~stop dates in
+ let injected = injected_index_of_assignment assignment candidate_by_slug in
+ let transferred_out_of : (int, ('r Celebration.t * Date.t) list) Hashtbl.t = Hashtbl.create 16 in
+ Hashtbl.iter
+ (fun slug (origin, target) ->
+ let cel = (Hashtbl.find candidate_by_slug slug).Precedence.cel in
+ let key = Date.to_rata origin in
+ Hashtbl.replace transferred_out_of key
+ ((cel, target) :: (try Hashtbl.find transferred_out_of key with Not_found -> [])))
+ assignment;
+ (* Canonicalise each day's departures: the accumulation above walks
+ [assignment] via [Hashtbl.iter], whose bucket order is not guaranteed
+ stable across runs (OCaml's hash seed can be randomised via
+ OCAMLRUNPARAM=R), so a day with more than one departure -- RG 97-98's
+ coinciding-feasts case -- would otherwise report them in a
+ run-dependent order: an environment read, in a kernel whose invariants
+ forbid one. [Layer.index_by_date] guards against exactly this by
+ re-sorting each date bucket after building it (layer.ml); same fix,
+ same reason. Sorted by target date -- which, for a correctly-converged
+ year, is also RG 97-98's own order: the higher-precedence loser claims
+ the earlier admissible day -- ties (not expected, but not assumed
+ impossible) broken on slug. *)
+ let by_target_then_slug (c1, t1) (c2, t2) =
+ let dc = Date.compare t1 t2 in
+ if dc <> 0 then dc else Slug.compare c1.Celebration.slug c2.Celebration.slug
+ in
+ 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
+
+let day (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) (date : Date.t) :
+ ('s, 'r) Liturgical_day.t =
+ let cy = Date.year date in
+ let y = if Date.compare date (rite.Rite.year_start cy) >= 0 then cy else cy - 1 in
+ let start, _ = year_bounds rite y in
+ (year rite layer y).(Date.to_rata date - Date.to_rata start)
diff --git a/lib/kernel/calendar.mli b/lib/kernel/calendar.mli
new file mode 100644
index 0000000..1c0b0ed
--- /dev/null
+++ b/lib/kernel/calendar.mli
@@ -0,0 +1,72 @@
+(** Resolution across a whole liturgical year (spec §2.4).
+
+ Transfers make per-date resolution impossible to do correctly: resolving
+ 25 March can push a feast onto 26 March, and RG 97-98 has coinciding
+ I-class feasts transfer in table order, which needs global knowledge of
+ the whole year. So [year] is the primitive -- it resolves every date in
+ one pass -- and [day] is derived: it finds the liturgical year containing
+ a date and indexes into it. Both are pure; neither caches.
+
+ Once every day's temporal-vs-sanctoral contest is resolved, [year] places
+ every deferred candidate (RG 96-98): a losing I-class candidate the
+ rite's rules send to [Precedence.Transfer] does not stay put -- it moves
+ to the next day [rite.transfer_target] names as admissible, and both
+ ends of the move are recorded: [transferred_in] on the day it arrives
+ (at most one -- RG 96 sends each departure to the next day that is not I
+ or II class, and the first to arrive occupies it), [transferred_out] on
+ the day it left (a list, not an option: RG 97-98 has coinciding I-class
+ feasts transfer "in order", so one day can lose more than one). Every
+ deferred candidate is accounted for exactly once: placed, or -- only if
+ the placement fixed point is not reached within the round guard (which
+ nothing in the 1962 calendar is expected to trigger), or the rite's own
+ [transfer_target] names a date outside this liturgical year's own range
+ (unproven to occur in the real EF calendar, but not ruled out by
+ construction) -- left in [Liturgical_day.omitted] with a reason that
+ says which, never silently dropped. See [calendar.ml]'s
+ [place_transfers] for the algorithm and its termination argument.
+
+ [Precedence.Repose]-disposed losers are gathered the same way
+ [Precedence.Transfer]-disposed ones are (Precedence folds both into
+ [deferred] as one case) and are routed through the same RG 96 search.
+ That is only correct for [Transfer]: [Repose] denotes RG 100-102's
+ *repositio*, a distinct rubric this module does not implement. Nothing
+ in the EF ruleset currently returns [Repose] (design spec §1.3:
+ "declared, not exercised" -- perpetual impediment arises from
+ proper/diocesan calendars, out of this plan's scope), so the gap is
+ latent rather than a live bug; documented here rather than given a
+ second mechanism for a disposition nothing emits. *)
+
+(** [year rite layer y] resolves every day of the liturgical year that opens
+ in civil year [y]: from [rite.year_start y] through the day before
+ [rite.year_start (y + 1)], inclusive of both ends.
+
+ Total over 1583..9999, including the boundary years, and beyond them too:
+ [y] is clamped to [1582, 9999] before either bound is computed (not just
+ guarded near the two edges independently -- see [year_bounds] in
+ [calendar.ml] for why that distinction matters), so [year] never raises
+ regardless of the [y] it is given, not only for values near the domain
+ edge.
+ - At [y] = 9999, [rite.year_start (y + 1)] would ask for civil year
+ 10000, out of {!Date}'s domain (this is the bug Plan 2 shipped in
+ [Validate] and later fixed). The end of the walk clamps to 31 December
+ 9999 instead of computing that call; the returned year comes back
+ truncated to whatever the rite's own temporal cycle covers between
+ [rite.year_start 9999] and the last day of that civil year, not
+ un-computable.
+ - Symmetrically, [y] < 1583 clamps the start of the walk to 1 January
+ 1583 instead of calling [rite.year_start y] on an out-of-domain civil
+ year. [year] is never called this way directly by anything in this
+ module; {!day} is the only caller that can reach [y] = 1582 (one below
+ the floor, never lower), when the date it was asked about sits in civil
+ year 1583 before that year's own [rite.year_start] -- i.e. the sliver
+ whose true liturgical year opened in civil year 1582, which the domain
+ cannot represent. Calling [year] with such a [y] directly is also safe:
+ it returns exactly that truncated sliver. *)
+val year : ('s, 'r) Rite.t -> 'r Layer.t -> int -> ('s, 'r) Liturgical_day.t array
+
+(** [day rite layer date] finds the liturgical year containing [date] -- the
+ year [y] with [rite.year_start y <= date < rite.year_start (y + 1)] --
+ and returns its slot for [date]. Recomputes that whole year on every
+ call: pure, no cache, no mutable state. Acceptable cost for the natural
+ usage (dump a year, sweep years for validation), which pays it once. *)
+val day : ('s, 'r) Rite.t -> 'r Layer.t -> Date.t -> ('s, 'r) Liturgical_day.t
diff --git a/lib/kernel/celebration.ml b/lib/kernel/celebration.ml
index 2963366..eb4249e 100644
--- a/lib/kernel/celebration.ml
+++ b/lib/kernel/celebration.ml
@@ -8,10 +8,17 @@ open Sexplib0.Sexp_conv
but would carry no information while forcing every consumer (Layer,
Overlay, and later Precedence and Calendar) to thread a variable that
means nothing. *)
+(* Whether this celebration can be the observed day at all. The 1960 reform
+ reduced many feasts to a bare commemoration; they retain a rank (RG 111 orders
+ admitted commemorations by dignity) but can never be observed. NOT a fifth
+ rank: RG 8 fixes the classes at four. *)
+type status = Feast | Commemoration_only [@@deriving sexp]
+
type 'r t = {
slug : Slug.t;
names : Names.t;
rank : 'r;
+ status : status;
colour : Colour.t;
subject : Subject.t;
citations : Citation.t list;
@@ -19,6 +26,6 @@ type 'r t = {
}
[@@deriving sexp]
-let make ~slug ?(names = Names.empty) ~rank ~colour ?(subject = Subject.Temporal)
- ?(citations = []) ~layer () =
- { slug; names; rank; colour; subject; citations; layer }
+let make ~slug ?(names = Names.empty) ~rank ?(status = Feast) ~colour
+ ?(subject = Subject.Temporal) ?(citations = []) ~layer () =
+ { slug; names; rank; status; colour; subject; citations; layer }
diff --git a/lib/kernel/celebration.mli b/lib/kernel/celebration.mli
index 1c84d35..a3c2960 100644
--- a/lib/kernel/celebration.mli
+++ b/lib/kernel/celebration.mli
@@ -1,8 +1,15 @@
+(** Whether this celebration can be the observed day at all. The 1960 reform
+ reduced many feasts to a bare commemoration; they retain a rank (RG 111
+ orders admitted commemorations by dignity) but can never be observed. NOT
+ a fifth rank: RG 8 fixes the classes at four. *)
+type status = Feast | Commemoration_only [@@deriving sexp]
+
(** A celebration. Parameterised by the rite's rank type only. *)
type 'r t = {
slug : Slug.t;
names : Names.t;
rank : 'r;
+ status : status;
colour : Colour.t;
subject : Subject.t;
citations : Citation.t list;
@@ -10,7 +17,8 @@ type 'r t = {
}
[@@deriving sexp]
-(** [subject] defaults to [Subject.Temporal], [names] to empty, [citations] to []. *)
+(** [status] defaults to [Feast], [subject] to [Subject.Temporal], [names] to
+ empty, [citations] to []. *)
val make :
- slug:Slug.t -> ?names:Names.t -> rank:'r -> colour:Colour.t ->
+ slug:Slug.t -> ?names:Names.t -> rank:'r -> ?status:status -> colour:Colour.t ->
?subject:Subject.t -> ?citations:Citation.t list -> layer:string -> unit -> 'r t
diff --git a/lib/kernel/liturgical_day.ml b/lib/kernel/liturgical_day.ml
new file mode 100644
index 0000000..bbb52b8
--- /dev/null
+++ b/lib/kernel/liturgical_day.ml
@@ -0,0 +1,27 @@
+open Sexplib0.Sexp_conv
+
+(* The single stable result schema (parent spec §2). *)
+type ('s, 'r) t = {
+ date : Date.t;
+ rite : string;
+ temporal : ('s, 'r) Temporal.t;
+ (** embedded, not flattened: it is already a coherent unit with its own
+ invariants, and re-listing season/week/weekday here would create two
+ places for them to disagree *)
+ observed : 'r Celebration.t;
+ commemorations : ('r Celebration.t * Precedence.privilege) list;
+ transferred_in : 'r Celebration.t option;
+ (** arrived here from an impeded day *)
+ transferred_out : ('r Celebration.t * Date.t) list;
+ (** celebrations that left this day, and where each one went. A list,
+ not an option: RG 97-98 has coinciding I-class feasts transfer
+ "in order" -- plural -- so a day can lose more than one. Asymmetric
+ with [transferred_in] deliberately: a day receives at most one
+ arrival, because RG 96 sends each departure to the next day that
+ is not I or II class, and the first to arrive occupies it. *)
+ omitted : ('r Celebration.t * string) list;
+ (** with the reason, never silent -- Task 12's no-celebration-lost
+ invariant reads this *)
+ citations : Citation.t list; (** always empty until Plan 4 *)
+}
+[@@deriving sexp]
diff --git a/lib/kernel/liturgical_day.mli b/lib/kernel/liturgical_day.mli
new file mode 100644
index 0000000..a109251
--- /dev/null
+++ b/lib/kernel/liturgical_day.mli
@@ -0,0 +1,25 @@
+(** The single stable result schema (parent spec §2). *)
+type ('s, 'r) t = {
+ date : Date.t;
+ rite : string;
+ temporal : ('s, 'r) Temporal.t;
+ (** embedded, not flattened: it is already a coherent unit with its own
+ invariants, and re-listing season/week/weekday here would create two
+ places for them to disagree *)
+ observed : 'r Celebration.t;
+ commemorations : ('r Celebration.t * Precedence.privilege) list;
+ transferred_in : 'r Celebration.t option;
+ (** arrived here from an impeded day *)
+ transferred_out : ('r Celebration.t * Date.t) list;
+ (** celebrations that left this day, and where each one went. A list,
+ not an option: RG 97-98 has coinciding I-class feasts transfer
+ "in order" -- plural -- so a day can lose more than one. Asymmetric
+ with [transferred_in] deliberately: a day receives at most one
+ arrival, because RG 96 sends each departure to the next day that
+ is not I or II class, and the first to arrive occupies it. *)
+ omitted : ('r Celebration.t * string) list;
+ (** with the reason, never silent -- Task 12's no-celebration-lost
+ invariant reads this *)
+ citations : Citation.t list; (** always empty until Plan 4 *)
+}
+[@@deriving sexp]
diff --git a/lib/kernel/precedence.ml b/lib/kernel/precedence.ml
new file mode 100644
index 0000000..05ad69f
--- /dev/null
+++ b/lib/kernel/precedence.ml
@@ -0,0 +1,69 @@
+(* The rite-parameterised resolver. RG 91 says who wins; RG 92-95 says what
+ happens to the loser; RG 108-111 says how many commemorations are admitted.
+ Three separate functions, because the loser's fate depends on the loser's own
+ rank, not the winner's. *)
+open Sexplib0.Sexp_conv
+
+type origin = Temporal | Sanctoral [@@deriving sexp]
+type privilege = Privileged | Ordinary [@@deriving sexp]
+type disposition = Omit | Commemorate of privilege | Transfer | Repose [@@deriving sexp]
+
+type 'r candidate = { cel : 'r Celebration.t; origin : origin } [@@deriving sexp]
+
+type 's context = { date : Date.t; season : 's; weekday : Date.weekday }
+
+type ('s, 'r) rules = {
+ band : 's context -> 'r candidate -> int;
+ disposition : winner:'r candidate -> loser:'r candidate -> disposition;
+ admit :
+ observed:'r candidate ->
+ ('r candidate * privilege) list ->
+ ('r candidate * privilege) list;
+}
+
+type 'r resolution = {
+ observed : 'r candidate;
+ commemorations : ('r candidate * privilege) list;
+ deferred : 'r candidate list;
+ omitted : ('r candidate * string) list;
+}
+
+(* Ties break on slug so the result never depends on input order. *)
+let compare_by rules ctx a b =
+ let ba = rules.band ctx a and bb = rules.band ctx b in
+ if ba <> bb then Int.compare ba bb
+ else Slug.compare a.cel.Celebration.slug b.cel.Celebration.slug
+
+let resolve rules ctx ~temporal ~sanctoral =
+ (* A commemoration-only entry can never be observed (see Celebration.status),
+ so it is held out of the contest entirely rather than relying on its band. *)
+ let eligible, forced_comm =
+ List.partition
+ (fun c -> c.cel.Celebration.status = Celebration.Feast)
+ sanctoral
+ in
+ let sorted = List.stable_sort (compare_by rules ctx) (temporal :: eligible) in
+ let observed = List.hd sorted in
+ let losers = List.tl sorted @ forced_comm in
+ let comms, deferred, omitted =
+ List.fold_left
+ (fun (comms, defs, omits) l ->
+ match rules.disposition ~winner:observed ~loser:l with
+ | Commemorate p -> ((l, p) :: comms, defs, omits)
+ | Transfer | Repose -> (comms, l :: defs, omits)
+ | Omit -> (comms, defs, (l, "omitted: yielded to a higher day") :: omits))
+ ([], [], []) losers
+ in
+ let comms = List.rev comms and deferred = List.rev deferred in
+ let admitted = rules.admit ~observed comms in
+ let dropped =
+ List.filter (fun c -> not (List.exists (fun a -> fst a == fst c) admitted)) comms
+ in
+ {
+ observed;
+ commemorations = admitted;
+ deferred;
+ omitted =
+ List.rev omitted
+ @ List.map (fun (c, _) -> (c, "omitted: admission limit reached")) dropped;
+ }
diff --git a/lib/kernel/precedence.mli b/lib/kernel/precedence.mli
new file mode 100644
index 0000000..ae054dd
--- /dev/null
+++ b/lib/kernel/precedence.mli
@@ -0,0 +1,78 @@
+(** The rite-parameterised resolver: RG 91 says who wins, RG 92-95 says what
+ happens to the loser, RG 108-111 says how many commemorations are admitted.
+ Three separate rite-supplied functions, because the loser's fate depends on
+ the loser's own rank, not the winner's -- conflating them would resist
+ extension to a second rite. *)
+
+(** Which of the day's two office streams a candidate came from. *)
+type origin = Temporal | Sanctoral [@@deriving sexp]
+
+(** RG 111: an admitted commemoration's own standing, distinct from its rank. *)
+type privilege = Privileged | Ordinary [@@deriving sexp]
+
+(** What becomes of a losing candidate. *)
+type disposition =
+ | Omit (** yields with no trace in the day's celebration *)
+ | Commemorate of privilege (** kept as a commemoration of the observed day *)
+ | Transfer (** moved to the next free day (RG 92-95) *)
+ | Repose (** kept only in a votive/private sense; not commemorated today *)
+[@@deriving sexp]
+
+(** A celebration together with the office stream it was drawn from. Parameterised
+ by the rite's rank type only, matching {!Celebration.t}. *)
+type 'r candidate = { cel : 'r Celebration.t; origin : origin } [@@deriving sexp]
+
+(** The day a resolution is computed for. Parameterised by the rite's season
+ type only -- a context has no rank of its own. *)
+type 's context = { date : Date.t; season : 's; weekday : Date.weekday }
+
+(** The rite's three resolution functions. *)
+type ('s, 'r) rules = {
+ band : 's context -> 'r candidate -> int;
+ (** RG 91: orders candidates for the day; lower wins. *)
+ disposition : winner:'r candidate -> loser:'r candidate -> disposition;
+ (** RG 92-95: the loser's fate, which depends on the loser's own rank. *)
+ admit :
+ observed:'r candidate ->
+ ('r candidate * privilege) list ->
+ ('r candidate * privilege) list;
+ (** RG 108-111: how many commemorations are admitted, and in what order;
+ anything filtered out here is recorded in {!resolution.omitted}, not
+ dropped.
+
+ OBLIGATION ON THE IMPLEMENTATION, not enforced by this type: every
+ candidate this function returns must be a value taken UNCHANGED
+ from its input list, never rebuilt (e.g. via a [{ c with ... }]
+ record update, even one that copies every field back unchanged).
+ {!resolve}'s own [omitted] accounting distinguishes an admitted
+ candidate from a dropped one by PHYSICAL equality ([==]) on the
+ candidate value, not structural equality -- a rebuilt record is
+ [=] to the original but not [==], so {!resolve} cannot match the
+ rebuilt copy against the original it was given. The celebration
+ then surfaces TWICE in the same day's result -- once in
+ {!resolution.commemorations} (the rebuilt copy, admitted) and once
+ in {!resolution.omitted} (the original, which nothing in the
+ admitted set matches). One admission, double-reported, silently
+ rather than raising. This obligation previously lived only in one rite's
+ 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. *)
+}
+
+(** The outcome of resolving one day's candidates. *)
+type 'r resolution = {
+ observed : 'r candidate;
+ commemorations : ('r candidate * privilege) list;
+ deferred : 'r candidate list;
+ omitted : ('r candidate * string) list; (** each with a reason *)
+}
+
+(** Total: the temporal candidate is passed separately, so there is no
+ empty-candidate case. Ties break on slug, so the result never depends on
+ input order. A [Commemoration_only] celebration is held out of the contest
+ and can never be [observed]. Every input candidate appears exactly once in
+ [observed], [commemorations], [deferred] or [omitted] — nothing is dropped
+ silently. *)
+val resolve :
+ ('s, 'r) rules -> 's context -> temporal:'r candidate ->
+ sanctoral:'r candidate list -> 'r resolution
diff --git a/lib/kernel/rite.ml b/lib/kernel/rite.ml
new file mode 100644
index 0000000..89fceb6
--- /dev/null
+++ b/lib/kernel/rite.ml
@@ -0,0 +1,14 @@
+(* Everything a rite supplies, bundled. Passing these as loose arguments let a
+ caller pair one rite's vocab with another's temporal; bundling makes that
+ unrepresentable through the normal path. *)
+type ('s, 'r) t = {
+ id : string;
+ vocab : ('s, 'r) Vocab.t;
+ year_start : int -> Date.t;
+ temporal : Date.t -> ('s, 'r) Temporal.t;
+ anchors : int -> (string * Date.t) list;
+ rules : ('s, 'r) Precedence.rules;
+ season_runs : 's list;
+ transfer_target :
+ 'r Precedence.candidate -> Date.t -> (Date.t -> 'r Celebration.t) -> Date.t;
+}
diff --git a/lib/kernel/rite.mli b/lib/kernel/rite.mli
new file mode 100644
index 0000000..ffe9471
--- /dev/null
+++ b/lib/kernel/rite.mli
@@ -0,0 +1,62 @@
+(** Everything a rite supplies, bundled. Passing these as loose arguments let a
+ caller pair one rite's vocab with another's temporal; bundling makes that
+ unrepresentable through the normal path. Carries functions, so it has no
+ sexp form. *)
+type ('s, 'r) t = {
+ id : string;
+ vocab : ('s, 'r) Vocab.t;
+ year_start : int -> Date.t;
+ (** first day of the liturgical year opening in civil year y *)
+ temporal : Date.t -> ('s, 'r) Temporal.t;
+ anchors : int -> (string * Date.t) list;
+ (** Easter-derived days: (expected slug, date) *)
+ rules : ('s, 'r) Precedence.rules;
+ season_runs : 's list;
+ (** the expected run-length-compressed season sequence over one liturgical
+ year. NOT necessarily [vocab.seasons]: a rite may have one season
+ appear in two separate runs (the modern form's Ordinary Time does). *)
+ transfer_target :
+ 'r Precedence.candidate -> Date.t -> (Date.t -> 'r Celebration.t) -> Date.t;
+ (** RG 96: where an impeded I-class feast goes. Given the deferred
+ candidate, the date it was impeded on, and [occupant] -- a callback
+ exposing what {!Calendar} currently resolves as observed on any
+ given date -- returns the date to place it on.
+
+ Deliberately one rite-supplied function, not a generic search Calendar
+ drives itself: "not I or II class" is not derivable from [band] or
+ [disposition] alone. RG 91's own table would let a universal I-class
+ feast (entry 11) numerically outrank an ordinary Sunday (entry 15,
+ II class) in a raw occurrence contest -- entry 11 comes before entry
+ 15, and lower wins -- so testing "would the translated feast win
+ here" is not the same question as "is this day free to receive a
+ translation": RG 96 forbids landing on the Sunday regardless of
+ which one would structurally win. Only the rite knows which of its
+ own ranks are exempt from translation onto them. The rite also
+ owns the search's starting point, because RG 96's exception is
+ rite-specific too: the Annunciation does not search forward from
+ its own impeded date at all, it goes straight to the Monday after
+ Low Sunday (searching onward from there only if that day is itself
+ blocked). [occupant] is supplied rather than a raw layer/temporal
+ pair so the rite never has to re-implement occurrence resolution
+ just to answer "what sits here".
+
+ OBLIGATIONS (not enforced by the type, and {!Calendar}'s own
+ termination argument depends on both): the result must be
+ {b strictly later} than the [Date.t] argument (the date the
+ candidate was impeded on) -- {!Calendar}'s placement pass treats
+ [target = origin] or [target < origin] as a legitimate placement,
+ not an error, so a rite whose search can stand still or go
+ backward would silently loop candidates in place or resurrect an
+ already-superseded occupant rather than failing loudly. The call
+ must also {b terminate} on its own: {!Calendar}'s round guard
+ (calendar.ml's [max_transfer_rounds]) bounds how many ROUNDS the
+ whole-year placement pass takes, which is a distinct, outer thing
+ from whatever internal search a single call to this function runs
+ -- an implementation that walks forward day by day looking for an
+ admissible date, without its own bound, can hang the caller
+ outright on a rite/data shape it does not handle, never reaching
+ the round guard at all. See rite_ef/precedence_ef.ml's
+ [transfer_target] for a concrete termination argument (a
+ structural step bound, not an appeal to the real calendar's own
+ structure). *)
+}
diff --git a/lib/kernel/slug.ml b/lib/kernel/slug.ml
index 5139c99..25281f2 100644
--- a/lib/kernel/slug.ml
+++ b/lib/kernel/slug.ml
@@ -1,6 +1,10 @@
(* Stable celebration identifiers. Also the lectionary key: EF slugs are adopted
- verbatim from lectio so the Plan 3 lectionary bootstrap needs no mapping
- table (spec §4.4). *)
+ verbatim from lectio so the Plan 4 lectionary bootstrap needs no mapping
+ table (spec §4.4). CORRECTED (final fix wave, item 7): this comment said
+ "Plan 3" -- the SANCTORAL bootstrap (data/ef/sanctoral.sexp) is Plan 3 and
+ shipped in this branch; the LECTIONARY bootstrap (reading citations,
+ Liturgical_day.t's own [citations] field) is a separate, later Plan 4,
+ per that field's own doc comment ("always empty until Plan 4"). *)
type t = string
let valid_char c = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c = '-'
diff --git a/lib/kernel/validate.ml b/lib/kernel/validate.ml
index 7be3425..cc8bdce 100644
--- a/lib/kernel/validate.ml
+++ b/lib/kernel/validate.ml
@@ -20,7 +20,35 @@ let has_duplicate strings =
let rec go = function a :: (b :: _ as rest) -> a = b || go rest | _ -> false in
go sorted
-let run vocab ~year_start ~temporal ~anchors ~year =
+(* Like [has_duplicate], but names the offender(s) instead of only reporting
+ that one exists -- the ["slugs"] check below wants a useful failure
+ detail, not just a bool. *)
+let duplicates strings =
+ let sorted = List.sort String.compare strings in
+ let rec go acc = function
+ | a :: (b :: _ as rest) -> go (if a = b then a :: acc else acc) rest
+ | _ -> acc
+ in
+ List.sort_uniq String.compare (go [] sorted)
+
+(* Task 12's "unconverged" check has no structural signal to key off --
+ Calendar's placement pass records its round-guard reason as a plain
+ string in [Liturgical_day.omitted] (calendar.ml's own [unconverged_reason],
+ not exposed as a public constant), and [Liturgical_day.omitted]'s own doc
+ comment says exactly this check is meant to read it. A short, distinctive
+ substring rather than the full literal keeps the coupling to calendar.ml's
+ exact wording as loose as it can be while still being unambiguous: nothing
+ else this kernel emits into [omitted] talks about "converging". *)
+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 run (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) ~year =
+ let vocab = rite.Rite.vocab in
+ let year_start = rite.Rite.year_start in
+ let temporal = rite.Rite.temporal in
+ let anchors = rite.Rite.anchors in
let start = year_start year in
let stop =
(* [year_start (year + 1)] needs a date in civil year (year+1); at
@@ -68,18 +96,16 @@ let run vocab ~year_start ~temporal ~anchors ~year =
(* Weekday agreement. *)
if t.Temporal.weekday <> Date.weekday date then
fail date "weekday" "temporal weekday disagrees with Date.weekday";
- (* Slug: three properties, none checked here, all delivered
- elsewhere. Well-formedness needs no check: [Slug.t] is a private
- string validated on every construction path ([of_string],
- [of_string_exn], [t_of_sexp]), and [to_string] is the identity,
- so round-tripping an existing [Slug.t] can never fail -- a check
- here would be structurally incapable of firing, which is worse
- than no check, since it would look like coverage that isn't
- there. Uniqueness *per date* needs no check either: [temporal]
- returns exactly one office by construction. Uniqueness *across
- the year* is deliberately NOT asserted -- a resumed Sunday
- reuses an earlier Epiphany key on purpose, so the check would be
- false. *)
+ (* Slug: three properties. Well-formedness needs no check: [Slug.t]
+ is a private string validated on every construction path
+ ([of_string], [of_string_exn], [t_of_sexp]), and [to_string] is
+ the identity, so round-tripping an existing [Slug.t] can never
+ fail -- a check here would be structurally incapable of firing,
+ which is worse than no check, since it would look like coverage
+ that isn't there. Uniqueness *per date* needs no check either:
+ [temporal] returns exactly one office by construction.
+ Uniqueness *across the year* IS asserted, below, once the whole
+ walk is in hand -- see the ["slugs"] check after this loop. *)
(* Vocabulary closure. *)
if not (List.exists (fun r -> vocab.Vocab.rank_to_string r
= vocab.Vocab.rank_to_string cel.Celebration.rank)
@@ -97,9 +123,28 @@ let run vocab ~year_start ~temporal ~anchors ~year =
| None -> fail date "determinism" "a second call to temporal raised where the first succeeded"))
days;
let observed = List.rev !observed in
+ (* Slug uniqueness across the year (Plan 2 carried item 4): moved into
+ [Validate] itself so every consumer gets it, not only a 200-sample
+ QCheck property scoped to one rite. Asserted OUTRIGHT, no exemption:
+ Plan 2 verified zero duplicate slugs domain-wide, across all 8 416
+ years, for the EF rite's own resumed-Sunday mechanism -- the exemption
+ the test property used to carry protected nothing real, because a
+ resumed Sunday only ever backfills a week number Septuagesima cut short
+ that same liturgical year (so it was never actually used that year to
+ begin with), never repeats one the year's own January Sundays already
+ used. If a future rite genuinely needs an exemption, it can supply one
+ then -- not speculatively here. *)
+ (match duplicates (List.map (fun (_, t) -> Slug.to_string t.Temporal.office.Celebration.slug) observed) with
+ | [] -> ()
+ | dups ->
+ fail start "slugs"
+ (Printf.sprintf "slug(s) sighted on more than one date this year: %s" (String.concat ", " dups)));
(* Season contiguity and completeness: the run-length-compressed sequence must
- equal vocab.seasons exactly -- all seasons, each in one unbroken run, in
- canonical order. No EF season can be empty in any year. *)
+ equal the rite's own [season_runs] exactly, in canonical order. This is
+ NOT necessarily [vocab.seasons] -- most rites have each season in one
+ unbroken run, but a rite may legitimately have one season appear in two
+ separate runs (the modern form's Ordinary Time does), so the expected
+ sequence is rite-supplied rather than derived from the vocabulary. *)
let compressed =
List.fold_left
(fun acc (_, t) ->
@@ -108,7 +153,7 @@ let run vocab ~year_start ~temporal ~anchors ~year =
[] observed
|> List.rev
in
- let expected = List.map vocab.Vocab.season_to_string vocab.Vocab.seasons in
+ let expected = List.map vocab.Vocab.season_to_string rite.Rite.season_runs in
if compressed <> expected then
fail start "seasons"
(Printf.sprintf "season runs %s; expected %s"
@@ -157,4 +202,121 @@ let run vocab ~year_start ~temporal ~anchors ~year =
if actual <> expected_slug then
fail date "anchor" (Printf.sprintf "expected slug %S, got %S" expected_slug actual))
anchor_pairs;
+ (* Resolution invariants (Task 12): everything above only ever asked
+ [rite.temporal] for a date's office in isolation. From here on the
+ LITURGICAL YEAR IS ACTUALLY RESOLVED against [layer] -- occurrence,
+ transfer placement, commemorations, the works (spec §2.4) -- and the
+ result checked for five further properties a temporal-only pass cannot
+ see at all. [days] (the walk built above) is reused rather than
+ recomputed: it names exactly the same [start, stop] span
+ {!Calendar.year} resolves for this [year]. *)
+ (match Calendar.year rite layer year with
+ | exception exn ->
+ (* The kernel contract forbids [run] itself from ever raising on
+ in-range input, and an exception escaping resolution is the most
+ total form of "silently lost" there is: nothing about this year's
+ sanctoral entries could be verified as accounted for at all. *)
+ fail start "lost"
+ (Printf.sprintf "resolving the year raised (%s); nothing could be verified as accounted for"
+ (Printexc.to_string exn))
+ | resolved ->
+ let idx = Layer.index_by_date layer in
+ let bump tbl slug = Hashtbl.replace tbl slug (1 + (try Hashtbl.find tbl slug with Not_found -> 0)) in
+ (* Expected: how many times each layer entry's own Date_spec resolves
+ within [start, stop]. Walking dates and querying [Layer.on_date]
+ (rather than resolving each entry's Date_spec against candidate
+ civil years directly) is what naturally counts a fixed late-
+ November date TWICE in the ~20% of liturgical years whose 371-day
+ span reaches it on both ends -- see validate.mli's own note on 30
+ November / St Andrew. *)
+ let expected : (string, int) Hashtbl.t = Hashtbl.create 64 in
+ List.iter
+ (fun date ->
+ Layer.on_date idx ~month:(Date.month date) ~day:(Date.day date)
+ |> List.iter (fun (e : 'r Layer.entry) ->
+ bump expected (Slug.to_string e.Layer.cel.Celebration.slug)))
+ days;
+ (* Actual: how many times each slug is actually sighted across the
+ resolved year. Deliberately [observed] + [commemorations] +
+ [omitted] only, NOT [transferred_out]: a successfully transferred
+ celebration is already counted once, via [observed] (+
+ [transferred_in]) on the day it lands; also counting
+ [transferred_out] at the day it left would double-book every clean
+ transfer, which is exactly what this check exists to catch, not
+ cause. *)
+ let actual : (string, int) Hashtbl.t = Hashtbl.create 64 in
+ let bump_cel tbl (c : 'r Celebration.t) = bump tbl (Slug.to_string c.Celebration.slug) in
+ Array.iter
+ (fun (d : ('s, 'r) Liturgical_day.t) ->
+ bump_cel actual d.Liturgical_day.observed;
+ List.iter (fun (c, _) -> bump_cel actual c) d.Liturgical_day.commemorations;
+ List.iter (fun (c, _) -> bump_cel actual c) d.Liturgical_day.omitted)
+ resolved;
+ Hashtbl.fold (fun slug exp acc -> (slug, exp) :: acc) expected []
+ |> List.sort compare (* stable failure order: Hashtbl.iter's own order is hash-seed-dependent *)
+ |> List.iter (fun (slug, exp) ->
+ let act = try Hashtbl.find actual slug with Not_found -> 0 in
+ if act < exp then
+ fail start "lost"
+ (Printf.sprintf "%s: sighted %d time(s) this year, but its own Date_spec resolves %d"
+ slug act exp)
+ else if act > exp then
+ fail start "duplicated"
+ (Printf.sprintf "%s: sighted %d time(s) this year, but its own Date_spec resolves only %d"
+ slug act exp));
+ Array.iter
+ (fun (d : ('s, 'r) Liturgical_day.t) ->
+ let date = d.Liturgical_day.date in
+ let observed_slug = Slug.to_string d.Liturgical_day.observed.Celebration.slug in
+ let has_slug (c, _) = Slug.to_string c.Celebration.slug = observed_slug in
+ (* "observed": the day's own winner must not ALSO be listed as one
+ of its own losers -- see validate.mli's own note on why this is
+ reachable (two distinct layer entries sharing a slug, one
+ transferred onto the other's natural date, the transferred one
+ winning) despite {!Precedence.resolve}'s fold never letting the
+ SAME candidate value appear as both winner and loser. *)
+ if List.exists has_slug d.Liturgical_day.commemorations
+ || List.exists has_slug d.Liturgical_day.omitted
+ then
+ fail date "observed"
+ (Printf.sprintf
+ "%s is this day's observed celebration and also appears among its own \
+ commemorations/omissions"
+ observed_slug);
+ (* "unconverged": see [contains_substring]'s own comment above. *)
+ if
+ List.exists
+ (fun (_, reason) -> contains_substring reason ~needle:"did not converge")
+ d.Liturgical_day.omitted
+ then
+ fail date "unconverged"
+ "transfer placement did not reach a fixed point within the round guard (RG 96-98)";
+ (* "admission": re-offer this day's own admitted commemorations
+ back to [rite.rules.admit] and require the exact same set back.
+ [origin] is reconstructed as [Sanctoral] uniformly:
+ {!Liturgical_day.t} does not retain a commemoration's original
+ origin, and the real EF [admit] (precedence_ef.ml) reads only
+ rank and slug from a candidate, never [origin], so this
+ reconstruction is exact for it; documented in validate.mli as
+ the one place a rite whose [admit] DOES consult [origin] could
+ see a false negative from this check. *)
+ let observed_candidate : 'r Precedence.candidate =
+ { Precedence.cel = d.Liturgical_day.observed; origin = Precedence.Sanctoral }
+ in
+ let as_candidates comms =
+ List.map (fun (c, p) -> ({ Precedence.cel = c; origin = Precedence.Sanctoral }, p)) comms
+ in
+ let offered = as_candidates d.Liturgical_day.commemorations in
+ let readmitted = rite.Rite.rules.Precedence.admit ~observed:observed_candidate offered in
+ let norm l =
+ List.map (fun (c, p) -> (Slug.to_string c.Precedence.cel.Celebration.slug, p)) l
+ |> List.sort compare
+ in
+ if norm readmitted <> norm offered then
+ fail date "admission"
+ (Printf.sprintf
+ "admit is not a fixed point on this day's own commemorations: re-offering %d \
+ admitted %d back"
+ (List.length offered) (List.length readmitted)))
+ resolved);
List.rev !failures
diff --git a/lib/kernel/validate.mli b/lib/kernel/validate.mli
index 709a711..5e55fc5 100644
--- a/lib/kernel/validate.mli
+++ b/lib/kernel/validate.mli
@@ -5,26 +5,71 @@ type failure = { year : int; date : string; check : string; detail : string }
val failure_to_string : failure -> string
-(** [run vocab ~year_start ~temporal ~anchors ~year] returns every invariant
- violation in the liturgical year opening in civil year [year]. An empty
- list means the year is clean.
+(** [run rite layer ~year] returns every invariant violation in the
+ liturgical year opening in civil year [year]. An empty list means the
+ year is clean.
- [anchors y] is the rite's own independent restatement of its fixed and
- Easter-derived named days for civil year [y], as (expected slug, date)
- pairs -- not derived from [temporal] itself, so a drift between the two
- is caught rather than invisible. [run] consults both [anchors year] and
- [anchors (year + 1)], since a liturgical year straddles two civil years,
- and checks only the pairs whose date actually falls within the year
- walked.
+ [rite.Rite.anchors y] is the rite's own independent restatement of its
+ fixed and Easter-derived named days for civil year [y], as (expected
+ slug, date) pairs -- not derived from [rite.Rite.temporal] itself, so a
+ drift between the two is caught rather than invisible. [run] consults
+ both [anchors year] and [anchors (year + 1)], since a liturgical year
+ straddles two civil years, and checks only the pairs whose date actually
+ falls within the year walked.
+
+ ["slugs"]: no two dates within the walked liturgical year may carry the
+ same office slug (Plan 2 carried item 4). Asserted outright, with no
+ exemption for the resumed-Sunday reuse a slug's own name might suggest:
+ a resumed Sunday only ever backfills a week number Septuagesima cut
+ short that same year, so by construction it never repeats a number that
+ year's own January Sundays actually used.
+
+ The season check compares the run-length-compressed season sequence
+ against [rite.Rite.season_runs], not [rite.Rite.vocab.seasons]: a rite may
+ have one season appear in two separate runs (the modern form's Ordinary
+ Time does), so the two are not necessarily the same list.
+
+ [layer] is resolved against [rite] via {!Calendar.year} (spec §2.4's
+ occurrence/transfer/commemoration pass), and the resulting fully-resolved
+ liturgical year is checked for five further invariants a temporal-only
+ pass cannot see (Task 12), each its own ["check"] label:
+ - ["observed"]: a day's [observed] celebration is never ALSO listed among
+ that same day's [commemorations] or [omitted] -- a day reports one
+ winner, not a winner that also lost to itself.
+ - ["lost"]: no sanctoral entry is silently dropped. Per slug, the number
+ of times it is actually sighted ([observed] + [commemorations] +
+ [omitted], summed over the whole year -- NOT [transferred_out], which
+ would double-count a successfully placed transfer against its own
+ arrival) must never fall below the number of times its own
+ {!Date_spec} resolves within the year's span (an entry with two
+ occurrences, e.g. 30 November in the nine liturgical years where the
+ 371-day span reaches it twice, must be sighted twice, not once). Also
+ fires if resolving the year raises at all -- an escaping exception is
+ the most total form of silent loss, and the kernel contract forbids
+ [run] itself from propagating it.
+ - ["duplicated"]: the same per-slug count must never EXCEED the number of
+ {!Date_spec} resolutions either. Deliberately NOT "no slug appears
+ twice in the year" -- a fixed date can legitimately resolve twice, per
+ ["lost"] above -- it is "resolutions and sightings agree", the property
+ that actually distinguishes a transfer that moved from one that
+ duplicated.
+ - ["unconverged"]: no day's [omitted] carries the reason {!Calendar}'s
+ placement pass records when its round guard (calendar.ml's
+ [max_transfer_rounds]) is hit before every deferred candidate reaches a
+ fixed point.
+ - ["admission"]: the rite's own [rules.admit] is a fixed point on what it
+ already admitted -- re-offering a day's [commemorations] back to
+ [admit] (reconstructed with {!Precedence.Sanctoral} origin; the real EF
+ admit reads only rank and slug, never origin, so this reconstruction is
+ exact for it) must return exactly that same set. A cap-enforcing
+ selector that is not idempotent on its own output has, by definition,
+ admitted something its own rule would not admit if asked again -- the
+ rite-agnostic form of "the admission limit was not exceeded" available
+ without embedding a rite's specific numeric caps (RG 111's, for EF)
+ into kernel code.
Total over the whole 1583..9999 domain, including [year] = 9999: the
liturgical year opening there continues into out-of-domain civil year
10000, so the walk is clamped to 31 December 9999 and the checks run
against that truncated final year rather than raising. *)
-val run :
- ('s, 'r) Vocab.t ->
- year_start:(int -> Date.t) ->
- temporal:(Date.t -> ('s, 'r) Temporal.t) ->
- anchors:(int -> (string * Date.t) list) ->
- year:int ->
- failure list
+val run : ('s, 'r) Rite.t -> 'r Layer.t -> year:int -> failure list
diff --git a/lib/kernel/vocab.ml b/lib/kernel/vocab.ml
index 78729bb..a4e61a3 100644
--- a/lib/kernel/vocab.ml
+++ b/lib/kernel/vocab.ml
@@ -7,13 +7,32 @@
parametric types natively. *)
type ('s, 'r) t = {
seasons : 's list;
- (** canonical liturgical-year order; Validate's contiguity check reads this *)
+ (** canonical liturgical-year order. CORRECTED (final fix wave, item
+ 7): this used to say "Validate's contiguity check reads this" --
+ false since validate.ml's own "seasons" check switched to
+ {!Colitur_kernel.Rite.t}.season_runs in this branch (Plan 2
+ carried item 1: EF has each season in one run, but the modern
+ form's Ordinary Time does not, so the expected run sequence had
+ to become rite-supplied rather than derived from this field).
+ For EF specifically [Rite_ef.rite] sets season_runs to
+ this very list, so the two happen to agree there, but Validate
+ itself no longer reads [seasons] to build its expectation. *)
season_to_string : 's -> string;
season_of_string : string -> 's option;
ranks : 'r list;
(** documentation order, highest first. Plan 2 uses it only for the
- closure check -- it is not a precedence relation until Plan 3
- defines one. *)
+ closure check. CORRECTED (final fix wave, item 7): this used to
+ say "it is not a precedence relation until Plan 3 defines one" --
+ Plan 3 did define one (RG 111's dignity ordering, Rite_ef.
+ Precedence_ef.dignity/compare_dignity), but as its OWN small,
+ separately-hardcoded function, not one derived from this field:
+ [admit] needs Vocab_ef.rank's dignity as plain data (RG 8's four
+ classes), and reusing this field's own [int list] position would
+ couple that meaning to documentation order the way {!band} is
+ explicitly NOT allowed to (precedence_ef.ml's own file comment).
+ This field therefore still carries no precedence relation of its
+ own; a rite that wanted one derived from it would have to build
+ it itself. *)
rank_to_string : 'r -> string;
rank_of_string : string -> 'r option;
}
diff --git a/lib/kernel/vocab.mli b/lib/kernel/vocab.mli
index 78729bb..a4e61a3 100644
--- a/lib/kernel/vocab.mli
+++ b/lib/kernel/vocab.mli
@@ -7,13 +7,32 @@
parametric types natively. *)
type ('s, 'r) t = {
seasons : 's list;
- (** canonical liturgical-year order; Validate's contiguity check reads this *)
+ (** canonical liturgical-year order. CORRECTED (final fix wave, item
+ 7): this used to say "Validate's contiguity check reads this" --
+ false since validate.ml's own "seasons" check switched to
+ {!Colitur_kernel.Rite.t}.season_runs in this branch (Plan 2
+ carried item 1: EF has each season in one run, but the modern
+ form's Ordinary Time does not, so the expected run sequence had
+ to become rite-supplied rather than derived from this field).
+ For EF specifically [Rite_ef.rite] sets season_runs to
+ this very list, so the two happen to agree there, but Validate
+ itself no longer reads [seasons] to build its expectation. *)
season_to_string : 's -> string;
season_of_string : string -> 's option;
ranks : 'r list;
(** documentation order, highest first. Plan 2 uses it only for the
- closure check -- it is not a precedence relation until Plan 3
- defines one. *)
+ closure check. CORRECTED (final fix wave, item 7): this used to
+ say "it is not a precedence relation until Plan 3 defines one" --
+ Plan 3 did define one (RG 111's dignity ordering, Rite_ef.
+ Precedence_ef.dignity/compare_dignity), but as its OWN small,
+ separately-hardcoded function, not one derived from this field:
+ [admit] needs Vocab_ef.rank's dignity as plain data (RG 8's four
+ classes), and reusing this field's own [int list] position would
+ couple that meaning to documentation order the way {!band} is
+ explicitly NOT allowed to (precedence_ef.ml's own file comment).
+ This field therefore still carries no precedence relation of its
+ own; a rite that wanted one derived from it would have to build
+ it itself. *)
rank_to_string : 'r -> string;
rank_of_string : string -> 'r option;
}
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"