summaryrefslogtreecommitdiff
path: root/lib/rites/rite_ef/lectionary_ef.ml
diff options
context:
space:
mode:
Diffstat (limited to 'lib/rites/rite_ef/lectionary_ef.ml')
-rw-r--r--lib/rites/rite_ef/lectionary_ef.ml259
1 files changed, 207 insertions, 52 deletions
diff --git a/lib/rites/rite_ef/lectionary_ef.ml b/lib/rites/rite_ef/lectionary_ef.ml
index 37ec1d8..2658cd3 100644
--- a/lib/rites/rite_ef/lectionary_ef.ml
+++ b/lib/rites/rite_ef/lectionary_ef.ml
@@ -1,12 +1,116 @@
open Colitur_kernel
+(* The Commons of the 1962 Missal, plus the per-saint assignments that route
+ a readingless class-3 feast to one. Data only -- every value in the
+ shipped file is transcribed from the Missal and carries its own source
+ citation there (data/ef/commons.sexp). This module knows the SHAPE and
+ the invariants, never the values.
+
+ Caller-supplied, exactly as [lectionary] is, and for the same reason
+ (see this module's own .mli): a rite module that reads the filesystem as
+ a side effect of being linked breaks every caller that touches none of
+ its data. *)
+module Commons = struct
+ open Sexplib0.Sexp_conv
+
+ (* [commons]: a Common's id -> the Epistle and Gospel PRINTED WITH the
+ named Mass formulary. [assigned]: a saint's slug -> the Common his own
+ day sends him to. Two tables, not one, because the same Common serves
+ several saints and the two facts have different warrants -- the
+ formulary is read from the Commune Sanctorum, the assignment from the
+ saint's own date in the Proprium Sanctorum. *)
+ type t = {
+ commons : (Slug.t * Citation.t list) list;
+ assigned : (Slug.t * Slug.t) list;
+ }
+ [@@deriving sexp]
+
+ let empty = { commons = []; assigned = [] }
+ let formularies t = t.commons
+ let assignments t = t.assigned
+
+ (* Same discipline as [Colitur_kernel.Lectionary.of_entries]: canonically
+ sorted, and a duplicate key is an [Error] naming it rather than a
+ silently-shadowed second answer. *)
+ let sorted_by_slug xs = List.stable_sort (fun (a, _) (b, _) -> Slug.compare a b) xs
+
+ let first_dup xs =
+ let rec go = function
+ | (a, _) :: ((b, _) :: _ as rest) -> if Slug.equal a b then Some a else go rest
+ | _ -> None
+ in
+ go xs
+
+ let of_tables ~commons ~assigned =
+ let commons = sorted_by_slug commons and assigned = sorted_by_slug assigned in
+ match first_dup commons with
+ | Some s -> Error (Printf.sprintf "commons: duplicate common %S" (Slug.to_string s))
+ | None -> (
+ match first_dup assigned with
+ | Some s -> Error (Printf.sprintf "commons: duplicate assignment for %S" (Slug.to_string s))
+ | None -> (
+ (* A formulary with no citations is indistinguishable at the call
+ site from "this saint has no Common" -- [commons_for] would
+ return [Some []] and [readings] would emit [] either way. That
+ is exactly the silent hole this project does not allow, so it
+ is rejected here where it is still nameable. *)
+ match List.find_opt (fun (_, cs) -> cs = []) commons with
+ | Some (s, _) ->
+ Error (Printf.sprintf "commons: common %S has no citations" (Slug.to_string s))
+ | None -> (
+ (* An assignment pointing at a Common that does not exist
+ would otherwise degrade to [None] -- i.e. to "this saint
+ has no Common", the same answer as no assignment at all --
+ so a typo in the data file would be invisible. Named
+ loudly instead. *)
+ match
+ List.find_opt (fun (_, common) -> not (List.mem_assoc common commons)) assigned
+ with
+ | Some (saint, common) ->
+ Error
+ (Printf.sprintf "commons: %S is assigned to unknown common %S"
+ (Slug.to_string saint) (Slug.to_string common))
+ | None -> Ok { commons; assigned })))
+
+ let find t saint =
+ match List.assoc_opt saint t.assigned with
+ | None -> None
+ | Some common -> List.assoc_opt common t.commons
+
+ (* Byte-for-byte the failure discipline of [Lectionary.load] (see its own
+ comments for why each catch-all is placed where it is): every parse and
+ validation failure comes back as [Error], never as an exception, and
+ never at module-initialisation time. *)
+ let load path =
+ match Sexplib.Sexp.load_sexp path with
+ | exception Sexplib.Sexp.Parse_error e ->
+ Error (Printf.sprintf "commons: %s: %s" path e.err_msg)
+ | exception Sys_error e -> Error (Printf.sprintf "commons: %s" e)
+ | exception exn -> Error (Printf.sprintf "commons: %s: %s" path (Printexc.to_string exn))
+ | sexp -> (
+ match t_of_sexp sexp with
+ | exception Sexplib0.Sexp_conv_error.Of_sexp_error (exn, _) ->
+ Error (Printf.sprintf "commons: %s: %s" path (Printexc.to_string exn))
+ | exception exn -> Error (Printf.sprintf "commons: %s: %s" path (Printexc.to_string exn))
+ | parsed -> of_tables ~commons:parsed.commons ~assigned:parsed.assigned)
+end
+
+let commons_for ~commons saint = Commons.find commons saint
+
(* Step 1: the observed celebration's own proper.
+ Step 4: the observed SAINT's assigned Common -- see the branch comment in
+ [readings] for why it sits here, second, and not last.
Step 2: the day's own temporal slug.
Step 3: a weekday whose own slug has no entry says the preceding Sunday's
Mass -- see the implementation comment on that branch in [readings] for
the termination argument and why it is the Sunday's TEMPORAL, not
observed, identity.
+ The steps keep their original NUMBERS (the plan's, and every existing
+ test's and comment's) even though step 4 now runs second: renumbering
+ would silently invalidate every "step 3" reference already written down.
+ Execution order is 1, 4, 2, 3.
+
Nothing here encodes "Lent has daily propers": the presence of an entry in
[lectionary] is the sole discriminator -- this function does not branch on
season, rank, or any other field to decide whether a temporal slug "ought"
@@ -20,7 +124,9 @@ open Colitur_kernel
rules ... Have lectio's behaviour; confirm against the Missal's
ferial-Mass rubrics when coding") -- that confirmation has not been done;
do not read this comment as citing RG/the Missal for the SELECTION rule
- itself, only [Lectionary.find]'s presence-or-absence as the mechanism. *)
+ itself, only [Lectionary.find]'s presence-or-absence as the mechanism.
+ Step 4 is the one step of the four that DOES have a direct primary-source
+ warrant; its own comment gives it. *)
(* Days from a given weekday back to the preceding Sunday. Sunday itself
yields 0, which is why step 3 must guard on it -- see [readings] below. *)
let days_since_sunday : Date.weekday -> int = function
@@ -32,61 +138,110 @@ let days_since_sunday : Date.weekday -> int = function
| Date.Fri -> 5
| Date.Sat -> 6
-let readings ~lectionary ~observed ~temporal ~date ~temporal_at =
+let readings ~lectionary ~commons ~observed ~temporal ~date ~temporal_at =
match observed.Celebration.citations with
| _ :: _ as cs -> cs
| [] -> (
- match Lectionary.find lectionary temporal.Temporal.office.Celebration.slug with
+ (* Step 4: a saint who is the day's observed office and has no proper
+ says his assigned Common. The assignment is explicit, never
+ inferred -- see data/ef/commons.sexp.
+
+ ORDER. This runs SECOND, before the temporal fallbacks, not last as
+ the task brief sketched. The brief's ordering was tried first and is
+ provably dead code: measured against the real resolver over
+ 1950-2200, EVERY day on which one of the fifteen readingless
+ class-3 saints is actually the observed office also has a
+ non-empty step-2 or step-3 answer waiting (a Septuagesima or
+ Paschaltide feria resolves through its own slug or its preceding
+ Sunday's), so a step 4 placed after them is never reached on any
+ date in the domain. It would also be WRONG where it did fire: on
+ 2038-03-06 the observed office is Sts Perpetua and Felicity, a
+ III-class feast that beat the feria, and the Mass said that day is
+ theirs -- not Septuagesima II Saturday's 2 Cor 11:19-33 / Luke
+ 8:4-15, which is what the brief's ordering emits.
+
+ WARRANT, and it is the strongest in this chain: the Missal itself,
+ at each of these saints' own dates, names the Mass to be said --
+ "Missa Cognovi, de Communi non Virginum II loco, praeter orationem
+ sequentem" (9 March), "Missa Os iusti, de Communi Abbatum"
+ (21 March), and so on. That is a direct instruction about what is
+ read when the feast is the office of the day, quoted per saint in
+ data/ef/commons.sexp. Steps 2 and 3, by contrast, rest only on
+ lectio's observed behaviour (above). So the one step with a primary
+ source outranks the two without -- which is also simply what the
+ steps MEAN: steps 2 and 3 answer "what does this day's TEMPORAL
+ office read", a question that only governs when the temporal office
+ is the one being celebrated.
+
+ The guard makes that precondition structural rather than a property
+ of the data file: the Commons are consulted only when the observed
+ celebration is not itself the day's temporal office. Without it, a
+ future overlay that assigned a Common to a temporal slug by mistake
+ would silently replace a feria's Mass; with it, ferias, Sundays, the
+ Triduum and the RG 78 Saturday Office of the BVM (whose observed
+ celebration IS its temporal office, deliberately sharing the ferial
+ slug) can never be diverted here at all. [Validate] already asserts
+ slug uniqueness per liturgical year, so a sanctoral feast can never
+ collide with a temporal slug and be wrongly excluded by it. *)
+ let sanctoral_office =
+ not (Slug.equal observed.Celebration.slug temporal.Temporal.office.Celebration.slug)
+ in
+ match
+ if sanctoral_office then commons_for ~commons observed.Celebration.slug else None
+ with
| Some cs -> cs
| None -> (
- (* Step 3: a feria with no proper of its own says the preceding
- Sunday's Mass. WARRANT is the same as step 2's -- lectio's own
- observed behaviour, not a confirmed Missal citation: this is the
- rule lectio hard-codes as data on the four Advent ferias
- (Advent II's readings copied verbatim onto the following
- Monday-Saturday) and leaves absent on the other slugs this step
- now also reaches; docs/research/rules-register.md already
- records the ferial-Mass selection rule itself as unconfirmed
- against the primary source.
+ match Lectionary.find lectionary temporal.Temporal.office.Celebration.slug with
+ | Some cs -> cs
+ | None -> (
+ (* Step 3: a feria with no proper of its own says the preceding
+ Sunday's Mass. WARRANT is the same as step 2's -- lectio's own
+ observed behaviour, not a confirmed Missal citation: this is the
+ rule lectio hard-codes as data on the four Advent ferias
+ (Advent II's readings copied verbatim onto the following
+ Monday-Saturday) and leaves absent on the other slugs this step
+ now also reaches; docs/research/rules-register.md already
+ records the ferial-Mass selection rule itself as unconfirmed
+ against the primary source.
- Guarded on weekday, but NOT because a Sunday reaching this
- branch would loop (fix round 1, coordinator review: the
- original comment here claimed exactly that, and it was wrong).
- [readings] is not recursive -- step 3's fallback is one flat
- [Lectionary.find], never a re-entrant call into [readings] --
- so without the guard, [days_since_sunday Sun = 0] would just
- repeat the SAME [Lectionary.find] step 2 already ran and
- already got [None] from (same pure inputs, same date), and
- return [] once, normally. The chain as a whole terminates
- because every step either consults data (a lookup) or, here,
- a strictly EARLIER date via [temporal_at] -- no step ever calls
- back into [readings] itself, so there is no recursion anywhere
- in this function for a cycle to form in the first place. The
- real reason for the guard is simpler: a Sunday has no
- PRECEDING Sunday to resume -- consulting itself would be
- meaningless (it would re-ask the question step 2 just
- answered), not dangerous, so the guard exists to make that
- intent explicit rather than to prevent a runaway loop that was
- never actually possible.
+ Guarded on weekday, but NOT because a Sunday reaching this
+ branch would loop (fix round 1, coordinator review: the
+ original comment here claimed exactly that, and it was wrong).
+ [readings] is not recursive -- step 3's fallback is one flat
+ [Lectionary.find], never a re-entrant call into [readings] --
+ so without the guard, [days_since_sunday Sun = 0] would just
+ repeat the SAME [Lectionary.find] step 2 already ran and
+ already got [None] from (same pure inputs, same date), and
+ return [] once, normally. The chain as a whole terminates
+ because every step either consults data (a lookup) or, here,
+ a strictly EARLIER date via [temporal_at] -- no step ever calls
+ back into [readings] itself, so there is no recursion anywhere
+ in this function for a cycle to form in the first place. The
+ real reason for the guard is simpler: a Sunday has no
+ PRECEDING Sunday to resume -- consulting itself would be
+ meaningless (it would re-ask the question step 2 just
+ answered), not dangerous, so the guard exists to make that
+ intent explicit rather than to prevent a runaway loop that was
+ never actually possible.
- The preceding Sunday's TEMPORAL slug, never its observed one:
- the rubric is the preceding Sunday's Mass even in a year when a
- feast displaced that Sunday from being observed (see
- test_step3_uses_temporal_not_observed). [temporal_at] gives the
- temporal identity of any date, so the Sunday is reached by date
- arithmetic and a fresh call to the temporal cycle -- never by
- string surgery on [own_slug]: the slug shapes are genuinely
- inconsistent across seasons (e.g. [ef-advent-sunday-1] versus
- [ef-advent-1-monday], the week number on opposite sides of the
- season name), so deriving one from the other textually would be
- a latent bug the moment a season's naming convention differs. *)
- let offset = days_since_sunday temporal.Temporal.weekday in
- if offset = 0 then []
- else
- let sunday = Date.add_days date (-offset) in
- let sunday_temporal = temporal_at sunday in
- match
- Lectionary.find lectionary sunday_temporal.Temporal.office.Celebration.slug
- with
- | Some cs -> cs
- | None -> []))
+ The preceding Sunday's TEMPORAL slug, never its observed one:
+ the rubric is the preceding Sunday's Mass even in a year when a
+ feast displaced that Sunday from being observed (see
+ test_step3_uses_temporal_not_observed). [temporal_at] gives the
+ temporal identity of any date, so the Sunday is reached by date
+ arithmetic and a fresh call to the temporal cycle -- never by
+ string surgery on [own_slug]: the slug shapes are genuinely
+ inconsistent across seasons (e.g. [ef-advent-sunday-1] versus
+ [ef-advent-1-monday], the week number on opposite sides of the
+ season name), so deriving one from the other textually would be
+ a latent bug the moment a season's naming convention differs. *)
+ let offset = days_since_sunday temporal.Temporal.weekday in
+ if offset = 0 then []
+ else
+ let sunday = Date.add_days date (-offset) in
+ let sunday_temporal = temporal_at sunday in
+ match
+ Lectionary.find lectionary sunday_temporal.Temporal.office.Celebration.slug
+ with
+ | Some cs -> cs
+ | None -> [])))