aboutsummaryrefslogtreecommitdiff
path: root/lib/kernel
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-24 16:29:23 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-24 16:29:23 +0200
commit8a5da8756cf6b57aa36c01b71ce893f7967d3a29 (patch)
tree10873a692400fa25ce54c67ac9fc34d46af86272 /lib/kernel
parent4f87cbde4ee79ac96aa7ebfed105b3a5ec02be43 (diff)
parent73b15551804bb63ee0081005e2869d36afb54be2 (diff)
downloadcolitur-8a5da8756cf6b57aa36c01b71ce893f7967d3a29.tar.gz
colitur-8a5da8756cf6b57aa36c01b71ce893f7967d3a29.zip
merge: celebrant Mass rubrics, and four rubrical corrections
colitur now prints what a real ordo prints for the Mass: which formulary is said and how it was reached, the Gloria, the Creed, the preface, and the commemorations with their Low-Mass/sung distinction. colitur rubrics joins day and readings. Four defects were found and fixed on the way, each by an external witness rather than by inspection: the Creed said at Requiem Masses (RG 476(f)), the missing bissextile shift of St Matthias and St Gabriel (2 041 leap years), Rogation Monday and Tuesday coloured violet in Paschaltide (RG 119(b)), and the ferias after the Ascension resuming the wrong Sunday's Mass rather than the Ascension's. Validation gained a sixth layer and then some: the preface is checked against three independent publishers over seven witness-years (FIUV, three LMS editions, three extraordinaryform.org editions), none of which shares the Divinum Officium -> missalemeum -> lectio lineage the older layers all descend from.
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