aboutsummaryrefslogtreecommitdiff
path: root/lib/kernel/layer.ml
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-17 17:22:43 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-17 17:22:43 +0200
commit2b9f8133a22ce6dc309644a98bbe61507f574b38 (patch)
treeedb24a59d092f56f00dcbca654224a0622725d5e /lib/kernel/layer.ml
parentf2a0660a9b60b70af32f1157dc78cdfcb298c9f7 (diff)
downloadcolitur-2b9f8133a22ce6dc309644a98bbe61507f574b38.tar.gz
colitur-2b9f8133a22ce6dc309644a98bbe61507f574b38.zip
feat(kernel): movable Date_spec variants, and Rogation Wednesday
Date_spec carried one variant, Fixed(month, day), and its own header said Sunday- and Easter-relative forms would arrive with the OF sanctoral. They arrive early because two things needed them at once: a user-supplied overlay carrying a local movable feast had no way to express "the first Sunday of October", and Rogation Wednesday's commemoration has been recorded as architecturally blocked since 2026-08-13. That blockage turns out to have been a premise, not a fact. The register concluded the Rogation "is not Commemoration_only sanctoral data by nature... it needs a genuinely THIRD kind of thing this architecture has no name for" -- but the reasoning rested on there being "no civil (month, day) pair to anchor a Fixed entry to". That is a statement about Date_spec's expressiveness. With Easter_offset the entity is ordinary sanctoral data, the identical shape Add major-litanies already ships, differing only in how the date is written. No third channel was needed. The register is corrected rather than left contradicting the code. Two variants: Easter_offset of int, and Nth_weekday of {month; nth; weekday} with negative nth counting from the end. Fixed's sexp representation is untouched, so all 327 sanctoral entries and the whole overlay parse unchanged. Both are re-validated in t_of_sexp through their smart constructors, extending the idiom Fixed already used -- the failure that guards against is invisible, a spec deserialising into something that silently never resolves and a celebration vanishing with no diagnostic. Easter is supplied by the rite, not computed in the kernel. Computus ships Gregorian and Julian both, and picking one here would hard-code a Roman assumption into rite-agnostic code and be silently wrong for a Julian-reckoning rite. Rite.t gains an easter field; Rite_ef supplies the Gregorian one. Layer keeps a split index. Fixed entries stay in the year-independent (month, day) table, which preserves the fast path and the "30 November counted twice in a 371-day span" behaviour validate.mli documents for St Andrew. Movable entries have no year-independent key by construction -- the same spec lands on a different (month, day) each year -- so they resolve per civil year into a rata-die table. index_by_date is removed rather than kept as an alias: two ways to index a layer, one of which silently ignores movable entries, is the trap this change exists to avoid. Two domain-edge defects, both found by the suite rather than reasoning. A liturgical year is Advent-anchored, so resolving civil year y names y +/- 1, and at the edges those are 1582 and 10000, which Computus correctly refuses by raising. The ceiling surfaced through the domain-ceiling test, the floor through `colitur day 1583` failing outright. Fixed once, in Layer.index, the single point that calls the rite's easter -- a clamp repeated per caller would have been two places to get wrong a third time. Rogation Wednesday: RG 87 assigns the Litaniae minores to the Monday, Tuesday AND Wednesday before Ascension (scan1:691); colitur built the first two from the start and never the third. RG 88 keeps it out of the Office, so the Ascension Vigil remains observed. RG 89 routes it through the Major Litanies' rules, but RG 109's closed list names only maiores, so by RG 107 it is an ORDINARY commemoration -- confirmed in the output, not assumed. Name from the Missal's own "feria IV" (scan1:20495-20497), violet to match the Monday and Tuesday offices. Blast radius, full 1583-9999 sweep against the pre-change binary: 2257 days, every one the same shape, zero unclassified, line counts equal both sides. The complement is the informative half -- in the other 6160 years the Rogation does not appear at all, because RG 111 admits one commemoration on a II-class day and an impeded feast takes it under RG 113, a Commemoration_only candidate having no row in RG 91's table. Both shapes are pinned. Allow-lists unmoved: layer 3 compares no commemorations and is blind by construction, and layer 4's 2026-2027 window has a competing feast on both Wednesdays, so the Rogation is capped out in exactly the years the oracle can see. C8 is unchanged, asserted by its own count pin. Not built: Sunday-relative specs, which nothing needs yet, and the --overlay plumbing that would let a user actually supply a local movable feast. Nth_weekday therefore ships with synthetic and property coverage only; inventing a shipped entry to exercise it would be fabricating calendar data. Register: section 6.10.
Diffstat (limited to 'lib/kernel/layer.ml')
-rw-r--r--lib/kernel/layer.ml65
1 files changed, 51 insertions, 14 deletions
diff --git a/lib/kernel/layer.ml b/lib/kernel/layer.ml
index 515b052..ec074a7 100644
--- a/lib/kernel/layer.ml
+++ b/lib/kernel/layer.ml
@@ -20,26 +20,63 @@ let set t entry =
let t = remove t entry.cel.Celebration.slug in
{ t with entries = canonical (entry :: t.entries) }
-(* Dates are year-independent, so the index is built once per layer rather than
- once per year -- a full 1583..9999 sweep would otherwise rescan the entry
- list for every day. *)
-type 'r by_date = (int * int, 'r entry list) Hashtbl.t
+(* FIXED entries are year-independent, so their table is built once and keyed
+ (month, day) -- a full 1583..9999 sweep would otherwise rescan the entry
+ list for every day.
-let key = function Date_spec.Fixed { month; day } -> (month, day)
+ MOVABLE entries have no such key BY CONSTRUCTION: the same spec lands on a
+ different (month, day) in different years, so one year-independent table
+ cannot serve a multi-year span. They are resolved once per civil year in
+ the span and keyed by rata die instead.
-let index_by_date t =
- let tbl : 'r by_date = Hashtbl.create 512 in
+ Keeping the two separate, rather than resolving everything per year,
+ preserves the fixed path exactly as it was -- including the "30 November
+ counted twice in a 371-day liturgical span" behaviour validate.mli
+ documents for St Andrew, which falls straight out of querying by (month,
+ day) and would have to be re-established by hand under a uniform rata-die
+ index. *)
+type 'r index = {
+ fixed : (int * int, 'r entry list) Hashtbl.t;
+ movable : (int, 'r entry list) Hashtbl.t; (** keyed by rata die *)
+}
+
+let index t ~easter ~years =
+ (* Filter to the kernel domain BEFORE calling [easter] on anything. A
+ liturgical year is Advent-anchored, so a caller resolving civil year [y]
+ legitimately names [y - 1] or [y + 1] -- and at the two edges those are
+ 1582 and 10000, which {!Computus} correctly refuses by raising. Both
+ edges bit during development (the ceiling via the domain-ceiling test,
+ the floor via `colitur day 1583`), which is why the guard lives HERE, at
+ the single point that calls [easter], rather than as a clamp repeated in
+ every caller. A spec cannot resolve outside 1583..9999 anyway, so
+ dropping those years loses nothing. *)
+ let years = List.filter (fun y -> y >= 1583 && y <= 9999) years in
+ let fixed : (int * int, 'r entry list) Hashtbl.t = Hashtbl.create 512 in
+ let movable : (int, 'r entry list) Hashtbl.t = Hashtbl.create 32 in
+ let add tbl k e = Hashtbl.replace tbl k (e :: (try Hashtbl.find tbl k with Not_found -> [])) in
List.iter
(fun e ->
- let k = key e.date in
- Hashtbl.replace tbl k (e :: (try Hashtbl.find tbl k with Not_found -> [])))
+ match e.date with
+ | Date_spec.Fixed { month; day } -> add fixed (month, day) e
+ | _ ->
+ (* A spec resolving to nothing in a given year is not an error:
+ [Date_spec.resolve]'s own [None] means "does not occur this
+ year", the same contract 29 February has always had. *)
+ List.iter
+ (fun year ->
+ match Date_spec.resolve e.date ~year ~easter:(easter year) with
+ | Some d -> add movable (Date.to_rata d) e
+ | None -> ())
+ years)
t.entries;
- (* restore canonical order within each date bucket *)
- Hashtbl.iter (fun k v -> Hashtbl.replace tbl k (canonical v)) tbl;
- tbl
+ Hashtbl.iter (fun k v -> Hashtbl.replace fixed k (canonical v)) fixed;
+ Hashtbl.iter (fun k v -> Hashtbl.replace movable k (canonical v)) movable;
+ { fixed; movable }
-let on_date tbl ~month ~day =
- try Hashtbl.find tbl (month, day) with Not_found -> []
+let on_date idx date =
+ let f = try Hashtbl.find idx.fixed (Date.month date, Date.day date) with Not_found -> [] in
+ let m = try Hashtbl.find idx.movable (Date.to_rata date) with Not_found -> [] in
+ match m with [] -> f | _ -> canonical (f @ m)
let load rank_of_sexp path =
match Sexplib.Sexp.load_sexp path with