From 2ad350e0f97a99f76ac1d2d95cfa210f7393c777 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 11 Aug 2026 23:25:39 +0200 Subject: 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. --- lib/rites/rite_ef/precedence_ef.ml | 224 ++++++++++++++++++++++++++++++++++--- 1 file changed, 208 insertions(+), 16 deletions(-) (limited to 'lib/rites/rite_ef/precedence_ef.ml') 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 + -- 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-", + 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 -- cgit v1.3