aboutsummaryrefslogtreecommitdiff
path: root/lib/rites
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-11 23:25:39 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-11 23:25:39 +0200
commit2ad350e0f97a99f76ac1d2d95cfa210f7393c777 (patch)
tree5ff5c9463c4316a974b7559dafe7c650ba4ae37c /lib/rites
parent8c6788053dce2a365e3ddf22eccc9c68e641f32e (diff)
downloadcolitur-2ad350e0f97a99f76ac1d2d95cfa210f7393c777.tar.gz
colitur-2ad350e0f97a99f76ac1d2d95cfa210f7393c777.zip
rite(ef): commemoration privilege and admission (RG 108-111)
Precedence_ef.privilege_of classifies a commemoration candidate against RG 109's closed list of privileged commemorations (a Sunday; a I-class day; a day within the Octave of the Nativity; a September Ember day; a feria of Advent, Lent or Passiontide; the Major Rogations in Mass), read entirely off the candidate's own rank/slug/origin, no context needed. Major Rogations (f) is left unimplemented rather than guessed: no producer for the Major Litanies exists anywhere in this codebase yet. disposition's two Commemorate sites now call privilege_of instead of Task 8's interim_privilege placeholder, which is removed entirely (binding and .mli export both gone). Precedence_ef.admit applies RG 111's four admission counts, keyed on the observed day's own class and Sunday-ness: a I-class day admits none except one privileged commemoration; a II-class Sunday admits one, but a privileged commemoration due displaces any ordinary one regardless of dignity; any other II-class day admits one by dignity alone, with no such override; III/IV-class days admit at most two by dignity. Ties break on slug, matching Precedence.compare_by, so the admitted set never depends on input order. Every admitted candidate is a value taken unchanged from the input list, never rebuilt, so Precedence.resolve's physical-equality-based dropped/omitted accounting keeps working -- closing a note left open since Task 2. Tests: RG 109 category rows (one per reachable category, plus boundary rows proving Advent/Lent Ember days and Minor Rogations are correctly excluded), RG 111 admission rows checked on slug identity rather than count, an order-independence check, and three end-to-end integration tests proving the admission limit's drop lands in resolution.omitted rather than vanishing.
Diffstat (limited to 'lib/rites')
-rw-r--r--lib/rites/rite_ef/precedence_ef.ml224
-rw-r--r--lib/rites/rite_ef/precedence_ef.mli62
2 files changed, 261 insertions, 25 deletions
diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml
index 02181ed..06bbf7c 100644
--- a/lib/rites/rite_ef/precedence_ef.ml
+++ b/lib/rites/rite_ef/precedence_ef.ml
@@ -74,8 +74,18 @@ let is_vigil slug = String.ends_with ~suffix:vigil_suffix slug
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. *)
-let ember_prefixes = [ "ef-advent-ember-"; "ef-lent-ember-"; "ef-september-ember-" ]
+ 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 privileging it while leaving the Advent and
+ Lent sets ordinary (register lines 375-376) -- building [ember_prefixes]
+ from it 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
@@ -259,15 +269,95 @@ let impedes_vigil (winner : Vocab_ef.rank Precedence.candidate) =
cel.Celebration.rank = Vocab_ef.Class1
|| is_sunday_slug (Slug.to_string cel.Celebration.slug)
-(* Not yet RG 109 (Task 9's job: the closed list of privileged commemorations
- and RG 108-111's admission counts). Every [Commemorate] this function
- returns carries this one placeholder rather than a silent default, so the
- choice is visible and grep-able. [Ordinary] chosen over [Privileged]
- deliberately: it grants no admission entitlement RG 111 has not earned,
- so code that trusts this value before Task 9 replaces it under-privileges
- a commemoration rather than over-privileges one -- the safer direction to
- be wrong in. *)
-let interim_privilege = Precedence.Ordinary
+(* 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" (register line 376) -- 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 (register lines 374-377, docs/research/rules-register.md §4): 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) register line 374: "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) register line 374-375: "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) register line 375: "of days within the Octave of the Nativity". *)
+ else if is_temporal && String.starts_with ~prefix:nativity_octave_prefix slug then
+ Precedence.Privileged
+ (* (d) register line 375-376: "of September Ember days" -- deliberately
+ ONLY the September set: RG 109 does not list the Advent or Lent Ember
+ sets (also II class, RG 91 entry 18), so those must fall through to
+ "ordinary", not be caught here or at (e) below. *)
+ else if is_temporal && String.starts_with ~prefix:september_ember_prefix slug then
+ Precedence.Privileged
+ (* (e) register line 376: "of ferias of Advent, Lent and Passiontide" --
+ [not (is_ember_18 slug)] is required, not redundant with (d): the
+ Advent and Lent Ember prefixes ("ef-advent-ember-", "ef-lent-ember-")
+ also start with this branch's own [alp_feria_prefixes] entries
+ ("ef-advent-", "ef-lent-"), and RG 109 does not privilege them (see (d)
+ above) -- without this exclusion they would wrongly match here. *)
+ else if is_temporal
+ && (not (is_ember_18 slug))
+ && List.exists (fun p -> String.starts_with ~prefix:p slug) alp_feria_prefixes
+ then Precedence.Privileged
+ (* (f) register line 376-377: "of the Major Rogations, in Mass" -- the
+ Major Litanies (25 April, RG 80) are not yet computed anywhere in this
+ codebase (temporal_ef.ml's own comment on [temporal]'s Rogation branch:
+ "The Major Litanies... are a fixed date and are not yet computed; they
+ arrive with Plan 3's sanctoral"), 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 =
@@ -278,8 +368,12 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate)
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. *)
- Precedence.Commemorate interim_privilege
+ 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
(cel.Celebration.rank = Class1 || cel.Celebration.rank = Class2)
&& is_vigil (Slug.to_string cel.Celebration.slug)
@@ -302,8 +396,9 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate)
(* RG 95's other branch, for everything below I class: "aut
commemorantur aut penitus omittuntur" -- commemorated or wholly
omitted. Which of the two survives is RG 108-111's admission count
- (Task 9's [admit]), not this function's decision; this only opens the
- commemoration.
+ ([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
@@ -312,4 +407,101 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate)
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 interim_privilege
+ 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, register line 378, "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 dropped candidate would then match nothing in
+ [admitted], and {!Precedence.resolve} would count it as dropped a
+ SECOND time (once for real, once because its identity no longer
+ matches its own admitted copy) without ever raising -- a silent
+ double-count, not a crash, 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: "II-class Sundays: one (dropped if a privileged one is
+ due)." Read as: the day's one slot goes to a privileged
+ commemoration whenever one is due, categorically -- not by
+ comparing its dignity against the ordinary contender's -- so an
+ ordinary commemoration that would otherwise win the slot on raw
+ dignity is still dropped once any privileged commemoration is also
+ due. This is the asymmetric clause the brief and task report flag
+ as deliberate, not present at "other II class" below; see the task
+ report for the reasoning and its residual uncertainty (the register
+ does not spell out the mechanism beyond this one sentence). *)
+ (match List.filter is_privileged sorted with
+ | best :: _ -> [ best ]
+ | [] -> ( match 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
diff --git a/lib/rites/rite_ef/precedence_ef.mli b/lib/rites/rite_ef/precedence_ef.mli
index 9fe8789..9c3af95 100644
--- a/lib/rites/rite_ef/precedence_ef.mli
+++ b/lib/rites/rite_ef/precedence_ef.mli
@@ -65,13 +65,6 @@ val band : Vocab_ef.season Precedence.context -> Vocab_ef.rank Precedence.candid
somewhere to be caught other than a silently-wrong RG 33 disposition. *)
val sunday_marker : string
-(** The placeholder {!Precedence.privilege} every [Commemorate] disposition
- below carries until Task 9 implements RG 109's closed list of privileged
- commemorations and RG 108-111's admission counts. Exposed so Task 9 (and
- any test wanting to assert on it explicitly) does not have to duplicate
- the literal [Precedence.Ordinary]. *)
-val interim_privilege : Precedence.privilege
-
(** [disposition ~winner ~loser]: RG 92-95, 33, 94 (docs/research/
rules-register.md §4, "Occurrence" and "Vigils"). What becomes of a
losing candidate, decided by the LOSER's own rank and status (RG 95),
@@ -89,8 +82,8 @@ val interim_privilege : Precedence.privilege
right of translation; this is also what moves All Souls, register
line 334, once it loses to an occurring Sunday -- WHERE it lands is
{!Rite.t.transfer_target}'s job, not this function's);
- - everything else is [Commemorate], carrying {!interim_privilege} until
- Task 9 replaces it with RG 109's real per-day computation.
+ - everything else is [Commemorate], carrying its real RG 109 privilege
+ (see {!admit} below).
Total over every winner/loser pair {!Precedence.resolve} or {!Calendar}
can construct: [Vocab_ef.rank] (RG 8) and {!Celebration.status} are both
@@ -102,3 +95,54 @@ 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) privileges September Ember days specifically while leaving the
+ Advent and Lent sets (also {!ember_prefixes}) ordinary -- register lines
+ 375-376. {!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", register lines 371-379). 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
+ -- a privileged commemoration takes the day's one slot over any
+ ordinary one whenever one is due, not by comparing dignity, so an
+ ordinary commemoration that would otherwise win on dignity is still
+ dropped;
+ - [observed] any other [Class2] day: one, by dignity alone -- no
+ privilege override, 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