diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-13 21:19:12 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-13 21:19:12 +0200 |
| commit | 2af482c7293d821f10c3bbc96b7913d658c5839c (patch) | |
| tree | 608eaf6bcc46d9dbda0a6dd3a830a5967b201bfa /lib/rites/rite_ef/precedence_ef.ml | |
| parent | a3afb19dc6390c9e33ed5fa0ecbd6e5895e60e8d (diff) | |
| parent | 72b19098905cd609d87783f6e68311de4f1b61a0 (diff) | |
| download | colitur-2af482c7293d821f10c3bbc96b7913d658c5839c.tar.gz colitur-2af482c7293d821f10c3bbc96b7913d658c5839c.zip | |
Merge branch 'ef-major-litanies': the Major Litanies, and a kernel bug
Builds the Major Litanies (RG 80, 81, 109(f)) -- an item deferred three
times, each time on a reason that did not survive contact. Two recorded
blockers were false (a kernel signature said to be missing already
carried what was needed; Easter Monday, said to be unmarkable, occurs
exactly 8417 times in 8417 years) and a displacement estimate of ~97.7%
of years measured at 4 years in 46.
8611 days change in four shapes, zero unclassified: the commemoration
appearing on ordinary 25 Aprils, displacing St Mark's own on the four
II-class Sundays RG 111(b) governs, and relocating to the following
Tuesday in the 194 years RG 80's transfer clause fires. No day outside
25/26/27 April differs at all, and observed, rank, colour, season, week
and transferred_in are byte-identical across all 3074246 days.
The entity is a Commemoration_only sanctoral entry, which honours RG 81
structurally rather than by special case: Precedence.resolve partitions
such candidates out of the band contest, so 'nihil fit in Officio' holds
by construction, and being sanctoral-origin it can never claim RG 113's
de-Tempore first place.
And it surfaced a latent kernel bug. Calendar.build_day decided whether
a transferred candidate had settled by testing only whether it became
that day's observed celebration -- correct for every prior use, a losing
feast that wins its unblocked target, and wrong for a Commemoration_only
candidate, which can never be observed by the same status that lets it
transfer. The first fix accepted two settlement channels and the review
found a third: a candidate capped out by admit at its target. That third
case was already visible inside the task's own mutation proof, as an
undiagnosed ninth failure it had set aside.
The fix is a provable no-op in isolation: a3afb19 plus the new
calendar.ml alone, swept over every day of the domain, is byte-identical
to base.
Diffstat (limited to 'lib/rites/rite_ef/precedence_ef.ml')
| -rw-r--r-- | lib/rites/rite_ef/precedence_ef.ml | 239 |
1 files changed, 211 insertions, 28 deletions
diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml index 7b64b83..c9e6a55 100644 --- a/lib/rites/rite_ef/precedence_ef.ml +++ b/lib/rites/rite_ef/precedence_ef.ml @@ -651,6 +651,48 @@ let nativity_octave_prefix = "ef-nativity-octave-day-" [universal_layer] -- private: nothing outside [privilege_of] needs it. *) let alp_feria_prefixes = [ "ef-advent-"; "ef-lent-"; "ef-passiontide-" ] +(* RG 80 (Caput X, "De Litaniis maioribus et minoribus", §A "De Litaniis + maioribus"; both photographic scans and the electronic transcription, + word for word, no scan-vs-transcription conflict to adjudicate -- + docs/research/rules-register.md's own citation, this task): "80. + Litaniae maiores assignatae sunt diei 25 aprilis; si vero eo die + occurrit dominica Paschatis vel feria II post Pascha, transferuntur in + sequentem feriam III." -- the Major Litanies are assigned to 25 April; + but if Easter Sunday or Easter Monday falls on that day, they are + transferred to the FOLLOWING TUESDAY. Both trigger shapes land on the + SAME target, Easter+2 (worked out from the text, not independently + stated by it): 25 April = Easter Sunday means the "following Tuesday" + is Easter+2; 25 April = Easter Monday means Easter itself is 24 April, + and the following Tuesday is again Easter+2. {!transfer_target}'s own + Litanies branch, below, computes that directly. + + RG 81, same division, immediately following: "81. De Litaniis + maioribus nihil fit in Officio, sed tantum in Missa. Earum autem + commemoratio non est habenda commemoratio 'de Tempore'." -- nothing is + done in the Office, only in the Mass, and its commemoration is not to + be reckoned a "de Tempore" one. This is why [major_litanies_slug]'s own + data/ef/adjustments.sexp entry is [Commemoration_only]: RG 91's table + enumerates "dies liturgici" (Office days), and this is explicitly + denied any Office standing at all -- the same reasoning {!band}'s own + top-of-function guard already gives every [Commemoration_only] + candidate (never a table row, never [observed]), which is exactly the + behaviour RG 81 requires here independently. + + [major_litanies_slug] is this codebase's OWN invented English slug: RG + 80/81/109(f) name the observance, never a computer key for it, and + lectio does not compute the Major Litanies at all (this task's own + measurement) -- nothing to adopt verbatim, unlike every other slug this + file cross-checks against temporal_ef.ml's own conventions. + data/ef/adjustments.sexp's own [Add] directive is the ONE other place + this exact string is written; a rename here with no matching rename + there silently stops every branch below from ever seeing a live + candidate again -- flagged the same way [rg110_companion_slug] already + flags its own three hand-maintained pairs. *) +let major_litanies_slug = "major-litanies" + +let is_major_litanies (c : Vocab_ef.rank Precedence.candidate) = + String.equal (Slug.to_string c.Precedence.cel.Celebration.slug) major_litanies_slug + (* 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 @@ -741,23 +783,30 @@ let privilege_of (c : Vocab_ef.rank Precedence.candidate) : Precedence.privilege 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. *) + (* (f) RG 109(f) (Caput XVI, "De Commemorationibus", §4): "de Litaniis + maioribus, in Missa" -- of the Major Litanies, IN THE MASS (RG 81's + own restriction: never in the Office). NOW LIVE (this task, + ef-major-litanies): this branch used to be dead code -- "no candidate + this engine can currently construct represents one" -- because + nothing built the Major Litanies as a candidate at all. + data/ef/adjustments.sexp's own [major_litanies_slug] entry + ({!major_litanies_slug}'s own citation above has the full RG 80/81 + text) is exactly that candidate now. Read by slug alone, the same + convention every other one-off entry in this file uses + ([nativity_octave_prefix], [rg110_companion_slug], [annunciation_slug] + below) -- RG 109(f) names one specific, closed-list observance, not a + structural property [is_temporal]/[rank] could derive the way (c)/(d)/ + (e) above do for whole classes of temporal ferias. Deliberately NOT + matched by anything above it: 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, and [is_sunday_slug]/ + [rank = Class1]/[nativity_octave_prefix]/the Ember prefixes/ + [alp_feria_prefixes] never match their slugs either), so they + correctly fall through to "ordinary" below, not this category -- + {!privilege_cases}'s own negative row in test_precedence_ef.ml pins + this boundary. *) + else if is_major_litanies c then Precedence.Privileged else Precedence.Ordinary let disposition ~(winner : Vocab_ef.rank Precedence.candidate) @@ -793,6 +842,63 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate) exception for two commemorations invoking the identical BVM, the exact gap this fix closes. *) Precedence.Omit + else if + is_major_litanies loser + && (let wslug = Slug.to_string winner.Precedence.cel.Celebration.slug in + String.equal wslug "ef-easter-sunday" || String.equal wslug "ef-easter-1-monday") + then + (* RG 80 -- {!major_litanies_slug}'s own citation above has the full + text and the "both trigger shapes land on Easter+2" derivation. + data/ef/adjustments.sexp's own [major_litanies_slug] entry is + UNCONDITIONALLY Fixed at 25 April, so it is offered as a candidate + every year regardless of what 25 April turns out to be -- this is + where the two RETRACTED blockers this task's own brief names + (docs/research/rules-register.md's "the recorded blocker was + wrong") actually close: [disposition] already receives [~winner], + so a rite-local test on the WINNER's own slug alone (no kernel + signature change, no [context]/date needed here at all) is enough + to tell the 194 trigger years apart from the other 8,223 -- exactly + the "zero kernel surface" the retraction predicted. "ef-easter- + sunday" and "ef-easter-1-monday" are [Temporal_ef.named]'s/[temporal]'s + own slugs for Easter Sunday and Easter Monday respectively (this + file's own [entry_15_band]-adjacent branches above already trust + the same convention); Easter Monday's own reliability as a marker + (measured, register: 8,417 occurrences in 8,417 years, once per + year, never displaced, being a I-class octave day) is what makes + reading it off a bare slug string safe here, the same argument that + retracted the second blocker. + + Checked ahead of the [Commemoration_only] branch immediately below: + that branch's own "always Commemorate, nothing overrides it" would + otherwise fire first, and this candidate would wrongly commemorate + 25 April itself in exactly the 194 years RG 80 forbids it from + doing so. No earlier branch in this function can pre-empt it + either -- [is_bvm_office] above is keyed on Marian/BVM subjects + this candidate never carries ({!major_litanies_slug}'s own entry is + [subject = Saint], and its slug is not on {!marian_slugs}). + + [Transfer], not [Omit]: RG 80 does not say the Litanies simply + vanish in a trigger year, it says WHERE they move to -- and + {!Precedence.disposition}'s own [Transfer] constructor, together + with the placement machinery {!Calendar} already has for RG 96 + (calendar.ml's [place_transfers]/[resolve_with_injected]), is + exactly "move a losing candidate to another day and inject it + there" -- reused here rather than invented a second time, because + RG 80's own transfer is structurally the same operation, just with + its OWN named target instead of RG 96's generic forward search + ({!transfer_target}'s own Litanies branch, below, computes that + target directly rather than searching for it). A + [Commemoration_only] candidate transferring is a new shape for this + codebase, checked rather than assumed safe: {!Precedence.resolve}'s + own fold matches [Transfer | Repose] uniformly, with no [status] + test anywhere in it, and once re-offered at the target date this + candidate is still [Commemoration_only], so it is still held out of + the band contest there too (the same [forced_comm] partition, + {!Precedence.resolve}'s own top comment) -- it can never + accidentally become [observed] at its transfer target either, + matching RG 81's "nihil fit in Officio" for the same reason it + cannot become [observed] on 25 April itself. *) + Precedence.Transfer else 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 @@ -1509,7 +1615,46 @@ let admit ~(observed : Vocab_ef.rank Precedence.candidate) 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. *) + pre-fix code admitted him regardless. + + CORROBORATED (ef-major-litanies task, fix round 1, F5): RG 111 + sits in Caput XVI, "De Commemorationibus", governing "tam pro + Missa quam pro Officio" per RG 106 -- read alone it could be + mistaken for Office-shaped, incidentally reused for the Mass. + Rubricae Generales Missalis Romani n. 434(b) (Part "VIII - De + diversis Missae partibus", "D) De orationibus", "I - De + orationibus in genere"), verified word for word, all three + documents: "in dominicis II classis, nulla alia admittitur + oratio, praeter commemorationem festi II classis, quae tamen + omittitur si commemoratio privilegiata facienda sit" -- + IDENTICAL IN ITS OPERATIVE CLAUSE to the one above -- not word + for word throughout: RG 111(b) opens "una tantum admittitur + commemoratio, SCILICET DE FESTO II CLASSIS", n. 434(b) recasts + that into the orations register as "NULLA ALIA ADMITTITUR ORATIO, + PRAETER COMMEMORATIONEM festi II classis", and only the trailing + "quae tamen omittitur si commemoratio privilegiata facienda sit" + is verbatim in both. That trailing clause is the one this + adjudication turns on. And n. 434 is not merely a different part + of the same document: the running heads show RG 111 under + "Rubricae generales" and n. 434 under "Rubricae generales Missalis + Romani" -- two distinct rubrical corpora bound in one volume, + which is the whole force of the corroboration. Explicitly scoped "post + orationem Missae" -- an independent, Mass-structure-rubric + confirmation of the exact same privilege-overrides-ordinary rule + this branch already implements, from a different part of the + same document. First load-bearing live witness for this branch's + "privileged wins even against a WORSE table position" half: the + Major Litanies (RG 109(f), major_litanies_slug above), whose own + [band] value is {!unclassified} (worse than every real table + entry, per that constant's own comment) yet still displaces St + Mark's real entry-16 table position on the four Sunday-conflict + years the Litanies' own data/ef/expected-divergences-missalemeum + .sexp M20 entry adjudicates -- every earlier witness for this + branch (Holy Family/Hyginus above; the RG16(a) admit_cases table) + happened to also be the day's own privileged Sunday/feast winning + OUTRIGHT (band already decided it before admit ran), not two + independently-offered LOSING candidates settled by this clause + alone. *) (match List.filter is_privileged sorted with | best :: _ -> [ drop_band best ] | [] -> ( @@ -1656,14 +1801,52 @@ let rec search_from (occupant : Date.t -> Vocab_ef.rank Celebration.t) (steps : 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 + if is_major_litanies c then + (* RG 80 -- {!major_litanies_slug}'s own citation (precedence_ef.ml, + above [privilege_of]) has the full text and the "both trigger + shapes land on Easter+2" derivation. Checked BEFORE computing + [general_target] at all (unlike the Annunciation branch below, + which computes the general RG 96 walk first and only overrides it + conditionally) -- this candidate's target is never the general + walk's own result, so running {!search_from} for it would be + wasted work at best. + + "The following Tuesday", unconditionally: [search_from]'s ordinary + forward walk exists because a DISPLACED FEAST needs an unoccupied + (non-I/II-class) day to be fully celebrated as itself (RG 96's own + "next day that is not I or II class"). The Major Litanies are never + a feast -- RG 81: "nihil fit in Officio, sed tantum in Missa" -- + only ever a commemoration once they arrive, and a commemoration + needs no unoccupied day at all (RG 108-111 admit commemorations on + I-class days routinely, see [privilege_of]'s own (b) branch and + [admit]'s [Class1] case above). Running {!search_from} here would + therefore be actively WRONG, not merely unnecessary: Easter+2 is + itself I-class (within the Easter octave, {!band}'s own entry-10 + branch, {!is_blocking}'s own [Class1] test), so a search starting + there would walk PAST the exact date RG 80 names, looking for a + day the rubric never asked for. + + Total and terminating trivially (no search performed at all). + Strictly after [origin], {!Rite.t.transfer_target}'s own contract + (rite.mli), on both trigger shapes (this file's header, worked + examples): 25 April = Easter Sunday (origin = Easter itself = + Easter+0, target = Easter+2 = origin+2) or 25 April = Easter Monday + (Easter itself = 24 April = origin-1, target = Easter+2 = + origin+1) -- either way strictly forward. Not conditioned on + [occupant] at all, unlike every other branch in this function -- + deliberately: nothing about RG 80's own text makes the target + depend on what else is observed that year, only on Easter's own + date. *) + Date.add_days easter 2 + else + 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 + 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 |
