From 7f183c847f8b67c88ad6ea3bf2d635c5c0534651 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 12 Aug 2026 00:44:36 +0200 Subject: cli: colitur day dumps resolved liturgical days Assembles Rite_ef.context (lib/rites/rite_ef/rite_ef.ml[i]): temporal, anchors and vocab from Temporal_ef; rules from Precedence_ef's band, disposition and admit; season_runs = Vocab_ef.seasons; transfer_target newly implemented here. transfer_target (RG 96): the next following day that is not I or II class, with the Annunciation's own exception (Monday after Low Sunday). Terminates by a structural step bound on its internal search, independent of Calendar's own round guard, which bounds rounds across a year, not one call's walk; documented as an obligation on rite.mli's transfer_target field, which did not previously state it. Fixes the vigil-naming mismatch Task 7's review predicted: the sanctoral bootstrap names its vigils with a vigil-of-X prefix (lectio's own convention), while Precedence_ef's is_vigil only recognised the temporal cycle's own X-vigil suffix. Both are now recognised, fixing RG 91 entries 21/26 and RG 33's vigil omission for the four affected celebrations. Verified by unit test and by mutation-testing the fix (reverting it fails exactly the new rows) and against real output across several years. Suppresses data/ef/sanctoral.sexp's vigil-of-christmas via a new overlay, data/ef/adjustments.sexp: it is the same celebration as the temporal cycle's own ef-nativity-vigil, both dated 24 December. colitur day : one line per civil-year day, temporal and sanctoral fully resolved through Layer, Overlay, Precedence_ef and Calendar -- the first CLI path exercising the whole Plan 3 pipeline against real data. Verified the All Souls transfer chain (Tasks 7-8-11) end to end against real output for both a Sunday year (2025, lands on 3 Nov) and a non-Sunday year (2026, observed directly on 2 Nov). --- lib/rites/rite_ef/precedence_ef.ml | 112 ++++++++++++++++++++++++++++++++++-- lib/rites/rite_ef/precedence_ef.mli | 68 ++++++++++++++++++++-- lib/rites/rite_ef/rite_ef.ml | 24 ++++++++ lib/rites/rite_ef/rite_ef.mli | 35 +++++++++++ 4 files changed, 228 insertions(+), 11 deletions(-) create mode 100644 lib/rites/rite_ef/rite_ef.ml create mode 100644 lib/rites/rite_ef/rite_ef.mli (limited to 'lib/rites/rite_ef') diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml index ff6f991..e1c13d1 100644 --- a/lib/rites/rite_ef/precedence_ef.ml +++ b/lib/rites/rite_ef/precedence_ef.ml @@ -55,13 +55,22 @@ let is_universal layer = String.equal layer universal_layer {!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"). Exposed so a future task naming a - sanctoral vigil (Task 10: Assumption, John Baptist, Peter & Paul, - Lawrence -- only Ascension exists today) uses the same suffix; a - differently-named vigil would band 16/24 instead of 21/26, silently. *) + [named], e.g. "ef-ascension-vigil"). *) let vigil_suffix = "-vigil" -let is_vigil slug = String.ends_with ~suffix:vigil_suffix slug +(* 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/ @@ -519,3 +528,96 @@ let admit ~(observed : Vocab_ef.rank Precedence.candidate) (* 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, register-transcribed: "(Annunciation + exception): -> Monday after Low Sunday." 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 + +(* 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. If the bound is + reached, the last date visited is returned WITHOUT probing [occupant] + again -- one more finite (not necessarily admissible) date, not a + further search -- because the val 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 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 ordinary branch + starts the search at [Date.add_days origin 1] and [search_from] only ever + advances forward from its own starting point, so the result is always >= + origin + 1. The Annunciation branch starts instead at 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. *) +let transfer_target (c : Vocab_ef.rank Precedence.candidate) (origin : Date.t) + (occupant : Date.t -> Vocab_ef.rank Celebration.t) : Date.t = + let start = + if Slug.to_string c.Precedence.cel.Celebration.slug = annunciation_slug 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. *) + Date.add_days (Computus.gregorian_easter (Date.year origin)) 8 + else Date.add_days origin 1 + in + search_from occupant 0 start diff --git a/lib/rites/rite_ef/precedence_ef.mli b/lib/rites/rite_ef/precedence_ef.mli index e1b3638..d06b058 100644 --- a/lib/rites/rite_ef/precedence_ef.mli +++ b/lib/rites/rite_ef/precedence_ef.mli @@ -19,14 +19,31 @@ val universal_layer : string val indult_prefix : string (** Slug suffix marking a celebration as a vigil (RG 91 entries 21, 26), - e.g. "ef-ascension-vigil". Also colitur's own convention, not an RG - citation, exposed for the same reason as {!universal_layer}: only the - Ascension Vigil exists today (rite_ef/temporal_ef.ml); the Assumption, - John Baptist, Peter & Paul and Lawrence vigils arrive as sanctoral data - in a future task, and must use this same suffix or {!band} will band - them 16/24 (an ordinary feast of the same rank) instead of 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 @@ -153,3 +170,42 @@ 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). RG 96's own named exception: the + Annunciation ({!annunciation_slug}) does not search from [origin + 1] at + all -- it starts at the Monday after Low Sunday for [origin]'s own civil + year, searching onward from there only if that day is itself occupied by + a blocking class. + + 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), 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 the caller. Strictly later than [origin]: the ordinary search + starts at [origin + 1] and only ever advances forward from there; the + Annunciation's own 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 -- cgit v1.3