diff options
Diffstat (limited to 'lib/kernel')
| -rw-r--r-- | lib/kernel/calendar.ml | 28 | ||||
| -rw-r--r-- | lib/kernel/date.mli | 7 | ||||
| -rw-r--r-- | lib/kernel/layer.ml | 8 | ||||
| -rw-r--r-- | lib/kernel/layer.mli | 13 | ||||
| -rw-r--r-- | lib/kernel/liturgical_day.ml | 6 | ||||
| -rw-r--r-- | lib/kernel/liturgical_day.mli | 30 | ||||
| -rw-r--r-- | lib/kernel/mass_formulary.ml | 11 | ||||
| -rw-r--r-- | lib/kernel/mass_formulary.mli | 57 | ||||
| -rw-r--r-- | lib/kernel/precedence.ml | 14 | ||||
| -rw-r--r-- | lib/kernel/precedence.mli | 44 | ||||
| -rw-r--r-- | lib/kernel/preface.ml | 59 | ||||
| -rw-r--r-- | lib/kernel/preface.mli | 50 | ||||
| -rw-r--r-- | lib/kernel/rite.ml | 6 | ||||
| -rw-r--r-- | lib/kernel/rite.mli | 85 | ||||
| -rw-r--r-- | lib/kernel/validate.ml | 31 |
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 |
