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 (common, [])] 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 }))) (* Returns the Common's own id ALONGSIDE its citations, not the citations alone: [readings]' step 4 needs the id to build the day's {!Colitur_kernel.Mass_formulary.t} ("the Common's own id as its slug"), and the id is only ever in scope here, at the point [common] is looked up -- re-deriving it afterwards would mean a second [assoc_opt] search over [t.assigned] for a value this function already held. *) let find t saint = match List.assoc_opt saint t.assigned with | None -> None | Some common -> ( match List.assoc_opt common t.commons with | None -> None | Some cs -> Some (common, cs)) (* 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" to have its own Mass. The WARRANT for that shape is lectio's own observed behaviour only, not a confirmed Missal citation: Lent 1 Monday returns its own Ezech 34:11-16, while Advent, Christmas and post-Pentecost Mondays return their Sunday's Mass, in both streams. docs/research/rules-register.md records this openly as unconfirmed against the primary source ("EF reading-selection 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. Step 4 is the one step of the four that does NOT rest on that shape: it is step 1's own rule continued (the observed office's readings win over the day's temporal ones, wherever colitur happens to store them), and it is separately corroborated by a direct Missal citation. Its own comment gives both. *) (* 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 | Date.Sun -> 0 | Date.Mon -> 1 | Date.Tue -> 2 | Date.Wed -> 3 | Date.Thu -> 4 | Date.Fri -> 5 | Date.Sat -> 6 (* ---- The votive Mass of Our Lady on Saturday (RG 309(a)) ---- RG 78 gives the OFFICE: "In sabbatis, in quibus occurrit Officium de feria IV classis, fit de sancta Maria in sabbato" -- and Temporal_ef has built that since the ef-bvm-saturday task, white and IV class. What it did NOT build is WHICH MASS is said, so the day observed Our Lady and then read the feria's own Epistle and Gospel: white, IV class, de sancta Maria, and Colossians on the parable of the weeds. That is test_oracle.ml's own M26 shape 1. RG 309(a) (LT.txt:2445-2447) is the rule: "in Ecclesia universa, Missae quae pro sancta Maria in sabbato, IUXTA TEMPORUM DIVERSITATEM, in Missali assignantur" -- the Masses the Missal assigns according to the diversity of SEASONS. It prints five, each under its own season heading (the "Missae de sancta Maria in sabbato" section, scan1.txt:42982-43290): I TEMPORE ADVENTUS II A NATIVITATE DOMINI USQUE AD PURIFICATIONEM III A DIE 3 FEBRUARII USQUE AD FERIAM IV HEBDOMADAE SANCTAE IV TEMPORE PASCHALI V A FESTO SS. TRINITATIS USQUE AD SABBATUM ANTE DOMINICAM I ADVENTUS THE MISSAL'S RANGES ARE NOT THIS RITE'S SEASONS, and that looked like the hard part: II ends at the Purification, mid-season; III runs from a civil date to a movable one, spanning four of the eight seasons. But measured against what a IV-class Saturday can actually BE, it collapses to a seasonal mapping plus one date test: - Mass II vs III splits at 2/3 February, inside Time_after_epiphany. That is the ONLY boundary needing a date, and it is a fixed civil one. - III's end (Feria IV of Holy Week) and IV's start (Paschaltide) leave the Triduum unassigned -- UNREACHABLE: Holy Saturday is I class, so no IV-class Saturday exists there. - IV's end and V's start (Trinity) leave Pentecost week unassigned -- ALSO UNREACHABLE, and verified rather than assumed: colitur puts Pentecost week in Paschaltide and its Saturday is [ef-pentecost-ember-sat], an Ember Saturday, never IV class. Time_after_pentecost then begins exactly at Trinity Sunday, which is exactly where Mass V begins. So no Easter arithmetic is needed here at all. Both gaps are covered by dedicated tests rather than left to inference. CITATION WITNESSES, stated because they differ: Masses II-V were each confirmed twice, against the scan AND against a live missalemeum capture of 2038 which names them ("II Mass of the B. V. M.", "III Mass...", and so on). Mass I rests on the SCAN ALONE -- no oracle year to hand has an Advent Saturday carrying this office, so its two citations have one witness where the others have two. *) let bvm_saturday_citations season ~month ~day = let cite first gospel = [ { Citation.part = Citation.First; reference = first }; { Citation.part = Citation.Gospel; reference = gospel } ] in match (season : Vocab_ef.season) with (* I -- Tempore Adventus. Isai. 7, 10-15 (scan1.txt:43008-43009) / Luc. 1, 26-38 (scan1.txt:42998-42999). STRUCTURALLY UNREACHABLE as the Saturday office, measured not assumed: zero IV-class Advent Saturdays across 2000-2100, because Advent has no IV-class ferias at all. [ferial_rank] gives Class2 from 17 December (RG 91 entry 18) and Class3 before it (entry 25), and RG 78's own protasis is "Officium de feria IV CLASSIS". So this branch can never fire on the universal calendar. It is kept, and deliberately: the Missal does print this Mass under "Tempore Adventus", because RG 309's own subject is Masses that "de beata Maria Virgine celebrari POSSUNT, TAMQUAM VOTIVAE" -- a votive Mass in Advent is a real thing, even though the Saturday OFFICE never falls there. Removing the branch would make the season mapping incomplete against its own source, and a diocesan overlay that ever produced a IV-class Advent Saturday would then silently fall through. This is also why Mass I has ONE witness where II-V have two: no oracle year can corroborate it, not because none was looked for, but because no such day exists to query. *) | Vocab_ef.Advent -> cite "Isa 7:10-15" "Luke 1:26-38" (* II -- A Nativitate Domini usque ad Purificationem. Tit. 3, 4-7 (scan1.txt:43056-43057) / Luc. 2, 15-20 (scan1.txt:43096-43097). *) | Vocab_ef.Christmastide -> cite "Titus 3:4-7" "Luke 2:15-20" | Vocab_ef.Time_after_epiphany when month = 1 || (month = 2 && day <= 2) -> cite "Titus 3:4-7" "Luke 2:15-20" (* III -- A die 3 februarii usque ad feriam IV Hebdomadae Sanctae. Eccli. 24, 14-16 (scan1.txt:43121-43122) / Luc. 11, 27-28 (scan1.txt:43165-43166). *) | Vocab_ef.Time_after_epiphany | Vocab_ef.Septuagesima | Vocab_ef.Lent | Vocab_ef.Passiontide -> cite "Ecclus 24:14-16" "Luke 11:27-28" (* IV -- Tempore paschali. Eccli. 24, 14-16 (scan1.txt:43177-43178) / Io. 19, 25-27 (scan1.txt:43202-43203). *) | Vocab_ef.Paschaltide -> cite "Ecclus 24:14-16" "John 19:25-27" (* V -- A festo Ss. Trinitatis usque ad sabbatum ante dominicam I Adventus. Eccli. 24, 14-16 (scan1.txt:43224-43226) / Luc. 11, 27-28 (scan1.txt:43250-43252). *) | Vocab_ef.Time_after_pentecost -> cite "Ecclus 24:14-16" "Luke 11:27-28" (* The office is identified STRUCTURALLY, never off the slug -- it deliberately reuses the ordinary ferial slug (ef-bvm-saturday task: a bespoke one would have broken Validate's own slug-uniqueness-per-year invariant, since the office recurs many times a year). Three conditions, all necessary: [Bvm] subject, IV class, and a Saturday. A Marian FEAST on a Saturday carries [Bvm] too, but has its own citations and so is answered by step 1 long before reaching here; the rank and weekday tests close the gap anyway. *) let is_bvm_saturday_office (observed : Vocab_ef.rank Celebration.t) (temporal : (Vocab_ef.season, Vocab_ef.rank) Temporal.t) = observed.Celebration.subject = Subject.Bvm && observed.Celebration.rank = Vocab_ef.Class4 && temporal.Temporal.weekday = Date.Sat let readings ~lectionary ~commons ~observed ~temporal ~date ~temporal_at = match observed.Celebration.citations with | _ :: _ as cs -> (Some { Mass_formulary.said = observed.Celebration.slug; via = Mass_formulary.Proper }, cs) | [] -> ( (* 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 (1) -- INTERNAL, and it is the decisive one: step 4 is STEP 1'S CONTINUATION, not a fifth thing bolted after the temporal fallbacks. Step 1 already runs the observed office's own proper ahead of steps 2 and 3; step 4 is that same rule for the saints whose readings the Missal keeps in a Common instead of printing on the celebration. Placing it last would have made the chain internally inconsistent with code that already existed -- one saint (St Joseph, test_step1_wins_over_a_competing_step2_entry) beating a competing temporal entry because his readings happen to sit on his Celebration.t, and another (St Vincent Ferrer) losing to one because his sit one indirection away. Nothing in the rubrics draws that distinction; it is an artefact of where colitur stores the data. So the plan's ordering was not merely wrong on the data (below) -- it contradicted step 1. WARRANT (2) -- EXTERNAL, corroborating: 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. The guard is a NO-OP on the shipped data -- no assignment in data/ef/commons.sexp names a temporal slug -- which is exactly what makes it defensive rather than load-bearing, and exactly why nothing in the real data can exercise it. It is nevertheless tested, against a SYNTHETIC [Commons.t] built by [Commons.of_tables] that deliberately assigns a Common to a really-observed ferial slug: test_step4_guard_refuses_a_common_assigned_to_a_ferial_slug. That test fails with this guard removed; without it the guard had no coverage at all (fix round 1, coordinator review -- the reviewer forced [sanctoral_office = true] and the whole suite stayed green). COULD THE GUARD WRONGLY EXCLUDE A REAL SAINT? Only if a sanctoral slug ever equalled the day's own temporal slug. It cannot today -- every temporal slug [Temporal_ef] builds carries the "ef-" prefix and 0 of the 327 entries in data/ef/sanctoral.sexp do -- but that is an ASSUMPTION this codebase leans on, NOT an asserted invariant, and it should not be dressed up as one. (CORRECTED, fix round 1: this comment previously cited [Validate]'s slug-uniqueness check as the authority. That check compares [Temporal.office] slugs to each other across the days of one liturgical year -- it says nothing about collision BETWEEN the temporal and sanctoral streams.) [Validate] is candid about the same assumption where it makes it, for a different purpose -- recovering a commemoration's origin, validate.ml's own note: "This is exact whenever slugs cannot collide across the two streams (Task 12's own 'observed' check already assumes this for a different purpose), which is the same assumption the rest of this codebase already leans on." The failure mode if that assumption ever broke is benign and one-directional: a colliding saint would be denied his Common and fall through to steps 2/3, i.e. to the temporal Mass -- which is precisely the answer the unguarded chain would have given him anyway. No day gains a reading it should not have. *) 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 (common_id, cs) -> (Some { Mass_formulary.said = common_id; via = Mass_formulary.Common }, cs) | None -> ( (* The votive Mass of Our Lady on Saturday (RG 309(a)) runs HERE: after the proper (step 1) and the Common (step 4), which answer for a saint who beat the feria, and BEFORE the temporal slug lookup -- which is exactly what used to answer, with the feria's own Mass, on a day whose office is Our Lady's. Placing it later would be dead code; placing it earlier would let it outrank a real saint's proper. FORMULARY PROVENANCE, a genuine judgement call: {!Mass_formulary.source} has no fifth constructor for "the RG 309(a) seasonal votive Mass", so this is tagged [Own_slug] -- [is_bvm_saturday_office] only ever fires when [sanctoral_office] above is false, i.e. the observed celebration already IS the day's own temporal office (the office deliberately reuses the ordinary ferial slug, [Temporal_ef]'s own [bvm_saturday_names]), so [said] is genuinely "the day's own slug" -- [Own_slug]'s own documented meaning (mass_formulary.mli's [said] comment) -- even though the citations themselves come from [bvm_saturday_citations]'s season table rather than a [Lectionary.find] hit. Flagged in the task report as an interpretation, not a specified answer. *) if is_bvm_saturday_office observed temporal then let said = temporal.Temporal.office.Celebration.slug in ( Some { Mass_formulary.said; via = Mass_formulary.Own_slug }, bvm_saturday_citations temporal.Temporal.season ~month:(Date.month date) ~day:(Date.day date) ) else match Lectionary.find lectionary temporal.Temporal.office.Celebration.slug with | Some cs -> ( Some { Mass_formulary.said = temporal.Temporal.office.Celebration.slug; via = Mass_formulary.Own_slug }, 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 (None, []) 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 -> let said = sunday_temporal.Temporal.office.Celebration.slug in (Some { Mass_formulary.said; via = Mass_formulary.Preceding_sunday }, cs) | None -> (None, []))))