aboutsummaryrefslogtreecommitdiff
path: root/lib/rites/rite_ef/precedence_ef.ml
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 00:44:36 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 00:44:36 +0200
commit7f183c847f8b67c88ad6ea3bf2d635c5c0534651 (patch)
tree0c4a7adb94e5374b61d979c2b4c54637da677cda /lib/rites/rite_ef/precedence_ef.ml
parent4624441d0bc9c1f28dea54606ab7999041d4b323 (diff)
downloadcolitur-7f183c847f8b67c88ad6ea3bf2d635c5c0534651.tar.gz
colitur-7f183c847f8b67c88ad6ea3bf2d635c5c0534651.zip
cli: colitur day <year> 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 <year>: 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).
Diffstat (limited to 'lib/rites/rite_ef/precedence_ef.ml')
-rw-r--r--lib/rites/rite_ef/precedence_ef.ml112
1 files changed, 107 insertions, 5 deletions
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