diff options
Diffstat (limited to 'lib/rites/rite_ef/lectionary_ef.ml')
| -rw-r--r-- | lib/rites/rite_ef/lectionary_ef.ml | 259 |
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 -> []))) |
