diff options
Diffstat (limited to 'lib/rites/rite_ef')
| -rw-r--r-- | lib/rites/rite_ef/lectionary_ef.ml | 259 | ||||
| -rw-r--r-- | lib/rites/rite_ef/lectionary_ef.mli | 96 | ||||
| -rw-r--r-- | lib/rites/rite_ef/rite_ef.ml | 29 | ||||
| -rw-r--r-- | lib/rites/rite_ef/rite_ef.mli | 17 |
4 files changed, 319 insertions, 82 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 -> []))) diff --git a/lib/rites/rite_ef/lectionary_ef.mli b/lib/rites/rite_ef/lectionary_ef.mli index e54a0b1..baf25d4 100644 --- a/lib/rites/rite_ef/lectionary_ef.mli +++ b/lib/rites/rite_ef/lectionary_ef.mli @@ -3,12 +3,13 @@ open Colitur_kernel (** The EF lectionary resolution chain. All rubric knowledge about what a day with no proper falls back to lives here, not in the kernel. - [lectionary] is caller-supplied, not loaded by this module -- the same - reasoning rite_ef.mli's own [context] doc comment already gives for why - the sanctoral {!Colitur_kernel.Layer.t} stays a separate argument rather - than an embedded field: it lets a caller load data/ef/lectionary.sexp - however suits it, and leaves room for a future diocesan/proper - lectionary overlay to attach without this module changing at all. + [lectionary] and [commons] are both caller-supplied, not loaded by this + module -- the same reasoning rite_ef.mli's own [context] doc comment + already gives for why the sanctoral {!Colitur_kernel.Layer.t} stays a + separate argument rather than an embedded field: it lets a caller load + data/ef/lectionary.sexp and data/ef/commons.sexp however suits it, and + leaves room for a future diocesan/proper lectionary overlay to attach + without this module changing at all. An eager filesystem read at module initialisation was tried first and reverted (fix round 1, coordinator review): [readings] used to close @@ -18,17 +19,84 @@ open Colitur_kernel missing from a bare `dune build`'s own default target (it was only present because test/dune's own deps happened to materialise it, masking the gap in every test run). See the task report for the - reproduction. + reproduction. The Commons follow the same path for the same reason. *) - Steps 1-3 (Tasks 4-5): the observed celebration's own proper, else the - day's own temporal slug in the lectionary, else -- for a weekday whose - own slug has no entry -- the preceding Sunday's temporal slug (never its - observed one; a Sunday is guarded out because it has no PRECEDING Sunday - to resume, not because consulting itself would loop -- [readings] is not - recursive, see its own implementation comment). A day matching none of - the three gets [] for now -- the Commons (Task 6) are not built here. *) +(** The Commons of the 1962 Missal (Epistle and Gospel citations only) and + the per-saint assignments that route a readingless class-3 feast to one. + + Two tables rather than one, because the two facts have different + warrants and different lifetimes: a FORMULARY is read from the Commune + Sanctorum and is the same for every saint sent to it, while an + ASSIGNMENT is read from one saint's own date in the Proprium Sanctorum. + A diocesan overlay adds assignments; it rarely adds formularies. + + Assignment is explicit per saint, never inferred: {!Colitur_kernel.Subject.t} + is [Temporal|Saint|Bvm|Lord] and {!Colitur_kernel.Celebration.t} carries + no martyr/confessor/virgin/bishop/abbot classification at all, so there + is nothing to infer one from -- those words appear only inside display + names. A saint with no proper and no assignment gets no Common. *) +module Commons : sig + type t + + (** No formularies and no assignments -- the identity for this table, and + what a caller that genuinely has no Commons data should pass. Every + lookup returns [None]; nothing is silently invented. *) + val empty : t + + (** Loads from a sexp file. Parse and validation failures come back as + [Error], never as an exception, and never at module-initialisation + time -- the same contract {!Colitur_kernel.Lectionary.load} makes. + + [Error] (never a silently-degraded lookup) on: a duplicate common id; + a duplicate assignment for one saint; a formulary with no citations + (indistinguishable downstream from "no Common at all"); and an + assignment naming a common that does not exist (likewise). *) + val load : string -> (t, string) result + + (** The Commons themselves, canonically sorted by id. *) + val formularies : t -> (Slug.t * Citation.t list) list + + (** Saint slug -> common id, canonically sorted by saint. *) + val assignments : t -> (Slug.t * Slug.t) list +end + +(** The Common assigned to a saint who has no proper, if any. Exposed for the + golden pins, which must show WHICH Common fired, not merely that two + citations appeared. + + Takes the table explicitly for the same reason {!readings} takes + [~lectionary]: the data is the caller's, not this module's. *) +val commons_for : commons:Commons.t -> Slug.t -> Citation.t list option + +(** The day's Epistle and Gospel citations, or []. + + Four steps, in EXECUTION order 1, 4, 2, 3 (the numbers are the plan's and + are kept as written, so that every "step 3" already recorded in a test + name, comment or report still means the same branch): + + - {b Step 1} -- the observed celebration's own proper. + - {b Step 4} -- a saint who is the day's observed office and has no + proper says his assigned Common. Runs before the temporal fallbacks, + not after them: this is the only step in the chain with a direct + primary-source warrant (the Missal names the Mass at each such saint's + own date), and placing it last makes it unreachable on every date in + 1583-9999 as well as wrong on the days it would fire. The full + argument, with the measurement behind it, is on the branch itself. + Guarded so it can only ever apply to a SANCTORAL observed office -- + a feria, a Sunday, the Triduum and the RG 78 Saturday Office of the + BVM (whose observed celebration is its own temporal office) are + structurally excluded, not merely absent from the data. + - {b Step 2} -- the day's own temporal slug in the lectionary. + - {b Step 3} -- for a weekday whose own slug has no entry, the preceding + Sunday's temporal slug (never its observed one; a Sunday is guarded + out because it has no PRECEDING Sunday to resume, not because + consulting itself would loop -- [readings] is not recursive, see its + own implementation comment). + + A day matching none of the four gets []. *) val readings : lectionary:Lectionary.t -> + commons:Commons.t -> observed:Vocab_ef.rank Celebration.t -> temporal:(Vocab_ef.season, Vocab_ef.rank) Temporal.t -> date:Date.t -> diff --git a/lib/rites/rite_ef/rite_ef.ml b/lib/rites/rite_ef/rite_ef.ml index ded960b..60065b7 100644 --- a/lib/rites/rite_ef/rite_ef.ml +++ b/lib/rites/rite_ef/rite_ef.ml @@ -11,16 +11,23 @@ module Lectionary_ef = Lectionary_ef open Colitur_kernel -(* [~lectionary], not a value closed over an internal load: fix round 1 - (coordinator review) found the previous version -- [context] as a plain - value, [Lectionary_ef] loading data/ef/lectionary.sexp as a side effect - of being linked -- made `colitur easter <year>` (no lectionary data - touched at all) die at startup the moment that file was absent from a - bare `dune build`'s own default target. A function mirrors how the - sanctoral [Layer.t] already travels: caller-supplied, not embedded (see - this module's own .mli doc comment on [context] for the fuller - rationale, shared with data/ef/sanctoral.sexp). *) -let context ~lectionary : (Vocab_ef.season, Vocab_ef.rank) Rite.t = +(* [~lectionary] and [~commons], not values closed over an internal load: + fix round 1 (coordinator review) found the previous version -- [context] + as a plain value, [Lectionary_ef] loading data/ef/lectionary.sexp as a + side effect of being linked -- made `colitur easter <year>` (no + lectionary data touched at all) die at startup the moment that file was + absent from a bare `dune build`'s own default target. A function mirrors + how the sanctoral [Layer.t] already travels: caller-supplied, not + embedded (see this module's own .mli doc comment on [context] for the + fuller rationale, shared with data/ef/sanctoral.sexp). + + [~commons] is REQUIRED, not optional-with-a-default: an omitted + [?commons] would silently give a caller [Commons.empty], i.e. a rite + whose class-3 saints quietly lose their Mass, and nothing in the suite + compares citations across layers 3-5, so that loss would be invisible. + A caller that genuinely has no Commons data passes + [Lectionary_ef.Commons.empty] and says so. *) +let context ~lectionary ~commons : (Vocab_ef.season, Vocab_ef.rank) Rite.t = { Rite.id = Temporal_ef.id; vocab = Vocab_ef.vocab; year_start = Temporal_ef.year_start; @@ -32,4 +39,4 @@ let context ~lectionary : (Vocab_ef.season, Vocab_ef.rank) Rite.t = admit = Precedence_ef.admit }; season_runs = Vocab_ef.seasons; transfer_target = Precedence_ef.transfer_target; - readings = Lectionary_ef.readings ~lectionary } + readings = Lectionary_ef.readings ~lectionary ~commons } diff --git a/lib/rites/rite_ef/rite_ef.mli b/lib/rites/rite_ef/rite_ef.mli index 12f5609..2183799 100644 --- a/lib/rites/rite_ef/rite_ef.mli +++ b/lib/rites/rite_ef/rite_ef.mli @@ -26,10 +26,12 @@ module Lectionary_ef = Lectionary_ef value's own documentation for the termination and forward-progress argument {!Colitur_kernel.Rite.t.transfer_target}'s contract requires). - [readings]: {!Lectionary_ef.readings} partially applied to the caller's - own [~lectionary] -- the observed celebration's own proper, else the - day's own temporal slug, else (a weekday with no entry of its own) the - preceding Sunday's temporal slug, in data/ef/lectionary.sexp (chain - steps 1-3; the Commons are later work). + own [~lectionary] and [~commons] -- the observed celebration's own + proper, else (for a saint who is the day's observed office) his + assigned Common from data/ef/commons.sexp, else the day's own temporal + slug, else (a weekday with no entry of its own) the preceding Sunday's + temporal slug, in data/ef/lectionary.sexp. See {!Lectionary_ef.readings} + for why the Common is consulted second rather than last. Deliberately carries no [sanctoral]/[lectionary] fields the way the original design-doc sketch of [RITE] does: {!Colitur_kernel.Rite.t} (the @@ -44,7 +46,12 @@ module Lectionary_ef = Lectionary_ef that never touches lectionary data at all the moment that file was missing from a bare build -- see the task report). A future diocesan/proper lectionary overlay has a caller-side seam to attach to - for the same reason the sanctoral overlay already does. *) + for the same reason the sanctoral overlay already does. [~commons] + travels the same seam, and is deliberately REQUIRED rather than + defaulted -- see this module's .ml for why a silently-defaulted + {!Lectionary_ef.Commons.empty} would be undetectable by any of the five + validation layers. *) val context : lectionary:Colitur_kernel.Lectionary.t -> + commons:Lectionary_ef.Commons.t -> (Vocab_ef.season, Vocab_ef.rank) Colitur_kernel.Rite.t |
