From 1299815ea03c2b1e4ff19d92d32e386fec24f4e4 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 15 Aug 2026 01:40:54 +0200 Subject: ef(lectionary): the Commons, chain step 4 Fifteen class-3 saints had no readings anywhere in colitur -- the 6 March - 5 April cluster whose RANKS lectio's generator fix of 2026-08-12 restored without backfilling readings, and lectio has no Commons concept to have backfilled them from. They are exactly the fifteen `status Feast` entries in data/ef/sanctoral.sexp carrying `(citations ())`. The Missal splits them eight/seven, and NOT as the plan predicted. It expected propers for Benedict, Patrick, Thomas Aquinas and Gregory the Great; only Thomas Aquinas has one. Benedict ("Missa Os iusti, de Communi Abbatum"), Patrick ("Missa Statuit, de Communi Confessoris Pontificis I loco") and Gregory the Great ("Missa Si diligis me, de Communi unius aut plurium Summorum Pontificum") are all sent to a Common, with only Oratio, Secreta and Postcommunio printed at the date. All three reversals verified on the page images (pp. 488, 492) as well as in both OCR text layers. PROPERS (data/ef/adjustments.sexp, `Edit ... Set_citation`): thomas-aquinas 7 Mar Sap 7:7-14 / Mt 5:13-19 john-of-god 8 Mar Eccli 31:8-11 / Mt 22:34-46 forty-holy-martyrs 10 Mar Hebr 11:33-39 / Luc 6:17-23 cyril-of-jerusalem 18 Mar Eccli 39:6-14 / Mt 10:23-28 gabriel-the-archangel 24 Mar Dan 9:21-26 / Luc 1:26-38 john-damascene 27 Mar Sap 10:10-17 / Luc 6:6-11 john-of-capistrano 28 Mar Sap 10:10-14 / Luc 9:1-6 francis-of-paola 2 Apr Philipp 3:7-12 / Luc 12:32-34 COMMONS (data/ef/commons.sexp), each quoting the Missal's own instruction at the saint's date: sts-felicitas-perpetua 6 Mar non Virginum I Eccli 51:1-8,12 / Mt 13:44-52 frances-rome 9 Mar non Virginum II Prov 31:10-31 / Mt 13:44-52 gregory-the-great 12 Mar Summ. Pontificum 1 Petri 5:1-4,10-11 / Mt 16:13-19 patrick 17 Mar Conf. Pont. I Eccli 44:16-27;45:3-20 / Mt 25:14-23 benedict 21 Mar Abbatum Eccli 45:1-6 / Mt 19:27-29 isidore-of-seville 4 Apr Doctorum 2 Tim 4:1-8 / Mt 5:13-19 vincent-ferrer 5 Apr Conf. non Pont. I Eccli 31:8-11 / Luc 12:35-40 Every value read in three independent places -- both OCR text layers (two DIFFERENT printings, whose Commune Sanctorum pagination differs by 1-2, so the data keys on heading plus Mass incipit and never on the bracketed page number) and the rendered page images. That caught a genuine typographical error in one printing: it gives St Gabriel's Gospel as "Luc. 2, 26-38" (on the page image, so not an OCR artefact). Luke 1:26-38 is settled by the other printing, by the pericope text in both, and by the SAME edition citing the identical pericope correctly two pages later at the Annunciation. Step 4 EXECUTES SECOND (order 1, 4, 2, 3), not last as the plan sketched. Measured: every day on which one of the fifteen is actually the observed office already has a non-empty step-2 or step-3 answer waiting, so a step 4 placed after the ferial fallback is unreachable on every date in 1583-9999 -- and wrong where it would fire (2038-03-06 would emit Septuagesima II Saturday's Mass on a day whose office is Sts Perpetua and Felicity). Step 4 is also the only step in the chain with a direct primary-source warrant: the Missal names the Mass at each such saint's own date, while steps 2 and 3 rest only on lectio's observed behaviour. Guarded so it can never divert a day whose observed office IS its temporal office -- ferias, Sundays, the Triduum and the RG 78 Saturday Office of the BVM (which deliberately shares the ferial slug) are structurally excluded. Step NUMBERS are unchanged so every "step 3" already written down still names the same branch. The Commons are caller-supplied, like the lectionary: `Rite_ef.context` gains a REQUIRED `~commons`, not an optional one -- a silently-defaulted empty table would give a rite whose class-3 saints quietly lose their Mass, and nothing in layers 3-5 compares citations, so that loss would be invisible. `Commons.load` rejects the four defects indistinguishable downstream from "this saint has no Common": duplicate common id, duplicate assignment, empty formulary, assignment naming a common that does not exist. KNOWN LIMITS, recorded rather than papered over. There is no oracle: lectio has no readings for these feasts and missalemeum's 2026-2027 window never observes one of the fifteen. Eight of the fifteen are NEVER the observed office anywhere in the domain (10-28 March always falls to a Lenten or Passiontide feria that outranks a III-class feast), so they have no end-to-end witness and are covered by dedicated table tests instead. The Commune Sanctorum's own opening rubric permits any Epistle or Gospel of a Common in any Mass of that Common unless one is assigned, so what is emitted is the reading PRINTED WITH the named formulary, not the only lawful one; labelled alternatives (e.g. the widow's Epistle 1 Tim 5:3-10, squarely available to St Frances of Rome) are noted in the data and deliberately not encoded. The BVM Common is NOT authored: the RG 78 Saturday Office shares the ordinary ferial slug, so assigning a Common to it would hand the BVM Mass to every feria sharing that slug; routing it needs a subject-keyed lookup and a season-keyed choice among the Missal's five "Missae de sancta Maria in sabbato" -- new behaviour, not new data. It currently emits its feria's Mass, now pinned as such. Blast radius, measured (git archive HEAD binary vs HEAD, full 1583-9999 `colitur day` sweep, diffed): ZERO differing lines, 3 074 246 lines each side, byte-identical -- the CLI prints no citations, so this change is invisible to layers 2-5 by construction, and the sanctoral overlay perturbed nothing else. 357 tests green (344 before), 358 with the exhaustive sweep. Mutation-tested six ways, all reverted after confirming: relegating step 4 to last reddens exactly the four step-4 pins and nothing else; corrupting a Common's citation, dropping an assignment, dropping a proper Edit, and corrupting either an unreachable proper or an unreachable Common each redden their own pins. The load-bearing assertion is stated over the loaded layer rather than a hard-coded list: every sanctoral entry that can ever BE the observed office now has either a proper or an assigned Common -- fifteen failures before, zero after, and a future re-bootstrap adding a readingless class-3 feast fails there instead of silently emitting its feria's Mass. --- lib/rites/rite_ef/lectionary_ef.ml | 263 +++++++++++++++++++++++++++++-------- 1 file changed, 209 insertions(+), 54 deletions(-) (limited to 'lib/rites/rite_ef/lectionary_ef.ml') 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. - - 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 -> [])) + 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. + + 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 -> []))) -- cgit v1.3