aboutsummaryrefslogtreecommitdiff
path: root/lib/kernel
diff options
context:
space:
mode:
Diffstat (limited to 'lib/kernel')
-rw-r--r--lib/kernel/calendar.ml28
-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/liturgical_day.ml6
-rw-r--r--lib/kernel/liturgical_day.mli30
-rw-r--r--lib/kernel/mass_formulary.ml11
-rw-r--r--lib/kernel/mass_formulary.mli57
-rw-r--r--lib/kernel/precedence.ml14
-rw-r--r--lib/kernel/precedence.mli44
-rw-r--r--lib/kernel/preface.ml59
-rw-r--r--lib/kernel/preface.mli50
-rw-r--r--lib/kernel/rite.ml6
-rw-r--r--lib/kernel/rite.mli85
-rw-r--r--lib/kernel/validate.ml31
15 files changed, 433 insertions, 16 deletions
diff --git a/lib/kernel/calendar.ml b/lib/kernel/calendar.ml
index 98e9032..a1309f9 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))
@@ -543,6 +544,19 @@ let build_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index)
|> List.map (fun c -> (c.Precedence.cel, reason_for c)))
@ rg33_omitted
in
+ let formulary, citations =
+ rite.Rite.readings ~observed:resolution.Precedence.observed.Precedence.cel ~temporal ~date
+ ~temporal_at:rite.Rite.temporal
+ in
+ let creed =
+ rite.Rite.creed ~temporal ~observed:resolution.Precedence.observed.Precedence.cel ~date
+ in
+ let gloria =
+ rite.Rite.gloria ~temporal ~observed:resolution.Precedence.observed.Precedence.cel ~date
+ in
+ let preface =
+ rite.Rite.preface ~temporal ~observed:resolution.Precedence.observed.Precedence.cel ~date
+ in
{
Liturgical_day.date;
rite = rite.Rite.id;
@@ -553,9 +567,11 @@ let build_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index)
transferred_in;
transferred_out;
omitted;
- citations =
- rite.Rite.readings ~observed:resolution.Precedence.observed.Precedence.cel ~temporal ~date
- ~temporal_at:rite.Rite.temporal;
+ citations;
+ formulary;
+ creed;
+ gloria;
+ preface;
}
let year (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) (y : int) :
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/liturgical_day.ml b/lib/kernel/liturgical_day.ml
index bbb52b8..c3ecb37 100644
--- a/lib/kernel/liturgical_day.ml
+++ b/lib/kernel/liturgical_day.ml
@@ -23,5 +23,11 @@ type ('s, 'r) t = {
(** with the reason, never silent -- Task 12's no-celebration-lost
invariant reads this *)
citations : Citation.t list; (** always empty until Plan 4 *)
+ formulary : Mass_formulary.t option;
+ (** which Mass the day says, and how that was decided; [None] only for
+ a rite with no lectionary -- see {!Mass_formulary} *)
+ creed : bool; (** whether the Creed is said at this day's Mass; see {!Rite.t.creed} *)
+ gloria : bool; (** whether the Gloria in excelsis is said; see {!Rite.t.gloria} *)
+ preface : Preface.t option; (** which preface is said; see {!Rite.t.preface} *)
}
[@@deriving sexp]
diff --git a/lib/kernel/liturgical_day.mli b/lib/kernel/liturgical_day.mli
index f0ea3d9..643097b 100644
--- a/lib/kernel/liturgical_day.mli
+++ b/lib/kernel/liturgical_day.mli
@@ -8,6 +8,16 @@ type ('s, 'r) t = {
places for them to disagree *)
observed : 'r Celebration.t;
commemorations : ('r Celebration.t * Precedence.privilege) list;
+ (** The LOW MASS admitted set (RG 111(b)/(c)/(d) -- {!Precedence.rules.admit}'s
+ own wider caps). Not named [low_mass_commemorations]: this field
+ predates the sung/low distinction and every existing reader
+ ([emit], the differential/oracle layers, {!Record}) already reads
+ it under this name, so renaming it would be a needless breaking
+ change for a doc clarification alone. The SUNG-Mass equivalent
+ (RG 111(a): at most one commemoration, and it must be privileged)
+ is {!Precedence.sung_mass_commemorations}, a pure derivation over
+ this same list -- see its own citation for why it is a function,
+ not a second stored field. *)
transferred_in : 'r Celebration.t option;
(** arrived here from an impeded day *)
transferred_out : ('r Celebration.t * Date.t) list;
@@ -28,5 +38,25 @@ type ('s, 'r) t = {
["citations"] / ["citations-unresolved"] checks. (This said "always
empty until Plan 4" until Task 10; the lectionary landed before
Plan 4 did, and the comment outlived its truth.) *)
+ formulary : Mass_formulary.t option;
+ (** Which Mass this day says, and how that was decided -- see
+ {!Mass_formulary}. [None] only for a rite with no lectionary; for
+ EF it is [Some] on every day of every year 1583..9999, asserted by
+ {!Validate}. *)
+ creed : bool;
+ (** Whether the Creed is said at this day's Mass -- {!Rite.t.creed},
+ EF: RG 475-476. A decision, not an [option]: [false] for a rite
+ that has not implemented the rule, same as [creed] itself. *)
+ gloria : bool;
+ (** Whether the Gloria in excelsis is said at this day's Mass --
+ {!Rite.t.gloria}, EF: RG 431-432. Same seam as [creed] in every
+ respect. *)
+ preface : Preface.t option;
+ (** Which preface is said at this day's Mass -- {!Rite.t.preface},
+ EF: RG 482-499. Same seam as [creed]/[gloria], but [option], not
+ a bare {!Preface.t}: see {!Rite.t.preface}'s own citation for why
+ -- [None] both for a rite that has not implemented the rule and
+ for a real day this engine resolves that has no Mass at all
+ (Good Friday). *)
}
[@@deriving sexp]
diff --git a/lib/kernel/mass_formulary.ml b/lib/kernel/mass_formulary.ml
new file mode 100644
index 0000000..60f9f1c
--- /dev/null
+++ b/lib/kernel/mass_formulary.ml
@@ -0,0 +1,11 @@
+open Sexplib0.Sexp_conv
+
+type source = Proper | Own_slug | Preceding_sunday | Common | Votive [@@deriving sexp]
+type t = { said : Slug.t option; via : source } [@@deriving sexp]
+
+let source_to_string = function
+ | Proper -> "proper"
+ | Own_slug -> "own"
+ | Preceding_sunday -> "preceding-sunday"
+ | Common -> "common"
+ | Votive -> "votive"
diff --git a/lib/kernel/mass_formulary.mli b/lib/kernel/mass_formulary.mli
new file mode 100644
index 0000000..b990e69
--- /dev/null
+++ b/lib/kernel/mass_formulary.mli
@@ -0,0 +1,57 @@
+(** Which Mass a day actually says, and how that was decided.
+
+ A day does not always say its own Mass. A weekday with no proper of its own
+ resumes the preceding Sunday's; a saint with no proper says a Common. The
+ resolution already happens inside a rite's {!Rite.readings} -- this type is
+ what makes the answer visible instead of discarding it once the citations
+ have been extracted. An ordo prints it as "Mass of the 9th Sunday after
+ Pentecost".
+
+ Rite-agnostic by construction: it names a slug and a provenance, and
+ carries no rank, season or rubric vocabulary of any rite. *)
+
+type source =
+ | Proper (** the observed celebration's own citations *)
+ | Own_slug (** the lectionary's entry for the day's own slug *)
+ | Preceding_sunday (** a weekday with no proper resumes the preceding Sunday *)
+ | Common (** a saint's assigned Common *)
+ | Votive
+ (** a Mass said IN PLACE of the day's own office's Mass, the office
+ itself being kept unchanged -- RG 309(a): "in Ecclesia universa,
+ Missae quae pro sancta Maria in sabbato, iuxta temporum
+ diversitatem, in Missali assignantur", corroborated by RG 431(e)'s
+ own classification of that Mass as "Missa votiva IV classis ...
+ de B. Maria Virg." The office/Mass split this constructor exists
+ to name is general (any rite may say a votive Mass on a day whose
+ OFFICE is not itself votive), even though EF's only witness today
+ is the RG 78/309(a) Saturday Mass of Our Lady. *)
+[@@deriving sexp]
+
+type t = { said : Slug.t option; via : source } [@@deriving sexp]
+
+(** The slug whose CITATIONS were actually used to build the day's
+ readings, when the shipped data can name one.
+
+ [Some] for four of the five sources: {!Proper} and {!Own_slug} each
+ carry the day's own slug; {!Preceding_sunday} carries that Sunday's
+ TEMPORAL slug; {!Common} carries the Common's own id.
+
+ [None] for {!Votive}, and ONLY for {!Votive} (whole-branch review fix
+ round, celebrant-rubrics-phase1): RG 309(a)'s five seasonal "Missae de
+ sancta Maria in sabbato" carry no slug of their own anywhere in the
+ shipped data -- their citations come from a season-keyed function
+ ({!Rite_ef.Lectionary_ef.bvm_saturday_citations}), never from a
+ [Lectionary.find] against any slug, so there is genuinely no slug this
+ field could honestly report. An earlier version of this type set
+ [said] to the day's own OFFICE slug for {!Votive} too (RG 78's Office
+ of Our Lady, kept unchanged under the votive Mass) -- readable at the
+ call site as "this is the slug whose Mass is said", which is false for
+ exactly this one case: that slug's own [Lectionary] entry, if it has
+ one at all, is NOT what the day's citations came from. [None] says so
+ directly instead of silently mis-naming a Mass. The day's own OFFICE
+ is not lost by this change -- it is still on the very same
+ {!Liturgical_day.t} this formulary lives on, via [observed.slug],
+ which every caller already has in scope regardless of [via]; this
+ field does not need to duplicate it. *)
+
+val source_to_string : source -> string
diff --git a/lib/kernel/precedence.ml b/lib/kernel/precedence.ml
index c207117..7138b6a 100644
--- a/lib/kernel/precedence.ml
+++ b/lib/kernel/precedence.ml
@@ -91,3 +91,17 @@ let resolve rules ctx ~temporal ~sanctoral =
List.rev omitted
@ List.map (fun (c, _) -> (c, "omitted: admission limit reached")) dropped;
}
+
+(* RG 111(a): at most one commemoration, and it must be privileged -- see
+ the .mli for the full citation and why this is a derivation over the
+ admitted Low-Mass list rather than a second admission rule. [List.find_opt]
+ keeps input order, which is already precedence order (RG 113), so "the
+ first privileged entry, if any" is "the highest-precedence privileged
+ entry, if any" -- the correct single survivor if more than one privileged
+ commemoration were ever admitted together (not known to occur on any
+ shipped rite's data, but not assumed impossible either: this reads the
+ list rather than asserting its length). *)
+let sung_mass_commemorations low_mass_set =
+ match List.find_opt (fun (_, p) -> p = Privileged) low_mass_set with
+ | Some c -> [ c ]
+ | None -> []
diff --git a/lib/kernel/precedence.mli b/lib/kernel/precedence.mli
index a0b4b1a..05022c5 100644
--- a/lib/kernel/precedence.mli
+++ b/lib/kernel/precedence.mli
@@ -145,3 +145,47 @@ type 'r resolution = {
val resolve :
('s, 'r) rules -> 's context -> temporal:'r candidate ->
sanctoral:'r candidate list -> 'r resolution
+
+(** RG 111(a) (EF; docs/research/rules-register.md; LT.txt, grep "Ratio
+ admittendi commemorationes"): {i "in diebus liturgicis I classis et in
+ Missis in cantu non conventualibus, nulla admittitur commemoratio,
+ praeter unam privilegiatam"} -- on a liturgical day of the first class,
+ AND at any non-conventual SUNG Mass regardless of the day's own class,
+ at most ONE commemoration is admitted, and only if it is privileged.
+ (b)/(c)/(d), immediately following in the same rubric, give the wider
+ caps -- two ordinary/privileged commemorations depending on class --
+ that apply everywhere else; {!rules.admit} already computes exactly
+ that wider set, which is why this function's own INPUT is the admitted
+ LOW-MASS list, not a fresh resolution: (a) is not a distinct admission
+ RULE with its own candidate-ranking logic, it is a narrower CAP applied
+ afterwards to the identical admitted, precedence-ordered list -- "at
+ most one, and it must be privileged" is exactly "keep the first
+ admitted entry, if any, that is privileged", nothing else in the list
+ can ever outrank it (RG 113: admission order already follows the
+ rite's own table of precedence, {!rules.admit}'s own citation).
+
+ Deliberately a pure post-hoc DERIVATION over
+ {!Liturgical_day.t.commemorations}, not a second stored field: the
+ input list is already validated (privilege-tagged, admission-capped);
+ this function adds no new information and can regress in no way the
+ input list itself could not already regress, so a second field would
+ only create a second place for the same fact to drift out of sync with
+ the first -- the identical reasoning {!Liturgical_day.t.temporal}'s own
+ "embedded, not flattened" comment gives for a different field. Exposed
+ here, at the kernel level, rather than left for an output layer to
+ reimplement: the filter is small but the RULE it encodes (RG 111(a))
+ is not obvious from the type alone, and a caller (the [rubrics] CLI
+ column, a future template, a differential test) should name the
+ rubric, not re-derive "privileged commemorations, capped at one" for
+ itself.
+
+ SCOPE: "non conventualibus" is read literally -- this models the
+ ordinary (non-conventual) sung Mass only. RG 111(a)'s own text implies
+ a CONVENTUAL sung Mass keeps the wider (b)/(c)/(d) caps even when sung
+ (a choir-obligation distinction), but this engine has no concept of
+ "conventual" at all (no community/choir dimension anywhere in
+ {!Celebration.t} or {!Liturgical_day.t}), so this function's result
+ should be read as "what a normal parish/private Low OR sung Mass
+ admits", never "what every sung Mass, everywhere, admits" -- a
+ documented scope limit, not an oversight. *)
+val sung_mass_commemorations : ('a * privilege) list -> ('a * privilege) list
diff --git a/lib/kernel/preface.ml b/lib/kernel/preface.ml
new file mode 100644
index 0000000..08d00b4
--- /dev/null
+++ b/lib/kernel/preface.ml
@@ -0,0 +1,59 @@
+(* RG 482-499 -- see the .mli for the rubric quoted in full and the
+ placement rationale. Argument-less variants only, so no
+ [Sexplib0.Sexp_conv] open is needed -- see CLAUDE.md's own gotcha note. *)
+type t =
+ | Nativity
+ | Epiphany
+ | Lent
+ | Holy_cross
+ | Easter
+ | Ascension
+ | Sacred_heart
+ | Christ_the_king
+ | Holy_spirit
+ | Trinity
+ | Bvm
+ | St_joseph
+ | Apostles
+ | Common
+ | Requiem
+[@@deriving sexp]
+
+let all =
+ [ Nativity; Epiphany; Lent; Holy_cross; Easter; Ascension; Sacred_heart; Christ_the_king;
+ Holy_spirit; Trinity; Bvm; St_joseph; Apostles; Common; Requiem ]
+
+let to_string = function
+ | Nativity -> "nativity"
+ | Epiphany -> "epiphany"
+ | Lent -> "lent"
+ | Holy_cross -> "holy-cross"
+ | Easter -> "easter"
+ | Ascension -> "ascension"
+ | Sacred_heart -> "sacred-heart"
+ | Christ_the_king -> "christ-the-king"
+ | Holy_spirit -> "holy-spirit"
+ | Trinity -> "trinity"
+ | Bvm -> "bvm"
+ | St_joseph -> "st-joseph"
+ | Apostles -> "apostles"
+ | Common -> "common"
+ | Requiem -> "requiem"
+
+let of_string = function
+ | "nativity" -> Some Nativity
+ | "epiphany" -> Some Epiphany
+ | "lent" -> Some Lent
+ | "holy-cross" -> Some Holy_cross
+ | "easter" -> Some Easter
+ | "ascension" -> Some Ascension
+ | "sacred-heart" -> Some Sacred_heart
+ | "christ-the-king" -> Some Christ_the_king
+ | "holy-spirit" -> Some Holy_spirit
+ | "trinity" -> Some Trinity
+ | "bvm" -> Some Bvm
+ | "st-joseph" -> Some St_joseph
+ | "apostles" -> Some Apostles
+ | "common" -> Some Common
+ | "requiem" -> Some Requiem
+ | _ -> None
diff --git a/lib/kernel/preface.mli b/lib/kernel/preface.mli
new file mode 100644
index 0000000..b43890e
--- /dev/null
+++ b/lib/kernel/preface.mli
@@ -0,0 +1,50 @@
+(** The Mass preface (Missale Romanum, Rubricae Generales, Caput XVII("De
+ Ritibus servandis in celebratione Missae"), "H) De praefatione", RG
+ 482-499; docs/research/LT.txt, grep "praefatione dicitur quae cuique").
+
+ RG 482 gives the resolution chain in full: {i "Praefatio dicitur quae
+ cuique Missae propria est; qua deficiente, dicitur praefatio de
+ Tempore, secus communis"} -- the Mass's own PROPER preface; failing
+ that, the SEASONAL ("de Tempore") one; failing that, the COMMON. RG
+ 483: {i "Nulla commemoratio, in Missa occurrens, praefationem propriam
+ inducit"} -- a commemoration never induces its own proper preface,
+ which is why {!Rite_ef.Rubrics_ef.preface} (the one rite that
+ implements this so far) reads only the day's OBSERVED celebration,
+ never its commemorations, the identical discipline {!Rite.t.creed}'s
+ own RG 476(e) already established.
+
+ RG 484-497 enumerate the fourteen named propers -- Nativity, Epiphany,
+ Lent, Holy Cross, [the Chrism Mass, out of scope: see the .ml's own
+ N/A note], Easter, Ascension, the Sacred Heart, Christ the King, the
+ Holy Spirit, the Trinity, the Blessed Virgin Mary, St Joseph, the
+ Apostles. RG 498 is the residual Common; RG 499 the Requiem.
+
+ A KERNEL type, not a rite-specific one (the same placement as
+ {!Colour}, {!Subject}, {!Mass_formulary}): {!Liturgical_day.t} is
+ parameterised only over a rite's season/rank, so any field it carries
+ generically must live here, even though only the EF rite constructs a
+ value of it today -- CLAUDE.md's own "generalise when forced, not
+ speculatively" stance, already the precedent {!Mass_formulary}
+ follows. *)
+
+type t =
+ | Nativity (** RG 484 *)
+ | Epiphany (** RG 485 *)
+ | Lent (** RG 486, "de Quadragesima" *)
+ | Holy_cross (** RG 487, "de sancta Cruce" *)
+ | Easter (** RG 489, "paschalis" *)
+ | Ascension (** RG 490 *)
+ | Sacred_heart (** RG 491, "de Ss.mo Corde Iesu" *)
+ | Christ_the_king (** RG 492, "de D. N. Iesu Christo Rege" *)
+ | Holy_spirit (** RG 493, "de Spiritu Sancto" *)
+ | Trinity (** RG 494, "de Ss.ma Trinitate" *)
+ | Bvm (** RG 495, "de beata Maria Virgine" *)
+ | St_joseph (** RG 496, "de S. Ioseph" *)
+ | Apostles (** RG 497, "de Apostolis" *)
+ | Common (** RG 498, "communis" -- the residual when nothing else applies *)
+ | Requiem (** RG 499, "defunctorum" *)
+[@@deriving sexp]
+
+val all : t list
+val to_string : t -> string
+val of_string : string -> t option
diff --git a/lib/kernel/rite.ml b/lib/kernel/rite.ml
index 600a691..e44c2c6 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 :
@@ -17,5 +18,8 @@ type ('s, 'r) t = {
temporal:('s, 'r) Temporal.t ->
date:Date.t ->
temporal_at:(Date.t -> ('s, 'r) Temporal.t) ->
- Citation.t list;
+ Mass_formulary.t option * Citation.t list;
+ creed : temporal:('s, 'r) Temporal.t -> observed:'r Celebration.t -> date:Date.t -> bool;
+ gloria : temporal:('s, 'r) Temporal.t -> observed:'r Celebration.t -> date:Date.t -> bool;
+ preface : temporal:('s, 'r) Temporal.t -> observed:'r Celebration.t -> date:Date.t -> Preface.t option;
}
diff --git a/lib/kernel/rite.mli b/lib/kernel/rite.mli
index 0324bf2..d53e4e6 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
@@ -71,13 +98,63 @@ type ('s, 'r) t = {
temporal:('s, 'r) Temporal.t ->
date:Date.t ->
temporal_at:(Date.t -> ('s, 'r) Temporal.t) ->
- Citation.t list;
- (** The day's Epistle and Gospel citations, or []. Rite-supplied for the
- same reason [transfer_target] is: what a day with no proper of its
- own falls back to is a rubric of a particular rite, not a universal.
+ Mass_formulary.t option * Citation.t list;
+ (** The Mass actually said -- which formulary, and how that was decided
+ -- paired with its Epistle and Gospel citations. Rite-supplied for
+ the same reason [transfer_target] is: what a day with no proper of
+ its own falls back to is a rubric of a particular rite, not a
+ universal.
+
+ The [Mass_formulary.t option] is [None] exactly when the rite's
+ lectionary is not built at all (the citation list is then also
+ []): a rite that HAS a lectionary is expected to resolve [Some] on
+ every day it covers, the same total-coverage discipline
+ {!Validate}'s own ["formulary"] check holds it to. [None] is never
+ a per-day "no Mass today" answer for a rite that otherwise
+ resolves readings -- that shape is coverage FAILURE, not a
+ legitimate outcome, which is exactly what makes the [Validate]
+ check meaningful.
[temporal_at] is a callback so the rite can reach another date's
temporal identity (the preceding Sunday's, for the ferial rule)
without re-implementing the temporal cycle -- the same shape
[transfer_target]'s own [occupant] callback established. *)
+ creed : temporal:('s, 'r) Temporal.t -> observed:'r Celebration.t -> date:Date.t -> bool;
+ (** Whether the Creed is said, post-Gospel/homily, at this day's Mass
+ (EF: RG 475-476). A [bool], not an [option]: this is a decision,
+ and a rite that has not implemented the rule returns [false]
+ explicitly rather than leaving the question unanswered.
+
+ [temporal] and [observed] are supplied for the same reason
+ [readings] gets both: a rubric like this one can turn on either
+ the day's TEMPORAL-cycle identity (e.g. "is this a Sunday, even
+ one a feast has displaced") or on the celebration actually
+ observed, and only the rite knows which. [date] is supplied for
+ the same reason [readings] gets it too -- a rubric keyed to an
+ Easter-relative window (e.g. "within the octave of Easter") needs
+ the civil date and the rite's own Easter to test it, and neither
+ [temporal] nor [observed] alone carries that arithmetic. *)
+ gloria : temporal:('s, 'r) Temporal.t -> observed:'r Celebration.t -> date:Date.t -> bool;
+ (** Whether the Gloria in excelsis is said at this day's Mass (EF: RG
+ 431-432). Same seam as {!creed} in every respect: same three
+ parameters and the same reasons for each, a [bool] not an
+ [option], and [false] is the answer a rite that has not
+ implemented the rule returns explicitly. *)
+ preface : temporal:('s, 'r) Temporal.t -> observed:'r Celebration.t -> date:Date.t -> Preface.t option;
+ (** Which preface is said at this day's Mass (EF: RG 482-499). Same
+ seam and same three parameters as {!creed}/{!gloria}, for the
+ same reasons -- {!Preface}'s own citation has the rubric in full.
+ [Preface.t option], not a bare [Preface.t]: unlike [creed]/
+ [gloria], where "not said" is itself a legitimate answer a
+ [bool] can carry, a preface is said only at a MASS, and this
+ engine constructs at least one day (Good Friday, the
+ 1955-restored Holy Week) that resolves an [observed] celebration
+ but has no Mass at all -- [None] is the honest answer there, not
+ a manufactured preface. [None] is also the value a rite that has
+ not implemented this rule returns, the same "the type's own
+ neutral value" contract {!creed}/{!gloria} give for [false] --
+ the two meanings ("this rite does not model the question" and
+ "this specific day has no Mass to preface") collapse onto the
+ same representation deliberately: a caller with no rite-specific
+ context to distinguish them should not need one. *)
}
diff --git a/lib/kernel/validate.ml b/lib/kernel/validate.ml
index c9d4263..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;
@@ -453,5 +453,34 @@ let run (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) ~year =
fail date "citations"
(Printf.sprintf "expected exactly one First and one Gospel, got [%s]"
(String.concat "," (List.map Citation.part_to_string sorted))))
+ resolved;
+ (* ---- Formulary invariant (Task 3, celebrant-rubrics-phase1) ----
+
+ Same rite-agnostic gating as the citation checks immediately
+ above, and for the same reason: a rite whose lectionary is not
+ built returns [(None, [])] from {!Rite.readings} on every day, so
+ [year_has_formulary] is false and this check never fires for it.
+ A rite that resolves a formulary AT ALL is held to resolving one
+ on every day of the year -- a day that says no Mass at all is a
+ defect, not a gap, the same discipline the ["citations-unresolved"]
+ check above already holds for the citations themselves. One check
+ name, not two: unlike [citations], there is no separate
+ "well-formed but wrong" shape to distinguish -- [Mass_formulary.t
+ option] is either the day's answer or it is missing, so
+ [year_has_formulary] gates a single ["formulary"] label. *)
+ let year_has_formulary =
+ Array.exists
+ (fun (d : ('s, 'r) Liturgical_day.t) -> d.Liturgical_day.formulary <> None)
+ resolved
+ in
+ if year_has_formulary then
+ Array.iter
+ (fun (d : ('s, 'r) Liturgical_day.t) ->
+ match d.Liturgical_day.formulary with
+ | Some _ -> ()
+ | None ->
+ fail d.Liturgical_day.date "formulary"
+ "no Mass formulary resolved for this day: the lectionary chain fell through \
+ every step")
resolved);
List.rev !failures