aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/kernel/calendar.ml7
-rw-r--r--lib/kernel/date.mli7
-rw-r--r--lib/kernel/layer.ml8
-rw-r--r--lib/kernel/layer.mli13
-rw-r--r--lib/kernel/rite.ml1
-rw-r--r--lib/kernel/rite.mli27
-rw-r--r--lib/kernel/validate.ml2
-rw-r--r--lib/rites/rite_ef/rite_ef.ml4
-rw-r--r--lib/rites/rite_ef/temporal_ef.ml62
-rw-r--r--lib/rites/rite_ef/temporal_ef.mli11
10 files changed, 134 insertions, 8 deletions
diff --git a/lib/kernel/calendar.ml b/lib/kernel/calendar.ml
index d55e38d..db6c58e 100644
--- a/lib/kernel/calendar.ml
+++ b/lib/kernel/calendar.ml
@@ -79,7 +79,7 @@ let resolve_with_injected ?(suppressed = no_suppression) (rite : ('s, 'r) Rite.t
{ Precedence.cel = temporal.Temporal.office; origin = Precedence.Temporal }
in
let natural =
- Layer.on_date idx date
+ Layer.on_date ~fixed_key:rite.Rite.fixed_key idx date
|> List.map (fun (e : 'r Layer.entry) ->
{ Precedence.cel = e.Layer.cel; origin = Precedence.Sanctoral })
in
@@ -339,7 +339,7 @@ let rg33_suppressed (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index)
Array.iter
(fun date ->
let candidates =
- Layer.on_date idx date
+ Layer.on_date ~fixed_key:rite.Rite.fixed_key idx date
|> List.map (fun (e : 'r Layer.entry) ->
{ Precedence.cel = e.Layer.cel; origin = Precedence.Sanctoral })
in
@@ -529,7 +529,8 @@ let build_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index)
| None -> []
| Some slugs ->
let on_date =
- Layer.on_date idx date |> List.map (fun (e : 'r Layer.entry) -> e.Layer.cel)
+ Layer.on_date ~fixed_key:rite.Rite.fixed_key idx date
+ |> List.map (fun (e : 'r Layer.entry) -> e.Layer.cel)
in
let temporal_office = temporal.Temporal.office in
(temporal_office :: (on_date @ List.map (fun c -> c.Precedence.cel) arrived))
diff --git a/lib/kernel/date.mli b/lib/kernel/date.mli
index 2f632c8..0e86d54 100644
--- a/lib/kernel/date.mli
+++ b/lib/kernel/date.mli
@@ -14,6 +14,13 @@ val month : t -> int
val day : t -> int
val weekday : t -> weekday
+(** Proleptic Gregorian leap-year test: divisible by 4, except a century year
+ not divisible by 400. The same rule {!make}/{!days_in_month} already use
+ internally to admit 29 February -- exposed so a rite can key its own
+ calendar-reckoning rules (e.g. a kalends-doubling convention) off the
+ identical, single-sourced definition rather than re-deriving it. *)
+val is_leap : int -> bool
+
(** [to_rata]/[of_rata] expose the underlying day-count (days since 1970-01-01);
[of_rata] and [add_days] are unbounded total arithmetic (they may denote a
year outside 1583..9999 — only [make] enforces the domain). *)
diff --git a/lib/kernel/layer.ml b/lib/kernel/layer.ml
index f069546..2a072f0 100644
--- a/lib/kernel/layer.ml
+++ b/lib/kernel/layer.ml
@@ -73,8 +73,12 @@ let index t ~easter ~years =
Hashtbl.iter (fun k v -> Hashtbl.replace movable k (canonical v)) movable;
{ fixed; movable }
-let on_date idx date =
- let f = try Hashtbl.find idx.fixed (Date.month date, Date.day date) with Not_found -> [] in
+let on_date ?(fixed_key = fun d -> Some (Date.month d, Date.day d)) idx date =
+ let f =
+ match fixed_key date with
+ | None -> []
+ | Some k -> ( try Hashtbl.find idx.fixed k 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)
diff --git a/lib/kernel/layer.mli b/lib/kernel/layer.mli
index ab6e56b..a71e113 100644
--- a/lib/kernel/layer.mli
+++ b/lib/kernel/layer.mli
@@ -36,8 +36,17 @@ type 'r index
val index : 'r t -> easter:(int -> Date.t) -> years:int list -> 'r index
(** Entries falling on [date], fixed and movable together, in the layer's
- canonical slug order. *)
-val on_date : 'r index -> Date.t -> 'r entry list
+ canonical slug order.
+
+ [fixed_key] answers "which (month, day) does the FIXED half look up for
+ this civil date", defaulting to the identity [Some (Date.month date,
+ Date.day date)] -- exactly today's behaviour, for a caller that supplies
+ nothing. A rite's own kalends-reckoning convention (see {!Rite.t}'s
+ [fixed_key] field) is threaded through here rather than applied to
+ [date] itself, so it touches ONLY the fixed table: the MOVABLE half
+ (keyed by rata die, via {!Date_spec.Easter_offset}/{!Date_spec.
+ Nth_weekday}) always uses [date] unchanged. *)
+val on_date : ?fixed_key:(Date.t -> (int * int) option) -> 'r index -> Date.t -> 'r entry list
(** Loads a layer from a sexp file. Parse and validation failures come back as
[Error], never as an exception. *)
diff --git a/lib/kernel/rite.ml b/lib/kernel/rite.ml
index 4999fc2..738785a 100644
--- a/lib/kernel/rite.ml
+++ b/lib/kernel/rite.ml
@@ -8,6 +8,7 @@ type ('s, 'r) t = {
temporal : Date.t -> ('s, 'r) Temporal.t;
anchors : int -> (string * Date.t) list;
easter : int -> Date.t;
+ fixed_key : Date.t -> (int * int) option;
rules : ('s, 'r) Precedence.rules;
season_runs : 's list;
transfer_target :
diff --git a/lib/kernel/rite.mli b/lib/kernel/rite.mli
index 39538d0..0dcf17e 100644
--- a/lib/kernel/rite.mli
+++ b/lib/kernel/rite.mli
@@ -17,6 +17,33 @@ type ('s, 'r) t = {
into rite-agnostic code and be silently wrong for a Julian-reckoning
rite. Read by {!Layer.index} to resolve
{!Date_spec.Easter_offset}. *)
+ fixed_key : Date.t -> (int * int) option;
+ (** The (month, day) under which a FIXED sanctoral entry ({!Layer.on_date})
+ is looked up for this civil date. [Some (month, day)] identity for
+ every rite with no reason to differ -- a Byzantine or other
+ non-Roman rite supplies nothing beyond that, and its output is
+ therefore byte-identical to a rite that predates this field
+ entirely. [None] means no fixed entry can ever be found for this
+ date, regardless of what {!Layer.index} holds.
+
+ Exists for the Roman calendarium's own bissextile (leap-year)
+ footnote (February, docs/research/LT.txt:5011-5014): the
+ intercalary day is inserted by DOUBLING the sixth kalends of March
+ (civil 24 February in a common year), not by appending a 29th day
+ at the month's end, so every fixed feast dated at or after that
+ kalends position is kept one civil day later than usual, and 24
+ February itself carries no fixed entry that year. This is a fact
+ about the ROMAN rite's own kalends reckoning, not a universal
+ computus rule -- a rite with no such convention (or none at all,
+ the default above) must not have it hardcoded into rite-agnostic
+ code, the same reason {!easter} above is rite-supplied rather than
+ chosen here. See rite_ef/temporal_ef.ml's [bissextile_fixed_key]
+ for the concrete Roman implementation and its full citation.
+
+ Deliberately untouched: the MOVABLE half of {!Layer.on_date}'s
+ lookup ({!Date_spec.Easter_offset}, {!Date_spec.Nth_weekday}) --
+ this field's contract is fixed-date reckoning only, and nothing in
+ the calendarium footnote concerns Easter-relative dates. *)
rules : ('s, 'r) Precedence.rules;
season_runs : 's list;
(** the expected run-length-compressed season sequence over one liturgical
diff --git a/lib/kernel/validate.ml b/lib/kernel/validate.ml
index ce48615..f523b39 100644
--- a/lib/kernel/validate.ml
+++ b/lib/kernel/validate.ml
@@ -239,7 +239,7 @@ let run (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) ~year =
let expected : (string, int) Hashtbl.t = Hashtbl.create 64 in
List.iter
(fun date ->
- Layer.on_date idx date
+ Layer.on_date ~fixed_key:rite.Rite.fixed_key idx date
|> List.iter (fun (e : 'r Layer.entry) ->
bump expected (Slug.to_string e.Layer.cel.Celebration.slug)))
days;
diff --git a/lib/rites/rite_ef/rite_ef.ml b/lib/rites/rite_ef/rite_ef.ml
index e48ff33..ba1274b 100644
--- a/lib/rites/rite_ef/rite_ef.ml
+++ b/lib/rites/rite_ef/rite_ef.ml
@@ -39,6 +39,10 @@ let context ~lectionary ~commons : (Vocab_ef.season, Vocab_ef.rank) Rite.t =
Julian-reckoning rite can supply {!Computus.julian_easter}
instead. Read by [Layer.index] for [Date_spec.Easter_offset]. *)
easter = Colitur_kernel.Computus.gregorian_easter;
+ (* The calendarium's own bissextile footnote (February, LT.txt:5011-5014):
+ see {!Temporal_ef.bissextile_fixed_key} for the full citation and
+ mechanism. Read by [Layer.on_date]'s fixed half only. *)
+ fixed_key = Temporal_ef.bissextile_fixed_key;
rules =
{ Precedence.band = Precedence_ef.band;
disposition = Precedence_ef.disposition;
diff --git a/lib/rites/rite_ef/temporal_ef.ml b/lib/rites/rite_ef/temporal_ef.ml
index c16732b..1c916f4 100644
--- a/lib/rites/rite_ef/temporal_ef.ml
+++ b/lib/rites/rite_ef/temporal_ef.ml
@@ -1158,6 +1158,68 @@ let anchors y =
| Some d -> [ ("ef-holy-name-sunday", d) ]
| None -> [ ("ef-holy-name", holy_name_fallback_date y) ])
+(* The Missale Romanum's own CALENDARIUM table, February, footnote
+ (docs/research/LT.txt:5011-5014, scan-corroborated at
+ docs/research/scan2.txt:3050-3058, where OCR mangles it to
+ "bi&sextili"/"Matthue"/"Cabrielis" -- transcription and scan agree, so
+ this rests on two witnesses of the same document):
+
+ "In anno bissextili mensis februarius est dierum 29, et festum S.
+ Matthiae celebratur die 25 februarii, ac festum S. Gabrielis a
+ Virgine perdolente 28 februarii, et bis dicitur sexto calendas, id
+ est die 24 et die 25; et littera dominicalis, quae assumpta fuit in
+ mense ianuario, mutetur in praecedentem..."
+
+ In a leap year February has 29 days, St Matthias is kept on 25 February
+ (not 24), St Gabriel of Our Lady of Sorrows on 28 February (not 27), and
+ the sixth kalends of March is said TWICE -- "bis dicitur sexto
+ calendas... die 24 et die 25". That last clause names the MECHANISM, not
+ two independent exceptions: the Roman calendar's intercalary day is
+ inserted by DOUBLING the sixth kalends (civil 24 February in a common
+ year), not by appending a 29th day at the month's end. Reckoned by
+ kalends position: 24 Feb = VI Kal. Mart., 25 Feb = V Kal., 26 Feb = IV
+ Kal., 27 Feb = III Kal., 28 Feb = pridie Kal. In a leap year VI Kal.
+ itself falls on TWO consecutive civil days (24th and 25th, "bis"), and
+ every kalends position after it is pushed one civil day later as a
+ result: V Kal. from 25th to 26th, IV Kal. from 26th to 27th, III Kal.
+ (Gabriel) from 27th to 28th, pridie Kal. from 28th to 29th. A feast fixed
+ at VI Kal. itself (Matthias) is kept on the SECOND occurrence, not the
+ first -- 25 February carries it, 24 February carries no fixed office at
+ all that year (confirmed against the LMS Ordo witness,
+ data/ef/expected-divergences-lms.sexp entry L3: 24 February 2024 is
+ titled plainly "EMBER SATURDAY of LENT", no Matthias anywhere near it).
+
+ IMPLEMENTED AS THE GENERAL MECHANISM, not as "shift these two named
+ feasts": only three sanctoral entries exist in 23-29 February on shipped
+ data (peter-damien the 23rd, before the doubled kalends and therefore
+ unaffected; matthias the 24th; gabriel-of-our-lady-of-sorrows the 27th),
+ so "shift the two named feasts" and "shift every fixed entry from 24
+ February" are indistinguishable on universal data -- they produce
+ identical output. They differ only for a locally-supplied entry, e.g. an
+ [--overlay] patronal feast fixed at 26 February: under the mechanism
+ reading it shifts to 27 February in a leap year (IV Kal. -> the civil day
+ IV Kal. falls on that year), exactly as a diocesan calendar compiled
+ against the same kalends convention would expect. The rubric's own text
+ states a MECHANISM ("bis dicitur sexto calendas"), not a closed list of
+ two saints, and this project has already been bitten once by a rule
+ implemented against the shipped data's coincidental shape rather than the
+ rubric itself (RG 16(a), CLAUDE.md's own carried lesson) -- so the general
+ reading is the one implemented here.
+
+ Threaded through {!Rite.t}'s [fixed_key] field, read only by
+ {!Colitur_kernel.Layer.on_date}'s FIXED half: the kernel stays
+ rite-agnostic (a Byzantine or other non-Roman rite supplies nothing and
+ is unaffected), and the MOVABLE half ({!Date_spec.Easter_offset},
+ {!Date_spec.Nth_weekday}) is untouched -- nothing in the footnote
+ concerns Easter-relative dates. *)
+let bissextile_fixed_key (d : Date.t) : (int * int) option =
+ let m = Date.month d and day = Date.day d in
+ if m = 2 && Date.is_leap (Date.year d) then
+ if day = 24 then None (* the FIRST VI Kal.: no fixed entry lands here in a leap year *)
+ else if day >= 25 && day <= 29 then Some (2, day - 1) (* the SECOND VI Kal. onward, one day later *)
+ else Some (m, day)
+ else Some (m, day)
+
(* Compile-time check that this module satisfies the kernel's rite contract. *)
module _ : Colitur_kernel.Temporal.RITE = struct
let id = id
diff --git a/lib/rites/rite_ef/temporal_ef.mli b/lib/rites/rite_ef/temporal_ef.mli
index 5e85111..2bed5b6 100644
--- a/lib/rites/rite_ef/temporal_ef.mli
+++ b/lib/rites/rite_ef/temporal_ef.mli
@@ -51,6 +51,17 @@ val week : Date.t -> int option
(** The lectionary key for a Sunday, or [None] if [d] is not a Sunday. *)
val sunday_slug : Date.t -> string option
+(** The calendarium's own bissextile (leap-year) footnote, February
+ (docs/research/LT.txt:5011-5014): in a leap year the sixth kalends of
+ March (24 February) is doubled rather than a 29th day appended, so every
+ fixed entry from 24 through 28 February is kept one civil day later than
+ usual (Matthias 24->25, Gabriel of Our Lady of Sorrows 27->28), and 24
+ February itself carries no fixed office that year. Supplied as {!Rite.t}'s
+ [fixed_key] field -- see that field's own doc comment and this function's
+ definition for the full citation and the general-mechanism-vs-two-named-
+ feasts reasoning. *)
+val bissextile_fixed_key : Date.t -> (int * int) option
+
val id : string
(** Total over 1583..9999: every date yields exactly one temporal identity. *)