From 384b0789c0f4d9beb936080a5592bb4aa6134295 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Fri, 21 Aug 2026 22:39:12 +0200 Subject: feat(kernel,ef): the lectionary reports which Mass it said Rite.readings now returns (Mass_formulary.t option * Citation.t list) instead of a bare citation list, and Liturgical_day.t carries the result as a new formulary field. Validate holds a rite that resolves a formulary at all to resolving one on every day, the same discipline it already applies to citations; the EF lectionary chain resolves Some on every day of every year 1583-9999, confirmed by a direct sweep over 2005-2050 as well as through Validate itself. Plan Tasks 2 and 3 are merged into this one commit on the coordinator's own instruction: Rite.readings' signature and the field that consumes it are one atomic edit, and the intermediate state does not compile on its own. Each of the four lectionary steps now builds its own Mass_formulary.t at the point it decides, not by re-deriving it afterwards from the citations it returns: step 1 tags Proper with the observed slug, step 2 tags Own_slug with the day's own temporal slug, step 3 tags Preceding_sunday with the resumed Sunday's temporal slug, and step 4 tags Common with the Common's own id -- Commons.find now returns that id alongside its citations rather than discarding it, since it is only ever in scope at the point the assignment is looked up. The RG 309(a) Saturday votive Mass of Our Lady, which answers between steps 4 and 2 rather than as one of the four numbered steps, is tagged Own_slug too: Mass_formulary.source has no dedicated constructor for it, and its own guard only ever fires when the observed celebration already is the day's own (reused ferial) temporal slug, which is exactly what Own_slug documents. Recorded as a judgement call in the task report, not a specified answer. test/cli.t's `emit --format sexp` line count is repinned (8472 to 8881): that command serializes Liturgical_day.t whole, so the new field grows its output. `colitur day` itself is untouched -- verified byte-identical against the pre-change binary across 1583, 1900, 2026, 2038 and 9999. --- lib/rites/rite_ef/lectionary_ef.ml | 58 ++++++++++++++++++++++++++++--------- lib/rites/rite_ef/lectionary_ef.mli | 32 +++++++++++++++----- 2 files changed, 69 insertions(+), 21 deletions(-) (limited to 'lib/rites/rite_ef') diff --git a/lib/rites/rite_ef/lectionary_ef.ml b/lib/rites/rite_ef/lectionary_ef.ml index d7b2497..4325bed 100644 --- a/lib/rites/rite_ef/lectionary_ef.ml +++ b/lib/rites/rite_ef/lectionary_ef.ml @@ -51,9 +51,9 @@ module Commons = struct | 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. *) + 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)) @@ -72,10 +72,19 @@ module Commons = struct (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 -> List.assoc_opt common t.commons + | 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 @@ -250,7 +259,8 @@ let is_bvm_saturday_office (observed : Vocab_ef.rank Celebration.t) let readings ~lectionary ~commons ~observed ~temporal ~date ~temporal_at = match observed.Celebration.citations with - | _ :: _ as cs -> cs + | _ :: _ 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 @@ -346,7 +356,8 @@ let readings ~lectionary ~commons ~observed ~temporal ~date ~temporal_at = match if sanctoral_office then commons_for ~commons observed.Celebration.slug else None with - | Some cs -> cs + | 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 @@ -354,13 +365,32 @@ let readings ~lectionary ~commons ~observed ~temporal ~date ~temporal_at = 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. *) + 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 - bvm_saturday_citations temporal.Temporal.season ~month:(Date.month date) - ~day:(Date.day date) + 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 -> cs + | 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 @@ -404,12 +434,14 @@ let readings ~lectionary ~commons ~observed ~temporal ~date ~temporal_at = 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 [] + 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 -> cs - | None -> []))) + | Some cs -> + let said = sunday_temporal.Temporal.office.Celebration.slug in + (Some { Mass_formulary.said; via = Mass_formulary.Preceding_sunday }, cs) + | None -> (None, [])))) diff --git a/lib/rites/rite_ef/lectionary_ef.mli b/lib/rites/rite_ef/lectionary_ef.mli index caefc74..2f36926 100644 --- a/lib/rites/rite_ef/lectionary_ef.mli +++ b/lib/rites/rite_ef/lectionary_ef.mli @@ -72,21 +72,28 @@ module Commons : sig 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. +(** The Common assigned to a saint who has no proper, if any -- its own id + ALONGSIDE its citations, not the citations alone: {!readings}' step 4 + needs the id to name which Common fired in the {!Colitur_kernel.Mass_formulary.t} + it builds. Exposed for the golden pins too, 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 +val commons_for : commons:Commons.t -> Slug.t -> (Slug.t * Citation.t list) option -(** The day's Epistle and Gospel citations, or []. +(** The Mass actually said -- which formulary, and how that was decided -- + paired with the day's Epistle and Gospel citations. [(None, [])] when + none of the four steps below answers. 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): + name, comment or report still means the same branch). Each step's own + {!Colitur_kernel.Mass_formulary.source} is built at the point the step + decides, not re-derived afterwards from the citations it returns: - {b Step 1} -- the observed celebration's own proper. + {!Colitur_kernel.Mass_formulary.Proper}, [said] the observed slug. - {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 @@ -98,14 +105,23 @@ val commons_for : commons:Commons.t -> Slug.t -> Citation.t list option 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. + {!Colitur_kernel.Mass_formulary.Common}, [said] the Common's own id. - {b Step 2} -- the day's own temporal slug in the lectionary. + {!Colitur_kernel.Mass_formulary.Own_slug}, [said] that slug. The RG + 309(a) Saturday votive Mass of Our Lady also answers here (structurally, + not as a fifth numbered step): it is tagged the same way, because its + own guard only ever fires when the observed celebration already IS the + day's own temporal office (the office reuses the ordinary ferial + slug) -- see the implementation comment on that branch. - {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). + {!Colitur_kernel.Mass_formulary.Preceding_sunday}, [said] that + Sunday's temporal slug. - A day matching none of the four gets []. *) + A day matching none of the four gets [(None, [])]. *) val readings : lectionary:Lectionary.t -> commons:Commons.t -> @@ -113,4 +129,4 @@ val readings : temporal:(Vocab_ef.season, Vocab_ef.rank) Temporal.t -> date:Date.t -> temporal_at:(Date.t -> (Vocab_ef.season, Vocab_ef.rank) Temporal.t) -> - Citation.t list + Mass_formulary.t option * Citation.t list -- cgit v1.3 From 28dc226a31fa0cc72432f5607ab5cd74503ef43d Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Fri, 21 Aug 2026 22:54:13 +0200 Subject: fix(kernel,ef): a votive Mass is not the day's own -- add Mass_formulary.Votive Mass_formulary.source was missing a case for a Mass said IN PLACE of the day's own office's Mass while that office is itself kept unchanged -- RG 309(a) and RG 431(e) both classify the RG 78 Saturday Mass of Our Lady this way, in the Missal's own words, as a "Missa votiva IV classis... de B. Maria Virg.", not as the day's own office's Mass. The Latin Mass Society Ordo (docs/research/ordo/lms-ordo-2024-2025.pdf) witnesses it directly, printing that day as "V Mass of BVM". The BVM-Saturday branch in Lectionary_ef.readings was tagged Own_slug for lack of a better constructor when Task 2 landed, flagged there as a judgement call rather than a specified answer. That call was wrong: left as Own_slug, a future comparison against the LMS Ordo (a later task in this plan) would read every BVM Saturday as a manufactured divergence between colitur's "own" Mass and the Ordo's votive one. Retagged to Votive; said is unchanged (still the day's own, reused ferial, temporal slug) since the office itself is unaffected, only the Mass said for it. Added the new constructor's source_to_string case ("votive") and its own test row, and pinned the branch itself in test_lectionary_ef.ml's formulary cases at 1 August 2026, verified directly against the resolver rather than assumed. Both this session's own drifted pins that the earlier commit inherited from the task brief are unaffected by this change, and both are re-confirmed independently correct in this round: dune test and the exhaustive sweep are green, and colitur day stays byte-identical against the pre-fix-round binary. --- lib/kernel/mass_formulary.ml | 3 ++- lib/kernel/mass_formulary.mli | 16 +++++++++++++--- lib/rites/rite_ef/lectionary_ef.ml | 38 ++++++++++++++++++++++++------------- lib/rites/rite_ef/lectionary_ef.mli | 14 ++++++++++---- test/test_lectionary_ef.ml | 22 +++++++++++++++++---- test/test_mass_formulary.ml | 2 +- 6 files changed, 69 insertions(+), 26 deletions(-) (limited to 'lib/rites/rite_ef') diff --git a/lib/kernel/mass_formulary.ml b/lib/kernel/mass_formulary.ml index 8151720..cb3532b 100644 --- a/lib/kernel/mass_formulary.ml +++ b/lib/kernel/mass_formulary.ml @@ -1,4 +1,4 @@ -type source = Proper | Own_slug | Preceding_sunday | Common [@@deriving sexp] +type source = Proper | Own_slug | Preceding_sunday | Common | Votive [@@deriving sexp] type t = { said : Slug.t; via : source } [@@deriving sexp] let source_to_string = function @@ -6,3 +6,4 @@ let source_to_string = function | Own_slug -> "own" | Preceding_sunday -> "preceding-sunday" | Common -> "common" + | Votive -> "votive" diff --git a/lib/kernel/mass_formulary.mli b/lib/kernel/mass_formulary.mli index caea3eb..a2faf64 100644 --- a/lib/kernel/mass_formulary.mli +++ b/lib/kernel/mass_formulary.mli @@ -15,12 +15,22 @@ type source = | Own_slug (** the lectionary's entry for the day's own slug *) | Preceding_sunday (** a weekday with no proper resumes the preceding Sunday *) | Common (** a saint's assigned Common *) + | Votive + (** a Mass said IN PLACE of the day's own office's Mass, the office + itself being kept unchanged -- RG 309(a): "in Ecclesia universa, + Missae quae pro sancta Maria in sabbato, iuxta temporum + diversitatem, in Missali assignantur", corroborated by RG 431(e)'s + own classification of that Mass as "Missa votiva IV classis ... + de B. Maria Virg." The office/Mass split this constructor exists + to name is general (any rite may say a votive Mass on a day whose + OFFICE is not itself votive), even though EF's only witness today + is the RG 78/309(a) Saturday Mass of Our Lady. *) [@@deriving sexp] type t = { said : Slug.t; via : source } [@@deriving sexp] -(** The slug whose Mass is said. For {!Proper} and {!Own_slug} this is the day's - own; for {!Preceding_sunday} it is that Sunday's TEMPORAL slug; for - {!Common} it is the Common's own id. *) +(** The slug whose Mass is said. For {!Proper}, {!Own_slug} and {!Votive} + this is the day's own; for {!Preceding_sunday} it is that Sunday's + TEMPORAL slug; for {!Common} it is the Common's own id. *) val source_to_string : source -> string diff --git a/lib/rites/rite_ef/lectionary_ef.ml b/lib/rites/rite_ef/lectionary_ef.ml index 4325bed..13ff5d5 100644 --- a/lib/rites/rite_ef/lectionary_ef.ml +++ b/lib/rites/rite_ef/lectionary_ef.ml @@ -367,21 +367,33 @@ let readings ~lectionary ~commons ~observed ~temporal ~date ~temporal_at = 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. *) + FORMULARY PROVENANCE: [Votive], not [Own_slug] (CORRECTED, fix + round 1, coordinator review -- the first pass tagged this + [Own_slug] for lack of a better constructor and flagged it as a + judgement call; [Mass_formulary.source] has grown a [Votive] + case since, precisely for this branch). RG 309(a) (this + branch's own header comment above) and RG 431(e) ("in Missis + votivis IV classis ... de B. Maria Virg. quae in sabbato + celebrantur", temporal_ef.ml's own colour-chain comment quotes + it in full) both classify this Mass itself, in the Missal's own + words, as a "Missa votiva" -- a votive Mass said IN PLACE of the + day's own office's Mass, the office (RG 78, Officium sanctae + Mariae in sabbato) being kept unchanged. WITNESSED, not merely + argued: the Latin Mass Society Ordo (docs/research/ordo/lms- + ordo-2024-2025.pdf) prints this exact day as "OUR LADY on + SATURDAY IV Cl W / V Mass of BVM" -- "V" is that Ordo's own + abbreviation for Votive. + + [said] is UNCHANGED by this correction and stays the day's own + temporal 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]) -- only [via] needed correcting, the + office/Mass split [Votive] exists to name. *) 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 }, + ( Some { Mass_formulary.said; via = Mass_formulary.Votive }, bvm_saturday_citations temporal.Temporal.season ~month:(Date.month date) ~day:(Date.day date) ) else diff --git a/lib/rites/rite_ef/lectionary_ef.mli b/lib/rites/rite_ef/lectionary_ef.mli index 2f36926..43763ff 100644 --- a/lib/rites/rite_ef/lectionary_ef.mli +++ b/lib/rites/rite_ef/lectionary_ef.mli @@ -109,10 +109,16 @@ val commons_for : commons:Commons.t -> Slug.t -> (Slug.t * Citation.t list) opti - {b Step 2} -- the day's own temporal slug in the lectionary. {!Colitur_kernel.Mass_formulary.Own_slug}, [said] that slug. The RG 309(a) Saturday votive Mass of Our Lady also answers here (structurally, - not as a fifth numbered step): it is tagged the same way, because its - own guard only ever fires when the observed celebration already IS the - day's own temporal office (the office reuses the ordinary ferial - slug) -- see the implementation comment on that branch. + not as a fifth numbered step), but is tagged + {!Colitur_kernel.Mass_formulary.Votive} instead, not [Own_slug]: RG + 309(a)/431(e) classify it, in the Missal's own words, as a "Missa + votiva" said IN PLACE of the day's own office's Mass, the office (RG + 78) itself being kept -- witnessed by the Latin Mass Society Ordo, + which prints this day's Mass as "V" (Votive). [said] is still that + slug: only the source constructor differs from an ordinary Step 2 + lookup, because the guard that reaches this branch only ever fires + when the observed celebration already IS the day's own temporal + office -- see the implementation comment on that branch. - {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 diff --git a/test/test_lectionary_ef.ml b/test/test_lectionary_ef.ml index bc2be8c..5bdce12 100644 --- a/test/test_lectionary_ef.ml +++ b/test/test_lectionary_ef.ml @@ -582,9 +582,10 @@ let test_commons_load_rejects_bad_data () = (* ---------------------------------------------------------------------- *) (* The formulary itself (Task 2): each step of the chain now reports HOW *) -(* it resolved, not only what it resolved. One day per step -- the same *) -(* dates this file already uses (and hand-verifies) elsewhere for the *) -(* citations those days carry, so no new date needs independent checking. *) +(* it resolved, not only what it resolved. One day per step, plus the RG *) +(* 309(a) votive branch (fix round 1) -- the same dates this file already *) +(* uses (and hand-verifies) elsewhere for the citations those days carry, *) +(* so no new date needs independent checking. *) (* ---------------------------------------------------------------------- *) let formulary_cases = @@ -605,7 +606,20 @@ let formulary_cases = (* step 4: a saint sent to a Common -- same date as [test_step4_commons_perpetua_and_felicity]; [said] is the Common's OWN id (data/ef/commons.sexp), not the saint's slug. *) - (2038, 3, 6, "common-of-non-virgins-1", Colitur_kernel.Mass_formulary.Common) ] + (2038, 3, 6, "common-of-non-virgins-1", Colitur_kernel.Mass_formulary.Common); + (* Fix round 1 (coordinator review): the RG 309(a)/RG 78 Saturday votive + Mass of Our Lady, structurally reached between steps 4 and 2 (see + [readings]' own implementation comment) but tagged [Votive], not + [Own_slug] -- the Missal's own RG 309(a)/431(e) classify this Mass as + a "Missa votiva", said in place of the day's own office's Mass while + the office (RG 78) itself is kept; witnessed by the Latin Mass + Society Ordo, which prints this day's Mass as "V" (Votive). 1 August + 2026 verified directly against the real resolver (`colitur day + 2026`), not trusted from a supplied date, given this session's own + drifted-pin history: a IV-class Saturday, "Officium sanctae Mariae in + sabbato", temporal slug [ef-time-after-pentecost-9-saturday]. [said] + is unaffected by the retag and stays that same (reused ferial) slug. *) + (2026, 8, 1, "ef-time-after-pentecost-9-saturday", Colitur_kernel.Mass_formulary.Votive) ] let test_formulary_reports_its_source () = List.iter diff --git a/test/test_mass_formulary.ml b/test/test_mass_formulary.ml index b940161..824e0c7 100644 --- a/test/test_mass_formulary.ml +++ b/test/test_mass_formulary.ml @@ -14,7 +14,7 @@ let test_round_trips_through_sexp () = let test_to_string_names_the_source () = let cases = [ (MF.Proper, "proper"); (MF.Own_slug, "own"); (MF.Preceding_sunday, "preceding-sunday"); - (MF.Common, "common") ] + (MF.Common, "common"); (MF.Votive, "votive") ] in List.iter (fun (via, expected) -> -- cgit v1.3 From 27b07b447be9cf960d1a44e480d8356245481d17 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 22 Aug 2026 11:07:07 +0200 Subject: docs(ef): retract the false LMS Ordo witness claim for Mass_formulary.Votive Fix round 1's own comment claimed the Latin Mass Society Ordo witnesses the Votive tag directly, reading its "V Mass of BVM" as an abbreviation for "votive". That was wrong: the Ordo's I-V are roman numerals naming WHICH of the Missal's five seasonal "Missae de sancta Maria in sabbato" (RG 309(a)'s own "iuxta temporum diversitatem") is said on a given Saturday, not a marker of the Mass's kind. All five numerals occur through the Ordo (I x1, II x4, III x3, IV x3, V x8); the false reading came from "V Mass of BVM" also being a substring of "IV Mass of BVM". Votive itself is unaffected and stays correct on RG 431(e) alone, quoted verbatim in its place: "e) in Missis votivis IV classis de Angelis, quocumque die, et de B. Maria Virg. quae in sabbato celebrantur" -- votive Masses of the IV class, of the BVM, celebrated on Saturday. The retraction is recorded, not softened, in lectionary_ef.ml/.mli and the test pin's own comment, together with what the Ordo's numeral actually means so the mistake is not repeated, and the consequence it leaves behind: because the numeral names WHICH seasonal Mass is said, a future comparison against the Ordo (Task 6) can validate bvm_saturday_citations' own season-keyed selection, not merely that some BVM Mass was chosen. Comments and docs only -- no behaviour change. Both suites re-run green and colitur day re-confirmed byte-identical. --- lib/rites/rite_ef/lectionary_ef.ml | 45 ++++++++++++++++++++++++++++--------- lib/rites/rite_ef/lectionary_ef.mli | 25 ++++++++++++++------- test/test_lectionary_ef.ml | 14 ++++++++---- 3 files changed, 61 insertions(+), 23 deletions(-) (limited to 'lib/rites/rite_ef') diff --git a/lib/rites/rite_ef/lectionary_ef.ml b/lib/rites/rite_ef/lectionary_ef.ml index 13ff5d5..1fba3de 100644 --- a/lib/rites/rite_ef/lectionary_ef.ml +++ b/lib/rites/rite_ef/lectionary_ef.ml @@ -371,18 +371,41 @@ let readings ~lectionary ~commons ~observed ~temporal ~date ~temporal_at = round 1, coordinator review -- the first pass tagged this [Own_slug] for lack of a better constructor and flagged it as a judgement call; [Mass_formulary.source] has grown a [Votive] - case since, precisely for this branch). RG 309(a) (this - branch's own header comment above) and RG 431(e) ("in Missis - votivis IV classis ... de B. Maria Virg. quae in sabbato - celebrantur", temporal_ef.ml's own colour-chain comment quotes - it in full) both classify this Mass itself, in the Missal's own - words, as a "Missa votiva" -- a votive Mass said IN PLACE of the + case since, precisely for this branch). RG 431(e) + (docs/research/LT.txt, verbatim): "e) in Missis votivis IV + classis de Angelis, quocumque die, et de B. Maria Virg. quae in + sabbato celebrantur" -- votive Masses of the IV class, of the + Angels on any day, and of the BVM WHICH ARE CELEBRATED ON + SATURDAY: the Missal's own words classify this exact Mass as a + "Missa votiva", not as the day's own office's Mass, corroborated + by RG 309(a) (this branch's own header comment above, "iuxta + temporum diversitatem") naming the FIVE seasonal Masses RG 431(e) + is speaking of. Together: a votive Mass said IN PLACE of the day's own office's Mass, the office (RG 78, Officium sanctae - Mariae in sabbato) being kept unchanged. WITNESSED, not merely - argued: the Latin Mass Society Ordo (docs/research/ordo/lms- - ordo-2024-2025.pdf) prints this exact day as "OUR LADY on - SATURDAY IV Cl W / V Mass of BVM" -- "V" is that Ordo's own - abbreviation for Votive. + Mariae in sabbato) being kept unchanged. + + CORRECTED, fix round 2 (coordinator review): fix round 1's own + comment here claimed the Latin Mass Society Ordo witnesses + [Votive] directly, printing "V Mass of BVM" with "V" read as an + abbreviation for "votive". That was the coordinator's + misreading, not this codebase's, and it was wrong: the Ordo's + "I"-"V" are ROMAN NUMERALS naming WHICH of the Missal's five + seasonal "Missae de sancta Maria in sabbato" (this branch's own + header comment; RG 309(a)'s "iuxta temporum diversitatem") is + said on a given Saturday -- not an abbreviation of anything, and + not a marker of the Mass's class or kind. All five numerals + occur through the Ordo (I x1, II x4, III x3, IV x3, V x8, + counted across the whole document); "V Mass of BVM" merely also + happens to be substring-matched by a naive search for "V Mass of + BVM" inside "IV Mass of BVM", which is what produced the + original false reading. [Votive] itself is UNAFFECTED by this + correction and stays right, on RG 431(e) alone -- only the + Ordo-witness claim is retracted, not softened, removed. A + genuinely useful consequence survives the mistake: because the + Ordo's own numeral names WHICH seasonal Mass is said, a future + comparison against it (Task 6 of this plan) can validate + [bvm_saturday_citations]'s own season-keyed SELECTION, not + merely that some BVM Mass was chosen. [said] is UNCHANGED by this correction and stays the day's own temporal slug: [is_bvm_saturday_office] only ever fires when diff --git a/lib/rites/rite_ef/lectionary_ef.mli b/lib/rites/rite_ef/lectionary_ef.mli index 43763ff..0797c0d 100644 --- a/lib/rites/rite_ef/lectionary_ef.mli +++ b/lib/rites/rite_ef/lectionary_ef.mli @@ -111,14 +111,23 @@ val commons_for : commons:Commons.t -> Slug.t -> (Slug.t * Citation.t list) opti 309(a) Saturday votive Mass of Our Lady also answers here (structurally, not as a fifth numbered step), but is tagged {!Colitur_kernel.Mass_formulary.Votive} instead, not [Own_slug]: RG - 309(a)/431(e) classify it, in the Missal's own words, as a "Missa - votiva" said IN PLACE of the day's own office's Mass, the office (RG - 78) itself being kept -- witnessed by the Latin Mass Society Ordo, - which prints this day's Mass as "V" (Votive). [said] is still that - slug: only the source constructor differs from an ordinary Step 2 - lookup, because the guard that reaches this branch only ever fires - when the observed celebration already IS the day's own temporal - office -- see the implementation comment on that branch. + 431(e) ("in Missis votivis IV classis ... de B. Maria Virg. quae in + sabbato celebrantur", docs/research/LT.txt) classifies it, in the + Missal's own words, as a "Missa votiva" said IN PLACE of the day's own + office's Mass, the office (RG 78) itself being kept, corroborated by RG + 309(a)'s own "iuxta temporum diversitatem" naming the five seasonal + Masses RG 431(e) speaks of. (CORRECTED, fix round 2: this previously + also cited the Latin Mass Society Ordo as witnessing the tag directly, + reading its "V Mass of BVM" as an abbreviation for "votive" -- the + coordinator's own misreading. The Ordo's "I"-"V" are ROMAN NUMERALS + naming which of the five seasonal Masses is said, not a marker of the + Mass's kind; see the implementation comment on this branch for the + full account and the validation use this correction still leaves + available for Task 6.) [said] is still that slug: only the source + constructor differs from an ordinary Step 2 lookup, because the guard + that reaches this branch only ever fires when the observed celebration + already IS the day's own temporal office -- see the implementation + comment on that branch. - {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 diff --git a/test/test_lectionary_ef.ml b/test/test_lectionary_ef.ml index 5bdce12..04076c7 100644 --- a/test/test_lectionary_ef.ml +++ b/test/test_lectionary_ef.ml @@ -610,10 +610,16 @@ let formulary_cases = (* Fix round 1 (coordinator review): the RG 309(a)/RG 78 Saturday votive Mass of Our Lady, structurally reached between steps 4 and 2 (see [readings]' own implementation comment) but tagged [Votive], not - [Own_slug] -- the Missal's own RG 309(a)/431(e) classify this Mass as - a "Missa votiva", said in place of the day's own office's Mass while - the office (RG 78) itself is kept; witnessed by the Latin Mass - Society Ordo, which prints this day's Mass as "V" (Votive). 1 August + [Own_slug] -- RG 431(e) ("in Missis votivis IV classis ... de B. + Maria Virg. quae in sabbato celebrantur") classifies this Mass, in + the Missal's own words, as a "Missa votiva", said in place of the + day's own office's Mass while the office (RG 78) itself is kept. + (CORRECTED, fix round 2: this previously also claimed the Latin Mass + Society Ordo witnesses the tag by printing "V" as an abbreviation for + "votive" on this day -- that was the coordinator's own misreading; + the Ordo's "I"-"V" are roman numerals naming which of the Missal's + five SEASONAL Masses is said, not the Mass's kind -- see + [readings]' own implementation comment for the full account.) 1 August 2026 verified directly against the real resolver (`colitur day 2026`), not trusted from a supplied date, given this session's own drifted-pin history: a IV-class Saturday, "Officium sanctae Mariae in -- cgit v1.3 From d4682c13161b62bc83eaae37790493f670d39c01 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 22 Aug 2026 12:01:48 +0200 Subject: feat(ef): the Creed, RG 475-476 Whether the Creed is said at Mass. New Rubrics_ef.creed, reached through a new Rite.t.creed field and a new Liturgical_day.t.creed bool (false, not an option, for a rite that has not implemented the rule); colitur rubrics gains a fourth TAB-separated column. 475(a) reads the TEMPORAL cycle's own weekday, not the observed day: a Sunday impeded by a Feast of the Lord (RG 16a) still says the Creed. 475(d)'s three octaves (Nativity, Easter, Pentecost) are pure date/ Easter-offset windows, checked first, since a saint's feast winning the day inside one of them still says the Creed ("etiam in festis occurrentibus") -- St Stephen, 26 December, is the live witness. RG 23 classifies Ash Wednesday and every feria of Holy Week (including the Sacred Triduum) as feriae, not festa, however high their rank, so 475(b)/(c)'s "in festis" never reaches them; this single check subsumes 476(a)'s own naming of the Chrism/Lord's-Supper Mass and the Easter Vigil. RG 28-34 vigils are a third liturgical-day category, also excluded from "in festis" regardless of rank -- reuses the already- exported Precedence_ef.is_vigil rather than a new list. 475(e)'s Apostle/Evangelist natalicia list (creed_apostle_slugs) was derived by grepping data/ef/sanctoral.sexp directly and checking each candidate's own date against whether it is that saint's dies natalis -- not copied from any list supplied with the task. The Conversion of St Paul and the 30 June Commemoration of St Paul are excluded (neither is a natalicium); the Chair of St Peter and St Barnabas are included only because the clause names them explicitly, which is exactly why it has to: neither is a natalicium either. 475(c)'s BVM half reuses Precedence_ef.marian_slugs (newly exported) rather than the subject field alone: checked against the shipped data, almost every Marian sanctoral entry ships subject=Saint, not Bvm. man/colitur.1's rubrics section is updated to match the new column; test/cli.t repinned via dune promote for the same reason. Verified day/readings output byte-identical to v0.10.1 across the whole 1583-9999 domain (both binaries' concatenated day+readings output, 6,148,492 lines each, zero diff). Domain-wide: 882,996 days say the Creed, 2,191,250 do not; every one of the domain's 439,178 Sundays says it, zero exceptions. --- bin/main.ml | 14 ++- lib/kernel/calendar.ml | 4 + lib/kernel/liturgical_day.ml | 1 + lib/kernel/liturgical_day.mli | 4 + lib/kernel/rite.ml | 1 + lib/kernel/rite.mli | 15 +++ lib/rites/rite_ef/precedence_ef.mli | 16 +++ lib/rites/rite_ef/rite_ef.ml | 4 +- lib/rites/rite_ef/rite_ef.mli | 4 + lib/rites/rite_ef/rubrics_ef.ml | 223 +++++++++++++++++++++++++++++++++ lib/rites/rite_ef/rubrics_ef.mli | 37 ++++++ man/colitur.1 | 40 ++++-- test/cli.t | 18 +-- test/test_calendar.ml | 7 +- test/test_colitur.ml | 1 + test/test_rubrics_ef.ml | 240 ++++++++++++++++++++++++++++++++++++ test/test_validate.ml | 12 +- 17 files changed, 612 insertions(+), 29 deletions(-) create mode 100644 lib/rites/rite_ef/rubrics_ef.ml create mode 100644 lib/rites/rite_ef/rubrics_ef.mli create mode 100644 test/test_rubrics_ef.ml (limited to 'lib/rites/rite_ef') diff --git a/bin/main.ml b/bin/main.ml index e00e020..f957190 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -388,7 +388,16 @@ let readings_line ~lang ~sigla (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.r asserted by {!Colitur_kernel.Validate}'s own ["formulary"] check -- but the type itself permits [None] (a rite with no lectionary), so this prints "-" rather than pattern-matching partially and crashing on a - guarantee that belongs to DATA, not to the type. *) + guarantee that belongs to DATA, not to the type. + + Task 5 (celebrant-rubrics-phase1): a fourth column, whether the Creed is + said (EF: RG 475-476, {!Rite_ef.Rubrics_ef.creed}) -- "true"/"false" + ([string_of_bool], not "yes"/"no" or "1"/"0": this row has no other + boolean column to be consistent with, so OCaml's own literal is the + least surprising choice for a machine-readable field). Unlike + [formulary], [d.creed] is a plain [bool] with no [option] to guard: a + rite that has not implemented the rule answers [false] outright, so + there is no third "unknown" state this column could ever need to print. *) let rubrics_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) = let said, via = @@ -398,7 +407,8 @@ let rubrics_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_k Colitur_kernel.Mass_formulary.source_to_string f.Colitur_kernel.Mass_formulary.via ) | None -> ("-", "-") in - Printf.printf "%s\t%s\t%s\n" (D.to_iso8601 d.Colitur_kernel.Liturgical_day.date) said via + Printf.printf "%s\t%s\t%s\t%s\n" (D.to_iso8601 d.Colitur_kernel.Liturgical_day.date) said via + (string_of_bool d.Colitur_kernel.Liturgical_day.creed) (* One civil year, Jan 1 - Dec 31, matching [temporal_report]'s own scan -- NOT one liturgical year: [Colitur_kernel.Calendar.year] resolves a single diff --git a/lib/kernel/calendar.ml b/lib/kernel/calendar.ml index 7fe9f67..d55e38d 100644 --- a/lib/kernel/calendar.ml +++ b/lib/kernel/calendar.ml @@ -547,6 +547,9 @@ let build_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) rite.Rite.readings ~observed:resolution.Precedence.observed.Precedence.cel ~temporal ~date ~temporal_at:rite.Rite.temporal in + let creed = + rite.Rite.creed ~temporal ~observed:resolution.Precedence.observed.Precedence.cel ~date + in { Liturgical_day.date; rite = rite.Rite.id; @@ -559,6 +562,7 @@ let build_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) omitted; citations; formulary; + creed; } let year (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) (y : int) : diff --git a/lib/kernel/liturgical_day.ml b/lib/kernel/liturgical_day.ml index 709f878..09ac0a1 100644 --- a/lib/kernel/liturgical_day.ml +++ b/lib/kernel/liturgical_day.ml @@ -26,5 +26,6 @@ type ('s, 'r) t = { formulary : Mass_formulary.t option; (** which Mass the day says, and how that was decided; [None] only for a rite with no lectionary -- see {!Mass_formulary} *) + creed : bool; (** whether the Creed is said at this day's Mass; see {!Rite.t.creed} *) } [@@deriving sexp] diff --git a/lib/kernel/liturgical_day.mli b/lib/kernel/liturgical_day.mli index 4a71d6d..eb45e61 100644 --- a/lib/kernel/liturgical_day.mli +++ b/lib/kernel/liturgical_day.mli @@ -33,5 +33,9 @@ type ('s, 'r) t = { {!Mass_formulary}. [None] only for a rite with no lectionary; for EF it is [Some] on every day of every year 1583..9999, asserted by {!Validate}. *) + creed : bool; + (** Whether the Creed is said at this day's Mass -- {!Rite.t.creed}, + EF: RG 475-476. A decision, not an [option]: [false] for a rite + that has not implemented the rule, same as [creed] itself. *) } [@@deriving sexp] diff --git a/lib/kernel/rite.ml b/lib/kernel/rite.ml index a7d21d3..4999fc2 100644 --- a/lib/kernel/rite.ml +++ b/lib/kernel/rite.ml @@ -18,4 +18,5 @@ type ('s, 'r) t = { date:Date.t -> temporal_at:(Date.t -> ('s, 'r) Temporal.t) -> Mass_formulary.t option * Citation.t list; + creed : temporal:('s, 'r) Temporal.t -> observed:'r Celebration.t -> date:Date.t -> bool; } diff --git a/lib/kernel/rite.mli b/lib/kernel/rite.mli index 45f3ed6..39538d0 100644 --- a/lib/kernel/rite.mli +++ b/lib/kernel/rite.mli @@ -92,4 +92,19 @@ type ('s, 'r) t = { temporal identity (the preceding Sunday's, for the ferial rule) without re-implementing the temporal cycle -- the same shape [transfer_target]'s own [occupant] callback established. *) + creed : temporal:('s, 'r) Temporal.t -> observed:'r Celebration.t -> date:Date.t -> bool; + (** Whether the Creed is said, post-Gospel/homily, at this day's Mass + (EF: RG 475-476). A [bool], not an [option]: this is a decision, + and a rite that has not implemented the rule returns [false] + explicitly rather than leaving the question unanswered. + + [temporal] and [observed] are supplied for the same reason + [readings] gets both: a rubric like this one can turn on either + the day's TEMPORAL-cycle identity (e.g. "is this a Sunday, even + one a feast has displaced") or on the celebration actually + observed, and only the rite knows which. [date] is supplied for + the same reason [readings] gets it too -- a rubric keyed to an + Easter-relative window (e.g. "within the octave of Easter") needs + the civil date and the rite's own Easter to test it, and neither + [temporal] nor [observed] alone carries that arithmetic. *) } diff --git a/lib/rites/rite_ef/precedence_ef.mli b/lib/rites/rite_ef/precedence_ef.mli index 4c2523a..0100d40 100644 --- a/lib/rites/rite_ef/precedence_ef.mli +++ b/lib/rites/rite_ef/precedence_ef.mli @@ -132,6 +132,22 @@ val sunday_marker : string recognising a live candidate again. *) val major_litanies_slug : string +(** RG 112(d)'s own closed, hand-verified list of sanctoral entries that are + themselves a feast/commemoration OF the Blessed Virgin Mary in her own + right -- see the .ml's own citation for the full derivation and the + entries considered and excluded. A celebration is "of the BVM" for + {!disposition}'s RG 112(d) branch when its [subject] is [Subject.Bvm] OR + its slug is on this list ({!Celebration.t}'s [subject] field is NOT + reliably [Bvm] for most Marian sanctoral entries -- bootstrapped from + lectio, most carry [Saint] instead, this list is the reliable signal). + + Exposed (task 5, the Creed, RG 475(c) "festis II classis... B. Mariae + Virg.") so a second rite-local consumer can test the identical + Marian-identity question without re-deriving its own list and risking + drift from this one -- the same reasoning {!vigil_feast_table} and + {!is_vigil} are already exposed for. *) +val marian_slugs : string list + (** [disposition ~winner ~loser]: RG 92-95, 33, 21-27, 94 (docs/research/ rules-register.md §4, "Occurrence", "Vigils" and "Caput IV, 'De feriis'"). What becomes of a losing candidate, decided by the LOSER's diff --git a/lib/rites/rite_ef/rite_ef.ml b/lib/rites/rite_ef/rite_ef.ml index ec6d2b9..e48ff33 100644 --- a/lib/rites/rite_ef/rite_ef.ml +++ b/lib/rites/rite_ef/rite_ef.ml @@ -8,6 +8,7 @@ module Vocab_ef = Vocab_ef module Temporal_ef = Temporal_ef module Precedence_ef = Precedence_ef module Lectionary_ef = Lectionary_ef +module Rubrics_ef = Rubrics_ef open Colitur_kernel @@ -45,4 +46,5 @@ let context ~lectionary ~commons : (Vocab_ef.season, Vocab_ef.rank) Rite.t = vigil_feast = Precedence_ef.vigil_feast }; season_runs = Vocab_ef.seasons; transfer_target = Precedence_ef.transfer_target; - readings = Lectionary_ef.readings ~lectionary ~commons } + readings = Lectionary_ef.readings ~lectionary ~commons; + creed = Rubrics_ef.creed } diff --git a/lib/rites/rite_ef/rite_ef.mli b/lib/rites/rite_ef/rite_ef.mli index 2183799..a4b5d87 100644 --- a/lib/rites/rite_ef/rite_ef.mli +++ b/lib/rites/rite_ef/rite_ef.mli @@ -10,6 +10,7 @@ module Vocab_ef = Vocab_ef module Temporal_ef = Temporal_ef module Precedence_ef = Precedence_ef module Lectionary_ef = Lectionary_ef +module Rubrics_ef = Rubrics_ef (** The EF rite, bundled (design spec's [RITE] signature, realised as a {!Colitur_kernel.Rite.t} value rather than a functor -- see rite.mli): @@ -32,6 +33,9 @@ module Lectionary_ef = Lectionary_ef 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. + - [creed]: {!Rubrics_ef.creed}, RG 475-476 -- whether the Creed is said. + The first rubric in this phase governing a part of Mass rather than + occurrence/precedence. Deliberately carries no [sanctoral]/[lectionary] fields the way the original design-doc sketch of [RITE] does: {!Colitur_kernel.Rite.t} (the diff --git a/lib/rites/rite_ef/rubrics_ef.ml b/lib/rites/rite_ef/rubrics_ef.ml new file mode 100644 index 0000000..eef86cb --- /dev/null +++ b/lib/rites/rite_ef/rubrics_ef.ml @@ -0,0 +1,223 @@ +(* RG 475-476 (docs/research/LT.txt, grep "dicitur symbolum"; scan1.txt line + ~3872-3888), quoted here in full so every branch below can cite its own + letter without re-quoting the whole rubric: + + "475. Post Evangelium aut homiliam, dicitur symbolum: + a) in qualibet dominica, etsi eius Officium alicui festo locum + cedat, vel Missa votiva II classis celebretur; + b) in festis I classis et in Missis votivis I classis; + c) in festis II classis Domini et B. Mariae Virg.; + d) per octavas Nativitatis Domini, Paschatis et Pentecostes, etiam + in festis occurrentibus et in Missis votivis; + e) in festis nataliciis Apostolorum et Evangelistarum, necnon in + festis Cathedrae S. Petri et S. Barnabae Ap. + + 476. Non dicitur symbolum: + a) in Missis sive chrismatis sive in Cena Domini, feria V + Hebdomadae sanctae, et in Missa Vigiliae paschalis; + b) in festis II classis, iis exceptis quae supra, n. 475 c et e, + recensentur; + c) in Missis votivis II classis; + d) in Missis festivis et votivis III et IV classis; + e) ratione alicuius commemorationis in Missa occurrentis; + f) in Missis defunctorum." + + SCOPE NOTE, checked once here rather than at every clause below: this + engine resolves ONE observed office and ONE Mass per civil day (see + Rite.t.readings' own doc comment) -- it has no separate "which votive + Mass is said" dimension. So the "vel/et...votivis" halves of 475(a)/(b), + 476(c) entirely, 476(d)'s "et votivis" half, and 476(f) (Requiem Masses, + also not modelled) are genuinely inapplicable to this implementation -- + a documented scope limit, not a defect. 476(e) needs no branch at all: + [creed] below reads only [observed], never a day's admitted + commemorations, so a commemoration can never change its answer by + construction. *) + +open Colitur_kernel + +(* RG 475(e): "in festis nataliciis Apostolorum et Evangelistarum, necnon in + festis Cathedrae S. Petri et S. Barnabae Ap." NATALICIUM means the feast + of the saint's own death (dies natalis) -- not every feast that merely + names him. That is precisely why the clause has to name the Chair of St + Peter and St Barnabas EXPLICITLY: neither is a natalicium (Peter's own is + 29 June, shared with Paul; Barnabas's is his own day, 11 June, but the + clause names him anyway, redundantly with the natalicium reading, rather + than leave it to inference), so neither would be covered without the + explicit "necnon". + + DERIVED, not copied from any list supplied with this task: grepped + data/ef/sanctoral.sexp directly for every entry whose English or Polish + name mentions "Apostle"/"Evangelist"/"Aposto{l/ł}a", then each + candidate's own date, rank and status checked against the calendarium + and against whether it is that saint's own dies natalis. Every entry + below was cross-checked against the shipped data, not assumed: + + andrew (30 Nov, Class2) -- his natalicium. + barnabas (11 June, Class3) -- named explicitly; also his own natalicium. + bartholomew (24 Aug, Class2) -- his natalicium. + chair-of-st-peter (22 Feb, Class2) -- named explicitly ("Cathedrae S. + Petri"); NOT a natalicium (Peter's own is 29 June, shared with Paul) + -- exactly why the clause has to name it. + james-the-greater (25 July, Class2) -- his natalicium. + john-the-evangelist (27 Dec, Class2) -- his natalicium (the one Apostle + traditionally held to have died a natural death; "natalicium" still + names his own feast day, not only a martyr's). + luke-the-evangelist (18 Oct, Class2) -- his natalicium. + mark (25 April, Class2) -- the Evangelist ("Marka Ewangelisty" in the + data's own Polish name); NOT "mark-i" (7 Oct), a different saint (a + Pope), excluded. + matthew (21 Sep, Class2) -- Apostle and Evangelist, his natalicium. + matthias (24 Feb, Class2) -- his natalicium. + sts-peter-paul (29 June, Class1) -- the natalicium of both. + sts-philip-james (11 May, Class2) -- the natalicium of both (James the + Less; there is no separate "james-the-less" entry in the data). + sts-simon-jude (28 Oct, Class2) -- the natalicium of both. + thomas (21 Dec, Class2) -- his natalicium. + + Checked and DELIBERATELY EXCLUDED (the Trap this clause is built around): + conversion-of-st-paul (25 Jan, Class3) -- not a natalicium: it + commemorates an EVENT of his life, not his death. + in-commemoratione-sancti-pauli-apostoli (30 June, Class3, status + Feast, so it CAN be observed, unlike the two entries below) -- a + secondary commemoration of Paul, not his dies natalis (his own is 29 + June, with Peter); its own name says so ("In Commemoratione", not a + feast of his martyrdom). + "peter" (25 Jan) and "paul" (22 Feb) -- both status + [Commemoration_only] (RG 110's own Peter/Paul companions, on the + Conversion and Chair days respectively: {!Precedence_ef.disposition}'s + own citation), so neither can ever be [observed]; moot either way, + but neither is a natalicium regardless. + mark-i (7 Oct) -- a different saint (Pope St Mark), not the + Evangelist. *) +let creed_apostle_slugs = + [ "andrew"; "barnabas"; "bartholomew"; "chair-of-st-peter"; "james-the-greater"; + "john-the-evangelist"; "luke-the-evangelist"; "mark"; "matthew"; "matthias"; + "sts-peter-paul"; "sts-philip-james"; "sts-simon-jude"; "thomas" ] + +let creed ~(temporal : (Vocab_ef.season, Vocab_ef.rank) Temporal.t) + ~(observed : Vocab_ef.rank Celebration.t) ~(date : Date.t) : bool = + let easter = Computus.gregorian_easter (Date.year date) in + let n = Date.to_rata date - Date.to_rata easter in + let m = Date.month date and dd = Date.day date in + let slug = Slug.to_string observed.Celebration.slug in + if + (* RG 475(d): "per octavas Nativitatis Domini, Paschatis et + Pentecostes, etiam in festis occurrentibus et in Missis votivis" -- + an unconditional window override, checked first: EVEN a saint's + feast that wins the day within one of the three octaves (St Stephen, + 26 December, is the live witness -- RG 67's own "Com. octavae + Nativitatis" note, quoted in full in temporal_ef.ml's [named]) still + says the Creed. Pure date/Easter-offset arithmetic, not season or + rank: Ascension (Easter+39) and the Pentecost Vigil (Easter+48) are + both [Class1] and both fall inside the Paschaltide SEASON but + outside either 8-day OCTAVE, so a rank- or season-based test here + would wrongly include them -- checked and rejected for exactly this + reason. + + Nativity: 25-31 December (its own day plus 7) + 1 January (RG 91 + entry 5's own "Octave Day of the Nativity", the identical table + entry as 24 December's vigil -- temporal_ef.ml's [named]). Easter: + Easter Sunday (offset 0) through Low Sunday (offset 7) -- RG 11's + own "dominicae Paschatis et Pentecostes sunt pariter festa I classis + CUM OCTAVA". Pentecost: Pentecost (offset 49) through Trinity Sunday + (offset 56), the RG 91-entry-14-adjacent "octave day" Trinity Sunday + itself names in temporal_ef.ml. *) + (m = 12 && dd >= 25 && dd <= 31) + || (m = 1 && dd = 1) + || (n >= 0 && n <= 7) + || (n >= 49 && n <= 56) + then true + else if + (* RG 475(a): "in qualibet dominica, ETSI EIUS OFFICIUM ALICUI FESTO + LOCUM CEDAT" -- the Creed is said on any Sunday even when a feast + displaces the Sunday's own office (RG 16(a): a Feast of the Lord I + or II class occurring on a II-class Sunday takes its place "cum + omnibus iuribus et privilegiis"). Read off [temporal]'s own weekday + -- the day's calendar fact, independent of whatever [observed] turns + out to be -- never off [observed]'s slug or rank, which is exactly + what would go silently wrong the day such an impeding feast wins: + see {!Colitur_kernel.Precedence.rules.admit}'s own [~temporal] + parameter (precedence.mli) for the identical argument, made there + for RG 111(b) rather than RG 475(a). *) + temporal.Temporal.weekday = Date.Sun + then true + else if + (* RG 23 (Caput IV, "De Feriis"): "Feriae I classis sunt: a) feria IV + cinerum; b) omnes feriae Hebdomadae sanctae." Ash Wednesday and every + feria of Holy Week (Monday through Saturday -- RG 91 entry 2's + Sacred Triduum, Thursday-Saturday, is a THIRD sub-case of this same + "feria", not a "festum": RG 35, immediately below in Caput VI, + defines "festum" as a distinct liturgical-day category from "feria", + RG 21-27) are FERIAE, never FESTA, however high their RG 91 rank -- + so 475(b)/(c)'s "in festis" never reaches them, regardless of rank. + + This single structural check subsumes 476(a)'s own explicit naming + of the Chrism Mass, the Mass of the Lord's Supper (both Holy + Thursday) and the Easter Vigil Mass (Holy Saturday's date): both are + already excluded here as Holy Week feriae, so 476(a) needs no + separate branch. (Good Friday needs no rubric at all -- RG 28's own + closing sentence on the Paschal Vigil aside, Good Friday's own + liturgical action has no Mass in the 1955-restored Holy Week to + begin with, so the question is moot there independent of this + check -- but this structural test correctly excludes it too, since + it is also named in RG 23(b).) *) + n = -46 || (n >= -6 && n <= -1) + then false + else if + (* RG 28-34 (Caput V, "De Vigiliis"): a vigil is its OWN liturgical-day + category, distinct from "festum" (RG 35, Caput VI) the same way a + feria is (immediately above) -- so 475(b)/(c)'s "in festis" does not + reach a vigil either, regardless of its own RG 91 rank. This is also + why 476(a) has to name the Easter Vigil explicitly: RG 28's own + closing sentence says the Paschal Vigil, uniquely, "non sit dies + liturgicus" [is not a liturgical day] at all, so it is not even a + "vigilia" in RG 29-32's numbered sense -- nothing else in this + taxonomy would have excluded it without that explicit clause, unlike + every OTHER vigil, which is excluded merely by being one. + {!Precedence_ef.is_vigil} already tests both slug conventions this + codebase's data uses (the temporal cycle's "-vigil" suffix and the + sanctoral bootstrap's "vigil-of-" prefix) for the identical RG 33 + question; reused here rather than re-derived, on the same footing as + {!Precedence_ef.marian_slugs} just below. *) + Precedence_ef.is_vigil slug + then false + else if + (* RG 475(b): "in festis I classis". Genuine feasts only, by + construction of the two exclusions immediately above (feriae, + vigils) -- every remaining [Class1] candidate reaching this branch + is a real festum: the Nativity, Epiphany, Ascension, Corpus Christi, + the Sacred Heart, Christ the King, a I-class sanctoral feast (the + Assumption, the Immaculate Conception...), or a I-class Sunday + (already [true] above via 475(a), so this branch is never the FIRST + to grant those a [true], only ever redundant with it). *) + observed.Celebration.rank = Vocab_ef.Class1 + then true + else if + (* RG 475(c): "in festis II classis Domini et B. Mariae Virg." -- + [subject = Lord] is reliably set on genuine II-class sanctoral + feasts of the Lord (Exaltation of the Holy Cross, the Purification, + the Transfiguration, the Commemoration of the Baptism of the Lord, + the Dedication of the Lateran Archbasilica -- checked directly + against data/ef/sanctoral.sexp: six [subject = Lord] entries ship, + none a vigil or feria) and on the two temporal-cycle Class2 Lord + feasts (Holy Family, Holy Name of Jesus). [subject = Bvm], by + contrast, is NOT reliable for the BVM half: checked directly against + the data, almost every Marian sanctoral entry (the Assumption, the + Annunciation, the Immaculate Heart, the Nativity of the BVM...) + ships [subject = Saint] instead -- {!Precedence_ef.marian_slugs} is + the list built (and, here, reused rather than re-derived) precisely + because the [subject] field cannot be trusted alone for this + question; see its own citation in precedence_ef.mli. *) + observed.Celebration.rank = Vocab_ef.Class2 + && (observed.Celebration.subject = Subject.Lord + || observed.Celebration.subject = Subject.Bvm + || List.mem slug Precedence_ef.marian_slugs) + then true + else + (* RG 475(e): see {!creed_apostle_slugs}'s own citation. Checked last + and without a rank guard, on purpose -- Barnabas is only [Class3] + and the Chair of St Peter's own [subject] is [Saint], so neither + would ever be reached by the two branches above; every [Class1] + entry on the list (Sts Peter & Paul) is already [true] via 475(b), + so this branch is redundant, never wrong, for those. *) + List.mem slug creed_apostle_slugs diff --git a/lib/rites/rite_ef/rubrics_ef.mli b/lib/rites/rite_ef/rubrics_ef.mli new file mode 100644 index 0000000..16e7491 --- /dev/null +++ b/lib/rites/rite_ef/rubrics_ef.mli @@ -0,0 +1,37 @@ +(** RG 475-476 (docs/research/LT.txt, grep "dicitur symbolum") -- whether the + Creed is said at Mass. The first rubric in this phase governing a PART OF + MASS rather than occurrence/precedence, hence its own module: see the + .ml's own header for the rubric quoted in full and every branch's + citation. *) + +open Colitur_kernel + +(** RG 475(e)'s "festis nataliciis Apostolorum et Evangelistarum, necnon in + festis Cathedrae S. Petri et S. Barnabae Ap." -- see the .ml's own + citation for how each entry was derived from and verified against + data/ef/sanctoral.sexp, and for what was deliberately excluded. Exposed + so the test suite can assert completeness against the shipped data the + same way {!Precedence_ef.vigil_feast_table} already lets it. *) +val creed_apostle_slugs : string list + +(** Whether the Creed is said, post-Gospel/homily, at the Mass this day + resolves to. + + [temporal] is read for exactly one fact -- RG 475(a)'s own "in qualibet + dominica, ETSI EIUS OFFICIUM ALICUI FESTO LOCUM CEDAT" ("on any Sunday, + EVEN WHEN a feast displaces its own Office"): a Sunday impeded by a + Feast of the Lord (RG 16(a)) still says the Creed, so this reads the + TEMPORAL cycle's own weekday, never [observed]'s. Every other clause + reads [observed] -- the celebration whose Mass is actually said -- and + [date], read once against the rite's own (Gregorian) Easter for the two + Easter-relative window questions (RG 475(d)'s three octaves, RG 23's + Ash-Wednesday-and-Holy-Week feriae). [creed] never reads + {!Liturgical_day.t.commemorations}: RG 476(e), "ratione alicuius + commemorationis in Missa occurrentis" [never say the Creed merely + because of a commemoration], holds by construction rather than by a + checked branch. *) +val creed : + temporal:(Vocab_ef.season, Vocab_ef.rank) Temporal.t -> + observed:Vocab_ef.rank Celebration.t -> + date:Date.t -> + bool diff --git a/man/colitur.1 b/man/colitur.1 index 5a8ebbf..29f5640 100644 --- a/man/colitur.1 +++ b/man/colitur.1 @@ -122,10 +122,11 @@ occurrence, commemoration and transfer. The Mass reading citations, one line per day. .TP .BI rubrics " YEAR" -The Mass formulary actually said, one line per day \(em not always the -day's own: a weekday with no proper resumes the preceding Sunday's, a -saint with no proper says his assigned Common, and RG 78/309(a)'s votive -Saturday Mass of Our Lady is said in place of an unoccupied office's own. +Two rubrics of the Mass, one line per day: which formulary is actually +said \(em not always the day's own: a weekday with no proper resumes the +preceding Sunday's, a saint with no proper says his assigned Common, and +RG 78/309(a)'s votive Saturday Mass of Our Lady is said in place of an +unoccupied office's own \(em and whether the Creed is said (RG 475\-476). See .B OUTPUT FORMAT below. @@ -459,12 +460,12 @@ own trailing field, above. .SS rubrics .RS .nf -date [TAB] formulary\-slug [TAB] source +date [TAB] formulary\-slug [TAB] source [TAB] creed .fi .RE .PP -The day's own Mass formulary: which slug's Mass is actually said, and how -that was decided. +The day's own Mass formulary (which slug's Mass is actually said, and how +that was decided), followed by whether the Creed is said (RG 475\-476). .B rubrics separates its fields with a literal TAB \(em not a plain space like .B day @@ -473,9 +474,9 @@ or like .B readings \(em because a resolved formulary NAME (a column a later version may add, -not this one) can carry both spaces and punctuation a citation never does, -which rules out either separator already in use above. A separate command -for the identical mechanical reason +not either of these) can carry both spaces and punctuation a citation never +does, which rules out either separator already in use above. A separate +command for the identical mechanical reason .B day is separate from .BR readings : @@ -487,12 +488,25 @@ leave it unsplittable by field number. .I source is one of .BR proper ", " own ", " preceding\-sunday ", " common " or " votive . +.I creed +is +.B true +or +.B false +(OCaml's own literal, not +.RB \(lq yes / no \(rq +or +.RB \(lq 1/0 \(rq : +this row has no other boolean field to be consistent with). A day with no +Mass at all for a rite that has not implemented the rule reads +.B false +outright \(em it is a decision, never a third \(lqunknown\(rq state. .RS .nf -2026\-01\-01 [TAB] ef\-circumcision [TAB] own -2038\-03\-08 [TAB] john\-of\-god [TAB] proper -2025\-12\-01 [TAB] ef\-advent\-sunday\-1 [TAB] preceding\-sunday +2026\-01\-01 [TAB] ef\-circumcision [TAB] own [TAB] true +2038\-03\-08 [TAB] john\-of\-god [TAB] proper [TAB] false +2025\-12\-01 [TAB] ef\-advent\-sunday\-1 [TAB] preceding\-sunday [TAB] false .fi .RE .PP diff --git a/test/cli.t b/test/cli.t index 398c6fb..ac1351b 100644 --- a/test/cli.t +++ b/test/cli.t @@ -210,9 +210,9 @@ separate command for the same mechanical reason `readings` is: `day`'s row is fixed-width space-separated with a variable-length "+slug" tail. $ colitur rubrics 2026 | head -3 - 2026-01-01 ef-circumcision own - 2026-01-02 ef-christmas-1-friday own - 2026-01-03 ef-christmas-1-saturday votive + 2026-01-01 ef-circumcision own true + 2026-01-02 ef-christmas-1-friday own false + 2026-01-03 ef-christmas-1-saturday votive false $ colitur rubrics 2026 | wc -l 365 @@ -224,19 +224,19 @@ apply to it -- step 2 does (the day's own temporal slug in the lectionary), tagged `own`. Contrast a real sanctoral saint with his own proper: $ colitur rubrics 2038 | grep '^2038-03-08' - 2038-03-08 john-of-god proper + 2038-03-08 john-of-god proper false A saint with no proper of his own says his assigned Common (step 4): $ colitur rubrics 2038 | grep '^2038-03-06' - 2038-03-06 common-of-non-virgins-1 common + 2038-03-06 common-of-non-virgins-1 common false A weekday with no proper of its own resumes the preceding Sunday's, never its own observed slug -- 1 December 2025 is the Monday after Advent I, and Advent's ferias have no Mass of their own (step 3): $ colitur rubrics 2025 | grep '^2025-12-01' - 2025-12-01 ef-advent-sunday-1 preceding-sunday + 2025-12-01 ef-advent-sunday-1 preceding-sunday false 3 January 2026 above ("votive") is the RG 78/309(a) Saturday Mass of Our Lady, said IN PLACE of the day's own office's Mass while the office (an @@ -257,9 +257,9 @@ diocesan overlay's local patron observed instead (no proper or Common of his own in the fixture), the chain falls all the way back to step 3: $ colitur rubrics 2026 --overlay fixtures/overlay-example-diocesan.sexp | grep '^2026-07-11' - 2026-07-11 ef-time-after-pentecost-sunday-6 preceding-sunday + 2026-07-11 ef-time-after-pentecost-sunday-6 preceding-sunday false $ colitur rubrics 2026 | grep '^2026-07-11' - 2026-07-11 ef-time-after-pentecost-6-saturday votive + 2026-07-11 ef-time-after-pentecost-6-saturday votive false `--lang`/`--raw`/`--sigla-*` are refused rather than silently ignored, unlike `readings`: this row resolves no display name and no citation for any of @@ -537,7 +537,7 @@ CSV run rather than one per year: sexp and xml are also available: $ colitur emit --format sexp --from 2027 --to 2027 | wc -l - 8881 + 9010 $ colitur emit --format xml --from 2027 --to 2027 | head -2 diff --git a/test/test_calendar.ml b/test/test_calendar.ml index 599dfb2..74fe26a 100644 --- a/test/test_calendar.ml +++ b/test/test_calendar.ml @@ -104,6 +104,11 @@ module Fixture = struct the sanctoral side. *) let readings ~observed:_ ~temporal:_ ~date:_ ~temporal_at:_ = (None, []) + (* No fixture here exercises the Creed rubric either -- a rite that has + not implemented it returns [false] explicitly, {!Rite.t.creed}'s own + documented default. *) + let creed ~temporal:_ ~observed:_ ~date:_ = false + let rite : (season, rank) Rite.t = { Rite.id = "synthetic-calendar"; vocab; year_start; temporal; anchors = (fun _ -> []); (* Not a Roman rite, but a Rite.t must supply SOME Easter now that @@ -111,7 +116,7 @@ module Fixture = struct any for a fixture; nothing here is Easter-relative, so the value is never actually read. *) easter = Colitur_kernel.Computus.gregorian_easter; - rules; season_runs = [ A; B ]; transfer_target; readings } + rules; season_runs = [ A; B ]; transfer_target; readings; creed } let entry ~month ~day ~slug ~rank = { Layer.date = (match Date_spec.fixed ~month ~day with Ok d -> d | Error e -> failwith e); diff --git a/test/test_colitur.ml b/test/test_colitur.ml index 2d7f5e1..60b61fc 100644 --- a/test/test_colitur.ml +++ b/test/test_colitur.ml @@ -13,6 +13,7 @@ let () = Test_config.suite; Test_overlay.suite; Test_overlay_ini.suite; Test_temporal_ef.suite; Test_validate.suite; Test_precedence.suite; Test_calendar.suite; Test_precedence_ef.suite; Test_sanctoral_ef.suite; Test_rite_ef.suite; + Test_rubrics_ef.suite; Test_differential.suite; Test_oracle.suite; Test_oracle.suite_2038; Test_oracle.suite_2035; Test_golden.suite; ("lectionary", Test_lectionary.suite); ("lectionary-ef", Test_lectionary_ef.suite); diff --git a/test/test_rubrics_ef.ml b/test/test_rubrics_ef.ml new file mode 100644 index 0000000..2000d61 --- /dev/null +++ b/test/test_rubrics_ef.ml @@ -0,0 +1,240 @@ +(* RG 475-476, the Creed -- see lib/rites/rite_ef/rubrics_ef.ml for the + rubric quoted in full and every branch's own citation. + + One end-to-end test per clause of 475, plus a 476 negative, resolved + against REAL calendar dates through the shipped sanctoral data (the same + pipeline `colitur day`/`colitur rubrics` use) -- every expected value + below was derived from the rubric's own text and checked against + `colitur day `'s real output (slug/rank/subject/weekday) BEFORE + this module existed, never read off [Rubrics_ef.creed]'s own answer. + Two synthetic unit tests isolate Trap One (RG 475(a) reads [temporal], + never [observed]) directly, without depending on finding a real-calendar + coincidence. *) + +module Cal = Colitur_kernel.Calendar +module LD = Colitur_kernel.Liturgical_day +module Date = Colitur_kernel.Date +module Cel = Colitur_kernel.Celebration +module Colour = Colitur_kernel.Colour +module Subject = Colitur_kernel.Subject +module Slug = Colitur_kernel.Slug +module Temporal = Colitur_kernel.Temporal +module V = Rite_ef.Vocab_ef +module RE = Rite_ef.Rubrics_ef + +let mk y m d = match Date.make ~year:y ~month:m ~day:d with Ok t -> t | Error e -> failwith e + +(* Loaded once, module-level: every test below is a lookup against the same + shipped calendar, and {!Cal.day} recomputes its whole liturgical year on + every call (calendar.mli's own documented cost), so at minimum the layer + itself should not be reloaded and re-merged per test case. *) +let layer = + match Test_support.load_ef_layer () with Ok l -> l | Error e -> Alcotest.failf "%s" e + +let ctx = Test_support.ef_context () + +let creed_on y m d = (Cal.day ctx layer (mk y m d)).LD.creed + +let check name y m d expected = Alcotest.(check bool) name expected (creed_on y m d) + +(* ---- RG 475(a): "in qualibet dominica, etsi eius Officium alicui festo + locum cedat" ---- *) + +let test_475a_ordinary_sunday () = + (* 2026-01-25: an ordinary Time-after-Epiphany Sunday, Class2, green, + "Dominica III post Epiphaniam" -- confirmed via `colitur day 2026`, + no other clause of 475 could apply (Class2, subject Temporal, no + octave, no apostle/vigil slug), so [true] here can only come from + 475(a) itself. *) + check "475(a): an ordinary Sunday" 2026 1 25 true + +(* Trap One, isolated directly: RG 475(a)'s own "ETSI EIUS OFFICIUM ALICUI + FESTO LOCUM CEDAT" -- even when a feast has displaced the Sunday's own + office, the Creed is still said. [observed] below is deliberately shaped + so that NONE of 475(b)/(c)/(e) can produce [true] on their own (Class3, + subject Saint, a slug on no list this module knows); the only way + [creed] can return [true] is by reading [temporal]'s own [weekday], + never [observed]. The synthetic [date] (an ordinary July day, itself a + Wednesday in 2026) is chosen so nothing about the DATE itself suggests + a Sunday either -- proving the function reads [temporal.weekday], not + [Date.weekday date]. *) +let impeded_observed = + Cel.make ~slug:(Slug.of_string_exn "some-impeding-feast-of-the-lord") ~rank:V.Class3 + ~status:Cel.Feast ~colour:Colour.Red ~subject:Subject.Saint ~layer:"synthetic" () + +let synthetic_temporal ~weekday : (V.season, V.rank) Temporal.t = + { Temporal.season = V.Time_after_pentecost; week = Some 1; weekday; office = impeded_observed } + +let test_475a_reads_temporal_not_observed () = + Alcotest.(check bool) "Sunday-shaped [temporal] overrides a non-Sunday-shaped [observed]" true + (RE.creed ~temporal:(synthetic_temporal ~weekday:Date.Sun) ~observed:impeded_observed + ~date:(mk 2026 7 1)); + Alcotest.(check bool) "same [observed], non-Sunday [temporal]: false" false + (RE.creed ~temporal:(synthetic_temporal ~weekday:Date.Wed) ~observed:impeded_observed + ~date:(mk 2026 7 1)) + +(* ---- RG 475(b): "in festis I classis" ---- *) + +let test_475b_class1_feast () = + (* 2026-08-15: the Assumption, Class1. *) + check "475(b): a I-class feast" 2026 8 15 true + +(* ---- RG 475(c): "in festis II classis Domini et B. Mariae Virg." ---- *) + +let test_475c_lord () = + (* 2026-09-14: Exaltation of the Holy Cross, Class2, subject Lord. *) + check "475(c): a II-class feast of the Lord" 2026 9 14 true + +let test_475c_bvm_via_marian_slugs () = + (* 2026-08-22: Immaculate Heart of Mary, Class2 -- ships [subject = + Saint] in data/ef/sanctoral.sexp (confirmed by grep), so this can only + come out [true] via {!Rite_ef.Precedence_ef.marian_slugs}, not via + [subject = Bvm]. Also carries a real commemoration + (+sts-timothy-hippolytus-and-symphorianus-martyrs), a live instance of + 476(e): the commemoration plays no part in this answer. *) + check "475(c): a II-class BVM feast (via marian_slugs, subject=Saint in the data)" 2026 8 22 true + +(* ---- RG 475(d): "per octavas Nativitatis Domini, Paschatis et + Pentecostes, etiam in festis occurrentibus et in Missis votivis" ---- *) + +let test_475d_octave_even_occurring_feast () = + (* 2026-12-26: St Stephen, Class2, "S. Stephani Protomartyris" -- a real + saint's feast OCCURRING within the Octave of the Nativity (RG 67's own + "Com. octavae Nativitatis", carried as +ef-nativity-octave-day-2 in + colitur's own commemoration). RG 475(d)'s own "etiam in festis + occurrentibus" is written for exactly this shape: the Creed is said + regardless. (This is the one place this suite deliberately diverges + from the task brief's own worked example, which expected [false] here + -- the brief mis-cited 26 December as 476(b)'s "plain II-class feast" + case, missing that RG 475(d) explicitly overrides 476(b) inside the + Nativity octave; see the task report.) *) + check "475(d): a saint's feast occurring within the Nativity octave" 2026 12 26 true + +let test_475d_octave_day_boundary () = + (* 2026-01-01: the Octave Day of the Nativity itself (Circumcision), + Class1 -- also [true] via 475(b) alone, kept as a boundary check that + 1 January is correctly included in the 8-day window. *) + check "475(d): 1 January, the Octave Day of the Nativity" 2026 1 1 true + +(* ---- RG 475(e): "in festis nataliciis Apostolorum et Evangelistarum, + necnon in festis Cathedrae S. Petri et S. Barnabae Ap." ---- *) + +let test_475e_apostle_natalicium () = + (* 2026-11-30: St Andrew, Class2, subject Saint -- not covered by 475(c) + (not Domini/BVM), so [true] here can only come from 475(e)'s own + natalicia list. Also carries a real commemoration + (+ef-advent-1-monday), another live 476(e) instance. *) + check "475(e): an Apostle's own natalicium (Andrew)" 2026 11 30 true + +let test_475e_barnabas_named_explicitly () = + (* 2026-06-11: St Barnabas, Class3 -- named explicitly by the clause + ("S. Barnabae Ap."); at Class3 it could not reach [true] via 475(b) or + (c) regardless. *) + check "475(e): St Barnabas, named explicitly" 2026 6 11 true + +let test_475e_chair_of_peter_named_explicitly () = + (* 2027-02-22 (NOT 2026: Feb 22 2026 is impeded by Lent I Sunday, so the + Chair is not observed that year -- checked via `colitur day 2026` + before picking 2027 instead): the Chair of St Peter, Class2, subject + Saint -- NOT a natalicium (Peter's own is 29 June, shared with Paul), + so [true] here can only come from the clause's own explicit "Cathedrae + S. Petri" naming, not from the natalicium reading in general. *) + check "475(e): the Chair of St Peter, named explicitly (not a natalicium)" 2027 2 22 true + +let test_475e_excludes_conversion_of_paul () = + (* Trap Two, directly: 2027-01-25, the Conversion of St Paul, Class3 -- + names an Apostle but is NOT his natalicium (his own is 29 June, with + Peter); Class3 rules out 475(b)/(c), and this slug is deliberately + absent from [creed_apostle_slugs]. Picked 2027 for the same impeded- + Sunday reason as the Chair of Peter above (25 January 2026 is itself a + Sunday). *) + check "475(e) does NOT cover the Conversion of St Paul (not a natalicium)" 2027 1 25 false + +(* ---- RG 23 (feriae) / RG 476(a): Ash Wednesday, Holy Week's own feriae, + the Chrism/Lord's Supper Mass, the Easter Vigil Mass ---- *) + +let test_ash_wednesday_no_creed () = + check "RG 23(a)/476: Ash Wednesday, a I-class FERIA, not a festum" 2026 2 18 false + +let test_holy_thursday_no_creed () = + (* 2026-04-02: "Feria V in Cena Domini" -- RG 23(b)'s own "omnes feriae + Hebdomadae sanctae"; also explicitly named by 476(a) ("sive... in + Cena Domini"). *) + check "RG 23(b)/476(a): Holy Thursday (Mass of the Lord's Supper)" 2026 4 2 false + +let test_holy_saturday_easter_vigil_no_creed () = + (* 2026-04-04: "Sabbato sanctum" -- RG 23(b) again; also explicitly named + by 476(a) ("in Missa Vigiliae paschalis"). *) + check "RG 23(b)/476(a): Holy Saturday (the Easter Vigil Mass)" 2026 4 4 false + +(* ---- RG 476(b) negative: a plain II-class saint, not Domini/BVM, not an + Apostle/Evangelist ---- *) + +let test_476b_plain_class2_saint () = + (* 2026-08-10: St Lawrence, Class2, subject Saint -- a deacon and martyr, + no Apostle/Evangelist connection, not on any list this module reads. *) + check "476(b): a plain II-class saint (Lawrence) does not say the Creed" 2026 8 10 false + +(* ---- RG 28-34 (vigils): checked ahead of 475(c) so a Class2 vigil that + is ALSO on marian_slugs is still excluded ---- *) + +let test_vigil_excluded_even_when_class2_and_marian () = + (* 2026-08-14: Vigil of the Assumption, Class2 -- IS on + {!Rite_ef.Precedence_ef.marian_slugs} (a real Marian entry), so without + the vigil check ahead of 475(c) this would wrongly come out [true]. + Also carries a real commemoration (+eusebius-confessor), a second live + 476(e) instance. *) + check "vigils are excluded even when Class2 and Marian (Vigil of the Assumption)" 2026 8 14 false + +(* ---- RG 476(d): a IV-class office (also exercises RG 78/309(a)'s votive + Office of the BVM on Saturday, itself IV class) ---- *) + +let test_476d_bvm_saturday_office () = + (* 2026-07-11: the unoccupied Saturday's Office of Our Lady, Class4 -- + already a pinned example in test/cli.t (Task 4). *) + check "476(d): the BVM Saturday Office, IV class" 2026 7 11 false + +(* One full civil year, walked day by day: RG 475(a)'s own invariant, "every + Sunday says the Creed, no exceptions" -- the same sanity check the task + asks for at the domain-measurement step, pinned here as a real assertion + rather than left to a one-off shell scan. *) +let test_every_sunday_in_2026_says_the_creed () = + let days = Cal.year ctx layer 2026 in + Array.iter + (fun (d : (V.season, V.rank) LD.t) -> + if d.LD.temporal.Temporal.weekday = Date.Sun then + Alcotest.(check bool) + (Printf.sprintf "%s is a Sunday: creed must be true" (Date.to_iso8601 d.LD.date)) + true d.LD.creed) + days + +let suite = + ( "Rubrics_ef", + [ Alcotest.test_case "475(a): ordinary Sunday" `Quick test_475a_ordinary_sunday; + Alcotest.test_case "475(a): reads [temporal], not [observed] (Trap One)" `Quick + test_475a_reads_temporal_not_observed; + Alcotest.test_case "475(b): I-class feast" `Quick test_475b_class1_feast; + Alcotest.test_case "475(c): II-class feast of the Lord" `Quick test_475c_lord; + Alcotest.test_case "475(c): II-class BVM feast via marian_slugs" `Quick + test_475c_bvm_via_marian_slugs; + Alcotest.test_case "475(d): octave overrides an occurring feast" `Quick + test_475d_octave_even_occurring_feast; + Alcotest.test_case "475(d): 1 January octave-day boundary" `Quick test_475d_octave_day_boundary; + Alcotest.test_case "475(e): an Apostle's own natalicium" `Quick test_475e_apostle_natalicium; + Alcotest.test_case "475(e): Barnabas, named explicitly" `Quick + test_475e_barnabas_named_explicitly; + Alcotest.test_case "475(e): Chair of Peter, named explicitly" `Quick + test_475e_chair_of_peter_named_explicitly; + Alcotest.test_case "475(e) excludes the Conversion of St Paul (Trap Two)" `Quick + test_475e_excludes_conversion_of_paul; + Alcotest.test_case "RG 23/476: Ash Wednesday" `Quick test_ash_wednesday_no_creed; + Alcotest.test_case "RG 23/476(a): Holy Thursday" `Quick test_holy_thursday_no_creed; + Alcotest.test_case "RG 23/476(a): Holy Saturday / Easter Vigil" `Quick + test_holy_saturday_easter_vigil_no_creed; + Alcotest.test_case "476(b): plain II-class saint (negative)" `Quick + test_476b_plain_class2_saint; + Alcotest.test_case "vigils excluded even when Class2 and Marian" `Quick + test_vigil_excluded_even_when_class2_and_marian; + Alcotest.test_case "476(d): BVM Saturday Office, IV class" `Quick test_476d_bvm_saturday_office; + Alcotest.test_case "every Sunday in 2026 says the Creed" `Quick + test_every_sunday_in_2026_says_the_creed ] ) diff --git a/test/test_validate.ml b/test/test_validate.ml index 7387971..ea8fc0f 100644 --- a/test/test_validate.ml +++ b/test/test_validate.ml @@ -324,16 +324,22 @@ module Synthetic = struct { Colitur_kernel.Mass_formulary.said = Slug.of_string_exn "syn-formulary"; via = Colitur_kernel.Mass_formulary.Own_slug } + (* No fixture here exercises the Creed rubric -- a rite that has not + implemented it returns [false] explicitly, {!Rite.t.creed}'s own + documented default. Made overridable ([?creed] below) on the same + footing as [?readings] just above, for Task 6's own fixtures. *) + let creed ~temporal:_ ~observed:_ ~date:_ = false + let rite ?(vocab = vocab) ?(anchors = fun _ -> []) ?(season_runs = [ A; B ]) ?(rules = rules) - ?(transfer_target = fun _ origin _ -> origin) ?(readings = readings) temporal : - (season, rank) Rite.t = + ?(transfer_target = fun _ origin _ -> origin) ?(readings = readings) ?(creed = creed) temporal + : (season, rank) Rite.t = { Rite.id = "synthetic"; vocab; year_start; temporal; anchors; rules; season_runs; (* Not a Roman rite, but a Rite.t must supply SOME Easter now that movable Date_spec variants exist. The Gregorian one is as good as any for a fixture; nothing here is Easter-relative, so the value is never actually read. *) easter = Colitur_kernel.Computus.gregorian_easter; - transfer_target; readings } + transfer_target; readings; creed } (* Empty by default: every check built before Task 12 exercises the TEMPORAL-only pass, where an empty layer is exactly the fixture that -- cgit v1.3 From 89b6cfbc8c2a9f5921c19543f893decc2497ef69 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 22 Aug 2026 12:29:06 +0200 Subject: fix(ef): check RG 24/25's ferial invariant instead of asserting it rubrics_ef.ml's Creed check excluded feriae from 475(b)/(c)/(e) via an explicit test for feria I classis (Ash Wednesday, Holy Week) and vigils only. RG 24/25's feriae II and III classis (Advent 17-23 December, the Advent/Lent/September Ember sets, the numbered Lenten/Passiontide ferias) were excluded by no check at all, correct today only because of an unstated Temporal_ef property (no ferial-origin office it builds carries subject Lord/Bvm or an apostle-list slug). A comment read as if this were established "by construction of the two exclusions immediately above", which overclaimed: those two exclusions cover feria I classis only. Corrected the comments to name RG 24/25 explicitly, state what is and is not checked, and cite where the real guarantee comes from. Added a test that sweeps Temporal_ef.temporal across a century (1583-1682), asserting the invariant directly against every ferial-origin day found, with its own coverage counters confirming the span actually reaches Advent 17-23, all three non-Whitsun Ember sets, and Lenten/Passiontide ferias. The first run of the test found a real gap in its own population filter (RG 17(a)'s 2 January Holy Name fallback, a genuine feast sitting outside Temporal_ef.named for an architectural reason), fixed by naming that date explicitly rather than narrowing the span. Proved the test has teeth by mutation: forcing the ferial builder's default subject to Lord reddened it (and 24 other tests, expected collateral from an intentionally broad mutation) before being reverted. No production logic changed -- only comments in rubrics_ef.ml. day, readings and rubrics output confirmed byte-identical against the pre-fix binary for four spot years across the domain. Also corrects a stale test-case count in a prior task report (18 in the array, not the 19 recorded there); the array now genuinely holds 19. --- lib/rites/rite_ef/rubrics_ef.ml | 70 +++++++++++++++--- test/test_rubrics_ef.ml | 156 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 215 insertions(+), 11 deletions(-) (limited to 'lib/rites/rite_ef') diff --git a/lib/rites/rite_ef/rubrics_ef.ml b/lib/rites/rite_ef/rubrics_ef.ml index eef86cb..6345399 100644 --- a/lib/rites/rite_ef/rubrics_ef.ml +++ b/lib/rites/rite_ef/rubrics_ef.ml @@ -160,7 +160,48 @@ let creed ~(temporal : (Vocab_ef.season, Vocab_ef.rank) Temporal.t) liturgical action has no Mass in the 1955-restored Holy Week to begin with, so the question is moot there independent of this check -- but this structural test correctly excludes it too, since - it is also named in RG 23(b).) *) + it is also named in RG 23(b).) + + RG 24-25 (immediately following, same Caput IV) name TWO MORE + ferial classes this branch does not reach: feriae II classis (RG + 24 -- the Advent ferias 17-23 December, and the Quatuor Temporum + sets of Advent, Lent and September) and feriae III classis (RG 25 + -- the numbered Lenten/Passiontide ferias from the Thursday after + Ash Wednesday to the Saturday before Passion Sunday II, and the + un-Embered Advent ferias to 16 December). NEITHER is excluded by a + check anywhere in this module -- this branch and RG 33's vigil + branch immediately below are the whole of what [creed] tests + before falling through to 475(b)/(c)/(e)'s own rank/subject/slug + guards. They do not need an explicit exclusion of their own for + 475(b): temporal_ef.ml's own [ferial_rank] never returns [Class1] + for them (only Ash Wednesday and Holy Week do, RG 23's own population, + already excluded above), so that branch is unreachable for them + regardless of anything checked here. But RG 24's own II-class set + DOES reach 475(c)'s [Class2] guard below, and 475(e)'s + apostle-slug guard carries no rank floor at all, so both classes + genuinely reach a live branch of this function. Getting [false] + there is NOT established by this exclusion, or by any check in + this file: it rests on an unstated property of {!Temporal_ef} -- + every ferial-origin office it builds carries [subject = Temporal] + and an "ef-"-prefixed slug, never [Lord]/[Bvm] or a name on + {!creed_apostle_slugs}. Verified directly in temporal_ef.ml: the + Ember/Rogation/generic-ferial branches all go through [build]'s + own default [subject = Temporal], with exactly two documented + exceptions, neither able to reach a live branch below -- the + Sacred Triduum ([subject = Lord], but [Class1], already excluded + above) and the votive Office of the BVM on Saturday, RG 91 entry + 27 ([subject = Bvm], but [Class4], never satisfying 475(c)'s own + [Class2] guard). + + (CORRECTED, review fix: this comment, and 475(b)'s own immediately + below, previously read as though "the two exclusions immediately + above" disposed of ferias in general -- they dispose of feria I + classis only. RG 24/25's own higher classes were excluded by NO + check, an unstated-invariant gap now named here rather than + silently relied on: test/test_rubrics_ef.ml's own ferial-invariant + sweep now asserts the Temporal_ef property above directly against + real output, so a future change that broke it would fail that + test rather than silently changing the Creed.) *) n = -46 || (n >= -6 && n <= -1) then false else if @@ -183,13 +224,17 @@ let creed ~(temporal : (Vocab_ef.season, Vocab_ef.rank) Temporal.t) then false else if (* RG 475(b): "in festis I classis". Genuine feasts only, by - construction of the two exclusions immediately above (feriae, - vigils) -- every remaining [Class1] candidate reaching this branch - is a real festum: the Nativity, Epiphany, Ascension, Corpus Christi, - the Sacred Heart, Christ the King, a I-class sanctoral feast (the - Assumption, the Immaculate Conception...), or a I-class Sunday - (already [true] above via 475(a), so this branch is never the FIRST - to grant those a [true], only ever redundant with it). *) + construction of the two exclusions immediately above (feria I + classis -- Ash Wednesday and Holy Week, the only feriae that are + ever [Class1]; RG 24/25's II- and III-class feriae are a different + population and never reach this rank at all, see the RG 23 + comment's own note above -- and vigils) -- every remaining + [Class1] candidate reaching this branch is a real festum: the + Nativity, Epiphany, Ascension, Corpus Christi, the Sacred Heart, + Christ the King, a I-class sanctoral feast (the Assumption, the + Immaculate Conception...), or a I-class Sunday (already [true] + above via 475(a), so this branch is never the FIRST to grant those + a [true], only ever redundant with it). *) observed.Celebration.rank = Vocab_ef.Class1 then true else if @@ -207,7 +252,14 @@ let creed ~(temporal : (Vocab_ef.season, Vocab_ef.rank) Temporal.t) ships [subject = Saint] instead -- {!Precedence_ef.marian_slugs} is the list built (and, here, reused rather than re-derived) precisely because the [subject] field cannot be trusted alone for this - question; see its own citation in precedence_ef.mli. *) + question; see its own citation in precedence_ef.mli. + + This [Class2] guard is also the one live branch RG 24's own + II-class feriae (Advent 17-23, the Advent/Lent/September Ember + sets) genuinely reach -- the RG 23 comment above (this file, the + [n = -46 ...] branch) has the full account of why they still come + out [false] here: an unstated Temporal_ef property, checked by a + test, not a guard in this file. *) observed.Celebration.rank = Vocab_ef.Class2 && (observed.Celebration.subject = Subject.Lord || observed.Celebration.subject = Subject.Bvm diff --git a/test/test_rubrics_ef.ml b/test/test_rubrics_ef.ml index 2000d61..5109c69 100644 --- a/test/test_rubrics_ef.ml +++ b/test/test_rubrics_ef.ml @@ -9,7 +9,14 @@ this module existed, never read off [Rubrics_ef.creed]'s own answer. Two synthetic unit tests isolate Trap One (RG 475(a) reads [temporal], never [observed]) directly, without depending on finding a real-calendar - coincidence. *) + coincidence. + + One further test (celebrant-rubrics-phase1 review, finding 1) is + different in kind from the rest: not an end-to-end [creed] check + against a single real date, but a direct sweep of {!Rite_ef.Temporal_ef} + output across a century, asserting the unstated invariant [creed]'s own + RG 24/25 ferial branches rely on rather than leaving it asserted only + in prose -- see its own header comment for the full account. *) module Cal = Colitur_kernel.Calendar module LD = Colitur_kernel.Liturgical_day @@ -21,6 +28,7 @@ module Slug = Colitur_kernel.Slug module Temporal = Colitur_kernel.Temporal module V = Rite_ef.Vocab_ef module RE = Rite_ef.Rubrics_ef +module TE = Rite_ef.Temporal_ef let mk y m d = match Date.make ~year:y ~month:m ~day:d with Ok t -> t | Error e -> failwith e @@ -208,6 +216,148 @@ let test_every_sunday_in_2026_says_the_creed () = true d.LD.creed) days +(* ---- Review finding 1 (celebrant-rubrics-phase1 fix task): RG 24/25's + feriae II and III classis (the Advent ferias 17-23 December, the + Advent/Lent/September Ember sets, and the numbered Lenten/Passiontide + ferias) are excluded from the Creed by NO check in rubrics_ef.ml -- see + that file's own RG 23 comment (the [n = -46 ...] branch) for the full + account. [creed]'s correct [false] answer for them rests entirely on an + unstated property of {!Rite_ef.Temporal_ef}: no ferial-origin office it + ever builds carries [subject = Lord] or [subject = Bvm] at [Class2] + (RG 475(c)'s own rank guard), and none carries a slug on + [RE.creed_apostle_slugs] (RG 475(e), which has no rank guard at all). + This test asserts that property directly against real Temporal_ef + output, rather than leaving it asserted only in prose, so a future + Temporal_ef change that breaks it fails HERE loudly instead of silently + changing the Creed. + + Two real, harmless, already-documented exceptions are excluded from the + [subject] checks rather than papered over: the Sacred Triduum + ([subject = Lord], [Class1] -- verified in temporal_ef.ml's own + [triduum_names] branch) and the votive Office of the BVM on Saturday, + RG 91 entry 27 ([subject = Bvm], [Class4] -- {!TE}'s own [is_bvm_saturday] + citation). Neither can ever reach [creed]'s 475(c) branch: the Triduum + is caught earlier by the explicit feria-I-classis check (its own + [Class1] is not [Class2]), and the BVM Saturday Office is [Class4], not + [Class2], so 475(c)'s own rank guard excludes it regardless of subject. + Excluding them here is not a loophole in the test -- it is the precise + boundary of what RG 475(c) actually reads, confirmed rather than + assumed by restricting the [Lord]/[Bvm] checks to exactly [Class2] (RG + 24's own II-class population) and [Class3] (RG 25's III-class + population), the two ranks a real feria can carry that are NOT already + handled by an explicit branch or a rank mismatch. The apostle-slug + check has no such carve-out: it is asserted for every ferial-origin day + of every rank, matching 475(e)'s own unguarded text. + + A THIRD exception -- not anticipated when this test was first written, + found by actually running it, not by inspection -- had to be excluded + from the POPULATION itself, not from the subject checks: RG 17(a)'s own + "secus die 2 ianuarii" fallback (2 January, only in a civil year with + no 2-5 January Sunday) is a genuine FEAST, [subject = Lord], [Class2], + sitting outside {!TE.named}'s table for a purely architectural reason + (temporal_ef.ml's own citation: that table's shape cannot express a + fallback conditional on a per-year fact) and not always a Sunday + either, so neither of [is_ferial_origin]'s two filters caught it on the + first run -- it failed on 1584-01-02 before {!is_holy_name_fallback} + below was added. Left in as evidence the sweep has real teeth rather + than trimmed from the span: excluding a genuine mistaken positive by + name, once found, is the correct fix; narrowing the span to dodge it + would not have been. + + A day is "ferial-origin" here iff {!TE.named} returns [None] for it + (nothing in the fixed/movable table of named feasts, vigils and octave + days claims it), it is not a Sunday (RG 21's own definition: "Nomine + feriae intelleguntur singuli dies hebdomadae, praeter dominicam" -- + every day of the week EXCEPT Sunday), and it is not the RG 17(a) + fallback date just above. *) +let contains_substring ~needle haystack = + let nl = String.length needle and hl = String.length haystack in + let rec loop i = (i + nl <= hl) && (String.sub haystack i nl = needle || loop (i + 1)) in + nl = 0 || loop 0 + +(* RG 17(a)'s own "secus die 2 ianuarii" fallback (temporal_ef.ml's own + citation on [holy_name_fallback_date]): a genuine FEAST, [subject = + Lord], [Class2] -- legitimately outside {!TE.named}'s table only for an + architectural reason (that table's bare [Date.t -> ... option] shape + cannot express a fallback conditional on a per-year fact, + [holy_name_sunday y = None]), never a ferial one, and it is not always + a Sunday, so the weekday filter below does not catch it either. Found + by running this test before this exclusion existed: it failed on + 1584-01-02, a real year with no 2-5 January Sunday. *) +let is_holy_name_fallback (d : Date.t) = + let y = Date.year d in + Date.compare d (TE.holy_name_fallback_date y) = 0 && TE.holy_name_sunday y = None + +let is_ferial_origin (d : Date.t) = + TE.named d = None && Date.weekday d <> Date.Sun && not (is_holy_name_fallback d) + +(* Span: civil years 1583-1682, the domain's own opening century -- every + RG 24/25 population recurs every civil year (Advent 17-23, all three + non-Whitsun Ember sets, and Lenten/Passiontide ferias are none of them + rare or conditional), so one year already gives full COVERAGE; a + century instead of one is for confidence the invariant is not an + accident of a single Easter date, at a cost (day-by-day calls to the + pure {!TE.temporal}, no Calendar/Precedence resolution) cheap enough to + stay in the default, unsampled suite. 1583 is also the domain's own + lower bound, and this span contains 1598, the earliest possible Easter + (22 March) in [1583, 2500] per {!test_easter_extremes} above -- a real, + not merely nominal, edge case. Coverage is VERIFIED below via counters, + not assumed: a test that never actually walks an Ember day would prove + nothing about Ember days. *) +let test_ferial_origin_never_carries_lord_bvm_or_apostle_slug () = + let total = ref 0 + and advent_ember = ref 0 + and lent_ember = ref 0 + and september_ember = ref 0 + and advent_17_23 = ref 0 + and lenten_passiontide_class3 = ref 0 in + for y = 1583 to 1682 do + let dec31 = mk y 12 31 in + let d = ref (mk y 1 1) in + while Date.compare !d dec31 <= 0 do + (if is_ferial_origin !d then begin + let t = TE.temporal !d in + incr total; + let office = t.Temporal.office in + let slug = Slug.to_string office.Cel.slug in + let label = Printf.sprintf "%s (%s, rank=%s)" (Date.to_iso8601 !d) slug + (match office.Cel.rank with + | V.Class1 -> "1" + | V.Class2 -> "2" + | V.Class3 -> "3" + | V.Class4 -> "4") + in + (* Coverage tallies -- checked against zero below. *) + if contains_substring ~needle:"advent-ember" slug then incr advent_ember; + if contains_substring ~needle:"lent-ember" slug then incr lent_ember; + if contains_substring ~needle:"september-ember" slug then incr september_ember; + if Date.month !d = 12 && Date.day !d >= 17 && Date.day !d <= 23 then incr advent_17_23; + if + office.Cel.rank = V.Class3 + && (t.Temporal.season = V.Lent || t.Temporal.season = V.Passiontide) + then incr lenten_passiontide_class3; + (* The invariant itself. Apostle slugs: every rank, no carve-out. *) + Alcotest.(check bool) (label ^ ": not on the apostle-natalicium slug list") false + (List.mem slug RE.creed_apostle_slugs); + (* Lord/Bvm subject: only at the two ranks a real RG 24/25 feria + can carry -- see this test's own header for why Class1 + (Triduum) and Class4 (BVM Saturday) are deliberately excluded. *) + if office.Cel.rank = V.Class2 || office.Cel.rank = V.Class3 then begin + Alcotest.(check bool) (label ^ ": not subject Lord") false (office.Cel.subject = Subject.Lord); + Alcotest.(check bool) (label ^ ": not subject Bvm") false (office.Cel.subject = Subject.Bvm) + end + end); + d := Date.add_days !d 1 + done + done; + Alcotest.(check bool) "span examined a real number of ferial-origin days" true (!total > 10_000); + Alcotest.(check bool) "span reached Advent Ember days" true (!advent_ember > 0); + Alcotest.(check bool) "span reached Lent Ember days" true (!lent_ember > 0); + Alcotest.(check bool) "span reached September Ember days" true (!september_ember > 0); + Alcotest.(check bool) "span reached Advent 17-23 ferias" true (!advent_17_23 > 0); + Alcotest.(check bool) "span reached Lenten/Passiontide III-class ferias" true + (!lenten_passiontide_class3 > 0) + let suite = ( "Rubrics_ef", [ Alcotest.test_case "475(a): ordinary Sunday" `Quick test_475a_ordinary_sunday; @@ -237,4 +387,6 @@ let suite = test_vigil_excluded_even_when_class2_and_marian; Alcotest.test_case "476(d): BVM Saturday Office, IV class" `Quick test_476d_bvm_saturday_office; Alcotest.test_case "every Sunday in 2026 says the Creed" `Quick - test_every_sunday_in_2026_says_the_creed ] ) + test_every_sunday_in_2026_says_the_creed; + Alcotest.test_case "RG 24/25: no ferial-origin office carries Lord/Bvm or an apostle slug" `Quick + test_ferial_origin_never_carries_lord_bvm_or_apostle_slug ] ) -- cgit v1.3 From 41263d60432fadd2aef3f04de66b60561f89cbec Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 22 Aug 2026 13:38:27 +0200 Subject: fix(ef): RG 476(f) -- the Creed is not said at a Requiem Mass colitur said the Creed IS said on All Souls' Day and every other Requiem-shaped day: RG 475(b)'s "in festis I classis" branch fired on rank alone, with nothing checking RG 476(f), "Non dicitur symbolum ... f) in Missis defunctorum". Allow-listed as L1 in expected-divergences-lms.sexp on the argument that [creed] has no notion of a Requiem Mass in its inputs and could not express the rule without a signature change. That argument does not hold. Colour.Black is used by exactly two celebrations in the whole engine, verified by grepping every occurrence in lib/ and data/: commemoration-of-all-souls (data/ef/sanctoral.sexp) and Good Friday (temporal_ef.ml, already false via the earlier RG 23(b) feria exclusion and has no Mass at all in the 1955-restored Holy Week). So on every day this engine can construct, colour = Black if and only if the Mass is a Requiem -- a citable implication (RG 117 assigns black to Masses of the dead) on this data, not a heuristic. Rite_ef.Rubrics_ef.creed now reads observed.colour as RG 476(f)'s own guard, positioned ahead of 475(b)'s rank branch. A new test, test_colour_black_population_is_exactly_two, sweeps both the shipped data and Temporal_ef's own code across a century and fails loudly the day a third Colour.Black celebration is ever introduced, so the proxy cannot silently rot. L1 is closed in expected-divergences-lms.sexp, citation preserved. Blast radius, measured (full 1583-9999 sweep, grouped by observed slug): 8 416 days change from true to false, every one commemoration-of-all-souls (missing from exactly 1 of the 8 417 domain years, a boundary-year transfer effect at 1583/9999). A further 8 416 Colour.Black days are Good Friday, unaffected (already false before this fix). colitur day/readings stay byte-identical to v0.10.1 (checked directly against a v0.10.1 worktree build, 9 domain-spanning years); colitur rubrics changes on exactly the 8 416 days above. colitur emit --format sexp's pretty-printed line count for 2027 moved 9010 -> 9011 as a side effect: Sexplib.Sexp.to_string_hum wraps by column width, and All Souls' Day 2027's record grew one wrapped line because "false" is one character longer than "true". Cosmetic only -- no field's data changed -- and pinned in test/cli.t with the measurement recorded. --- data/ef/expected-divergences-lms.sexp | 131 ++++++++++++++++++++++++---------- lib/rites/rite_ef/rubrics_ef.ml | 58 +++++++++++++-- lib/rites/rite_ef/rubrics_ef.mli | 5 +- test/cli.t | 14 +++- test/test_rubrics_ef.ml | 88 +++++++++++++++++++++++ 5 files changed, 249 insertions(+), 47 deletions(-) (limited to 'lib/rites/rite_ef') diff --git a/data/ef/expected-divergences-lms.sexp b/data/ef/expected-divergences-lms.sexp index 96c9e50..e327419 100644 --- a/data/ef/expected-divergences-lms.sexp +++ b/data/ef/expected-divergences-lms.sexp @@ -15,41 +15,96 @@ ; backs, the SAME convention the missalemeum file's own header states: ; not always "colitur". ; -; L1 -- 2025-11-03 (All Souls' Day, transferred from 2 November, a Sunday -; that year, to the next available day -- RG's own transfer of a -; Commemoration when it falls on a Sunday). colitur (Rite_ef.Rubrics_ef. -; creed) says the Creed IS said (true): observed.rank = Class1, so RG -; 475(b)'s "in festis I classis" branch fires with nothing to stop it. -; The Ordo says it is NOT said (false): "Mon 3 ALL SOULS' DAY I Cl B / No -; Gl Sequence No Cr Pr of the Dead No Blessing" -- confirmed directly -; against the extracted pdftotext text, not merely the fixture row. -; -; The Ordo is right and colitur is not, on the rubric itself: RG 476(f), -; "Non dicitur symbolum ... f) in Missis defunctorum" -- the Creed is -; never said at a Requiem Mass, and All Souls' Day is definitionally one -; (colour black in colitur's own output on this very date). This is NOT a -; new defect this task found and is choosing to leave open -- it is -; rubrics_ef.ml's OWN documented scope limit, stated in that file's header -; comment before this task existed: "476(f) (Requiem Masses, also not -; modelled) ... genuinely inapplicable to this implementation -- a -; documented scope limit, not a defect." [creed] has no notion of "this -; Mass is a Requiem" anywhere in its own inputs (temporal/observed/date), -; so it cannot express 476(f) without a signature change this task's own -; constraints do not authorise (lib/ is read-only here except for this -; allow-list). Recorded here as the SPECIFIC real-world day the scope -; limit is finally witnessed on, rather than left as an abstract note in -; a doc comment nobody's data ever exercised. -; -; Domain-wide count: unmeasured (out of this task's scope -- would need a -; sweep of every I-class Requiem-shaped day 1583-9999, which does not -; exist as a distinct predicate in this codebase to sweep on). The two -; days this WOULD also apply to in principle -- 2 November itself in any -; year it does not fall on a Sunday, and the plain All Souls' Day office -; whenever it is not transferred -- are outside this fixture's own -; window's reach for a second occurrence, so only the one real day above -; is witnessed and adjudicated here. -((id L1) - (citation "RG 476(f): \"Non dicitur symbolum ... f) in Missis defunctorum\" -- the Creed is never said at a Mass of the dead; rubrics_ef.ml's own header already documents this Mass-type as unmodelled") - (verdict lms_ordo) - (note "colitur's [creed] has no notion of a Requiem Mass in its inputs (temporal/observed/date), so RG 475(b)'s I-class branch fires unopposed on All Souls' Day (Class1, black). A pre-existing, self-documented scope limit (rubrics_ef.ml's own header), witnessed here for the first time on a real date rather than left as an abstract note: 2025-11-03, All Souls' Day transferred from Sunday 2 November. The Ordo (\"No Gl Sequence No Cr Pr of the Dead\") is correct on the rubric.") - (expected_rows 1)) +; L1 -- CLOSED, FIXED (whole-branch review finding 1, 2026-08-22): was +; 2025-11-03 (All Souls' Day, transferred from 2 November, a Sunday that +; year). colitur used to say the Creed IS said (true) on rank alone +; (475(b), Class1) where the Ordo, correctly, says it is not: "Mon 3 ALL +; SOULS' DAY I Cl B / No Gl Sequence No Cr Pr of the Dead No Blessing". +; +; This entry's own PREVIOUS text argued the divergence could not be fixed +; without a signature change, because [creed]'s inputs (temporal/observed/ +; date) have no notion of "this Mass is a Requiem". That argument was +; wrong: {!Colour.Black} is used by exactly TWO celebrations in the whole +; engine (verified by grepping every [colour Black]/[Colour.Black] site in +; lib/ and data/) -- [commemoration-of-all-souls] (data/ef/sanctoral.sexp) +; and Good Friday (temporal_ef.ml, already [false] via the RG 23(b) feria +; exclusion regardless of colour, and has no Mass at all in the +; 1955-restored Holy Week) -- so on every day this engine can construct, +; [colour = Black] if and only if the Mass is a Requiem: a citable +; implication (RG 117 assigns black to Masses of the dead) on THIS DATA, +; not a heuristic guess. [Rite_ef.Rubrics_ef.creed] now reads +; [observed.colour] as RG 476(f)'s own guard, positioned ahead of 475(b)'s +; rank branch; see rubrics_ef.ml's own header and the guard's own comment +; for the full account, and test_rubrics_ef.ml's +; [test_colour_black_population_is_exactly_two] for the test that fails +; loudly the day a third [Colour.Black] celebration is ever added, so this +; proxy cannot silently rot. +; +; BLAST RADIUS, measured (full 1583-9999 sweep of {!Colitur_kernel. +; Calendar.year}, grouped by [observed]'s own slug): 8 416 days changed +; from [true] to [false], every one [commemoration-of-all-souls] (missing +; from exactly 1 of the 8 417 domain years -- a boundary-year transfer +; effect at 1583/9999, not a third population member: see the "know what +; each layer cannot see"-style caveats elsewhere in this project for other +; instances of the same edge). A further 8 416 [Colour.Black] days are +; Good Friday ([ef-passiontide-2-friday]), unchanged by this fix (already +; [false] before it, via the earlier feria exclusion) -- confirmed +; [creed_true_among_black = 0] after the fix, over the WHOLE domain, not +; merely the fixture's own 2024-2025 window. +; +; `colitur day`/`colitur readings` are unaffected (neither prints [creed]); +; `colitur rubrics` output changes on exactly the 8 416 days above. + +; L2 -- OPEN, colitur's own gap, found while building L1's fix (whole- +; branch review finding 2, 2026-08-22). NOT an active entry in this +; file's own machine-checked sense (no code loads or cross-checks an +; "L2" id anywhere -- this file's own [allow_entry] machinery is scoped +; to the CREED axis, {!test_lms_ordo.test_creed_matches_or_is_explained} +; alone; a second, unrelated axis reusing the same id-lookup mechanism +; would let one test's "declared but never fired" check spuriously trip +; on the other axis's own entry). Recorded here as prose only, the same +; discipline this file's own closed L1 note above and +; expected-divergences.sexp's own closed C-entries already use for a +; citation worth preserving without a live parser hook -- the REAL +; enforcement is test_lms_ordo.ml's own [test_formulary_override_matches], +; which asserts this population is exactly 3 days directly. +; +; Strengthening test_lms_ordo.ml's formulary-override check from PRESENCE +; to the actual STRING (this same finding 2) surfaced a real, separate +; defect in step 3 of the reading chain ({!Rite_ef.Lectionary_ef. +; readings}), independent of the Creed axis above. Three real days in the +; fixture window -- 2025-05-30 (Friday), 2025-06-02 (Monday), 2025-06-03 +; (Tuesday), all between Ascension Thursday and the following Sunday -- +; have no proper of their own and fall to {!Colitur_kernel. +; Mass_formulary.Preceding_sunday}. colitur resumes the nearest CALENDAR +; Sunday (the "5th"/"6th Sunday after Easter", Paschaltide's own week +; 6/7), but the Missal, and the Ordo, keep saying ASCENSION'S OWN Mass +; through this stretch instead: "Mass of the Ascension" on all three +; days, never a numbered-Sunday title. Step 3 has no notion of "inside +; Ascension's own after-feast period" -- it always resumes the nearest +; preceding Sunday, numbered or not. +; +; STRUCTURAL, not a fixture artefact: the Ascension is a fixed Easter+39 +; (a Thursday inside Paschaltide week 6), so every feria whose own +; Preceding_sunday fallback would otherwise resolve to week 6 or 7 falls +; in this same stretch, every year -- this is not a coincidence of +; 2024-2025 alone, though only this fixture's own window has been checked +; against a real Ordo. +; +; NOT FIXED in this task: fixing it means teaching step 3, or the +; lectionary data, about Ascension's own after-feast period -- the same +; shape as the Christmastide "infra octavam Nativitatis" entries +; data/ef/lectionary.sexp already carries directly rather than resolving +; through this fallback -- a lectionary-data change out of a fix round's +; own scope. test/test_lms_ordo.ml's own +; [expected_preceding_sunday_override] excludes exactly these two +; (season, week) pairs from its general mapping, and +; [test_formulary_override_matches] asserts the excluded population is +; exactly 3 real days, all showing "Mass of the Ascension", so a change +; to this population (a fourth day, a different Ordo string) would be +; caught rather than silently absorbed. +; +; Domain-wide count: unmeasured -- "which feria is inside Ascension's own +; after-feast period" is not a distinct predicate anywhere in this +; codebase to sweep on. Structurally recurs every year, so on the order +; of 3 days/year x domain years, not merely 3 total. diff --git a/lib/rites/rite_ef/rubrics_ef.ml b/lib/rites/rite_ef/rubrics_ef.ml index 6345399..ab9da32 100644 --- a/lib/rites/rite_ef/rubrics_ef.ml +++ b/lib/rites/rite_ef/rubrics_ef.ml @@ -26,12 +26,37 @@ engine resolves ONE observed office and ONE Mass per civil day (see Rite.t.readings' own doc comment) -- it has no separate "which votive Mass is said" dimension. So the "vel/et...votivis" halves of 475(a)/(b), - 476(c) entirely, 476(d)'s "et votivis" half, and 476(f) (Requiem Masses, - also not modelled) are genuinely inapplicable to this implementation -- - a documented scope limit, not a defect. 476(e) needs no branch at all: - [creed] below reads only [observed], never a day's admitted - commemorations, so a commemoration can never change its answer by - construction. *) + 476(c) entirely, and 476(d)'s "et votivis" half are genuinely + inapplicable to this implementation -- a documented scope limit, not a + defect. 476(e) needs no branch at all: [creed] below reads only + [observed], never a day's admitted commemorations, so a commemoration + can never change its answer by construction. + + 476(f) ("in Missis defunctorum") is DIFFERENT: this file used to carry + it in the same "not modelled" list above, on the reasoning that + [creed]'s inputs (temporal/observed/date) have no notion of "this Mass + is a Requiem". That reasoning was wrong, found by the LMS Ordo layer + (test_lms_ordo.ml, allow-list entry L1, now closed -- see + expected-divergences-lms.sexp) on 2025-11-03, All Souls' Day: colitur + said the Creed where the Ordo, correctly, does not. [observed] DOES + carry a usable signal -- RG 117 assigns black to Masses of the dead, + and {!Colour.Black} is used by exactly TWO celebrations in this whole + engine, verified by grepping every [colour Black]/[Colour.Black] site + in lib/ and data/: [commemoration-of-all-souls] in + data/ef/sanctoral.sexp, and Good Friday in temporal_ef.ml (which is a + Holy Week FERIA, RG 23(b), already excluded by the [n >= -6 && n <= + -1] branch below regardless of colour, and has no Mass at all in the + 1955-restored Holy Week). So on every day this engine can actually + construct, [colour = Black] if and only if the Mass is a Requiem -- + a citable implication ON THIS DATA, not a heuristic guess. [creed] + below uses it as 476(f)'s own guard. This is a PROXY, not a general + "is this a Requiem Mass" field, and it is only as good as that + two-member population: {!test_rubrics_ef}'s own + [test_colour_black_population_is_exactly_two] fails loudly the day a + third [Colour.Black] celebration is added anywhere, so the proxy + cannot silently rot into covering (or missing) a non-Requiem black + Mass. If that ever happens, this guard needs re-deriving, not merely + re-approving. *) open Colitur_kernel @@ -222,6 +247,27 @@ let creed ~(temporal : (Vocab_ef.season, Vocab_ef.rank) Temporal.t) {!Precedence_ef.marian_slugs} just below. *) Precedence_ef.is_vigil slug then false + else if + (* RG 476(f): "in Missis defunctorum" -- the Creed is never said at a + Requiem Mass, checked here, ahead of 475(b)'s rank branch, the same + "category excludes regardless of rank" position as the feria and + vigil checks immediately above (All Souls' Day is [Class1], so + without this guard 475(b) would grant it [true] unopposed, which is + exactly the defect this guard fixes -- see this file's own header + for the two-member [Colour.Black] population this proxy rests on, + and {!Colour.Black}'s own citation, RG 117, for why colour is the + Requiem signal. Placed before, not after, 475(b)/(c)/(e) so a black + Class1 or Class2 day can never reach them; nothing above this point + (the Nativity/Easter/Pentecost octave override, the Sunday rule, the + feria and vigil exclusions) is ever reachable by a Requiem Mass on + the shipped data either -- a Requiem is never kept on a Sunday or + inside a privileged octave -- so this guard's own position relative + to THOSE branches is moot on real data, checked, not merely assumed: + {!creed_apostle_slugs} and [marian_slugs] contain no Black-coloured + entry, and 2 November can never fall inside any of the three + octaves 475(d) names. *) + observed.Celebration.colour = Colour.Black + then false else if (* RG 475(b): "in festis I classis". Genuine feasts only, by construction of the two exclusions immediately above (feria I diff --git a/lib/rites/rite_ef/rubrics_ef.mli b/lib/rites/rite_ef/rubrics_ef.mli index 16e7491..7a1284b 100644 --- a/lib/rites/rite_ef/rubrics_ef.mli +++ b/lib/rites/rite_ef/rubrics_ef.mli @@ -29,7 +29,10 @@ val creed_apostle_slugs : string list {!Liturgical_day.t.commemorations}: RG 476(e), "ratione alicuius commemorationis in Missa occurrentis" [never say the Creed merely because of a commemoration], holds by construction rather than by a - checked branch. *) + checked branch. RG 476(f) ("in Missis defunctorum") reads + [observed]'s [colour]: see the .ml's own header for why [Colour.Black] + is a sound proxy for "this Mass is a Requiem" on the shipped data, and + what would have to be re-checked if that ever stops being true. *) val creed : temporal:(Vocab_ef.season, Vocab_ef.rank) Temporal.t -> observed:Vocab_ef.rank Celebration.t -> diff --git a/test/cli.t b/test/cli.t index ac1351b..e2d2eb5 100644 --- a/test/cli.t +++ b/test/cli.t @@ -534,10 +534,20 @@ CSV run rather than one per year: $ colitur emit --format csv --from 2027 --to 2028 | wc -l 732 -sexp and xml are also available: +sexp and xml are also available. This line count moved 9010 -> 9011 +(whole-branch review fix round, RG 476(f)): [emit --format sexp] pretty- +prints with [Sexplib.Sexp.to_string_hum], a column-width wrapping printer, +not a fixed-shape one -- 2027's All Souls' Day (2 November) record grew a +single wrapped line when its own [creed] field's value changed from +[true] to [false] (RG 476(f), the Creed is never said at a Requiem Mass; +see rubrics_ef.ml), because "false" is one character longer than "true" +and pushed that one line's rendered width over to_string_hum's own wrap +threshold. Purely cosmetic -- the record's DATA is unchanged in every +other field, and this is not a claim that [emit]'s FORMAT changed, only +that one record's pretty-printed SHAPE did: $ colitur emit --format sexp --from 2027 --to 2027 | wc -l - 9010 + 9011 $ colitur emit --format xml --from 2027 --to 2027 | head -2 diff --git a/test/test_rubrics_ef.ml b/test/test_rubrics_ef.ml index 5109c69..a778a5a 100644 --- a/test/test_rubrics_ef.ml +++ b/test/test_rubrics_ef.ml @@ -26,6 +26,7 @@ module Colour = Colitur_kernel.Colour module Subject = Colitur_kernel.Subject module Slug = Colitur_kernel.Slug module Temporal = Colitur_kernel.Temporal +module Computus = Colitur_kernel.Computus module V = Rite_ef.Vocab_ef module RE = Rite_ef.Rubrics_ef module TE = Rite_ef.Temporal_ef @@ -175,6 +176,87 @@ let test_holy_saturday_easter_vigil_no_creed () = by 476(a) ("in Missa Vigiliae paschalis"). *) check "RG 23(b)/476(a): Holy Saturday (the Easter Vigil Mass)" 2026 4 4 false +(* ---- RG 476(f): "in Missis defunctorum" -- the Creed is never said at a + Requiem Mass. Whole-branch review finding 1: colitur used to say [true] + here on rank alone (475(b), Class1) -- the LMS Ordo layer's own former + allow-list entry L1, now closed, was this exact day. See rubrics_ef.ml's + own header for why {!Colour.Black} is a sound RG 117 proxy for "this Mass + is a Requiem" on the shipped data. ---- *) + +let test_476f_all_souls_no_creed () = + (* 2025-11-03: All Souls' Day (2 November, a Sunday that year, transferred + to the next open day, RG's own Class1/black office unchanged by the + transfer) -- `colitur day 2025` confirms class-1/black/ + commemoration-of-all-souls on this date. Before this fix [creed] here + was [true] via 475(b); the Ordo's own text ("No Cr") says otherwise. *) + check "RG 476(f): All Souls' Day (transferred), a Requiem, says no Creed" 2025 11 3 false + +let test_476f_good_friday_no_creed () = + (* 2026-04-03: Good Friday, [colour = Black] (temporal_ef.ml's own + [days_between easter d = -2] branch) -- already [false] via the RG + 23(b) feria-I-classis exclusion checked earlier in [creed], so this is + a belt-and-braces witness that the 476(f) guard, were it ever reached + first, would agree rather than a case that depends on it: Good Friday + has no Mass in the 1955-restored Holy Week to begin with. *) + check "RG 476(f)/RG 23(b): Good Friday, black, says no Creed" 2026 4 3 false + +(* Whole-branch review finding 1's own instruction: a test that FAILS if a + third {!Colour.Black} celebration is ever introduced, so the RG 476(f) + guard's colour proxy cannot silently rot into covering the wrong + population. Two independent checks, matching the two places a black + celebration could be added: + + DATA: every entry the shipped sanctoral layer (sanctoral.sexp, with + adjustments.sexp merged on top -- the same [layer] every other test in + this file resolves against) carries directly, fixed AND movable alike + ({!Colitur_kernel.Layer.entries} holds the raw [Date_spec.t], not a + resolved index, so this needs no date walk at all to cover every civil + year). + + CODE: {!Rite_ef.Temporal_ef.temporal}'s own colour logic, which + sanctoral.sexp cannot see (Good Friday's black is computed, not data) -- + swept across a century the same span and for the same coverage reason + {!test_ferial_origin_never_carries_lord_bvm_or_apostle_slug} above + already uses, since a Colour.Black bug introduced into a rare branch + (an Ember day, Rogation Wednesday...) would not show up in one year. *) +let test_colour_black_population_is_exactly_two () = + let black_data_entries = + List.filter + (fun (e : V.rank Colitur_kernel.Layer.entry) -> e.Colitur_kernel.Layer.cel.Cel.colour = Colour.Black) + layer.Colitur_kernel.Layer.entries + in + Alcotest.(check int) "exactly one Colour.Black entry in the shipped data" 1 + (List.length black_data_entries); + (match black_data_entries with + | [ e ] -> + Alcotest.(check string) "the one Colour.Black data entry is All Souls" "commemoration-of-all-souls" + (Slug.to_string e.Colitur_kernel.Layer.cel.Cel.slug) + | _ -> ()); + let code_black = ref [] in + for y = 1583 to 1682 do + let dec31 = mk y 12 31 in + let d = ref (mk y 1 1) in + while Date.compare !d dec31 <= 0 do + let t = TE.temporal !d in + if t.Temporal.office.Cel.colour = Colour.Black then code_black := (y, !d, t) :: !code_black; + d := Date.add_days !d 1 + done + done; + Alcotest.(check bool) "the century sweep found at least 100 Colour.Black temporal days" true + (List.length !code_black >= 100); + List.iter + (fun (_, d, (t : (V.season, V.rank) Temporal.t)) -> + let easter = Computus.gregorian_easter (Date.year d) in + Alcotest.(check int) + (Printf.sprintf "%s: the only code-side Colour.Black day is Good Friday (Easter-2)" + (Date.to_iso8601 d)) + (-2) (Date.to_rata d - Date.to_rata easter); + Alcotest.(check string) + (Printf.sprintf "%s: Good Friday's own slug" (Date.to_iso8601 d)) + "ef-passiontide-2-friday" + (Slug.to_string t.Temporal.office.Cel.slug)) + !code_black + (* ---- RG 476(b) negative: a plain II-class saint, not Domini/BVM, not an Apostle/Evangelist ---- *) @@ -381,6 +463,12 @@ let suite = Alcotest.test_case "RG 23/476(a): Holy Thursday" `Quick test_holy_thursday_no_creed; Alcotest.test_case "RG 23/476(a): Holy Saturday / Easter Vigil" `Quick test_holy_saturday_easter_vigil_no_creed; + Alcotest.test_case "476(f): All Souls' Day (transferred), a Requiem, no Creed" `Quick + test_476f_all_souls_no_creed; + Alcotest.test_case "476(f)/RG 23(b): Good Friday, black, no Creed" `Quick + test_476f_good_friday_no_creed; + Alcotest.test_case "Colour.Black population is exactly two (data + code)" `Quick + test_colour_black_population_is_exactly_two; Alcotest.test_case "476(b): plain II-class saint (negative)" `Quick test_476b_plain_class2_saint; Alcotest.test_case "vigils excluded even when Class2 and Marian" `Quick -- cgit v1.3 From 94ad73cf74d7f1cf02913e2f462c01028a8ef4b4 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 22 Aug 2026 13:48:08 +0200 Subject: fix(kernel): Mass_formulary.t.said is honestly optional -- was false for Votive The .mli promised said is "the slug whose Mass is said". For Votive (RG 78/309(a), the Saturday votive Mass of Our Lady) it was set to the day's own ferial slug -- whose Mass is exactly the one NOT said. A consumer joining rubrics to readings on that slug would silently get the wrong Mass: 2026-01-03 reports ef-christmas-1-saturday, which has zero entries in data/ef/lectionary.sexp, because the citations actually come from bvm_saturday_citations, a season-keyed function with no slug of its own anywhere in the shipped data. Chose the type-honest fix over the interim documentation one: said is now Slug.t option, None exactly for Votive, because there is genuinely no slug in the shipped data this field could report for that one source. Adding real ids for the five seasonal BVM Masses (the reviewer's first option) is out of scope -- a data restructuring this round explicitly does not carry. Threading the office slug through a second field was considered and rejected as redundant: the day's own office is already available on the same Liturgical_day.t via observed.slug, which every caller already has in scope regardless of via, so said does not need to duplicate it. colitur rubrics stays byte-identical: rubrics_line already has d.observed in scope and falls back to its slug when said is None, printing the exact value it always printed for a Votive row (verified directly, diffed against pre-fix output across four years). colitur day/readings are unaffected (neither reads Mass_formulary at all). colitur emit --format sexp's pretty-printed line count for 2027 moved 9011 -> 9025: every day's formulary record widened by said's own extra option wrapping, and to_string_hum wraps by column width. Cosmetic only, diffed line by line to confirm every change is this shape or a consequent wrap shift; recorded in test/cli.t alongside the 476(f) note it now sits next to. --- bin/main.ml | 18 +++++++++++++++-- lib/kernel/mass_formulary.ml | 4 +++- lib/kernel/mass_formulary.mli | 29 +++++++++++++++++++++++---- lib/rites/rite_ef/lectionary_ef.ml | 30 +++++++++++++++++----------- lib/rites/rite_ef/lectionary_ef.mli | 14 ++++++++----- test/cli.t | 36 ++++++++++++++++++++++----------- test/test_lectionary_ef.ml | 40 +++++++++++++++++++++++++++---------- test/test_lms_ordo.ml | 15 ++++++++++++-- test/test_mass_formulary.ml | 16 +++++++++++++-- test/test_validate.ml | 2 +- 10 files changed, 153 insertions(+), 51 deletions(-) (limited to 'lib/rites/rite_ef') diff --git a/bin/main.ml b/bin/main.ml index f957190..582c776 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -397,13 +397,27 @@ let readings_line ~lang ~sigla (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.r least surprising choice for a machine-readable field). Unlike [formulary], [d.creed] is a plain [bool] with no [option] to guard: a rite that has not implemented the rule answers [false] outright, so - there is no third "unknown" state this column could ever need to print. *) + there is no third "unknown" state this column could ever need to print. + + Whole-branch review fix round: {!Colitur_kernel.Mass_formulary.t.said} + itself gained an [option] (its own citation has the full account -- + [None] exactly for [Votive], where the shipped data genuinely names no + slug for the Mass actually said). This column's own OUTPUT does not + change for that reason: when [said] is [None] it falls back to + [d.observed]'s own slug -- the SAME value this column always printed + for a [Votive] day before [said] became honest, and it is a value this + function already has in scope regardless of [via]. So this is not + "print a placeholder for the missing case", it is "the value was + already available from a different field, and still is". *) let rubrics_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) = let said, via = match d.Colitur_kernel.Liturgical_day.formulary with | Some f -> - ( Colitur_kernel.Slug.to_string f.Colitur_kernel.Mass_formulary.said, + ( Colitur_kernel.Slug.to_string + (match f.Colitur_kernel.Mass_formulary.said with + | Some s -> s + | None -> d.Colitur_kernel.Liturgical_day.observed.Colitur_kernel.Celebration.slug), Colitur_kernel.Mass_formulary.source_to_string f.Colitur_kernel.Mass_formulary.via ) | None -> ("-", "-") in diff --git a/lib/kernel/mass_formulary.ml b/lib/kernel/mass_formulary.ml index cb3532b..60f9f1c 100644 --- a/lib/kernel/mass_formulary.ml +++ b/lib/kernel/mass_formulary.ml @@ -1,5 +1,7 @@ +open Sexplib0.Sexp_conv + type source = Proper | Own_slug | Preceding_sunday | Common | Votive [@@deriving sexp] -type t = { said : Slug.t; via : source } [@@deriving sexp] +type t = { said : Slug.t option; via : source } [@@deriving sexp] let source_to_string = function | Proper -> "proper" diff --git a/lib/kernel/mass_formulary.mli b/lib/kernel/mass_formulary.mli index a2faf64..b990e69 100644 --- a/lib/kernel/mass_formulary.mli +++ b/lib/kernel/mass_formulary.mli @@ -27,10 +27,31 @@ type source = is the RG 78/309(a) Saturday Mass of Our Lady. *) [@@deriving sexp] -type t = { said : Slug.t; via : source } [@@deriving sexp] +type t = { said : Slug.t option; via : source } [@@deriving sexp] -(** The slug whose Mass is said. For {!Proper}, {!Own_slug} and {!Votive} - this is the day's own; for {!Preceding_sunday} it is that Sunday's - TEMPORAL slug; for {!Common} it is the Common's own id. *) +(** The slug whose CITATIONS were actually used to build the day's + readings, when the shipped data can name one. + + [Some] for four of the five sources: {!Proper} and {!Own_slug} each + carry the day's own slug; {!Preceding_sunday} carries that Sunday's + TEMPORAL slug; {!Common} carries the Common's own id. + + [None] for {!Votive}, and ONLY for {!Votive} (whole-branch review fix + round, celebrant-rubrics-phase1): RG 309(a)'s five seasonal "Missae de + sancta Maria in sabbato" carry no slug of their own anywhere in the + shipped data -- their citations come from a season-keyed function + ({!Rite_ef.Lectionary_ef.bvm_saturday_citations}), never from a + [Lectionary.find] against any slug, so there is genuinely no slug this + field could honestly report. An earlier version of this type set + [said] to the day's own OFFICE slug for {!Votive} too (RG 78's Office + of Our Lady, kept unchanged under the votive Mass) -- readable at the + call site as "this is the slug whose Mass is said", which is false for + exactly this one case: that slug's own [Lectionary] entry, if it has + one at all, is NOT what the day's citations came from. [None] says so + directly instead of silently mis-naming a Mass. The day's own OFFICE + is not lost by this change -- it is still on the very same + {!Liturgical_day.t} this formulary lives on, via [observed.slug], + which every caller already has in scope regardless of [via]; this + field does not need to duplicate it. *) val source_to_string : source -> string diff --git a/lib/rites/rite_ef/lectionary_ef.ml b/lib/rites/rite_ef/lectionary_ef.ml index 1fba3de..b996412 100644 --- a/lib/rites/rite_ef/lectionary_ef.ml +++ b/lib/rites/rite_ef/lectionary_ef.ml @@ -260,7 +260,7 @@ let is_bvm_saturday_office (observed : Vocab_ef.rank Celebration.t) 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) + (Some { Mass_formulary.said = Some 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 @@ -357,7 +357,7 @@ let readings ~lectionary ~commons ~observed ~temporal ~date ~temporal_at = 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) + (Some { Mass_formulary.said = Some 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 @@ -407,23 +407,29 @@ let readings ~lectionary ~commons ~observed ~temporal ~date ~temporal_at = [bvm_saturday_citations]'s own season-keyed SELECTION, not merely that some BVM Mass was chosen. - [said] is UNCHANGED by this correction and stays the day's own - temporal 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 + [said] used to be set HERE to the day's own temporal slug + (reasoning: [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, which deliberately reuses the ordinary ferial slug, [Temporal_ef]'s - own [bvm_saturday_names]) -- only [via] needed correcting, the - office/Mass split [Votive] exists to name. *) + own [bvm_saturday_names]). CORRECTED (whole-branch review fix + round): that value is the OFFICE's slug, not the slug whose + Mass is actually said -- the five seasonal Masses RG 309(a) + names have no slug of their own anywhere in the shipped data, + so [said] is [None] here, honestly, rather than silently + naming the wrong thing. See {!Colitur_kernel.Mass_formulary.t}'s + own [said] citation for the full account; the office itself is + not lost, it is still [observed.slug] on the very same + {!Colitur_kernel.Liturgical_day.t} this formulary lives on. *) if is_bvm_saturday_office observed temporal then - let said = temporal.Temporal.office.Celebration.slug in - ( Some { Mass_formulary.said; via = Mass_formulary.Votive }, + ( Some { Mass_formulary.said = None; via = Mass_formulary.Votive }, 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; + { Mass_formulary.said = Some temporal.Temporal.office.Celebration.slug; via = Mass_formulary.Own_slug }, cs ) | None -> ( @@ -478,5 +484,5 @@ let readings ~lectionary ~commons ~observed ~temporal ~date ~temporal_at = with | Some cs -> let said = sunday_temporal.Temporal.office.Celebration.slug in - (Some { Mass_formulary.said; via = Mass_formulary.Preceding_sunday }, cs) + (Some { Mass_formulary.said = Some said; via = Mass_formulary.Preceding_sunday }, cs) | None -> (None, [])))) diff --git a/lib/rites/rite_ef/lectionary_ef.mli b/lib/rites/rite_ef/lectionary_ef.mli index 0797c0d..715e9c2 100644 --- a/lib/rites/rite_ef/lectionary_ef.mli +++ b/lib/rites/rite_ef/lectionary_ef.mli @@ -123,11 +123,15 @@ val commons_for : commons:Commons.t -> Slug.t -> (Slug.t * Citation.t list) opti naming which of the five seasonal Masses is said, not a marker of the Mass's kind; see the implementation comment on this branch for the full account and the validation use this correction still leaves - available for Task 6.) [said] is still that slug: only the source - constructor differs from an ordinary Step 2 lookup, because the guard - that reaches this branch only ever fires when the observed celebration - already IS the day's own temporal office -- see the implementation - comment on that branch. + available for Task 6.) [said] is [None] here (whole-branch review fix + round, celebrant-rubrics-phase1): the five seasonal Masses RG 309(a) + names carry no slug of their own anywhere in the shipped data, so + there is no slug this field could honestly report -- see + {!Colitur_kernel.Mass_formulary.t}'s own [said] citation for the full + account. The day's own OFFICE (the guard that reaches this branch + only ever fires when the observed celebration already IS the day's + own temporal office) is unaffected and is still [observed.slug] on + the same {!Colitur_kernel.Liturgical_day.t}. - {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 diff --git a/test/cli.t b/test/cli.t index e2d2eb5..971e442 100644 --- a/test/cli.t +++ b/test/cli.t @@ -534,20 +534,32 @@ CSV run rather than one per year: $ colitur emit --format csv --from 2027 --to 2028 | wc -l 732 -sexp and xml are also available. This line count moved 9010 -> 9011 -(whole-branch review fix round, RG 476(f)): [emit --format sexp] pretty- -prints with [Sexplib.Sexp.to_string_hum], a column-width wrapping printer, -not a fixed-shape one -- 2027's All Souls' Day (2 November) record grew a -single wrapped line when its own [creed] field's value changed from -[true] to [false] (RG 476(f), the Creed is never said at a Requiem Mass; -see rubrics_ef.ml), because "false" is one character longer than "true" -and pushed that one line's rendered width over to_string_hum's own wrap -threshold. Purely cosmetic -- the record's DATA is unchanged in every -other field, and this is not a claim that [emit]'s FORMAT changed, only -that one record's pretty-printed SHAPE did: +sexp and xml are also available. This line count moved twice in the same +whole-branch review fix round, for two different reasons, both cosmetic: +[emit --format sexp] pretty-prints with [Sexplib.Sexp.to_string_hum], a +column-width wrapping printer, not a fixed-shape one, so any change to a +value's own rendered WIDTH can shift where it wraps. + +9010 -> 9011 (RG 476(f)): 2027's All Souls' Day (2 November) record grew +a single wrapped line when its own [creed] field's value changed from +[true] to [false] (the Creed is never said at a Requiem Mass; see +rubrics_ef.ml), because "false" is one character longer than "true" and +pushed that one line's rendered width over the wrap threshold. + +9011 -> 9025 (Mass_formulary.t.said honesty, Fix 3): [said] gained an +[option] (its own .mli has the full account) -- every day's formulary +record now prints [(said ())] instead of [(said )], one +character wider, and a [Votive] day (the RG 78 Saturday Mass of Our +Lady) prints [(said ())] instead of naming a slug at all, since the +data genuinely names none for the Mass actually said. Both changes ripple +across many lines' own wrap points, not just the days whose DATA changed +-- confirmed directly (diffed the full sexp output line by line): every +difference is exactly this [said] shape change or a consequent wrap +shift, nothing else. Not a claim that [emit]'s FORMAT changed, only that +individual records' pretty-printed SHAPE did: $ colitur emit --format sexp --from 2027 --to 2027 | wc -l - 9011 + 9025 $ colitur emit --format xml --from 2027 --to 2027 | head -2 diff --git a/test/test_lectionary_ef.ml b/test/test_lectionary_ef.ml index 04076c7..3e0880e 100644 --- a/test/test_lectionary_ef.ml +++ b/test/test_lectionary_ef.ml @@ -591,22 +591,22 @@ let test_commons_load_rejects_bad_data () = let formulary_cases = [ (* step 1: a saint with his own proper -- same date as [test_step1_proper_beats_any_common_john_of_god]. *) - (2038, 3, 8, "john-of-god", Colitur_kernel.Mass_formulary.Proper); + (2038, 3, 8, Some "john-of-god", Colitur_kernel.Mass_formulary.Proper); (* step 2: the day's own temporal slug -- same date as [test_step2_lenten_feria_has_its_own], Monday of Lent I. *) - (2026, 2, 23, "ef-lent-1-monday", Colitur_kernel.Mass_formulary.Own_slug); + (2026, 2, 23, Some "ef-lent-1-monday", Colitur_kernel.Mass_formulary.Own_slug); (* step 3: a feria resuming the preceding Sunday. The task brief's own snippet pinned this date against week 9 ("ef-time-after-pentecost- sunday-9"); running the real resolver against 2026 shows 3 August 2026 is Monday of week 10, resuming 2 August's "...sunday-10" -- corrected per the brief's own "find the dates by running the current binary if they drift" instruction. *) - (2026, 8, 3, "ef-time-after-pentecost-sunday-10", + (2026, 8, 3, Some "ef-time-after-pentecost-sunday-10", Colitur_kernel.Mass_formulary.Preceding_sunday); (* step 4: a saint sent to a Common -- same date as [test_step4_commons_perpetua_and_felicity]; [said] is the Common's OWN id (data/ef/commons.sexp), not the saint's slug. *) - (2038, 3, 6, "common-of-non-virgins-1", Colitur_kernel.Mass_formulary.Common); + (2038, 3, 6, Some "common-of-non-virgins-1", Colitur_kernel.Mass_formulary.Common); (* Fix round 1 (coordinator review): the RG 309(a)/RG 78 Saturday votive Mass of Our Lady, structurally reached between steps 4 and 2 (see [readings]' own implementation comment) but tagged [Votive], not @@ -623,9 +623,16 @@ let formulary_cases = 2026 verified directly against the real resolver (`colitur day 2026`), not trusted from a supplied date, given this session's own drifted-pin history: a IV-class Saturday, "Officium sanctae Mariae in - sabbato", temporal slug [ef-time-after-pentecost-9-saturday]. [said] - is unaffected by the retag and stays that same (reused ferial) slug. *) - (2026, 8, 1, "ef-time-after-pentecost-9-saturday", Colitur_kernel.Mass_formulary.Votive) ] + sabbato", temporal slug [ef-time-after-pentecost-9-saturday]. + (CORRECTED, whole-branch review fix round: [said] used to be + claimed "unaffected by the retag" and pinned to that same reused + ferial slug -- that was the defect this round fixed. [said] is + [None] here: the shipped data names no slug for the votive Mass + actually said, only for the office it replaces. The office slug + itself is checked separately, below, via [observed.slug], not + through [said] -- see {!Colitur_kernel.Mass_formulary.t}'s own + citation for why the two are no longer conflated.) *) + (2026, 8, 1, None, Colitur_kernel.Mass_formulary.Votive) ] let test_formulary_reports_its_source () = List.iter @@ -634,10 +641,10 @@ let test_formulary_reports_its_source () = match day.Colitur_kernel.Liturgical_day.formulary with | None -> Alcotest.failf "%04d-%02d-%02d: no formulary" y m d | Some f -> - Alcotest.(check string) + Alcotest.(check (option string)) (Printf.sprintf "%04d-%02d-%02d slug" y m d) expected_slug - (Colitur_kernel.Slug.to_string f.Colitur_kernel.Mass_formulary.said); + (Option.map Colitur_kernel.Slug.to_string f.Colitur_kernel.Mass_formulary.said); Alcotest.(check string) (Printf.sprintf "%04d-%02d-%02d source" y m d) (Colitur_kernel.Mass_formulary.source_to_string expected_via) @@ -645,6 +652,17 @@ let test_formulary_reports_its_source () = f.Colitur_kernel.Mass_formulary.via)) formulary_cases +(* [said = None] on the Votive day above does not mean the office is lost -- + {!Colitur_kernel.Mass_formulary.t}'s own citation says a caller reads it + off [observed.slug] instead, on the very same {!Colitur_kernel. + Liturgical_day.t}. Checked directly, not merely asserted: the same + 1 August 2026 date, same expected slug the old (pre-fix) [said] field + used to carry. *) +let test_votive_office_slug_still_available_via_observed () = + let d = day 2026 8 1 in + Alcotest.(check string) "2026-08-01 observed slug" "ef-time-after-pentecost-9-saturday" + (Colitur_kernel.Slug.to_string d.Colitur_kernel.Liturgical_day.observed.Colitur_kernel.Celebration.slug) + let suite = [ ("step 1: sanctoral proper", `Quick, test_step1_sanctoral_proper); ("step 2: own temporal proper", `Quick, test_step2_lenten_feria_has_its_own); @@ -685,4 +703,6 @@ let suite = ("Commons.load rejects the four silent-degradation defects", `Quick, test_commons_load_rejects_bad_data); ("the formulary reports its own source, one day per step", `Quick, - test_formulary_reports_its_source) ] + test_formulary_reports_its_source); + ("the Votive office slug is still available via observed, not said", `Quick, + test_votive_office_slug_still_available_via_observed) ] diff --git a/test/test_lms_ordo.ml b/test/test_lms_ordo.ml index cff4b3d..a728cbc 100644 --- a/test/test_lms_ordo.ml +++ b/test/test_lms_ordo.ml @@ -302,7 +302,9 @@ let describe_creed_mismatch (o : ordo_row) (c : colitur_row) = Printf.sprintf "%s %S: colitur creed=%b, Ordo creed=%b (formulary=%s)" o.date o.title c.c_creed (Option.get o.creed) (match c.c_formulary with - | Some f -> Colitur_kernel.Slug.to_string f.MF.said + | Some { MF.said = Some s; _ } -> Colitur_kernel.Slug.to_string s + | Some { MF.said = None; via = MF.Votive } -> "votive (said unnamed in the data)" + | Some { MF.said = None; _ } -> "NONE (said, unexpectedly outside Votive)" | None -> "NONE") (* The core assertion: every Creed divergence, over all 400 days (Good @@ -587,7 +589,16 @@ let test_formulary_override_matches () = :: !bad | Some { MF.via = MF.Preceding_sunday; said } -> ( bump "preceding_sunday"; - let slug = Colitur_kernel.Slug.to_string said in + (* [said] is [Some] for every constructor except [Votive] (see + {!Colitur_kernel.Mass_formulary.t}'s own citation) -- a bare + [Option.get] here would raise an unhelpful exception if that + ever stopped being true; [Alcotest.failf] names the day + instead. *) + let slug = + match said with + | Some s -> Colitur_kernel.Slug.to_string s + | None -> Alcotest.failf "%s: Preceding_sunday day with said = None (should be impossible)" o.date + in let ordo_says = Printf.sprintf "Mass of %s" in (* Try the generic [ef--sunday-] shape first; fall back to the one NAMED Sunday that also reaches this population diff --git a/test/test_mass_formulary.ml b/test/test_mass_formulary.ml index 824e0c7..1316e47 100644 --- a/test/test_mass_formulary.ml +++ b/test/test_mass_formulary.ml @@ -4,9 +4,19 @@ module Slug = Colitur_kernel.Slug let slug s = Slug.of_string_exn s let test_round_trips_through_sexp () = - let f = { MF.said = slug "ef-time-after-pentecost-sunday-9"; via = MF.Preceding_sunday } in + let f = { MF.said = Some (slug "ef-time-after-pentecost-sunday-9"); via = MF.Preceding_sunday } in let f' = MF.t_of_sexp (MF.sexp_of_t f) in - Alcotest.(check string) "slug survives" (Slug.to_string f.MF.said) (Slug.to_string f'.MF.said); + Alcotest.(check (option string)) "slug survives" + (Option.map Slug.to_string f.MF.said) (Option.map Slug.to_string f'.MF.said); + Alcotest.(check bool) "source survives" true (f.MF.via = f'.MF.via) + +(* [said] is [None] exactly for {!MF.Votive} -- see the .mli's own citation. + Round-tripped separately so the [None] case has its own witness, not only + inferred from the [Some] case above. *) +let test_none_said_round_trips_through_sexp () = + let f = { MF.said = None; via = MF.Votive } in + let f' = MF.t_of_sexp (MF.sexp_of_t f) in + Alcotest.(check bool) "said stays None" true (f'.MF.said = None); Alcotest.(check bool) "source survives" true (f.MF.via = f'.MF.via) (* [to_string] is what an ordo line shows. It names the SOURCE, not the slug, @@ -25,5 +35,7 @@ let test_to_string_names_the_source () = let suite = ( "Mass_formulary", [ Alcotest.test_case "sexp round-trips" `Quick test_round_trips_through_sexp; + Alcotest.test_case "sexp round-trips, said = None (Votive)" `Quick + test_none_said_round_trips_through_sexp; Alcotest.test_case "source_to_string names each source" `Quick test_to_string_names_the_source ] ) diff --git a/test/test_validate.ml b/test/test_validate.ml index ea8fc0f..1ceaca2 100644 --- a/test/test_validate.ml +++ b/test/test_validate.ml @@ -321,7 +321,7 @@ module Synthetic = struct (* The formulary equivalent of [well_formed_citations] above -- shape only, never rubrically meaningful content. *) let well_formed_formulary = - { Colitur_kernel.Mass_formulary.said = Slug.of_string_exn "syn-formulary"; + { Colitur_kernel.Mass_formulary.said = Some (Slug.of_string_exn "syn-formulary"); via = Colitur_kernel.Mass_formulary.Own_slug } (* No fixture here exercises the Creed rubric -- a rite that has not -- cgit v1.3 From 60e87914718dd2c8e69bb89d8cc48d6bef9bfc74 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 22 Aug 2026 16:40:07 +0200 Subject: feat(ef): implement the calendarium's bissextile February shift The Missale Romanum's calendarium footnote for February (LT.txt:5011-5014, scan-corroborated scan2.txt:3050-3058): in a leap year the sixth kalends of March (24 February) is doubled rather than a 29th day appended, so St Matthias moves 24->25 February and St Gabriel of Our Lady of Sorrows 27->28, with 24 February itself carrying no fixed office that year. data/ef/expected-divergences-lms.sexp entry L3 tracked this as an open gap. Implemented as the rubric's own general mechanism (every fixed entry from 24 through 28 February shifts one civil day later in a leap year), not as "move these two named saints": the two readings are indistinguishable on shipped data (nothing else is fixed in that window) and diverge only for a future --overlay entry in the same range, which the mechanism reading covers correctly and a two-saints special case would not. This project has already been bitten once (RG 16(a)) by a rule implemented against shipped data's coincidental shape rather than the rubric itself. Kernel stays rite-agnostic: Rite.t gains a fixed_key field (Date.t -> (int * int) option), the same seam easter already establishes, read only by Layer.on_date's FIXED half -- the movable half (Easter_offset/Nth_weekday) is untouched. Default is the identity mapping, an optional argument on on_date, so every existing caller and every rite that supplies nothing is byte-identical to before this field existed. Date.is_leap is exposed in date.mli (already existed in date.ml) so the rite reads the kernel's own single-sourced leap-year definition. The EF implementation lives in Rite_ef.Temporal_ef.bissextile_fixed_key, fully cited, wired into Rite_ef.context. Blast radius, measured over the full 1583-9999 domain (Calendar.year run twice per civil year, real fixed_key vs identity, every day diffed): all 2,041 leap years in the domain show a visible change for each saint; 6,983 individual liturgical days change total, zero unclassified, across four shapes (Matthias vacates 24 Feb in 1,803 years / occupies 25 Feb in 1,811; Gabriel vacates 27 Feb in 1,699 / occupies 28 Feb in 1,670). L3's own prior estimate (1,650 years, observed-outright only) is confirmed close on the same definition (1,677 measured); the broader observed-or- commemorated count is higher, not lower, showing the narrow estimate undercounted visible impact rather than overcounting it. The lectio differential (2005-2050 fixture) gains a new cited entry, C40 in data/ef/expected-divergences.sexp: lectio implements no such shift, so every leap year in its window now diverges on the two shifted days (21 rows, not the naive 44 -- the comparator never compares commemorations, so a side where the shifted saint has zero comparator-visible footprint on both engines produces no row). The LMS Ordo's 2023-2024 fixture -- the window L3 was originally found in -- now matches on the Creed comparison; its formulary-override bucket count is corrected 182->181 (24 February leaves the counted Proper population, becoming Own_slug-sourced). The missalemeum oracle fixtures (2026-2027, 2038, 2035) cover no leap year and are unaffected. L3 is closed: converted from an active allow-list record to a prose closure paragraph (L1's own precedent), citation preserved verbatim, fix and measured blast radius recorded. The id-list assertion narrows to [L4] alone, and the now-dead "2024-02-24" -> "L3" date mapping is removed. Six golden values pinned across four new test cases: Matthias in a leap year (both the vacated and occupied sides) and a common-year control; Gabriel likewise, deliberately choosing a leap year where he is admitted only as an ordinary commemoration rather than winning outright, a different shape from Matthias's. Mutation-tested: forcing fixed_key back to identity reddens exactly five test cases -- the differential's Layer C count-pin (C40 drops to 0 actual vs 21 expected), the LMS Ordo's Creed and formulary-bucket checks, and both new golden pins -- confirming the tests actually exercise the fix. Reverted; dune test, the exhaustive sweep, and make check are all green. --- data/ef/expected-divergences-lms.sexp | 100 +++++++++++++++++++++++++++++++--- data/ef/expected-divergences.sexp | 15 +++++ lib/kernel/calendar.ml | 7 ++- lib/kernel/date.mli | 7 +++ lib/kernel/layer.ml | 8 ++- lib/kernel/layer.mli | 13 ++++- lib/kernel/rite.ml | 1 + lib/kernel/rite.mli | 27 +++++++++ lib/kernel/validate.ml | 2 +- lib/rites/rite_ef/rite_ef.ml | 4 ++ lib/rites/rite_ef/temporal_ef.ml | 62 +++++++++++++++++++++ lib/rites/rite_ef/temporal_ef.mli | 11 ++++ test/test_calendar.ml | 3 + test/test_differential.ml | 32 +++++++++++ test/test_golden.ml | 71 +++++++++++++++++++++++- test/test_lms_ordo.ml | 36 ++++++++---- test/test_validate.ml | 3 + 17 files changed, 375 insertions(+), 27 deletions(-) (limited to 'lib/rites/rite_ef') diff --git a/data/ef/expected-divergences-lms.sexp b/data/ef/expected-divergences-lms.sexp index d67e291..4cb5d2a 100644 --- a/data/ef/expected-divergences-lms.sexp +++ b/data/ef/expected-divergences-lms.sexp @@ -126,13 +126,99 @@ ; per-year count is now known to vary (1-3 in the three years measured), ; not a flat "3 days/year". -; L3 -- OPEN, colitur's OWN genuine gap, found extending this suite to the -; 2023-2024 window (Witnesses task, 2026-08-22). Fired once: 2024-02-24. -((id L3) - (citation "The Missale Romanum's own CALENDARIUM table, February, footnote (docs/research/LT.txt:5011-5013, the Missal's printed calendar pages, not the numbered Rubricae Generales chapters): \"In anno bissextili mensis februarius est dierum 29, et festum S. Matthiae celebratur die 25 februarii, ac festum S. Gabrielis a Virgine perdolente 28 februarii, et bis dicitur sexto calendas, id est die 24 et die 25\" -- in a leap year February has 29 days, and the feast of St Matthias is kept on 25 February (not 24), with 24 and 25 February BOTH called \"the 6th kalends\"; St Gabriel of Our Lady of Sorrows (ordinarily 27 February) shifts to 28 February by the same rule. This bissextile shift is not RG-numbered -- it is a calendarium footnote, not a Rubricae Generales paragraph. SCAN-VERIFIED 2026-08-22 (coordinator), upgrading this entry's own earlier caveat that it rested on the transcription alone: the footnote IS present in the photographic scan at docs/research/scan2.txt:3050-3058, OCR-damaged but unmistakable -- \"In anno bi&sextili mensis februarius est dierum 29^ et festum S. Matthue celebra-tur die 2S februarii, ac festum S. Cabrielis a Virgine Perdolente die 28 februarii, et bis dicitur Sexto Kalen-\". The earlier search missed it because the OCR mangles both the saint (\"Matthue\", \"Cabrielis\") and the keyword (\"bi&sextili\"), so neither \"Matth\" nor \"bissext\" matches; it was found by searching for the undamaged phrase \"februarius est dierum\" instead. Transcription and scan agree, so the rule now rests on two witnesses of the SAME document rather than one -- still one document, which is why the citation says calendarium and not RG.") - (verdict ordo) - (note "colitur has NO bissextile-shift rule anywhere (grepped lib/rites/rite_ef/*.ml and data/ef/*.sexp for \"bissext\"/\"leap\": zero hits outside a code comment naming the saint, and the rules-register.md itself is silent on it too) -- St Matthias stays fixed at 24 February every year, leap or not. 2024 is a leap year: colitur's own [matthias] entry (Class2, an Apostle, RG91 entry 16 -- verified from the table itself to genuinely OUTRANK entry 18's privileged Ember-Saturday-of-Lent feria, so colitur's RANKING logic is not at fault here) WINS 24 February 2024 outright in colitur's output, correctly saying the Creed per RG475(e)'s Apostle clause. The Ordo shows no St Matthias anywhere near this date at all: 24 February 2024 is titled plainly \"EMBER SATURDAY of LENT\" (No Cr) and 25 February 2024 (a Sunday, 2nd Sunday in Lent, I class) is titled plainly \"2nd SUNDAY in LENT\" (Cr, but via RG475(a)'s own Sunday clause, not RG475(e)) -- consistent with Matthias having correctly MOVED to the 25th and then lost outright to the I-class Sunday there (RG91 entry 6 over entry 16), never even surviving as a commemoration (an I-class Sunday's own admission cap, RG111, is tight enough to exclude an ordinary II-class saint entirely -- the same shape already established for RG16(a)-adjacent cases elsewhere in this project). Both the 24th and 25th sides of the Ordo's silence are explained by ONE root cause (the missing shift), not two independent gaps -- a coherent, single-cause account, not a coincidence. BLAST RADIUS, roughly measured (a throwaway domain sweep, tools/probe_matthias.ml, run once for this task and not committed): of 2 041 leap years in 1583-9999, colitur observes [matthias] on 24 February in 1 650 of them -- an UPPER BOUND on how many years this gap could affect (some of those 1 650 years may see IDENTICAL final output either way, e.g. if Matthias would also win on the 25th against whatever competes there that year; a precise count needs the shift actually implemented, which is out of this task's own no-lib-changes scope). The Gabriel/27-28 February sibling shift is not witnessed by this fixture at all (no divergence on either date in this window) but is the same root cause and would need the same fix.") - (expected_rows 1)) +; L3 -- CLOSED, FIXED (bissextile-shift task, 2026-08-22; NOT an active +; sexp record any more, same convention L1 above established -- removed +; from [test_allow_list_ids_are_exactly_l3_l4]'s own expected set, which +; now names L4 alone). Was OPEN, colitur's OWN genuine gap, found +; extending this suite to the 2023-2024 window (Witnesses task, +; 2026-08-22), firing once: 2024-02-24. +; +; Citation, preserved verbatim: the Missale Romanum's own CALENDARIUM +; table, February, footnote (docs/research/LT.txt:5011-5013, scan- +; corroborated docs/research/scan2.txt:3050-3058, OCR-damaged to +; "bi&sextili"/"Matthue"/"Cabrielis" but unmistakable against the +; undamaged transcription): "In anno bissextili mensis februarius est +; dierum 29, et festum S. Matthiae celebratur die 25 februarii, ac festum +; S. Gabrielis a Virgine perdolente 28 februarii, et bis dicitur sexto +; calendas, id est die 24 et die 25" -- in a leap year February has 29 +; days, St Matthias is kept on 25 February (not 24), St Gabriel of Our +; Lady of Sorrows on 28 February (not 27), and the sixth kalends of March +; is said twice. Not RG-numbered -- a calendarium footnote, not a +; Rubricae Generales paragraph. +; +; FIXED: {!Rite_ef.Temporal_ef.bissextile_fixed_key}, threaded through +; {!Colitur_kernel.Rite.t}'s new [fixed_key] field (read only by +; {!Colitur_kernel.Layer.on_date}'s FIXED half, so the kernel stays +; rite-agnostic -- a rite that supplies nothing behaves exactly as +; before). Implemented as the rubric's own MECHANISM (every fixed entry +; from 24 through 28 February shifts one civil day later in a leap year, +; 24 February itself carrying no fixed entry that year), not as "move +; these two named saints": the two are indistinguishable on shipped data +; (nothing else is fixed in that window) and diverge only for a future +; [--overlay] entry in the same range, which the mechanism reading covers +; and a two-saints special case would not. +; +; 2024-02-24/25 now MATCH the Ordo: colitur's Creed follows the same +; Matthias-vacates-24/loses-outright-on-the-Sunday-25th path the note +; below already inferred from the Ordo's own titles, and the LMS +; 2023-2024 suite's Creed and formulary-override checks are both green +; (the formulary bucket's own [expected_proper] constant dropped 182->181 +; for this window: 24 February is now the Ember Saturday's own Own_slug- +; sourced proper, not Matthias's Proper-sourced one, leaving the counted +; population rather than changing which slug's proper it is). +; +; BLAST RADIUS, MEASURED (full 1583-9999 sweep, {!Colitur_kernel.Calendar. +; year} run twice per civil year -- once with the real [fixed_key], once +; with it forced to the identity default -- every changed liturgical day +; diffed on observed slug/rank/colour/subject/commemorations; throwaway +; probe, tools/probe_bissextile.ml, run for this task and not committed, +; same precedent as tools/probe_matthias.ml above): +; +; - ALL 2 041 leap years in the domain show at least one visible +; Matthias-related change, and separately all 2 041 show at least one +; Gabriel-related change -- every leap year in 1583-9999 is touched. +; - 6 983 individual liturgical days change domain-wide, ZERO +; unclassified: exactly four shapes, all the same mechanism -- +; Matthias vacates 24 February (1 803 years) and newly appears on 25 +; February (1 811 years); Gabriel vacates 27 February (1 699 years) +; and newly appears on 28 February (1 670 years). "Vacates"/"appears" +; covers BOTH observed-outright and commemorated-only presence, not +; observed alone -- RG 111's admission cap can relegate the shifted +; saint to a bare commemoration rather than the day's own office, and +; a classifier that only watched [observed] undercounted by roughly a +; tenth on a first pass (found and corrected before this note was +; written: the OBSERVED-outright figures alone, tracked separately +; for direct comparison against this entry's own prior estimate +; below, are 1 677 for Matthias and 571 for Gabriel). +; - THIS ENTRY'S OWN PRIOR ESTIMATE, checked: "of 2 041 leap years, +; colitur observes matthias on 24 February in 1 650 of them" is +; confirmed close but not exact against the real, precisely-measured +; figure on the SAME (observed-outright-only) definition -- 1 677, a +; 2% difference consistent with the prior probe's own "roughly +; measured" caveat, not a sign either probe was wrong in kind. The +; prior estimate's own further caveat -- "some of those years may see +; identical final output either way" -- is also confirmed real but +; small: 1 677 (narrow, observed-only) vs 1 803 (broad, observed-or- +; commemorated) for the SAME 24-February-vacated population shows the +; narrow definition undercounts genuine visible impact rather than +; overcounting it, because a saint who only lost outright pre-fix +; (never observed, never commemorated, invisible either way) is +; absent from BOTH counts and was never the source of the gap between +; them. +; - Gabriel's own sibling shift, unwitnessed by this fixture's own +; 2023-2024 window (no divergence fired on either 27 or 28 February +; 2024 -- 2024's own competing office there does not expose it), is +; the identical mechanism and closes with the same fix; its own +; measured figures are recorded above for the first time, this entry +; having previously had no estimate for it at all. +; +; The differential (lectio, layer 3) gained a new cited entry for its own +; 2005-2050 window, data/ef/expected-divergences.sexp's C40 -- lectio +; implements no such shift either (confirmed directly against the +; fixture: Matthias/Gabriel sit at their PRE-shift dates in every one of +; the 11 leap years that window covers), so every leap year in it now +; diverges on the two shifted days, 21 rows total. See C40's own note for +; the row-by-row account. ; L4 -- OPEN, adjudicated FOR colitur (a defensible primary-source reading, ; not a fix confirmed against the Ordo -- the Ordo's own contrary reading is diff --git a/data/ef/expected-divergences.sexp b/data/ef/expected-divergences.sexp index 19ff2ef..632854c 100644 --- a/data/ef/expected-divergences.sexp +++ b/data/ef/expected-divergences.sexp @@ -116,6 +116,21 @@ WHAT THIS ENTRY ALSO RESTS ON. Two things, neither of them a day-level witness. Gated on lectio's own slug being one of the three vigils this clause can reach and colitur's not being a vigil -- identity on both sides, not a diff-set shape. The Assumption's and the Ascension's vigils are absent from that list because their I-class feasts always keep their own day: 0 occurrences across the whole domain, measured rather than assumed.") (expected_rows 10)) + ((id C40) + (citation "The Missale Romanum's own CALENDARIUM table, February, footnote (docs/research/LT.txt:5011-5014, scan-corroborated docs/research/scan2.txt:3050-3058): \"In anno bissextili mensis februarius est dierum 29, et festum S. Matthiae celebratur die 25 februarii, ac festum S. Gabrielis a Virgine perdolente 28 februarii, et bis dicitur sexto calendas, id est die 24 et die 25\" -- in a leap year February has 29 days, St Matthias is kept on 25 February (not 24), St Gabriel of Our Lady of Sorrows on 28 February (not 27), and the sixth kalends of March is said twice. Implemented in Rite_ef.Temporal_ef.bissextile_fixed_key, threaded through Colitur_kernel.Rite.t's new [fixed_key] field (bissextile-shift task, 2026-08-22). Not RG-numbered -- a calendarium footnote, not a Rubricae Generales paragraph.") + (verdict colitur) + (note "lectio builds no such shift at all: checked directly against the fixture, Matthias sits at 24 February and Gabriel of Our Lady of Sorrows at 27 February in EVERY ONE of the 11 leap years the 2005-2050 window covers (2008, 2012, 2016, 2020, 2024, 2028, 2032, 2036, 2040, 2044, 2048), never moved. colitur now moves both, per the calendarium's own footnote, so every leap year in this window diverges on the two shifted civil days: colitur vacates 24/27 February (the saint moves away) and occupies 25/28 February instead (the saint newly appears there), while lectio keeps the pre-shift assignment throughout. + +19 Matthias rows + 2 Gabriel rows = 21, not the naive 11-years x 2-sides x 2-feasts = 44 ceiling: this comparator, BY DESIGN (see this file's own header), never compares commemorations -- only observed slug/rank/colour/citations -- so a side where the shifted saint has ZERO comparator-visible footprint, on both engines, whether pre- or post-shift, produces no row even though a real change happened underneath (the full-domain probe below, which DOES track commemorations, still counts it). + +MATTHIAS fires on both sides (24 vacated AND 25 occupied) in 8 of the 11 years; in the remaining 3 (2008, 2024, 2036) exactly one side is missing, and in every one of the three the missing side is a SUNDAY -- 2008-02-24 and 2036-02-24 (Matthias already loses outright to that Sunday whichever engine or shift-state is asked, so the vacated side shows no change); 2024-02-25 (the occupied side: colitur's post-shift Matthias loses outright to the 2nd Sunday of Lent, I class, with no commemoration surviving RG111's own tight cap -- Matthias's footprint there is genuinely zero, not merely uncompared). A Sunday's own observed slug is unaffected by this shift either way, so whichever side coincides with one contributes nothing comparator-visible; only the other, non-Sunday side shows the real new divergence. 2024-02-24 is L3's own original date (data/ef/expected-divergences-lms.sexp), now confirmed independently by a second, larger fixture. + +GABRIEL (Class3, weaker than Matthias's Class2/Apostle rank) fires in only 2 of the 11 years (2028 occupied, 2044 vacated) because in the other 9 his position is already dominated by a Class3-or-stronger competing office both before and after the shift, so his own presence is commemoration-only throughout -- invisible to this comparator on either side. He surfaces only where the position happens to be a plain, unprivileged Class4 feria weak enough for his own Class3 to win outright: 2028-02-28 (colitur: gabriel-of-our-lady-of-sorrows, Class3, White, beating ef-septuagesima-3-monday, Class4) and 2044-02-27 (lectio still shows gabriel there unmoved, Class3, White; colitur's post-shift 27th falls to RG78's own votive BVM-Saturday office instead, also White, hence the colour match but slug/rank/citation mismatch in that row). Every one of the 21 rows was read directly off the comparator's own failure output, not apportioned by hand. + +Diff shapes: most rows carry [Slug_f; Rank; Colour_f; First_f; Gospel_f] (the temporal office on one side and the saint's own Class2/3 proper on the other differ in rank too); a handful carry only [Slug_f; Colour_f; First_f; Gospel_f] where the competing temporal office happens to share Matthias's own Class2 (the privileged Lenten Ember Friday/Saturday, 2024 and 2040) or Gabriel's own White (a BVM Saturday, 2044-02-27's own vacated row). Season never differs on any of the 21 -- both sides stay in whichever season that Sunday-relative week already put them in, regardless of which fixed entry, if any, sits on the day. + +BLAST RADIUS, MEASURED, full 1583-9999 domain (not merely this fixture's 2005-2050 window): 6 983 liturgical days change, zero unclassified, across all 2 041 leap years in the domain -- see data/ef/expected-divergences-lms.sexp's own now-closed L3 entry for the full figures (this entry's own 21-row 2005-2050 population is the expected subset of that domain-wide count landing inside the fixture's own window: 11 leap years x up to 2 new rows each, minus the three already-explained overlaps above).") + (expected_rows 21)) ; C8 -- CLOSED, REMOVED (2026-08-18): 0 of 16801 rows, was 26. lectio builds ; Rogation Monday and Tuesday now (RG 87, Easter+36/+37, violet under ; RG 128(d)). The Wednesday is NOT built there and deliberately so: Easter+38 diff --git a/lib/kernel/calendar.ml b/lib/kernel/calendar.ml index d55e38d..db6c58e 100644 --- a/lib/kernel/calendar.ml +++ b/lib/kernel/calendar.ml @@ -79,7 +79,7 @@ let resolve_with_injected ?(suppressed = no_suppression) (rite : ('s, 'r) Rite.t { Precedence.cel = temporal.Temporal.office; origin = Precedence.Temporal } in let natural = - Layer.on_date idx date + Layer.on_date ~fixed_key:rite.Rite.fixed_key idx date |> List.map (fun (e : 'r Layer.entry) -> { Precedence.cel = e.Layer.cel; origin = Precedence.Sanctoral }) in @@ -339,7 +339,7 @@ let rg33_suppressed (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) Array.iter (fun date -> let candidates = - Layer.on_date idx date + Layer.on_date ~fixed_key:rite.Rite.fixed_key idx date |> List.map (fun (e : 'r Layer.entry) -> { Precedence.cel = e.Layer.cel; origin = Precedence.Sanctoral }) in @@ -529,7 +529,8 @@ let build_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) | None -> [] | Some slugs -> let on_date = - Layer.on_date idx date |> List.map (fun (e : 'r Layer.entry) -> e.Layer.cel) + Layer.on_date ~fixed_key:rite.Rite.fixed_key idx date + |> List.map (fun (e : 'r Layer.entry) -> e.Layer.cel) in let temporal_office = temporal.Temporal.office in (temporal_office :: (on_date @ List.map (fun c -> c.Precedence.cel) arrived)) diff --git a/lib/kernel/date.mli b/lib/kernel/date.mli index 2f632c8..0e86d54 100644 --- a/lib/kernel/date.mli +++ b/lib/kernel/date.mli @@ -14,6 +14,13 @@ val month : t -> int val day : t -> int val weekday : t -> weekday +(** Proleptic Gregorian leap-year test: divisible by 4, except a century year + not divisible by 400. The same rule {!make}/{!days_in_month} already use + internally to admit 29 February -- exposed so a rite can key its own + calendar-reckoning rules (e.g. a kalends-doubling convention) off the + identical, single-sourced definition rather than re-deriving it. *) +val is_leap : int -> bool + (** [to_rata]/[of_rata] expose the underlying day-count (days since 1970-01-01); [of_rata] and [add_days] are unbounded total arithmetic (they may denote a year outside 1583..9999 — only [make] enforces the domain). *) diff --git a/lib/kernel/layer.ml b/lib/kernel/layer.ml index f069546..2a072f0 100644 --- a/lib/kernel/layer.ml +++ b/lib/kernel/layer.ml @@ -73,8 +73,12 @@ let index t ~easter ~years = Hashtbl.iter (fun k v -> Hashtbl.replace movable k (canonical v)) movable; { fixed; movable } -let on_date idx date = - let f = try Hashtbl.find idx.fixed (Date.month date, Date.day date) with Not_found -> [] in +let on_date ?(fixed_key = fun d -> Some (Date.month d, Date.day d)) idx date = + let f = + match fixed_key date with + | None -> [] + | Some k -> ( try Hashtbl.find idx.fixed k with Not_found -> []) + in let m = try Hashtbl.find idx.movable (Date.to_rata date) with Not_found -> [] in match m with [] -> f | _ -> canonical (f @ m) diff --git a/lib/kernel/layer.mli b/lib/kernel/layer.mli index ab6e56b..a71e113 100644 --- a/lib/kernel/layer.mli +++ b/lib/kernel/layer.mli @@ -36,8 +36,17 @@ type 'r index val index : 'r t -> easter:(int -> Date.t) -> years:int list -> 'r index (** Entries falling on [date], fixed and movable together, in the layer's - canonical slug order. *) -val on_date : 'r index -> Date.t -> 'r entry list + canonical slug order. + + [fixed_key] answers "which (month, day) does the FIXED half look up for + this civil date", defaulting to the identity [Some (Date.month date, + Date.day date)] -- exactly today's behaviour, for a caller that supplies + nothing. A rite's own kalends-reckoning convention (see {!Rite.t}'s + [fixed_key] field) is threaded through here rather than applied to + [date] itself, so it touches ONLY the fixed table: the MOVABLE half + (keyed by rata die, via {!Date_spec.Easter_offset}/{!Date_spec. + Nth_weekday}) always uses [date] unchanged. *) +val on_date : ?fixed_key:(Date.t -> (int * int) option) -> 'r index -> Date.t -> 'r entry list (** Loads a layer from a sexp file. Parse and validation failures come back as [Error], never as an exception. *) diff --git a/lib/kernel/rite.ml b/lib/kernel/rite.ml index 4999fc2..738785a 100644 --- a/lib/kernel/rite.ml +++ b/lib/kernel/rite.ml @@ -8,6 +8,7 @@ type ('s, 'r) t = { temporal : Date.t -> ('s, 'r) Temporal.t; anchors : int -> (string * Date.t) list; easter : int -> Date.t; + fixed_key : Date.t -> (int * int) option; rules : ('s, 'r) Precedence.rules; season_runs : 's list; transfer_target : diff --git a/lib/kernel/rite.mli b/lib/kernel/rite.mli index 39538d0..0dcf17e 100644 --- a/lib/kernel/rite.mli +++ b/lib/kernel/rite.mli @@ -17,6 +17,33 @@ type ('s, 'r) t = { into rite-agnostic code and be silently wrong for a Julian-reckoning rite. Read by {!Layer.index} to resolve {!Date_spec.Easter_offset}. *) + fixed_key : Date.t -> (int * int) option; + (** The (month, day) under which a FIXED sanctoral entry ({!Layer.on_date}) + is looked up for this civil date. [Some (month, day)] identity for + every rite with no reason to differ -- a Byzantine or other + non-Roman rite supplies nothing beyond that, and its output is + therefore byte-identical to a rite that predates this field + entirely. [None] means no fixed entry can ever be found for this + date, regardless of what {!Layer.index} holds. + + Exists for the Roman calendarium's own bissextile (leap-year) + footnote (February, docs/research/LT.txt:5011-5014): the + intercalary day is inserted by DOUBLING the sixth kalends of March + (civil 24 February in a common year), not by appending a 29th day + at the month's end, so every fixed feast dated at or after that + kalends position is kept one civil day later than usual, and 24 + February itself carries no fixed entry that year. This is a fact + about the ROMAN rite's own kalends reckoning, not a universal + computus rule -- a rite with no such convention (or none at all, + the default above) must not have it hardcoded into rite-agnostic + code, the same reason {!easter} above is rite-supplied rather than + chosen here. See rite_ef/temporal_ef.ml's [bissextile_fixed_key] + for the concrete Roman implementation and its full citation. + + Deliberately untouched: the MOVABLE half of {!Layer.on_date}'s + lookup ({!Date_spec.Easter_offset}, {!Date_spec.Nth_weekday}) -- + this field's contract is fixed-date reckoning only, and nothing in + the calendarium footnote concerns Easter-relative dates. *) rules : ('s, 'r) Precedence.rules; season_runs : 's list; (** the expected run-length-compressed season sequence over one liturgical diff --git a/lib/kernel/validate.ml b/lib/kernel/validate.ml index ce48615..f523b39 100644 --- a/lib/kernel/validate.ml +++ b/lib/kernel/validate.ml @@ -239,7 +239,7 @@ let run (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) ~year = let expected : (string, int) Hashtbl.t = Hashtbl.create 64 in List.iter (fun date -> - Layer.on_date idx date + Layer.on_date ~fixed_key:rite.Rite.fixed_key idx date |> List.iter (fun (e : 'r Layer.entry) -> bump expected (Slug.to_string e.Layer.cel.Celebration.slug))) days; diff --git a/lib/rites/rite_ef/rite_ef.ml b/lib/rites/rite_ef/rite_ef.ml index e48ff33..ba1274b 100644 --- a/lib/rites/rite_ef/rite_ef.ml +++ b/lib/rites/rite_ef/rite_ef.ml @@ -39,6 +39,10 @@ let context ~lectionary ~commons : (Vocab_ef.season, Vocab_ef.rank) Rite.t = Julian-reckoning rite can supply {!Computus.julian_easter} instead. Read by [Layer.index] for [Date_spec.Easter_offset]. *) easter = Colitur_kernel.Computus.gregorian_easter; + (* The calendarium's own bissextile footnote (February, LT.txt:5011-5014): + see {!Temporal_ef.bissextile_fixed_key} for the full citation and + mechanism. Read by [Layer.on_date]'s fixed half only. *) + fixed_key = Temporal_ef.bissextile_fixed_key; rules = { Precedence.band = Precedence_ef.band; disposition = Precedence_ef.disposition; diff --git a/lib/rites/rite_ef/temporal_ef.ml b/lib/rites/rite_ef/temporal_ef.ml index c16732b..1c916f4 100644 --- a/lib/rites/rite_ef/temporal_ef.ml +++ b/lib/rites/rite_ef/temporal_ef.ml @@ -1158,6 +1158,68 @@ let anchors y = | Some d -> [ ("ef-holy-name-sunday", d) ] | None -> [ ("ef-holy-name", holy_name_fallback_date y) ]) +(* The Missale Romanum's own CALENDARIUM table, February, footnote + (docs/research/LT.txt:5011-5014, scan-corroborated at + docs/research/scan2.txt:3050-3058, where OCR mangles it to + "bi&sextili"/"Matthue"/"Cabrielis" -- transcription and scan agree, so + this rests on two witnesses of the same document): + + "In anno bissextili mensis februarius est dierum 29, et festum S. + Matthiae celebratur die 25 februarii, ac festum S. Gabrielis a + Virgine perdolente 28 februarii, et bis dicitur sexto calendas, id + est die 24 et die 25; et littera dominicalis, quae assumpta fuit in + mense ianuario, mutetur in praecedentem..." + + In a leap year February has 29 days, St Matthias is kept on 25 February + (not 24), St Gabriel of Our Lady of Sorrows on 28 February (not 27), and + the sixth kalends of March is said TWICE -- "bis dicitur sexto + calendas... die 24 et die 25". That last clause names the MECHANISM, not + two independent exceptions: the Roman calendar's intercalary day is + inserted by DOUBLING the sixth kalends (civil 24 February in a common + year), not by appending a 29th day at the month's end. Reckoned by + kalends position: 24 Feb = VI Kal. Mart., 25 Feb = V Kal., 26 Feb = IV + Kal., 27 Feb = III Kal., 28 Feb = pridie Kal. In a leap year VI Kal. + itself falls on TWO consecutive civil days (24th and 25th, "bis"), and + every kalends position after it is pushed one civil day later as a + result: V Kal. from 25th to 26th, IV Kal. from 26th to 27th, III Kal. + (Gabriel) from 27th to 28th, pridie Kal. from 28th to 29th. A feast fixed + at VI Kal. itself (Matthias) is kept on the SECOND occurrence, not the + first -- 25 February carries it, 24 February carries no fixed office at + all that year (confirmed against the LMS Ordo witness, + data/ef/expected-divergences-lms.sexp entry L3: 24 February 2024 is + titled plainly "EMBER SATURDAY of LENT", no Matthias anywhere near it). + + IMPLEMENTED AS THE GENERAL MECHANISM, not as "shift these two named + feasts": only three sanctoral entries exist in 23-29 February on shipped + data (peter-damien the 23rd, before the doubled kalends and therefore + unaffected; matthias the 24th; gabriel-of-our-lady-of-sorrows the 27th), + so "shift the two named feasts" and "shift every fixed entry from 24 + February" are indistinguishable on universal data -- they produce + identical output. They differ only for a locally-supplied entry, e.g. an + [--overlay] patronal feast fixed at 26 February: under the mechanism + reading it shifts to 27 February in a leap year (IV Kal. -> the civil day + IV Kal. falls on that year), exactly as a diocesan calendar compiled + against the same kalends convention would expect. The rubric's own text + states a MECHANISM ("bis dicitur sexto calendas"), not a closed list of + two saints, and this project has already been bitten once by a rule + implemented against the shipped data's coincidental shape rather than the + rubric itself (RG 16(a), CLAUDE.md's own carried lesson) -- so the general + reading is the one implemented here. + + Threaded through {!Rite.t}'s [fixed_key] field, read only by + {!Colitur_kernel.Layer.on_date}'s FIXED half: the kernel stays + rite-agnostic (a Byzantine or other non-Roman rite supplies nothing and + is unaffected), and the MOVABLE half ({!Date_spec.Easter_offset}, + {!Date_spec.Nth_weekday}) is untouched -- nothing in the footnote + concerns Easter-relative dates. *) +let bissextile_fixed_key (d : Date.t) : (int * int) option = + let m = Date.month d and day = Date.day d in + if m = 2 && Date.is_leap (Date.year d) then + if day = 24 then None (* the FIRST VI Kal.: no fixed entry lands here in a leap year *) + else if day >= 25 && day <= 29 then Some (2, day - 1) (* the SECOND VI Kal. onward, one day later *) + else Some (m, day) + else Some (m, day) + (* Compile-time check that this module satisfies the kernel's rite contract. *) module _ : Colitur_kernel.Temporal.RITE = struct let id = id diff --git a/lib/rites/rite_ef/temporal_ef.mli b/lib/rites/rite_ef/temporal_ef.mli index 5e85111..2bed5b6 100644 --- a/lib/rites/rite_ef/temporal_ef.mli +++ b/lib/rites/rite_ef/temporal_ef.mli @@ -51,6 +51,17 @@ val week : Date.t -> int option (** The lectionary key for a Sunday, or [None] if [d] is not a Sunday. *) val sunday_slug : Date.t -> string option +(** The calendarium's own bissextile (leap-year) footnote, February + (docs/research/LT.txt:5011-5014): in a leap year the sixth kalends of + March (24 February) is doubled rather than a 29th day appended, so every + fixed entry from 24 through 28 February is kept one civil day later than + usual (Matthias 24->25, Gabriel of Our Lady of Sorrows 27->28), and 24 + February itself carries no fixed office that year. Supplied as {!Rite.t}'s + [fixed_key] field -- see that field's own doc comment and this function's + definition for the full citation and the general-mechanism-vs-two-named- + feasts reasoning. *) +val bissextile_fixed_key : Date.t -> (int * int) option + val id : string (** Total over 1583..9999: every date yields exactly one temporal identity. *) diff --git a/test/test_calendar.ml b/test/test_calendar.ml index 74fe26a..0d402e6 100644 --- a/test/test_calendar.ml +++ b/test/test_calendar.ml @@ -116,6 +116,9 @@ module Fixture = struct any for a fixture; nothing here is Easter-relative, so the value is never actually read. *) easter = Colitur_kernel.Computus.gregorian_easter; + (* Not a Roman rite either, so no bissextile-doubling convention: + identity, {!Rite.t.fixed_key}'s own documented default. *) + fixed_key = (fun d -> Some (D.month d, D.day d)); rules; season_runs = [ A; B ]; transfer_target; readings; creed } let entry ~month ~day ~slug ~rank = diff --git a/test/test_differential.ml b/test/test_differential.ml index 8c1dd8d..2f63df6 100644 --- a/test/test_differential.ml +++ b/test/test_differential.ml @@ -1096,6 +1096,36 @@ let rg33_vigil_slugs = [ "vigil-of-st-lawrence"; "vigil-of-the-nativity-of-st-john-the-baptist"; "vigil-of-sts-peter-paul" ] +(* C40 -- the calendarium's own bissextile (leap-year) footnote (February, + docs/research/LT.txt:5011-5014; see rite_ef/temporal_ef.ml's + [bissextile_fixed_key] for the full citation and mechanism), implemented + 2026-08-22. lectio builds no such shift at all (confirmed directly + against the fixture: Matthias sits at 24 February and Gabriel of Our Lady + of Sorrows at 27 February in EVERY leap year the 2005-2050 fixture + covers, 11 of them), so every leap year now diverges on the two shifted + civil days: colitur vacates 24/27 February (Matthias/Gabriel move away) + and occupies 25/28 February instead, while lectio keeps the pre-shift + assignment on both. + + Four shapes, one mechanism -- OLD position vacated (colitur no longer + matches lectio's still-there Matthias/Gabriel) and NEW position occupied + (colitur now shows a saint lectio's own unmoved date does not) -- each + checked by NAME on both sides, the same identity discipline C39 and + C6/C14 already apply, not merely a diff-set shape: a coincidental + same-shape diff on an unrelated 24/25/27/28 February slug would not match + any of the four literal-slug conjuncts below. *) +let is_bissextile_shift_row (l : row) (c : row) ~month ~day = + month = 2 + && Date.is_leap (year_of_date c.date) + && ((day = 24 && String.equal l.slug "matthias" && not (String.equal c.slug "matthias")) + || (day = 25 && String.equal c.slug "matthias" && not (String.equal l.slug "matthias")) + || (day = 27 + && String.equal l.slug "gabriel-of-our-lady-of-sorrows" + && not (String.equal c.slug "gabriel-of-our-lady-of-sorrows")) + || (day = 28 + && String.equal c.slug "gabriel-of-our-lady-of-sorrows" + && not (String.equal l.slug "gabriel-of-our-lady-of-sorrows"))) + let layer_c_reason (l : row) (c : row) diffs = let m = month_of_date l.date and d = day_of_date l.date in if diffs = [] then None @@ -1158,6 +1188,8 @@ let layer_c_reason (l : row) (c : row) diffs = && (not (List.mem c.slug rg33_vigil_slugs)) && subset diffs [ Slug_f; Rank; Colour_f; First_f; Gospel_f ] then Some "C39" + else if is_bissextile_shift_row l c ~month:m ~day:d && subset diffs [ Slug_f; Rank; Colour_f; First_f; Gospel_f ] + then Some "C40" (* C25 -- CLOSED, BVM Saturday Mass (2026-08-17), predicate removed: 0 rows. Its days are unoccupied IV-class Saturdays, so they carry RG 78's office and now RG 309(a)'s Mass for it, which answers before step 3's walkback diff --git a/test/test_golden.ml b/test/test_golden.ml index ba25d8a..d680720 100644 --- a/test/test_golden.ml +++ b/test/test_golden.ml @@ -1315,6 +1315,67 @@ let test_rogation_wednesday_yields_to_a_feast_2026 () = "2026-05-13 wednesday season=paschaltide week=6 slug=ef-ascension-vigil rank=class-2 colour=white \ subject=temporal name_la=- comms=[robert-bellarmine:ordinary] in=- out=[]" +(* ---- The bissextile (leap-year) calendarium footnote, bissextile-shift + task, 2026-08-22 ---- + + The Missale Romanum's own CALENDARIUM table, February, footnote + (docs/research/LT.txt:5011-5014, scan-corroborated docs/research/ + scan2.txt:3050-3058): "In anno bissextili mensis februarius est dierum + 29, et festum S. Matthiae celebratur die 25 februarii, ac festum S. + Gabrielis a Virgine perdolente 28 februarii, et bis dicitur sexto + calendas, id est die 24 et die 25" -- in a leap year the sixth kalends + of March (24 February) is doubled rather than a 29th day appended, so + St Matthias (ordinarily 24 February) is kept on 25 February and St + Gabriel of Our Lady of Sorrows (ordinarily 27 February) on 28 February; + 24 February itself carries no fixed office that year. + {!Rite_ef.Temporal_ef.bissextile_fixed_key} implements this as the + rubric's own general MECHANISM, so a leap year moves the VACATED date's + slug/rank/colour away and the OCCUPIED date's toward it -- both sides + pinned for each saint, plus one common-year control apiece proving the + identity default is unchanged where the footnote does not apply. *) + +(* Matthias, 2044 (leap): 24 February carries no fixed office at all + (Class4 temporal only, zero commemorations); Matthias (Class2, an + Apostle) wins 25 February outright against that day's own Class4 + feria. *) +let test_bissextile_matthias_leap_2044 () = + check ~msg:"leap year: 24 February carries no fixed office, Matthias has moved away" 2044 2 24 + "2044-02-24 wednesday season=septuagesima week=2 slug=ef-septuagesima-2-wednesday rank=class-4 colour=violet \ + subject=temporal name_la=- comms=[] in=- out=[]"; + check ~msg:"leap year: Matthias observed outright on 25 February, not 24" 2044 2 25 + "2044-02-25 thursday season=septuagesima week=2 slug=matthias rank=class-2 colour=red subject=saint name_la=- \ + comms=[] in=- out=[]" + +(* Matthias, 2025 (common): unaffected control -- the identity default + applies, Matthias stays at his ordinary 24 February. *) +let test_bissextile_matthias_common_2025 () = + check ~msg:"common year: Matthias unaffected, still 24 February" 2025 2 24 + "2025-02-24 monday season=septuagesima week=2 slug=matthias rank=class-2 colour=red subject=saint name_la=- \ + comms=[] in=- out=[]" + +(* Gabriel of Our Lady of Sorrows, 2008 (leap): 27 February carries no + trace of him at all (not even a commemoration -- fixed_key removes him + from the candidate set entirely, RG 111's admission machinery never + sees him); on 28 February he is admitted as an ORDINARY commemoration + (Class3 ties the day's own privileged Lenten feria, which keeps + observed). A different shape from Matthias's outright win above, + deliberately pinned: the mechanism moves WHERE the saint is tried, not + whether he wins once he gets there. *) +let test_bissextile_gabriel_leap_2008 () = + check ~msg:"leap year: 27 February carries no trace of Gabriel, he has moved away" 2008 2 27 + "2008-02-27 wednesday season=lent week=3 slug=ef-lent-3-wednesday rank=class-3 colour=violet subject=temporal \ + name_la=- comms=[] in=- out=[]"; + check ~msg:"leap year: Gabriel admitted as an ordinary commemoration on 28 February, not 27" 2008 2 28 + "2008-02-28 thursday season=lent week=3 slug=ef-lent-3-thursday rank=class-3 colour=violet subject=temporal \ + name_la=- comms=[gabriel-of-our-lady-of-sorrows:ordinary] in=- out=[]" + +(* Gabriel of Our Lady of Sorrows, 2025 (common): unaffected control -- + observed outright at his ordinary 27 February. *) +let test_bissextile_gabriel_common_2025 () = + check ~msg:"common year: Gabriel unaffected, still observed on 27 February" 2025 2 27 + "2025-02-27 thursday season=septuagesima week=2 slug=gabriel-of-our-lady-of-sorrows rank=class-3 colour=white \ + subject=saint name_la=- comms=[] in=- out=[]" + (* RG 128, transcribed in docs/research/rules-register.md §3b: violet is used for "II/III-class vigils outside Paschaltide". Two of colitur's five vigils disagreed with that rule until the ef-oconnell-rubrics branch -- the @@ -1510,5 +1571,13 @@ let suite = Alcotest.test_case "RG87: Rogation Wednesday admitted (2024)" `Quick test_rogation_wednesday_admitted_2024; Alcotest.test_case "RG87/113: Rogation Wednesday yields to an impeded feast (2026)" `Quick - test_rogation_wednesday_yields_to_a_feast_2026 + test_rogation_wednesday_yields_to_a_feast_2026; + Alcotest.test_case "bissextile footnote: Matthias moves 24->25 February (leap, 2044)" `Quick + test_bissextile_matthias_leap_2044; + Alcotest.test_case "bissextile footnote: Matthias unaffected (common, 2025)" `Quick + test_bissextile_matthias_common_2025; + Alcotest.test_case "bissextile footnote: Gabriel of Our Lady of Sorrows moves 27->28 February (leap, 2008)" + `Quick test_bissextile_gabriel_leap_2008; + Alcotest.test_case "bissextile footnote: Gabriel of Our Lady of Sorrows unaffected (common, 2025)" `Quick + test_bissextile_gabriel_common_2025 ] ) diff --git a/test/test_lms_ordo.ml b/test/test_lms_ordo.ml index 84c4776..219e51f 100644 --- a/test/test_lms_ordo.ml +++ b/test/test_lms_ordo.ml @@ -502,13 +502,15 @@ let make_suite ~label ~fixture_path ~fixture_sha256 ~window_first ~window_last ~ this function declared [by_id]/[explained_counts] but never actually populated the latter (the mechanism this comment sits in) -- L1 was already CLOSED by the time that task shipped, so it had nothing real - to explain and the gap went unexercised. L3/L4 (Witnesses task, - 2026-08-22, both 2023-2024-only) are the first live tests of it. *) - let allow_list_id_for_date d = - if String.equal d "2024-02-24" then Some "L3" - else if String.equal d "2023-12-24" then Some "L4" - else None - in + to explain and the gap went unexercised. L4 (Witnesses task, + 2026-08-22, 2023-2024-only) is the first live test of it. L3's own + "2024-02-24" branch (bissextile-shift task, 2026-08-22) is REMOVED, + not left dead: the fix makes that date's Creed match outright, so the + branch would never fire again, and a stale date->id mapping for a + now-closed entry is exactly the kind of thing this file's own + "must be visible as a real change" discipline (this comment's own + opening sentence) argues against leaving in place. *) + let allow_list_id_for_date d = if String.equal d "2023-12-24" then Some "L4" else None in let test_creed_matches_or_is_explained () = let ordo = ordo_rows fixture_path in let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in @@ -651,17 +653,29 @@ let allow_list_path = "../data/ef/expected-divergences-lms.sexp" ever looks at its own explained_counts. Fixed to the two ids this task added (L3, L4) -- L1 is CLOSED/removed and L2 is deliberately prose-only (see the sexp file's own header on why), so neither is expected here. *) -let test_allow_list_ids_are_exactly_l3_l4 () = +(* L3 CLOSED (bissextile-shift task, 2026-08-22): removed from the active + sexp record set -- see its own now-prose closure note in + data/ef/expected-divergences-lms.sexp for the fix and the measured + blast radius. Only L4 remains active. *) +let test_allow_list_ids_are_exactly_l4 () = let ids = List.sort String.compare (List.map (fun e -> e.id) (load_allow_list allow_list_path)) in - Alcotest.(check (list string)) "the LMS allow-list declares exactly L3 and L4" [ "L3"; "L4" ] ids + Alcotest.(check (list string)) "the LMS allow-list declares exactly L4" [ "L4" ] ids -let suite_allow_list = ("lms-ordo-allow-list", [ Alcotest.test_case "declares exactly L3 and L4" `Quick test_allow_list_ids_are_exactly_l3_l4 ]) +let suite_allow_list = ("lms-ordo-allow-list", [ Alcotest.test_case "declares exactly L4" `Quick test_allow_list_ids_are_exactly_l4 ]) let suite_2023_2024 = make_suite ~label:"lms-ordo-2023-2024" ~fixture_path:"fixtures/lms-ordo-2023-2024.sexp" ~fixture_sha256:"c8d9d4b790f6b4438e932f70dad8ebe9a0316662f1eb02c26248e04c7be1a62b" ~window_first:"2023-12-01" ~window_last:"2024-12-31" ~year_lo:2022 ~year_hi:2025 ~allow_list_path ~expected_rows:397 - ~expected_bvm_votive:12 ~expected_proper:182 ~expected_common:2 ~expected_preceding_sunday:61 + (* expected_proper 182 -> 181 (bissextile-shift task, 2026-08-22): 24 + February 2024, previously Matthias's own Proper Mass, is now the + Ember Saturday of Lent's own temporal proper -- Own_slug-sourced, not + Proper, so it leaves this bucket's population rather than merely + changing which slug's proper it is (see this file's own header on why + Own_slug is excluded). Matthias himself moves to 25 February, an + I-class Sunday that admits him not even as a commemoration (RG16(a)), + so his own Mass is not said anywhere in this window any more. *) + ~expected_bvm_votive:12 ~expected_proper:181 ~expected_common:2 ~expected_preceding_sunday:61 ~expected_ascension_week:1 let suite_2024_2025 = diff --git a/test/test_validate.ml b/test/test_validate.ml index 1ceaca2..671c706 100644 --- a/test/test_validate.ml +++ b/test/test_validate.ml @@ -339,6 +339,9 @@ module Synthetic = struct any for a fixture; nothing here is Easter-relative, so the value is never actually read. *) easter = Colitur_kernel.Computus.gregorian_easter; + (* Not a Roman rite either, so no bissextile-doubling convention: + identity, {!Rite.t.fixed_key}'s own documented default. *) + fixed_key = (fun d -> Some (D.month d, D.day d)); transfer_target; readings; creed } (* Empty by default: every check built before Task 12 exercises the -- cgit v1.3 From f4cc032d7e812d716ff6b5df8192f79a2560e8f0 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 22 Aug 2026 17:56:42 +0200 Subject: feat(ef): the Gloria in excelsis, RG 431-432, deferring to Breviary 237-238 Phase 2 of celebrant-rubrics-phase1: colitur rubrics gains a fifth column, whether the Gloria is said. Follows the Creed's own seam exactly -- Rite.t.gloria, Liturgical_day.t.gloria, wired through calendar.ml the same way. RG 431(a)/432(a) defer the Gloria to the Breviary's own Te Deum rule (nn. 237-238), so te_deum is implemented as its own named predicate, cited clause by clause, not collapsed into a colour heuristic. 431(c) (Holy Thursday, the Easter Vigil Mass) and 432(b)/(d) (violet; a Requiem) are independent overrides checked ahead of the Te Deum-derived answer. Every clause this engine has no dimension to model (votive Mass classes, the wider n.302 "Missa festiva" categories) is stated as N/A with its own reasoning, not silently dropped. Validated against the FIUV universal Ordo (Gloria and Te Deum) and all three LMS editions (Gloria). A first pass over-trusted a clean-looking 15-for-15 FIUV contradiction of 237(b)'s own Septuagesima exception and replaced it with a blanket "every Sunday" rule; the evidence was itself corrupted -- the FIUV extractor recognised only one of the source's two Te Deum negations ("non dicitur", not "sine"), so every "sine Te Deum" Sunday read wrongly true. Fixed in tools/extract_fiuv_ordo.ml, fixture re-extracted, and the literal 237(b) reading restored once the corrected data confirmed it. A second bug surfaced alongside it (Palm/ Passion Sunday wrongly reading true via Temporal_ef.named's own table membership, then Christ the King wrongly reading false from an over-broad fix) is closed with an explicit two-slug exclusion. Domain-wide 1583-9999: every violet or Rose day is gloria=false except the Easter Vigil (RG 431(c) lex specialis), every Requiem is gloria=false, both measured exhaustively, zero exceptions. Mutation- proved: disabling 431(c) reddens 8 tests including all four oracle comparisons; disabling 238(c)'s feria-I-classis exclusion reddens exactly the dedicated Ash Wednesday unit test, a genuine blind spot in both oracle layers, reported rather than hidden. Two open, cited findings, neither fixed here (out of this task's "follow creed's exact seam" scope): a privileged Lenten/Passiontide feria carrying one commemoration reads Gloria=true in the LMS Ordo but Te-Deum=true/Gloria=false in FIUV -- the two oracles disagree with each other, not merely with colitur (data/ef/expected-divergences-lms.sexp L5, expected-divergences-fiuv.sexp F3); and a pre-existing, uncited Colour.Violet bug on Rogation Monday/Tuesday in Temporal_ef.temporal, surfaced by this comparison but root-caused as a separate defect (L6). day/readings verified byte-identical against a build from the branch tip before this task (v0.10.1's own tag predates an already-landed bissextile fix that legitimately changed both, so it is not the right baseline). 671 tests green (dune test); 678 with the exhaustive sweep (COLITUR_EXHAUSTIVE_SWEEP=1 dune test --force, ~104s). --- bin/main.ml | 26 ++- data/ef/expected-divergences-fiuv.sexp | 108 +++++++++- data/ef/expected-divergences-lms.sexp | 112 ++++++++++ lib/kernel/calendar.ml | 4 + lib/kernel/liturgical_day.ml | 1 + lib/kernel/liturgical_day.mli | 4 + lib/kernel/rite.ml | 1 + lib/kernel/rite.mli | 6 + lib/rites/rite_ef/rite_ef.ml | 3 +- lib/rites/rite_ef/rite_ef.mli | 3 + lib/rites/rite_ef/rubrics_ef.ml | 378 +++++++++++++++++++++++++++++++++ lib/rites/rite_ef/rubrics_ef.mli | 30 +++ man/colitur.1 | 24 ++- test/cli.t | 40 ++-- test/fixtures/fiuv-ordo-2025-2026.sexp | 79 ++++--- test/test_calendar.ml | 9 +- test/test_fiuv_ordo.ml | 169 ++++++++++++++- test/test_lms_ordo.ml | 112 +++++++++- test/test_rubrics_ef.ml | 324 +++++++++++++++++++++++++++- test/test_validate.ml | 16 +- tools/extract_fiuv_ordo.ml | 21 ++ 21 files changed, 1378 insertions(+), 92 deletions(-) (limited to 'lib/rites/rite_ef') diff --git a/bin/main.ml b/bin/main.ml index 01410f2..cf8cae2 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -408,7 +408,12 @@ let readings_line ~lang ~sigla (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.r for a [Votive] day before [said] became honest, and it is a value this function already has in scope regardless of [via]. So this is not "print a placeholder for the missing case", it is "the value was - already available from a different field, and still is". *) + already available from a different field, and still is". + + Task (celebrant-rubrics-phase1, Phase 2): a FIFTH column, whether the + Gloria in excelsis is said (EF: RG 431-432, {!Rite_ef.Rubrics_ef.gloria}) + -- same [string_of_bool] convention as [creed], same plain [bool] with + no [option] to guard, same reasoning throughout. *) let rubrics_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) = let said, via = @@ -421,8 +426,9 @@ let rubrics_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_k Colitur_kernel.Mass_formulary.source_to_string f.Colitur_kernel.Mass_formulary.via ) | None -> ("-", "-") in - Printf.printf "%s\t%s\t%s\t%s\n" (D.to_iso8601 d.Colitur_kernel.Liturgical_day.date) said via + Printf.printf "%s\t%s\t%s\t%s\t%s\n" (D.to_iso8601 d.Colitur_kernel.Liturgical_day.date) said via (string_of_bool d.Colitur_kernel.Liturgical_day.creed) + (string_of_bool d.Colitur_kernel.Liturgical_day.gloria) (* One civil year, Jan 1 - Dec 31, matching [temporal_report]'s own scan -- NOT one liturgical year: [Colitur_kernel.Calendar.year] resolves a single @@ -1281,8 +1287,8 @@ output formats: 2026-04-05 sunday paschaltide 1 ef-easter-sunday class-1 white readings date slug | Epistle | Gospel [| name] 2026-12-25 ef-nativity | Heb 1:1-12 | John 1:1-14 - rubrics date, formulary slug, source, creed -- TAB-separated - 2026-01-01[TAB]ef-circumcision[TAB]own[TAB]true + rubrics date, formulary slug, source, creed, gloria -- TAB-separated + 2026-01-01[TAB]ef-circumcision[TAB]own[TAB]true[TAB]true A citation contains spaces, so readings uses " | " between its fields while day stays space-separated; that is why they are separate commands rather @@ -1299,14 +1305,16 @@ output formats: (source: proper/own/preceding-sunday/common/votive) -- not always the day's own: a weekday with no proper resumes the preceding Sunday's, a saint with no proper says his assigned Common -- followed by whether the - Creed is said (RG 475-476: "true"/"false", OCaml's own literal, not - "yes"/"no" or "1"/"0"). TAB-separated rather than space or " | ": a + Creed is said (RG 475-476) and whether the Gloria in excelsis is said + (RG 431-432, deferring to the Breviary's own Te Deum rule, nn. 237-238, + for RG 431(a)) -- both "true"/"false", OCaml's own literal, not + "yes"/"no" or "1"/"0". TAB-separated rather than space or " | ": a resolved formulary NAME is a column a later version may add, and it can carry both spaces and punctuation a citation never does, which rules out either alternative already in use above. --overlay is accepted (the - observed celebration it changes decides the formulary and the Creed); - --lang/--raw/--sigla-* are refused -- this row resolves no display name - and no citation for any of them to affect. + observed celebration it changes decides the formulary, the Creed and the + Gloria); --lang/--raw/--sigla-* are refused -- this row resolves no + display name and no citation for any of them to affect. emit one schema (season, week, slug, rank, colour, subject, names, citations, commemorations), rendered five ways: csv (RFC 4180, diff --git a/data/ef/expected-divergences-fiuv.sexp b/data/ef/expected-divergences-fiuv.sexp index 5506e46..6ed9a04 100644 --- a/data/ef/expected-divergences-fiuv.sexp +++ b/data/ef/expected-divergences-fiuv.sexp @@ -15,12 +15,106 @@ ; backs, the SAME convention every other allow-list file in this project ; uses: not always "colitur". ; -; Empty as of this task: the Creed comparison found no unexplained +; The Creed comparison (Task 6/Witnesses task) found no unexplained ; divergence anywhere in the 400-day window (399 comparable days, Holy ; Saturday excluded per test_creed_coverage -- see that test's own -; citation). Kept as a file, not deleted, on the same footing as -; expected-divergences-lms.sexp's own empty-but-live state after L1's -; closure: the machinery that would explain a FUTURE divergence needs -; somewhere to live, and an empty allow-list with a live "every entry -; must be used" check is a real invariant worth keeping, not a -; formality. +; citation) and still does not. Phase 2 (celebrant-rubrics-phase1, +; 2026-08-22) extended this file to two more axes, Gloria and Te Deum -- +; F1 below (Gloria) and F3 (Te Deum). Both prose-only, the same L2/L5/L6 +; convention data/ef/expected-divergences-lms.sexp's own header explains: +; the real enforcement is test_fiuv_ordo.ml's own predicates and count +; assertions, not a machine-loaded [id] record. +; +; F2 and F4 (Te Deum: every vigil; the three September Ember days) were +; found, then CLOSED within the same task: tools/extract_fiuv_ordo.ml's +; own Te Deum parser recognised only ONE of the source's two negative +; phrasings ("non dicitur Te Deum"), so "sine Te Deum" -- how the source +; actually negates a Sunday's/vigil's/Ember-day's own Te Deum -- fell +; through to a bare "Te Deum" substring match and was wrongly read +; [true]. Fixed in the extractor (its own citation has the full account); +; the fixture was re-extracted from the SAME pdftotext dump; 24 of 400 +; rows flipped true->false, and colitur's own answer already matched the +; corrected value on every one of them -- no code change needed for F2/ +; F4 specifically. See test/fixtures/fiuv-ordo-2025-2026.sexp's own +; "RE-EXTRACTED" note and lib/rites/rite_ef/rubrics_ef.ml's own [te_deum] +; header (237(a)'s comment) for the fuller account, including a real, +; separate regression this same fix round found and reverted (a first +; attempt at "every Sunday says Te Deum" wrongly excluded Christ the +; King, caught by the LMS Ordo's own Gloria axis, 2024-10-27). + +; F1 -- OPEN, adjudicated FOR colitur. Fired once: 2026-04-03, Good +; Friday. +; +; The raw source (docs/research/ordo/fiuv-ordo-2025-2026.pdf, page 46, +; verified directly against the PDF, not merely the extracted fixture): +; "3. Nig in Actione liturgica usque ad 4am partem, Viol. in S. +; Communione. FERIA VI IN PASSIONE ET MORTE DOMINI, De ea, I cl. ... +; Missa pr., (omittuntur ps. Iudica me et Gloria Patri), Gloria, sine +; Credo, praef. comm." -- read LITERALLY, this states the Gloria IS said +; on Good Friday. colitur says [false] (RG 432(d)'s own {!Colour.Black} +; proxy this Mass shares with the Requiem population, {!Rite_ef.Rubrics_ +; ef.gloria}'s own header). +; +; ADJUDICATED FOR COLITUR: Good Friday's own liturgical action has no +; Mass at all in the 1955-restored Holy Week (RG 28's own "non sit dies +; liturgicus" framing for the Paschal Vigil is the closest parallel this +; project already cites, {!Rite_ef.Rubrics_ef.creed}'s own RG 28-34 +; comment) -- no Consecration, no Communion under both species by the +; celebrant, only Communion from the reserved Sacrament. That the Gloria +; is NOT said on Good Friday is among the most widely and consistently +; attested facts in the whole of 1962-rite literature (O'Connell, +; Fortescue, every published Ordo this project has touched, and this +; author's own independent knowledge of the rite) -- essentially +; undisputed. The printed "Missa pr., ..., Gloria, ..." block, structured +; identically to a REAL Mass's own rubric line elsewhere in this same +; Ordo (compare Easter Day's "Missa pr., Gloria, sequentia, Credo, praef. +; Pasch."), most plausibly describes the liturgical action's own +; STRUCTURE using the book's standard notation for bookkeeping +; consistency, or is a genuine single-word compiler error -- neither +; resolvable without contacting the compiler, out of this task's own +; scope. NOT the same failure mode as the Te Deum extractor bug above +; (F2/F4): this is the RAW pdftotext TEXT itself making the claim, not a +; downstream parsing artefact of test_fiuv_ordo.ml's own extraction -- +; re-run directly against a fresh -layout dump for this task, not merely +; trusted from the fixture. The PDF's own page IMAGE was not separately +; inspected (out of this task's own tool access), so a pdftotext-layer +; misread (a stray line from an adjacent block folded into this one) is +; not fully ruled out either -- named as a real possibility, not +; silently excluded. + +; F3 -- OPEN, single-witnessed, colitur's own possible gap (Te Deum +; axis). Fires 6 times, every one of the six dates +; data/ef/expected-divergences-lms.sexp's own L5 already found for +; Gloria: 2026-03-06/07/09/12/21/24, a privileged Lenten or Passiontide +; feria (Class3, RG25) whose own office is impeded and carries exactly +; one commemoration of a Class3 saint. colitur's [te_deum] reads [false] +; (a plain ferial office by every branch {!Rite_ef.Rubrics_ef.te_deum} +; checks); FIUV reads [true] in all six -- confirmed directly against +; the raw pdftotext dump, not merely the fixture: e.g. 12 March, "Ll. 1a +; et 2a (= 2a+3a) de Scr. occ., 3a de festo, Te Deum." -- a genuine, +; unnegated "Te Deum" (neither of the two negative phrasings the +; extractor now checks for), so this is NOT the same failure mode F2/F4 +; were. +; +; CITATION: the same n.302(b)/RG431(b) text L5 already cites (LT.txt -- +; a MISSAL rubric, not a Breviary one, so its literal text governs the +; MASS's own Gloria, not Matins' Te Deum) does not directly license this +; finding at all -- 237/238 (the BREVIARY rubric {!Rite_ef.Rubrics_ef. +; te_deum} actually implements) has no clause naming a commemoration as +; grounds for Te Deum. What this finding suggests, not yet established: +; the DIVINE OFFICE may treat a commemorated day more generously than the +; MASS does for exactly this shape (a commemorated saint's own lessons +; are read at Matins even on an impeded day, which could plausibly extend +; to Te Deum by an unwritten or unlocated convention) -- genuinely +; speculative, offered as a research lead for a follow-on task, not a +; citation this entry treats as settled. +; +; WHY NOT FIXED HERE: SINGLE-WITNESSED (the LMS fixtures never captured +; Te Deum at all, so there is no second Ordo to corroborate this shape +; the way the Sacred-Triduum/BVM-Saturday/vigil findings elsewhere in +; this task were cross-checked). Fixing it would need the SAME kernel +; signature widening L5 already declines for the identical reason +; (`~commemorations` on {!Colitur_kernel.Rite.t.gloria}, and by extension +; a parallel widening of [te_deum] if the two were ever to diverge in +; their own inputs) -- out of a task briefed to follow creed's existing +; seam exactly. Left OPEN for the same follow-on task L5 recommends. diff --git a/data/ef/expected-divergences-lms.sexp b/data/ef/expected-divergences-lms.sexp index 4cb5d2a..df813f9 100644 --- a/data/ef/expected-divergences-lms.sexp +++ b/data/ef/expected-divergences-lms.sexp @@ -6,6 +6,10 @@ ; (2025-11-28..2026-12-31) joined it. L3/L4 below fired only in the ; 2023-2024 window -- neither recurs in the other two, a fact worth ; keeping visible rather than merging away (see [expected_rows] on each). +; L5/L6 (Phase 2, 2026-08-22) extended the comparison to a second axis, +; Gloria (RG431-432) -- both fire in more than one window, at DIFFERENT +; counts per window, so both are prose-only (L2's own precedent), not +; machine-checked [id] records; see each for the real enforcement site. ; ; A SEPARATE file from data/ef/expected-divergences.sexp (the lectio ; allow-list) and data/ef/expected-divergences-missalemeum.sexp (the @@ -228,3 +232,111 @@ (verdict open) (note "2023-12-24 (the 4th Sunday of Advent AND Christmas Eve, a genuinely rare coincidence -- roughly 1 year in 7 -- absent from both the 2024-2025 and 2025-2026 windows, where Christmas Eve falls on a Tuesday and a Wednesday respectively). colitur says the Creed IS said (true), reading [temporal]'s own weekday (Sunday) per RG475(a)'s clause, positioned ahead of the vigil-exclusion check in {!Rite_ef.Rubrics_ef.creed} -- unconditionally, the same way it already does for RG16(a)'s Feast-of-the-Lord case. The Ordo says \"No Gl No Cr\" for this date. ADJUDICATED FOR COLITUR, not merely left open by default: RG30(a)'s own \"locum tenet ... nulla fit commemoratio\" language is textually closer to RG16(a) (which colitur already treats as Creed-preserving) than to an ordinary vigil superseding a Sunday -- the Vigil does not merely WIN THE DAY, it takes the SUNDAY'S OWN IDENTITY, the same shape RG475(a) was written to reach. The COUNTER-reading is real and not dismissed here: RG475(a)'s own text says \"locum cedat ... festo\" (gives way to a FEAST), and RG35 (Caput VI) classifies feria/vigilia/festum as three distinct liturgical-day categories -- a strict textual reading could confine 475(a)'s override to FEAST-displacement only, leaving a VIGIL-displaced Sunday to fall through to the general vigil exclusion (476, via RG33's own \"a vigil is a distinct category\" reasoning already coded elsewhere in {!Rite_ef.Rubrics_ef.creed}). Left OPEN rather than closed on this task's own authority: adjudicating scripture-grade primary-source questions is out of scope for a witness-wiring task, and the two readings are both defensible. Not fixable inside this task's own no-lib-changes constraint regardless of which reading eventually wins.") (expected_rows 1)) + +; L5 -- OPEN, colitur's own gap, found extending this suite to the Gloria +; axis (celebrant-rubrics-phase1 Phase 2, 2026-08-22). NOT an active +; machine-checked [id] entry -- same reasoning L2 above gives: the count +; varies window to window (5 in 2023-2024, 5 in 2024-2025, 6 in +; 2025-2026), so a single static [expected_rows] cannot check it. The REAL +; enforcement is test_lms_ordo.ml's own [is_l5_lenten_commemoration] +; predicate plus [make_suite]'s own ~expected_gloria_l5 parameter, +; asserted per window directly. +; +; SHAPE, identical in all 16 instances found (5+5+6, zero exceptions): +; a privileged Lenten or Passiontide feria (Class3, violet -- RG25, +; {!Rite_ef.Temporal_ef.ferial_rank}) whose own office is impeded and +; carries EXACTLY ONE commemoration, of a Class3 saint colitur's own +; precedence engine (independently validated, layers 3-5) correctly +; reduces to a mere commemoration. colitur's [gloria] reads [false] (the +; day is a plain ferial office by every test [Rite_ef.Rubrics_ef.te_deum] +; runs -- no Sunday, no vigil, no octave, [named] = [None], [subject] = +; [Temporal]); the Ordo reads [true] in every one of the 16 instances. +; +; CITATION (docs/research/LT.txt:2359-2361, "V - De Missis festivis", +; quoted in rubrics_ef.ml's own [gloria] header in full): "302. Sensu +; autem latiore, dicuntur quoque Missae de festo: ... b) Missa de +; commemoratione in Officio diei occurrente" -- in the WIDER sense, a Mass +; "of a commemoration occurring in the day's own Office" is ALSO called a +; "Missa festiva" -- and RG431(b): "Hymnus Gloria in excelsis dicitur ... +; in Missis festivis de quibus n. 302" -- such a Mass says the Gloria. +; Textually, this appears to be EXACTLY the shape found: the day's own +; ferial Office, carrying a commemoration, is elevated to "festive" status +; for the Gloria specifically. +; +; WHY NOT FIXED HERE: n.303(b), the very next paragraph (LT.txt:2359-2367, +; same quote in rubrics_ef.ml), qualifies n.302(b)'s own Masses: "dici +; potest tantum si occurrit dies liturgicus IV classis" -- CAN ONLY BE +; SAID if the liturgical day occurring is IV class. Every instance found +; here is a Class3 PRIVILEGED FERIA (RG25), not IV class, which reads as a +; textual CONTRADICTION of the empirical finding rather than a +; confirmation of it -- unresolved without n.304 onward (not in this +; project's sources), which may clarify that 301-303 govern which Mass a +; priest may freely CHOOSE among several options in one church on one day +; (a dimension colitur does not model -- see [gloria]'s own SCOPE NOTE), +; a different question from \"does THE Mass actually said, once +; determined, say the Gloria\" (431(b)'s own question). Implementing this +; also has a real architectural cost this task's own instructions +; explicitly avoided: [Rite_ef.Rubrics_ef.gloria] would need a FOURTH +; parameter, the day's own {!Colitur_kernel.Liturgical_day.t.commemorations} +; -- a kernel [Rite.t.gloria] signature change, the same shape RG16(a)'s +; own [admit] fix needed a [~temporal] parameter for, and squarely out of +; a task briefed to \"follow exactly how creed was wired -- same seam\". +; Left OPEN, not guessed into a possibly-wrong rule. Recommended for a +; follow-on task, ideally alongside Phase 3's Preface work (RG483 governs +; a DIFFERENT, but textually adjacent, commemoration-vs-Mass-part +; question, so the two may share the same kernel signature widening). +; +; UPDATE (same task, after wiring the FIUV Ordo's own Gloria and Te Deum +; axes, test_fiuv_ordo.ml): this paragraph previously said FIUV's own +; [gloria] axis was not compared at all. It now is, and the result +; WEAKENS this entry's own case rather than strengthening it: FIUV's own +; [gloria] is [false] on all six of these exact dates in its window -- +; AGREEING WITH COLITUR, DIRECTLY CONTRADICTING the LMS Ordo's [true] +; above. So this is not \"colitur has a corroborated gap\", it is \"two +; independent Ordos disagree with EACH OTHER on Gloria for this shape, +; and colitur happens to agree with the universal one, not the diocesan +; one\". What FIUV's OWN Te Deum axis DOES show for the identical six +; dates: [true] -- Te Deum, unlike Gloria, genuinely IS said at Matins on +; these days (data/ef/expected-divergences-fiuv.sexp's own F3), a real, +; single-witness finding that this entry's own n.302(b)/431(b) citation +; may in fact belong to the DIVINE OFFICE, not the MASS -- which would +; mean L5 itself is answering the wrong question, not merely open. +; Genuinely uncertain which of the two Mass-Gloria witnesses (LMS or +; FIUV) is right for this shape; left OPEN, not adjudicated either way, +; for a follow-on task with more primary-source access than this one +; had. + +; L6 -- OPEN, root-caused to a DIFFERENT, PRE-EXISTING bug this Gloria +; comparison merely surfaced, not a Gloria defect itself. Fired once, +; 2023-2024 only (2024-05-06, Rogation Monday) -- absent from the other +; two windows because Rogation Monday/Tuesday is impeded by a real +; sanctoral feast in both of them, so the office's own colour never +; reaches [observed] there. Same non-machine-checked-count reasoning as L5 +; above (count 1/0/0); enforced by test_lms_ordo.ml's own +; [is_l6_rogation_colour] predicate and [make_suite]'s own +; ~expected_gloria_l6 parameter. +; +; {!Rite_ef.Temporal_ef.temporal}'s own Rogation Monday/Tuesday branch +; hardcodes [~colour:Colour.Violet] with NO RG citation at all (verified +; by reading the branch directly, temporal_ef.ml, the Rogation comment +; block). The Ordo shows 2024-05-06 as \"FERIA IV Cl W †\" (white), +; borrowing \"Mass of 5th Sunday after Easter\" (matching colitur's own +; [Mass_formulary.Preceding_sunday] resolution for the identical date) -- +; consistent with RG88 (Caput X, \"De Litaniis maioribus et minoribus\"): +; \"De Litaniis minoribus nihil fit in Officio, sed tantum in Missa\" -- +; nothing changes in the OFFICE for the Minor Litanies, only the Mass +; TEXT is proper, which reads as the Office's own colour (Paschaltide, +; white) staying unchanged, the violet belonging only to the PROCESSION +; that precedes the Mass -- the same PER-ACTION colour nuance this +; project already documents as unmodelled for Palm Sunday's palm +; procession and Good Friday's Communion rite (temporal_ef.ml's own RG126/ +; RG128 citations), not a new kind of gap. +; +; NOT FIXED HERE: out of this task's own scope (Gloria/Te Deum, not the +; temporal cycle's colour assignment), and a real fix needs its own +; measured blast radius across 1583-9999 (every Rogation Monday/Tuesday +; not impeded by a stronger feast) plus a re-check against the +; differential/oracle layers, which already treat Rogation days as +; correctly violet on whatever data they compare against -- a +; cross-layer question, not a one-line patch. Recommended as a follow-on +; task's own first item.") diff --git a/lib/kernel/calendar.ml b/lib/kernel/calendar.ml index db6c58e..7178780 100644 --- a/lib/kernel/calendar.ml +++ b/lib/kernel/calendar.ml @@ -551,6 +551,9 @@ let build_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) let creed = rite.Rite.creed ~temporal ~observed:resolution.Precedence.observed.Precedence.cel ~date in + let gloria = + rite.Rite.gloria ~temporal ~observed:resolution.Precedence.observed.Precedence.cel ~date + in { Liturgical_day.date; rite = rite.Rite.id; @@ -564,6 +567,7 @@ let build_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) citations; formulary; creed; + gloria; } let year (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) (y : int) : diff --git a/lib/kernel/liturgical_day.ml b/lib/kernel/liturgical_day.ml index 09ac0a1..98c5084 100644 --- a/lib/kernel/liturgical_day.ml +++ b/lib/kernel/liturgical_day.ml @@ -27,5 +27,6 @@ type ('s, 'r) t = { (** which Mass the day says, and how that was decided; [None] only for a rite with no lectionary -- see {!Mass_formulary} *) creed : bool; (** whether the Creed is said at this day's Mass; see {!Rite.t.creed} *) + gloria : bool; (** whether the Gloria in excelsis is said; see {!Rite.t.gloria} *) } [@@deriving sexp] diff --git a/lib/kernel/liturgical_day.mli b/lib/kernel/liturgical_day.mli index eb45e61..939f8f5 100644 --- a/lib/kernel/liturgical_day.mli +++ b/lib/kernel/liturgical_day.mli @@ -37,5 +37,9 @@ type ('s, 'r) t = { (** Whether the Creed is said at this day's Mass -- {!Rite.t.creed}, EF: RG 475-476. A decision, not an [option]: [false] for a rite that has not implemented the rule, same as [creed] itself. *) + gloria : bool; + (** Whether the Gloria in excelsis is said at this day's Mass -- + {!Rite.t.gloria}, EF: RG 431-432. Same seam as [creed] in every + respect. *) } [@@deriving sexp] diff --git a/lib/kernel/rite.ml b/lib/kernel/rite.ml index 738785a..915941d 100644 --- a/lib/kernel/rite.ml +++ b/lib/kernel/rite.ml @@ -20,4 +20,5 @@ type ('s, 'r) t = { temporal_at:(Date.t -> ('s, 'r) Temporal.t) -> Mass_formulary.t option * Citation.t list; creed : temporal:('s, 'r) Temporal.t -> observed:'r Celebration.t -> date:Date.t -> bool; + gloria : temporal:('s, 'r) Temporal.t -> observed:'r Celebration.t -> date:Date.t -> bool; } diff --git a/lib/kernel/rite.mli b/lib/kernel/rite.mli index 0dcf17e..b9d1961 100644 --- a/lib/kernel/rite.mli +++ b/lib/kernel/rite.mli @@ -134,4 +134,10 @@ type ('s, 'r) t = { Easter-relative window (e.g. "within the octave of Easter") needs the civil date and the rite's own Easter to test it, and neither [temporal] nor [observed] alone carries that arithmetic. *) + gloria : temporal:('s, 'r) Temporal.t -> observed:'r Celebration.t -> date:Date.t -> bool; + (** Whether the Gloria in excelsis is said at this day's Mass (EF: RG + 431-432). Same seam as {!creed} in every respect: same three + parameters and the same reasons for each, a [bool] not an + [option], and [false] is the answer a rite that has not + implemented the rule returns explicitly. *) } diff --git a/lib/rites/rite_ef/rite_ef.ml b/lib/rites/rite_ef/rite_ef.ml index ba1274b..d7b3d83 100644 --- a/lib/rites/rite_ef/rite_ef.ml +++ b/lib/rites/rite_ef/rite_ef.ml @@ -51,4 +51,5 @@ let context ~lectionary ~commons : (Vocab_ef.season, Vocab_ef.rank) Rite.t = season_runs = Vocab_ef.seasons; transfer_target = Precedence_ef.transfer_target; readings = Lectionary_ef.readings ~lectionary ~commons; - creed = Rubrics_ef.creed } + creed = Rubrics_ef.creed; + gloria = Rubrics_ef.gloria } diff --git a/lib/rites/rite_ef/rite_ef.mli b/lib/rites/rite_ef/rite_ef.mli index a4b5d87..7025143 100644 --- a/lib/rites/rite_ef/rite_ef.mli +++ b/lib/rites/rite_ef/rite_ef.mli @@ -36,6 +36,9 @@ module Rubrics_ef = Rubrics_ef - [creed]: {!Rubrics_ef.creed}, RG 475-476 -- whether the Creed is said. The first rubric in this phase governing a part of Mass rather than occurrence/precedence. + - [gloria]: {!Rubrics_ef.gloria}, RG 431-432 -- whether the Gloria in + excelsis is said. Reads {!Rubrics_ef.te_deum} (Breviary nn. 237-238) + for RG 431(a)/432(a)'s own deferral. Deliberately carries no [sanctoral]/[lectionary] fields the way the original design-doc sketch of [RITE] does: {!Colitur_kernel.Rite.t} (the diff --git a/lib/rites/rite_ef/rubrics_ef.ml b/lib/rites/rite_ef/rubrics_ef.ml index ab9da32..a497b57 100644 --- a/lib/rites/rite_ef/rubrics_ef.ml +++ b/lib/rites/rite_ef/rubrics_ef.ml @@ -319,3 +319,381 @@ let creed ~(temporal : (Vocab_ef.season, Vocab_ef.rank) Temporal.t) entry on the list (Sts Peter & Paul) is already [true] via 475(b), so this branch is redundant, never wrong, for those. *) List.mem slug creed_apostle_slugs + +(* Breviarium Romanum, 1961 Codex Rubricarum, "N) De hymno Te Deum" + (docs/research/breviary/rubricae-breviarii-1961.txt; docs/research/ + breviary/PROVENANCE.md has this source's own provenance and weakness in + full), quoted here in full so every branch below can cite its own + letter without re-quoting the whole rubric: + + "237. Hymnus Te Deum dicitur ad Matutinum, post ultimam lectionem, loco + noni vel tertii responsorii: + a) in dominica in albis, in dominica Pentecostes, et in Matutino + dominicae Resurrectionis, quod recitatur ab iis qui Vigiliae paschali + non interfuerunt; + b) in dominicis II classis, exceptis dominicis in Septuagesima, in + Sexagesima et in Quinquagesima; + c) in omnibus festis; + d) per octavas Nativitatis Domini, Paschatis et Pentecostes; + e) in Officio feriali temporis natalicii et temporis paschalis; + f) in vigiliis Ascensionis et Pentecostes; + g) in Officio sanctae Mariae in sabbato. + + 238. Omittitur vero hymnus Te Deum: + a) in Officiis de Tempore a dominica I Adventus usque ad vigiliam + Nativitatis Domini inclusive; et a dominica in Septuagesima usque ad + Sabbatum sanctum inclusive; + b) in vigiliis II et III classis, excepta vigilia Ascensionis + Domini; + c) in omnibus feriis per annum; + d) in Officio defunctorum." + + THE WEAKNESS, restated (PROVENANCE.md has the full account): this is a + SINGLE WEB TRANSCRIPTION (ceremoniaire.net), not yet checked against a + photographic scan -- the weakest-sourced rule in this project. Mitigated, + not resolved, by the FIUV universal Ordo's own Te Deum column + (test/fixtures/fiuv-ordo-2025-2026.sexp, 262 "Te Deum" rows out of 400 -- + an independent day-level witness, never itself derived from this + transcription) -- see test_fiuv_ordo.ml, which compares [te_deum]'s own + output against it. A mismatch there may indict this transcription rather + than [te_deum]; adjudicated per that file's own allow-list, not assumed + either way. + + SCOPE: this predicate exists ONLY because RG 431(a) below defers a MASS + question to it. Building it is not the Divine Office arriving in scope -- + CLAUDE.md's own "Divine Office remains out of scope" line, and the + 2026-08-21 design spec's own SS1, are both unchanged: this borrows ONE + Breviary FACT per day (whether Te Deum was said at Matins), never models + Matins/Vespers/the psalter/concurrence. *) +let te_deum ~(temporal : (Vocab_ef.season, Vocab_ef.rank) Temporal.t) + ~(observed : Vocab_ef.rank Celebration.t) ~(date : Date.t) : bool = + let easter = Computus.gregorian_easter (Date.year date) in + let n = Date.to_rata date - Date.to_rata easter in + let m = Date.month date and dd = Date.day date in + let slug = Slug.to_string observed.Celebration.slug in + if + (* 238(d): "in Officio defunctorum" -- checked first, the same position + and the same {!Colour.Black} proxy [creed]'s own 476(f) branch uses + (this file's own header has the full argument for why colour is a + sound proxy for "this is a Requiem" on the shipped data, and + {!test_colour_black_population_is_exactly_two} in test_rubrics_ef.ml + is the SAME two-member population this predicate also depends on -- + no separate test needed). *) + observed.Celebration.colour = Colour.Black + then false + else if + (* 237(a): the three explicitly named Paschaltide days -- Easter + Sunday's own Matins (n=0), Low Sunday (n=7), Pentecost Sunday + (n=49). Pure Easter-offset arithmetic, the same style [creed]'s own + 475(d) uses and for the identical reason: season/rank alone cannot + express "this exact day", and nothing else in 237/238 names these + three individually. + + HISTORY WORTH KEEPING: this task's own FIRST pass REPLACED this + branch (and 237(b) below) with a blanket "every Sunday" rule, + having found what looked like a clean 15-for-15 FIUV Ordo + contradiction of 237(b)'s own Septuagesima/Sexagesima/Quinquagesima + exception. That evidence was ITSELF corrupted: tools/extract_fiuv_ + ordo.ml's own Te Deum parser recognised only ONE of the source's two + negative phrasings ("non dicitur Te Deum"), so every "sine Te Deum" + occurrence -- which is how the source actually negates a SUNDAY's + own Te Deum, found only by reading the raw pdftotext dump by hand, + not by trusting the fixture's own coverage counts -- fell through to + a bare "Te Deum" substring match and was wrongly recorded [true]. + Fixed in the extractor (see its own citation, tools/extract_fiuv_ + ordo.ml); the fixture was regenerated; 24 of the fixture's 400 rows + flipped, EVERY ONE true->false, EVERY ONE a day this branch or + 237(b) below governs. The blanket rule was reverted the moment the + corrected data confirmed the ORIGINAL literal reading instead: + 238(a)'s own window DOES silence Advent/Septuagesima/Lent/ + Passiontide Sundays after all. Left as a worked example, not + scrubbed from history: the failure mode was believing a clean- + looking oracle correlation over re-deriving the primary text, + exactly backwards from what "adjudicate, don't assume" should have + produced -- caught only by cross-checking the raw source directly + once the shape looked suspiciously total. *) + n = 0 || n = 7 || n = 49 + then true + else if + (* 237(f): the vigils of Ascension (n=38) and Pentecost (n=48), checked + BEFORE 238(b)'s general vigil omission below -- both are otherwise + reachable by it (Ascension's vigil is [Class2], squarely inside + 238(b)'s own "II et III classis"; Pentecost's is [Class1], RG 91 + entry 9, so 238(b) could never have reached it regardless, but is + named here anyway rather than left to fall through to 237(c), the + same "cite the specific clause, not a catch-all" discipline this + whole module holds to). *) + n = 38 || n = 48 + then true + else if + (* 237(d): the three privileged octaves -- reuses [creed]'s own three + windows verbatim (that function's own 475(d) comment has the full + citation and the argument for why a season- or rank-based test + would wrongly include the Ascension/Pentecost-Vigil days this + window must exclude). Overlaps 237(a) at n=0/7/49 -- redundant, not + wrong, the same "never the FIRST branch to grant those [true]" + pattern 475(b) documents for I-class Sundays. *) + (m = 12 && dd >= 25 && dd <= 31) + || (m = 1 && dd = 1) + || (n >= 0 && n <= 7) + || (n >= 49 && n <= 56) + then true + else if + (* 238(b): "vigiliis II et III classis" -- {!Precedence_ef.is_omissible_vigil} + is exactly this rank test (Class2 or Class3), reused rather than + re-derived, paired with {!Precedence_ef.is_vigil} the same way + [creed]'s own RG 28-34 branch already pairs them. The Ascension + vigil (237(f) above, already [true]) can never reach this branch; + the four sanctoral vigils (StJohnBaptist, SsPeter&Paul, StLawrence, + the Assumption -- {!Precedence_ef.vigil_feast_table}'s own + population) are exactly what this branch excludes. *) + Precedence_ef.is_omissible_vigil observed.Celebration.rank && Precedence_ef.is_vigil slug + then false + else if + (* NOT 238(b)'s own text (which names only "II et III classis"): the + Nativity Vigil ([Class1], RG 91 entry 5) is excluded here on the + SAME RG 21/35 taxonomy argument [creed]'s own RG 28-34 comment + already makes for the Paschal Vigil -- "vigilia" is its own + liturgical-day category, distinct from "festum", regardless of + class; RG 30's "beyond losing" is a PRECEDENCE exemption (nothing + lesser can displace it), not a claim that a vigil IS a festum for + Breviary purposes. Flagged honestly as an INFERENCE, not a literal + 238(b) citation -- checked against the FIUV Ordo's own Te Deum + marker for 24 December in test_fiuv_ordo.ml, since this is exactly + the shape a transcription gap could get wrong either direction. *) + Precedence_ef.is_vigil slug + then false + else if + (* 238(c)/RG 23: Ash Wednesday and every feria of Holy Week, the Sacred + Triduum included -- reuses [creed]'s own RG 23 test verbatim (that + function's own comment has the full citation and the argument for + why this excludes feria I classis specifically, not ferias in + general). *) + n = -46 || (n >= -6 && n <= -1) + then false + else if + (* 237(b): "in dominicis II classis, exceptis dominicis in Septuagesima, + in Sexagesima et in Quinquagesima" -- colitur's own single + [Septuagesima] season covers exactly those three Sundays (see + [season_colour]'s own grouping, temporal_ef.ml), so the exception is + one season-equality test. RE-VERIFIED, not merely restored: the + corrected FIUV extraction (237(a)'s own comment above has the full + account) shows all three Septuagesima-season Sundays [false], and + every OTHER, ordinary Class2 Sunday the fixture's window reaches + [true] -- exactly this clause's own literal text, no correction + needed here after all. Reads [temporal.weekday]/[.season], not + [observed]'s slug: unlike [creed]'s own 475(a), the Breviary's text + carries NO "even when a feast displaces the Sunday's own Office" + exception -- when a feast genuinely takes the Sunday's place (RG + 16(a)), Matins says the FEAST's own Office, and 237(c) below decides + it on the feast's own terms, not this clause. Advent/Lent/ + Passiontide Sundays are [Class1], never [Class2] ([creed]'s own RG + 11-12 citation, temporal_ef.ml), so this guard correctly excludes + them without a separate season check; [observed.rank], not + [temporal]'s own season-derived rank, is read here on purpose, for + the same RG 16(a) reason [Precedence.rules.admit]'s own + [~temporal] parameter exists: a feast that wins the day can carry a + DIFFERENT rank than the Sunday it displaced. *) + temporal.Temporal.weekday = Date.Sun + && observed.Celebration.rank = Vocab_ef.Class2 + && temporal.Temporal.season <> Vocab_ef.Septuagesima + then true + else if + (* 237(g): the votive Office of the BVM on Saturday, RG 78/91 entry 27 + -- {!Temporal_ef}'s own [subject = Bvm]/[Class4] pairing, the same + shape [creed]'s own 476(d) comment and the register's RG 112(d) fix + already establish as unique to this office (every OTHER + [subject = Bvm] candidate in the shipped data is [Commemoration_only] + and can therefore never be [observed]). *) + observed.Celebration.subject = Subject.Bvm && observed.Celebration.rank = Vocab_ef.Class4 + then true + else if + (* 237(e): "Officio feriali temporis natalicii et temporis paschalis" -- + every remaining (non-octave, non-vigil, non-BVM-Saturday) FERIA of + Christmastide or Paschaltide: the 2-5 January ferias, and the + ordinary weeks of Paschaltide (Rogation Monday/Tuesday included). + [temporal.weekday <> Sun] keeps this to FERIAS only, matching the + clause's own "Officio FERIALI" text; a Sunday in either season is + already [true] via the Sunday rule above regardless, so this guard + changes no OUTCOME, only which clause gets credit for it. *) + (temporal.Temporal.season = Vocab_ef.Christmastide + || temporal.Temporal.season = Vocab_ef.Paschaltide) + && temporal.Temporal.weekday <> Date.Sun + then true + else + (* 237(c): "in omnibus festis" -- every remaining genuine festum. By + this point every named Paschaltide day, every ordinary Sunday + (Class2, outside Septuagesima), every vigil, every feria I classis, + every Christmastide/Paschaltide feria and every BVM Saturday Office + has already been excluded or granted above, so what reaches here is + exactly: {!Temporal_ef.named}'s remaining population + (Epiphany, Ascension, Corpus Christi, the Sacred Heart, Christ the + King -- tested by PRESENCE in that table, since every [named] entry + carries [subject = Temporal] like any other, see [creed]'s own + 475(c) comment); and every genuine sanctoral feast actually observed + ([subject = Saint], or one of the handful of [subject = Lord]/[Bvm] + entries -- Holy Family, Holy Name of Jesus, the six [Lord]-tagged + sanctoral feasts, [most-holy-name-of-mary] -- [creed]'s own 475(c) + comment has the full census). The two ferial exceptions that ALSO + carry [Lord]/[Bvm] (the Sacred Triduum, the BVM Saturday Office) are + unreachable here: both were already excluded above (feria I + classis; 237(g)). + + [Temporal_ef.named]'s FIRST disjunct EXCLUDES Passion Sunday and + Palm Sunday BY THEIR OWN SLUG -- not by [temporal.weekday <> Sun], + which a first pass of this fix tried and had to REVERT: Christ the + King is ALSO always a Sunday (its own [christ_the_king] anchor + IS "the last Sunday of October"), [Class1] like Passion/Palm + Sunday, so a blanket weekday guard wrongly excluded it too -- + caught immediately by the LMS Ordo's own Gloria axis + (2024-10-27, "colitur gloria=false, Ordo gloria=true"), a + regression a same-session review round found before this task + closed. Passion Sunday and Palm Sunday are excluded because + neither is a genuine "festum" (RG 35's own taxonomy makes + "dominica" its own category, distinct from "festum") -- {!Temporal_ + ef.named} carries them anyway (RG 91 entry 6, for its own + occurrence-table reasons), so without SOME exclusion both would + wrongly reach [true] here BY ACCIDENT of table membership -- + confirmed wrong directly against the corrected FIUV extraction + (237(a)'s own comment above has the full account of the extractor + bug this was found alongside): both dates are [false] in the + source. Epiphany, Ascension, Corpus Christi, the Sacred Heart and + Christ the King -- {!Temporal_ef.named}'s only OTHER population -- + are genuine festa and must NOT be excluded, which is exactly why + the exclusion is two named slugs, not a day-of-week predicate. The + SECOND disjunct (subject) carries no such guard: a genuine feast + that has fully displaced a Sunday's own Office (RG16(a)) still + deserves 237(c)'s grant on the FEAST's own terms, regardless of + what day of the week it falls on. + + A plain, unnamed weekday feria (no Sunday, no vigil, no octave, + [named] = [None], [subject = Temporal]) correctly falls through to + [false] here -- 238(c)'s own "in omnibus feriis". *) + (Temporal_ef.named date <> None && slug <> "ef-passion-sunday" && slug <> "ef-palm-sunday") + || observed.Celebration.subject = Subject.Saint + || observed.Celebration.subject = Subject.Lord + || observed.Celebration.subject = Subject.Bvm + +(* Missale Romanum, Rubricae Generales, Caput XVII("De Ritibus servandis in + celebratione Missae"), "C) De hymno Glória in excélsis" (docs/research/ + LT.txt, grep "Hymnus Glória"), quoted here in full: + + "431. Hymnus Gloria in excelsis dicitur: + a) in Missis quae respondent Officio diei, quotiescumque ad + Matutinum dictus est hymnus Te Deum; + b) in Missis festivis de quibus n. 302; + c) in Missis feriae V in Cena Domini, et in Missa Vigiliae + paschalis; + d) in Missis votivis I, II et III classis, nisi adhibeatur color + violaceus paramentorum; + e) in Missis votivis IV classis de Angelis, quocumque die, et de B. + Maria Virg. quae in sabbato celebrantur. + + 432. Hymnus Gloria in excelsis omittitur: + a) in Missis quae respondent Officio diei, quando ad Matutinum + omittitur hymnus Te Deum; + b) in omnibus Missis in quibus adhibetur color violaceus + paramentorum; + c) in Missis votivis IV classis, iis exceptis de quibus n. 431 e; + d) in Missis defunctorum." + + SCOPE NOTE, checked once here rather than at every clause, the same + discipline [creed]'s own header uses: this engine resolves ONE Mass per + civil day (Rite.t.readings' own doc comment) -- it has no separate + "which votive Mass is said" dimension. n. 301-303 (LT.txt, immediately + above 431), quoted in substance: 301 defines "Missa de festo" in the + NARROW sense as the Mass of the day's own Office -- exactly what + [Rite_ef.Lectionary_ef.readings] already resolves for every day, + including a BORROWED formulary (a weekday resuming the preceding + Sunday's Mass, a saint using his assigned Common): still "the Mass which + corresponds to the day's own Office" in 431(a)/432(a)'s own sense, so + 431(a)/432(a) alone already cover it. 302's WIDER sense -- (a) a + III-class feast's own Mass said despite being impeded by another + III-class feast, (b) a commemoration's own Mass said in place of the + day's Office, (c) a saint's Mass said on his Martyrology elogium day -- + are all cases of a DIFFERENT Mass than the day's own resolved Office + being said, which this engine does not model; 431(b) is therefore + genuinely N/A, not merely unread. 431(d)/(e) and 432(c) are about VOTIVE + MASS CLASSES (I-IV), a dimension this engine has no field for at all -- + also N/A, EXCEPT 431(e)'s own "de B. Maria Virg. quae in sabbato + celebrantur" half: colitur does not model that Office as a votive Mass + (it has no votive-Mass dimension to model it AS), it models it as an + ORDINARY Office (RG 78's own text, [te_deum]'s own 237(g) branch above), + so its Gloria is produced as a side effect of 431(a) reading [te_deum], + not by a dedicated 431(e) branch -- checked directly: [te_deum]'s 237(g) + branch is unconditional (not colour-gated), and this Office's own colour + is white (never violet), so 432(b) below can never suppress it either. + 431(e)'s "de Angelis" half (the votive Mass of the Angels) has no data + in this engine at all and stays N/A. *) +let gloria ~(temporal : (Vocab_ef.season, Vocab_ef.rank) Temporal.t) + ~(observed : Vocab_ef.rank Celebration.t) ~(date : Date.t) : bool = + let easter = Computus.gregorian_easter (Date.year date) in + let n = Date.to_rata date - Date.to_rata easter in + if + (* 432(d): "in Missis defunctorum" -- checked first, the same + {!Colour.Black} proxy [creed]'s own 476(f) and [te_deum]'s own + 238(d) branch both use. Correctly also excludes Good Friday + (n=-2, [Colour.Black], temporal_ef.ml's own RG 132 citation) -- + which has no Mass at all in the 1955-restored Holy Week, so the + question is moot there regardless; the same "belt and braces" + stance [creed]'s own 476(f) comment takes for the identical day. *) + observed.Celebration.colour = Colour.Black + then false + else if + (* 431(c): "in Missis feriae V in Cena Domini, et in Missa Vigiliae + paschalis" -- Holy Thursday (n=-3) and the Easter Vigil Mass (n=-1, + Holy Saturday's own date). Checked BEFORE 432(b)'s general violet + exclusion below and before [te_deum] is ever read: this clause is + lex specialis over both. It must outrank 432(b) specifically + because colitur's own per-day colour model gives Holy Saturday + [Colour.Violet] (Passiontide's [season_colour], temporal_ef.ml -- + the historical vestment change from violet to white happens AT the + Gloria itself, a per-action nuance this whole day/colour model + already cannot express, the same acknowledged gap RG 126's palm + procession and RG 128's Good Friday Communion carry, temporal_ef.ml's + own citations) -- without this clause checked first, 432(b) would + wrongly silence the one Mass whose own Gloria is historically + unmistakable (the bells and organ restored at the Vigil). It must + also outrank [te_deum]: neither day's own Matins says Te Deum + (both are governed by [te_deum]'s own feria-I-classis exclusion, + n=-46/[-6,-1], the Sacred Triduum included), so without this + explicit override the Gloria would be wrongly silenced there too. *) + n = -3 || n = -1 + then true + else if + (* 432(b): "in omnibus Missis in quibus adhibetur color violaceus + paramentorum" -- independent and colour-keyed, exactly as the task + brief states; NOT a substitute for [te_deum] below, which still + decides every Mass this clause does not itself silence. Genuinely + unconditional ("in omnibus Missis") -- checked directly against + every violet day in the domain-wide sweep (see the module's own + test file), never merely assumed. + + [Colour.Rose] found and DELIBERATELY NOT added here, a real + "checked, then reverted" episode kept for the record: a first pass + of this task, WHILE the (since-reverted) blanket "every Sunday + says Te Deum" mutation to [te_deum] was in place, found Gaudete + and Laetare ([is_rose_sunday]) wrongly getting [gloria]=true and + fixed it by unioning [Colour.Rose] into this branch. Once + [te_deum] reverted to 237(b)'s own literal [Class2] guard, the + fix became REDUNDANT, not merely coincidentally silent: Rose can + ONLY ever colour a Sunday of Advent or Lent + ({!Temporal_ef.is_rose_sunday}'s own two cases), and EVERY Sunday + of Advent or Lent is [Class1] BY CONSTRUCTION + ({!Temporal_ef.temporal}'s own [match s with Advent | Lent -> + Class1 | _ -> Class2]) -- a structural guarantee, not a + coincidence of the shipped data, so [te_deum]'s own [Class2] guard + ALREADY excludes every Rose day before this branch is ever + reached. Verified, not assumed: removing this branch's own Rose + arm and re-running the full suite (including the two LMS dates, + 2023-12-17 and 2024-03-10, this finding was originally pinned + against) left every test green. Left out rather than kept as + dead code that would misleadingly read as load-bearing. *) + observed.Celebration.colour = Colour.Violet + then false + else + (* 431(a)/432(a): "in Missis quae respondent Officio diei, + quotiescumque/quando... Te Deum [dictus est/omittitur]" -- the + Gloria mirrors [te_deum] exactly for every Mass not already decided + above. This is the ONE call site [te_deum] exists to serve. *) + te_deum ~temporal ~observed ~date diff --git a/lib/rites/rite_ef/rubrics_ef.mli b/lib/rites/rite_ef/rubrics_ef.mli index 7a1284b..618af65 100644 --- a/lib/rites/rite_ef/rubrics_ef.mli +++ b/lib/rites/rite_ef/rubrics_ef.mli @@ -38,3 +38,33 @@ val creed : observed:Vocab_ef.rank Celebration.t -> date:Date.t -> bool + +(** Whether the Te Deum was said at Matins (Breviary 1961 Codex Rubricarum + nn. 237-238) -- NOT the Divine Office arriving in scope, but a single + Breviary fact {!gloria}'s own RG 431(a) defers a Mass question to. See + the .ml's own header for the rubric quoted in full, every branch's own + citation, and this source's own stated weakness (a single, not yet + scan-verified, web transcription -- docs/research/breviary/PROVENANCE.md). + Same three parameters as {!creed}, for the same reasons: [temporal] for + the day's own weekday/season, [observed] for the celebration whose + Office is actually kept, [date] for the Easter-relative window + questions. *) +val te_deum : + temporal:(Vocab_ef.season, Vocab_ef.rank) Temporal.t -> + observed:Vocab_ef.rank Celebration.t -> + date:Date.t -> + bool + +(** Whether the Gloria in excelsis is said at this day's Mass (RG 431-432). + 431(a)/432(a) defer to {!te_deum}; 431(c) (Holy Thursday, the Easter + Vigil Mass) and 432(b)/(d) (violet vestments; a Requiem) are + independent overrides, checked ahead of the Te Deum-derived answer -- + see the .ml's own header for the full account, including which of + 431/432's own clauses this engine has no votive-Mass-class dimension to + implement and are therefore marked not-applicable rather than silently + skipped. *) +val gloria : + temporal:(Vocab_ef.season, Vocab_ef.rank) Temporal.t -> + observed:Vocab_ef.rank Celebration.t -> + date:Date.t -> + bool diff --git a/man/colitur.1 b/man/colitur.1 index 09414ea..88c5fab 100644 --- a/man/colitur.1 +++ b/man/colitur.1 @@ -122,12 +122,13 @@ occurrence, commemoration and transfer. The Mass reading citations, one line per day. .TP .BI rubrics " YEAR" -Two rubrics of the Mass, one line per day: which formulary is actually +Three rubrics of the Mass, one line per day: which formulary is actually said \(em not always the day's own: a weekday with no proper resumes the preceding Sunday's, a saint with no proper says his assigned Common, and RG 78/309(a)'s votive Saturday Mass of Our Lady is said in place of an -unoccupied office's own \(em and whether the Creed is said (RG 475\-476). -See +unoccupied office's own \(em whether the Creed is said (RG 475\-476), and +whether the Gloria in excelsis is said (RG 431\-432, deferring to the +Breviary's own Te Deum rule, nn. 237\-238, for RG 431(a)). See .B OUTPUT FORMAT below. .TP @@ -460,12 +461,13 @@ own trailing field, above. .SS rubrics .RS .nf -date [TAB] formulary\-slug [TAB] source [TAB] creed +date [TAB] formulary\-slug [TAB] source [TAB] creed [TAB] gloria .fi .RE .PP The day's own Mass formulary (which slug's Mass is actually said, and how -that was decided), followed by whether the Creed is said (RG 475\-476). +that was decided), followed by whether the Creed is said (RG 475\-476) and +whether the Gloria in excelsis is said (RG 431\-432). .B rubrics separates its fields with a literal TAB \(em not a plain space like .B day @@ -489,7 +491,9 @@ leave it unsplittable by field number. is one of .BR proper ", " own ", " preceding\-sunday ", " common " or " votive . .I creed -is +and +.I gloria +are each .B true or .B false @@ -498,15 +502,15 @@ or or .RB \(lq 1/0 \(rq : this row has no other boolean field to be consistent with). A day with no -Mass at all for a rite that has not implemented the rule reads +Mass at all for a rite that has not implemented a rule reads .B false outright \(em it is a decision, never a third \(lqunknown\(rq state. .RS .nf -2026\-01\-01 [TAB] ef\-circumcision [TAB] own [TAB] true -2038\-03\-08 [TAB] john\-of\-god [TAB] proper [TAB] false -2025\-12\-01 [TAB] ef\-advent\-sunday\-1 [TAB] preceding\-sunday [TAB] false +2026\-01\-01 [TAB] ef\-circumcision [TAB] own [TAB] true [TAB] true +2038\-03\-08 [TAB] john\-of\-god [TAB] proper [TAB] false [TAB] true +2025\-12\-01 [TAB] ef\-advent\-sunday\-1 [TAB] preceding\-sunday [TAB] false [TAB] false .fi .RE .PP diff --git a/test/cli.t b/test/cli.t index 5bf8d83..8bd5b9f 100644 --- a/test/cli.t +++ b/test/cli.t @@ -210,9 +210,9 @@ separate command for the same mechanical reason `readings` is: `day`'s row is fixed-width space-separated with a variable-length "+slug" tail. $ colitur rubrics 2026 | head -3 - 2026-01-01 ef-circumcision own true - 2026-01-02 ef-christmas-1-friday own false - 2026-01-03 ef-christmas-1-saturday votive false + 2026-01-01 ef-circumcision own true true + 2026-01-02 ef-christmas-1-friday own false true + 2026-01-03 ef-christmas-1-saturday votive false true $ colitur rubrics 2026 | wc -l 365 @@ -224,19 +224,19 @@ apply to it -- step 2 does (the day's own temporal slug in the lectionary), tagged `own`. Contrast a real sanctoral saint with his own proper: $ colitur rubrics 2038 | grep '^2038-03-08' - 2038-03-08 john-of-god proper false + 2038-03-08 john-of-god proper false true A saint with no proper of his own says his assigned Common (step 4): $ colitur rubrics 2038 | grep '^2038-03-06' - 2038-03-06 common-of-non-virgins-1 common false + 2038-03-06 common-of-non-virgins-1 common false true A weekday with no proper of its own resumes the preceding Sunday's, never its own observed slug -- 1 December 2025 is the Monday after Advent I, and Advent's ferias have no Mass of their own (step 3): $ colitur rubrics 2025 | grep '^2025-12-01' - 2025-12-01 ef-advent-sunday-1 preceding-sunday false + 2025-12-01 ef-advent-sunday-1 preceding-sunday false false 3 January 2026 above ("votive") is the RG 78/309(a) Saturday Mass of Our Lady, said IN PLACE of the day's own office's Mass while the office (an @@ -257,9 +257,9 @@ diocesan overlay's local patron observed instead (no proper or Common of his own in the fixture), the chain falls all the way back to step 3: $ colitur rubrics 2026 --overlay fixtures/overlay-example-diocesan.sexp | grep '^2026-07-11' - 2026-07-11 ef-time-after-pentecost-sunday-6 preceding-sunday false + 2026-07-11 ef-time-after-pentecost-sunday-6 preceding-sunday false true $ colitur rubrics 2026 | grep '^2026-07-11' - 2026-07-11 ef-time-after-pentecost-6-saturday votive false + 2026-07-11 ef-time-after-pentecost-6-saturday votive false true `--lang`/`--raw`/`--sigla-*` are refused rather than silently ignored, unlike `readings`: this row resolves no display name and no citation for any of @@ -343,13 +343,13 @@ prints for the identical day, so the two cannot silently drift apart again in either direction: $ colitur --help | grep '^ rubrics date' - rubrics date, formulary slug, source, creed -- TAB-separated + rubrics date, formulary slug, source, creed, gloria -- TAB-separated $ colitur --help | sed -n '/^ rubrics date/{n;p}' - 2026-01-01[TAB]ef-circumcision[TAB]own[TAB]true + 2026-01-01[TAB]ef-circumcision[TAB]own[TAB]true[TAB]true $ colitur rubrics 2026 | grep '^2026-01-01' | sed $'s/\t/[TAB]/g' - 2026-01-01[TAB]ef-circumcision[TAB]own[TAB]true + 2026-01-01[TAB]ef-circumcision[TAB]own[TAB]true[TAB]true --version prints the version alone, to standard output, exit 0. Deliberately not embedded in the help text above: this pin would then have to be edited @@ -573,10 +573,24 @@ across many lines' own wrap points, not just the days whose DATA changed -- confirmed directly (diffed the full sexp output line by line): every difference is exactly this [said] shape change or a consequent wrap shift, nothing else. Not a claim that [emit]'s FORMAT changed, only that -individual records' pretty-printed SHAPE did: +individual records' pretty-printed SHAPE did. + +9025 -> 9197 (Gloria, celebrant-rubrics-phase1 Phase 2): {!Liturgical_day.t} +gained a [gloria] field, the same seam [creed] already used -- [emit +--format sexp] dumps the whole record ({!Sexplib.Sexp.to_string_hum} over +[Liturgical_day.sexp_of_t]) unlike [csv]/[json]/[xml] (a curated +[View]/[Record] projection that has never included [creed] either, hence +their own line counts above are UNCHANGED by this task -- confirmed by +this cram file's own diff touching only the [sexp] count). All 365 of +2027's records print a new [(gloria )] token (checked directly, +[grep -c gloria]), but the wrap-point mechanics are the same cosmetic +reflow the two entries above describe, not a fixed one-line-per-record +addition: 2027-01-01's own record wraps [gloria] onto its own new line, +while 2027-01-02's fits it on the same line as [creed] and [formulary] -- +172 of 365 records happened to cross a wrap boundary, the rest did not. $ colitur emit --format sexp --from 2027 --to 2027 | wc -l - 9025 + 9197 $ colitur emit --format xml --from 2027 --to 2027 | head -2 diff --git a/test/fixtures/fiuv-ordo-2025-2026.sexp b/test/fixtures/fiuv-ordo-2025-2026.sexp index 7b72ca3..d31c5d5 100644 --- a/test/fixtures/fiuv-ordo-2025-2026.sexp +++ b/test/fixtures/fiuv-ordo-2025-2026.sexp @@ -97,7 +97,24 @@ ; ; MEASURED DISTRIBUTION (this extraction, not the brief's figures): Gloria ; true=269 false=130 unresolved=1; Credo true=116 false=283 unresolved=1; -; Te Deum true=231 false=42 unresolved=127. +; Te Deum true=207 false=66 unresolved=127. +; +; RE-EXTRACTED (celebrant-rubrics-phase1 Phase 2, 2026-08-22): the FIRST +; extraction's own Te Deum parser recognised only ONE of the source's two +; negative phrasings ("non dicitur Te Deum") -- "sine Te Deum" (how the +; source actually negates a SUNDAY's own Te Deum, among others) fell +; through to a bare "Te Deum" substring match and was wrongly read +; [true]. Found by hand, comparing the raw pdftotext dump against this +; fixture's own values directly, not by any coverage check (both counted +; the same 127 unresolved rows before and after). Fixed in +; tools/extract_fiuv_ordo.ml's own [extract_te_deum] (see its own +; citation); this fixture re-extracted from the SAME pdftotext dump +; (SHA-256 of the source PDF unchanged, above). 24 of 400 rows changed, +; every one Te Deum true->false, every one independently confirmed +; against the raw text: true 231->207, false 42->66, unresolved 127 +; unchanged. See lib/rites/rite_ef/rubrics_ef.ml's own [te_deum] header +; (237(a)'s comment) for the full account of what this corrected, and +; the .superpowers task report for the complete before/after date list. (((date 2025-11-27) (class_ ("IV cl.")) (title "Vir Feria V post Dom. XXIV & Ultima post Pentecosten, De ea,") (te_deum (false)) (gloria (false)) (credo (false)) @@ -109,7 +126,7 @@ (title "Alb Sabb. S. Maria in Sabbato,") (te_deum (true)) (gloria (true)) (credo (false)) (praef ("BMV Et te in Veneratione \226\128\147"))) ((date 2025-11-30) (class_ ("I cl.")) - (title "Viol DOM. I ADVENTUS, De ea,") (te_deum (true)) (gloria (false)) + (title "Viol DOM. I ADVENTUS, De ea,") (te_deum (false)) (gloria (false)) (credo (true)) (praef ("Trinit. Ad II"))) ((date 2025-12-01) (class_ ("III cl.")) (title "Viol Feria II post Dom. I Adventus, De ea,") (te_deum ()) @@ -134,8 +151,8 @@ (gloria (true)) (credo (false)) (praef ("comm. Sabbatum primum in mense. I"))) ((date 2025-12-07) (class_ ("I cl.")) - (title "Viol DOM. II ADVENTUS, De ea,") (te_deum (true)) (gloria (false)) - (credo (true)) (praef ("Trinit. Ad II"))) + (title "Viol DOM. II ADVENTUS, De ea,") (te_deum (false)) + (gloria (false)) (credo (true)) (praef ("Trinit. Ad II"))) ((date 2025-12-08) (class_ ("I cl.")) (title "Alb Feria II. IN CONCEPTIONE IMMACULATA B. MARIAE VIRGINIS,") (te_deum ()) (gloria (true)) (credo (true)) @@ -156,7 +173,7 @@ (title "Rub Sabb. S Luciae Virg. et Mart.,") (te_deum (true)) (gloria (true)) (credo (false)) (praef ("comm. vel de Martyribus. I"))) ((date 2025-12-14) (class_ ("I cl.")) - (title "Viol/ DOM. III ADVENTUS (GAUDETE), De ea,") (te_deum (true)) + (title "Viol/ DOM. III ADVENTUS (GAUDETE), De ea,") (te_deum (false)) (gloria (false)) (credo (true)) (praef ("Trinit. Ad II"))) ((date 2025-12-15) (class_ ("III cl.")) (title "Viol Feria II post Dom. III Adventus (Gaudete), De ea,") @@ -180,8 +197,8 @@ (praef ("comm. \226\128\147 Quoad Flectamus genua vide RM 440. Quoad lectiones vide RM 468. I"))) ((date 2025-12-21) (class_ ("I cl.")) - (title "Viol DOM. IV ADVENTUS, De ea,") (te_deum (true)) (gloria (false)) - (credo (true)) (praef ("Trinit. Ad II"))) + (title "Viol DOM. IV ADVENTUS, De ea,") (te_deum (false)) + (gloria (false)) (credo (true)) (praef ("Trinit. Ad II"))) ((date 2025-12-22) (class_ ("II cl.")) (title "Viol FERIA II POST DOM. IV ADVENTUS, De ea,") (te_deum ()) (gloria (false)) (credo (false)) (praef (comm.))) @@ -189,7 +206,7 @@ (title "Viol FERIA III POST DOM. IV ADVENTUS, De ea,") (te_deum ()) (gloria (false)) (credo (false)) (praef (comm.))) ((date 2025-12-24) (class_ ("I cl.")) - (title "Viol Feria IV. VIGILIA NATIVITATIS DOMINI,") (te_deum (true)) + (title "Viol Feria IV. VIGILIA NATIVITATIS DOMINI,") (te_deum (false)) (gloria (false)) (credo (false)) (praef ("comm. I"))) ((date 2025-12-25) (class_ ("I cl.")) (title "Alb Feria V. IN NATIVITATE DOMINI,") (te_deum ()) (gloria (true)) @@ -331,7 +348,7 @@ (title "Alb Sabb. S Ioannis Bosco Conf.,") (te_deum (true)) (gloria (true)) (credo (false)) (praef ("comm. I"))) ((date 2026-02-01) (class_ ("II cl.")) - (title "Viol DOM. SEPTUAGESIMAE, De ea,") (te_deum (true)) + (title "Viol DOM. SEPTUAGESIMAE, De ea,") (te_deum (false)) (gloria (false)) (credo (true)) (praef ("Trinit. II"))) ((date 2026-02-02) (class_ ("II cl.")) (title "Alb Feria II. IN PURIFICATIONE B. MARIAE VIRG.,") (te_deum ()) @@ -354,8 +371,8 @@ (title "Alb Sabb. S Romualdi Abb.,") (te_deum (true)) (gloria (true)) (credo (false)) (praef ("comm. Sabbatum primum in mense. I"))) ((date 2026-02-08) (class_ ("II cl.")) - (title "Viol DOM. SEXAGESIMAE, De ea,") (te_deum (true)) (gloria (false)) - (credo (true)) (praef ("Trinit. II"))) + (title "Viol DOM. SEXAGESIMAE, De ea,") (te_deum (false)) + (gloria (false)) (credo (true)) (praef ("Trinit. II"))) ((date 2026-02-09) (class_ ("III cl.")) (title "Alb Feria II. S Cyrilli Ep. Alexandrini, Conf. et Eccl. Doct.,") (te_deum (true)) (gloria (true)) (credo (false)) @@ -379,7 +396,7 @@ (title "Alb Sancta Maria in Sabbato,") (te_deum (true)) (gloria (true)) (credo (false)) (praef ("BMV Et te in Veneratione \226\128\147"))) ((date 2026-02-15) (class_ ("II cl.")) - (title "Viol DOM. QUINQUAGESIMAE, De ea,") (te_deum (true)) + (title "Viol DOM. QUINQUAGESIMAE, De ea,") (te_deum (false)) (gloria (false)) (credo (true)) (praef ("Trinit. II"))) ((date 2026-02-16) (class_ ("IV cl.")) (title "Viol Feria II Dom. Quinquagesimae, De ea,") (te_deum ()) @@ -403,7 +420,7 @@ (gloria (false)) (credo (false)) (praef ("Quadr., or. super populum. I"))) ((date 2026-02-22) (class_ ("I cl.")) - (title "Viol DOM. I QUADRAGESIMAE, De ea,") (te_deum (true)) + (title "Viol DOM. I QUADRAGESIMAE, De ea,") (te_deum (false)) (gloria (false)) (credo (true)) (praef ("Quadr. II"))) ((date 2026-02-23) (class_ ("III cl.")) (title "Viol Feria II post Dom. I Quadragesimae, De ea,") (te_deum ()) @@ -428,7 +445,7 @@ (praef ("Quadr., or. super populum. \226\128\147 Quoad Flectamus genua vide RM 440. Quoad lectiones vide RM 468. I"))) ((date 2026-03-01) (class_ ("I cl.")) - (title "Viol DOM. II QUADRAGESIMAE, De ea,") (te_deum (true)) + (title "Viol DOM. II QUADRAGESIMAE, De ea,") (te_deum (false)) (gloria (false)) (credo (true)) (praef ("Quadr. II"))) ((date 2026-03-02) (class_ ("III cl.")) (title "Viol Feria II post Dom. II Quadragesimae, De ea,") (te_deum ()) @@ -451,7 +468,7 @@ (gloria (false)) (credo (false)) (praef ("Quadr., or. super populum. Sabbatum primum in mense. I"))) ((date 2026-03-08) (class_ ("I cl.")) - (title "Viol DOM. III QUADRAGESIMAE, De ea,") (te_deum (true)) + (title "Viol DOM. III QUADRAGESIMAE, De ea,") (te_deum (false)) (gloria (false)) (credo (true)) (praef ("Quadr. II"))) ((date 2026-03-09) (class_ ("III cl.")) (title "Viol Feria II post Dom. III Quadragesimae, De ea,") @@ -475,7 +492,7 @@ (gloria (false)) (credo (false)) (praef ("Quadr., or. super populum. I"))) ((date 2026-03-15) (class_ ("I cl.")) - (title "Viol/ DOM. IV QUADRAGESIMAE (LAETARE), De ea,") (te_deum (true)) + (title "Viol/ DOM. IV QUADRAGESIMAE (LAETARE), De ea,") (te_deum (false)) (gloria (false)) (credo (true)) (praef ("Quadr. II"))) ((date 2026-03-16) (class_ ("III cl.")) (title "Viol Feria II post Dom. IV Quadragesimae (Laetare), De ea,") @@ -503,8 +520,8 @@ (te_deum (true)) (gloria (false)) (credo (false)) (praef ("Quadr., or. super populum. I"))) ((date 2026-03-22) (class_ ("I cl.")) - (title "Viol DOM. I PASSIONIS, De ea,") (te_deum (true)) (gloria (false)) - (credo (true)) (praef ("de Sancta Cruce. II"))) + (title "Viol DOM. I PASSIONIS, De ea,") (te_deum (false)) + (gloria (false)) (credo (true)) (praef ("de Sancta Cruce. II"))) ((date 2026-03-23) (class_ ("III cl.")) (title "Viol Feria II post Dom. I Passionis, De ea,") (te_deum ()) (gloria (false)) (credo (false)) @@ -533,7 +550,7 @@ ((date 2026-03-29) (class_ ("I cl.")) (title "Rub in Officio, Rub. ad Bened. ramorum et in Processione, Viol. in Missa. DOM. IN PALMIS, De ea,") - (te_deum (true)) (gloria (false)) (credo (true)) + (te_deum (false)) (gloria (false)) (credo (true)) (praef ("de Sancta Cruce. \194\171Asperges\194\187 omittitur. \226\128\147 Benedictio palmorum et processio ante missam principalem celebrandae sunt. \226\128\147 Orationes praeparatoriae omittuntur. \226\128\147 Evangelium de passione secundum S Matthiam 26, 36-75; 27,1-60 legitur. Munda cor dicitur, celebrans autem (vel diaconus chronista) signum crucis nec super libro nec super se facit, necque librum osculatur, necque Laus tibi Domine dicit. \226\128\147 Evangelium finale omittitur. \226\128\147 In Missis sine processione, legitur in fine Evangelium Cum appropinquasset de benedictione ramorum. Sacerdotibus plures quam unam missam dicentibus licet Evangelium S Matthi\195\166 27, 45-52 legere potius quam Evangelium Passionis. II"))) ((date 2026-03-30) (class_ ("I cl.")) @@ -858,7 +875,7 @@ (gloria (true)) (credo (false)) (praef (comm.))) ((date 2026-06-23) (class_ ("II cl.")) (title "Viol Feria III. VIGILIA NATIVITATIS S IOANNIS * BAPTISTAE,") - (te_deum (true)) (gloria (false)) (credo (false)) (praef ("comm. I"))) + (te_deum (false)) (gloria (false)) (credo (false)) (praef ("comm. I"))) ((date 2026-06-24) (class_ ("I cl.")) (title "Alb Feria IV. IN NATIVITATE S IOANNIS * BAPTISTAE,") (te_deum ()) (gloria (true)) (credo (true)) @@ -1041,7 +1058,7 @@ (praef ("comm. \226\128\147"))) ((date 2026-08-14) (class_ ("II cl.")) (title "Viol Feria VI. VIGILIA ASSUMPTIONIS B. MARIAE VIRG.,") - (te_deum (true)) (gloria (false)) (credo (false)) (praef ("comm. I"))) + (te_deum (false)) (gloria (false)) (credo (false)) (praef ("comm. I"))) ((date 2026-08-15) (class_ ("I cl.")) (title "Alb Sabb. IN ASSUMPTIONE B. MARIAE VIRG.,") (te_deum ()) (gloria (true)) (credo (true)) (praef ("BMV Et te in Assumptione. Ad"))) @@ -1176,7 +1193,7 @@ (praef ("comm. \226\128\147 Vel (sec. decretum \194\171Cum sanct.\194\187) Missa SS Mauritii et Sociorum Mm. (rub.) Intret in conspectu tuo, Gloria, ors. et Evangelium pr., comm. S Thomae de Villanova Ep. et Conf., praef. comm. vel de Martyribus."))) ((date 2026-09-23) (class_ ("II cl.")) - (title "Viol FERIA IV QUATTUOR TEMP., De ea,") (te_deum (true)) + (title "Viol FERIA IV QUATTUOR TEMP., De ea,") (te_deum (false)) (gloria (false)) (credo (false)) (praef ("comm. \226\128\147 Quoad Flectamus genua vide RM 440."))) ((date 2026-09-24) (class_ ("IV cl.")) @@ -1184,10 +1201,10 @@ (te_deum (false)) (gloria (false)) (credo (false)) (praef ("comm. \226\128\147"))) ((date 2026-09-25) (class_ ("II cl.")) - (title "Viol FERIA VI QUATTUOR TEMP., De ea,") (te_deum (true)) + (title "Viol FERIA VI QUATTUOR TEMP., De ea,") (te_deum (false)) (gloria (false)) (credo (false)) (praef (comm.))) ((date 2026-09-26) (class_ ("II cl.")) - (title "Viol SABB. QUATTUOR TEMP., De eo,") (te_deum (true)) + (title "Viol SABB. QUATTUOR TEMP., De eo,") (te_deum (false)) (gloria (false)) (credo (false)) (praef ("comm. \226\128\147 Quoad Flectamus genua vide RM 440. Quoad lectiones vide RM 468. I"))) @@ -1441,7 +1458,7 @@ (title "Alb Sancta Maria in Sabbato,") (te_deum (true)) (gloria (true)) (credo (false)) (praef ("BMV Et te in Veneratione. I"))) ((date 2026-11-29) (class_ ("I cl.")) - (title "Viol DOM. I ADVENTUS, De ea,") (te_deum (true)) (gloria (false)) + (title "Viol DOM. I ADVENTUS, De ea,") (te_deum (false)) (gloria (false)) (credo (true)) (praef ("Trinit. Ad II"))) ((date 2026-11-30) (class_ ("II cl.")) (title "Rub Feria II. S ANDREAE * APOSTOLI,") (te_deum ()) @@ -1465,8 +1482,8 @@ (gloria (false)) (credo (false)) (praef ("comm. Sabbatum primum in mense. I"))) ((date 2026-12-06) (class_ ("I cl.")) - (title "Viol DOM. II ADVENTUS, De ea,") (te_deum (true)) (gloria (false)) - (credo (true)) (praef ("Trinit. Ad II"))) + (title "Viol DOM. II ADVENTUS, De ea,") (te_deum (false)) + (gloria (false)) (credo (true)) (praef ("Trinit. Ad II"))) ((date 2026-12-07) (class_ ("III cl. (Priv.)")) (title "Alb Feria II. S Ambrosii Ep., Conf. et Eccl. Doct.,") (te_deum (true)) (gloria (true)) (credo (false)) @@ -1488,7 +1505,7 @@ (title "Viol Sabb. post Dom. II Adventus, De eo,") (te_deum ()) (gloria (false)) (credo (false)) (praef ("comm. I"))) ((date 2026-12-13) (class_ ("I cl.")) - (title "DOM. III ADVENTUS (GAUDETE), De ea,") (te_deum (true)) + (title "DOM. III ADVENTUS (GAUDETE), De ea,") (te_deum (false)) (gloria (false)) (credo (true)) (praef ("Trinit. Ad II"))) ((date 2026-12-14) (class_ ("III cl.")) (title "Viol Feria II post Dom. III Adventus (Gaudete), De ea,") @@ -1512,8 +1529,8 @@ (praef ("comm. \226\128\147 Quoad Flectamus genua vide RM 440. Quoad lectiones vide RM 468. I"))) ((date 2026-12-20) (class_ ("I cl.")) - (title "Viol DOM. IV ADVENTUS, De ea,") (te_deum (true)) (gloria (false)) - (credo (true)) (praef ("Trinit. Ad II"))) + (title "Viol DOM. IV ADVENTUS, De ea,") (te_deum (false)) + (gloria (false)) (credo (true)) (praef ("Trinit. Ad II"))) ((date 2026-12-21) (class_ ("II cl.")) (title "Rub Feria II. S THOM\195\134 * APOSTOLI,") (te_deum ()) (gloria (true)) (credo (true)) (praef ("App. Ad"))) @@ -1524,7 +1541,7 @@ (title "Viol FERIA IV POST DOM. IV ADVENTUS, De ea,") (te_deum ()) (gloria (false)) (credo (false)) (praef (comm.))) ((date 2026-12-24) (class_ ("I cl.")) - (title "Viol Feria V. VIGILIA NATIVITATIS DOMINI,") (te_deum (true)) + (title "Viol Feria V. VIGILIA NATIVITATIS DOMINI,") (te_deum (false)) (gloria (false)) (credo (false)) (praef ("comm. I"))) ((date 2026-12-25) (class_ ("I cl.")) (title "Alb Feria VI. IN NATIVITATE DOMINI,") (te_deum ()) diff --git a/test/test_calendar.ml b/test/test_calendar.ml index 0d402e6..157841b 100644 --- a/test/test_calendar.ml +++ b/test/test_calendar.ml @@ -104,10 +104,11 @@ module Fixture = struct the sanctoral side. *) let readings ~observed:_ ~temporal:_ ~date:_ ~temporal_at:_ = (None, []) - (* No fixture here exercises the Creed rubric either -- a rite that has - not implemented it returns [false] explicitly, {!Rite.t.creed}'s own - documented default. *) + (* No fixture here exercises the Creed or Gloria rubrics either -- a rite + that has not implemented them returns [false] explicitly, + {!Rite.t.creed}/{!Rite.t.gloria}'s own documented default. *) let creed ~temporal:_ ~observed:_ ~date:_ = false + let gloria ~temporal:_ ~observed:_ ~date:_ = false let rite : (season, rank) Rite.t = { Rite.id = "synthetic-calendar"; vocab; year_start; temporal; anchors = (fun _ -> []); @@ -119,7 +120,7 @@ module Fixture = struct (* Not a Roman rite either, so no bissextile-doubling convention: identity, {!Rite.t.fixed_key}'s own documented default. *) fixed_key = (fun d -> Some (D.month d, D.day d)); - rules; season_runs = [ A; B ]; transfer_target; readings; creed } + rules; season_runs = [ A; B ]; transfer_target; readings; creed; gloria } let entry ~month ~day ~slug ~rank = { Layer.date = (match Date_spec.fixed ~month ~day with Ok d -> d | Error e -> failwith e); diff --git a/test/test_fiuv_ordo.ml b/test/test_fiuv_ordo.ml index 8b3e942..24eeed0 100644 --- a/test/test_fiuv_ordo.ml +++ b/test/test_fiuv_ordo.ml @@ -68,7 +68,7 @@ let sha256_of_file path = | Some i -> String.sub line 0 i | None -> Alcotest.failf "unexpected sha256sum output for %s: %S" path line) -let fixture_sha256 = "becadaad43b3a42c4eb820cb4e93d68f9b1c07a2c8e5273167cd599758af5c67" +let fixture_sha256 = "a52cc4dae32ce4d07446c9daa86287cb99796f6584b3d571e3a3cbdc93fca396" let real_layer () = let layer = @@ -132,7 +132,7 @@ let window_last = "2026-12-31" (* late). *) (* ---------------------------------------------------------------------- *) -type colitur_row = { c_date : string; c_creed : bool } +type colitur_row = { c_date : string; c_creed : bool; c_gloria : bool } let colitur_rows () = let layer = real_layer () in @@ -148,7 +148,50 @@ let colitur_rows () = let stop = mk window_last in while Date.compare !d stop <= 0 do (match Hashtbl.find_opt by_rata (Date.to_rata !d) with - | Some day -> rows := { c_date = Date.to_iso8601 day.LD.date; c_creed = day.LD.creed } :: !rows + | Some day -> + rows := + { c_date = Date.to_iso8601 day.LD.date; c_creed = day.LD.creed; c_gloria = day.LD.gloria } :: !rows + | None -> Alcotest.failf "no colitur day resolved for %s" (Date.to_iso8601 !d)); + d := Date.add_days !d 1 + done; + List.rev !rows + +(* Te Deum has no colitur-side [Liturgical_day.t] field of its own -- it is + a Breviary fact {!Rite_ef.Rubrics_ef.gloria} reads internally, not a Mass + part {!Colitur_kernel.Rite.t} exposes. Resolved separately, straight from + {!Rite_ef.Rubrics_ef.te_deum}, over the identical window/day set. *) +type colitur_te_deum_row = { + t_date : string; + t_te_deum : bool; + t_rank : V.rank; + t_slug : string; + t_has_commemoration : bool; +} + +let colitur_te_deum_rows () = + let layer = real_layer () in + let rite = Rite_ef.context ~lectionary:(real_lectionary ()) ~commons:(real_commons ()) in + let by_rata : (int, (V.season, V.rank) LD.t) Hashtbl.t = Hashtbl.create 800 in + for y = 2024 to 2027 do + let days = Cal.year rite layer y in + Array.iter (fun (d : (V.season, V.rank) LD.t) -> Hashtbl.replace by_rata (Date.to_rata d.LD.date) d) days + done; + let mk s = match Date.of_iso8601 s with Ok d -> d | Error e -> Alcotest.failf "%s: %s" s e in + let rows = ref [] in + let d = ref (mk window_first) in + let stop = mk window_last in + while Date.compare !d stop <= 0 do + (match Hashtbl.find_opt by_rata (Date.to_rata !d) with + | Some day -> + let te_deum = + Rite_ef.Rubrics_ef.te_deum ~temporal:day.LD.temporal ~observed:day.LD.observed ~date:day.LD.date + in + rows := + { t_date = Date.to_iso8601 day.LD.date; t_te_deum = te_deum; + t_rank = day.LD.observed.Colitur_kernel.Celebration.rank; + t_slug = Colitur_kernel.Slug.to_string day.LD.observed.Colitur_kernel.Celebration.slug; + t_has_commemoration = day.LD.commemorations <> [] } + :: !rows | None -> Alcotest.failf "no colitur day resolved for %s" (Date.to_iso8601 !d)); d := Date.add_days !d 1 done; @@ -258,11 +301,129 @@ let test_creed_matches_or_is_explained () = Alcotest.failf "allow-list entry %s is declared but never matched a real divergence" e.id) allow_list +(* ---------------------------------------------------------------------- *) +(* Gloria (RG 431-432, {!Rite_ef.Rubrics_ef.gloria}) -- Phase 2. Same shape *) +(* as the Creed comparison above, over the identical 400-row window. *) +(* ---------------------------------------------------------------------- *) + +let test_gloria_coverage () = + let ordo = ordo_rows () in + let no_gloria = List.filter (fun o -> o.gloria = None) ordo in + Alcotest.(check int) "exactly one day has no Ordo Gloria marker" 1 (List.length no_gloria) + +let describe_gloria_mismatch (o : ordo_row) (c : colitur_row) = + Printf.sprintf "%s %S: colitur gloria=%b, Ordo gloria=%b" o.date o.title c.c_gloria (Option.get o.gloria) + +(* F1 -- OPEN, adjudicated FOR colitur. The single Gloria mismatch found: + Good Friday (2026-04-03). See data/ef/expected-divergences-fiuv.sexp's + own F1 for the full citation and the raw source text this was checked + against directly (docs/research/ordo/fiuv-ordo-2025-2026.pdf, page 46). *) +let is_f1_good_friday (o : ordo_row) = String.equal o.date "2026-04-03" + +let test_gloria_matches_or_is_explained () = + let ordo = ordo_rows () in + let colitur = colitur_rows () in + let unexplained = ref [] in + let f1_count = ref 0 in + List.iter2 + (fun (o : ordo_row) (c : colitur_row) -> + if not (String.equal o.date c.c_date) then Alcotest.failf "misaligned: ordo %s vs colitur %s" o.date c.c_date; + match o.gloria with + | None -> () + | Some ogloria -> + if Bool.equal ogloria c.c_gloria then () + else if is_f1_good_friday o then incr f1_count + else unexplained := describe_gloria_mismatch o c :: !unexplained) + ordo colitur; + Alcotest.(check (list string)) "every Gloria mismatch is named in the allow-list -- none unexplained" [] + (List.rev !unexplained); + Alcotest.(check int) "F1 (Good Friday) count" 1 !f1_count + +(* ---------------------------------------------------------------------- *) +(* Te Deum (Breviary 237-238, {!Rite_ef.Rubrics_ef.te_deum}) -- Phase 2, *) +(* the mitigation the task brief names for this source's own stated *) +(* weakness (a single, not yet scan-verified, web transcription). A *) +(* SEPARATE colitur-side resolution ({!colitur_te_deum_rows}), since Te *) +(* Deum has no {!Colitur_kernel.Liturgical_day.t} field of its own. *) +(* *) +(* The SUNDAY shape this same comparison originally found (237(b)'s own *) +(* literal Septuagesima/Sexagesima/Quinquagesima exception, contradicted *) +(* 15/15) is FIXED at the source ({!Rite_ef.Rubrics_ef.te_deum}'s own *) +(* header has the correction and its full citation) -- not allow-listed, *) +(* because it no longer diverges. Three OTHER shapes remain OPEN, *) +(* single-witnessed (never captured by the LMS fixtures, which do not *) +(* record Te Deum at all) -- see data/ef/expected-divergences-fiuv.sexp's *) +(* own F2/F3/F4 for the full citations. Matched by PREDICATE, the same *) +(* "varies by shape, not by a fixed date list" reasoning L5/L6 already *) +(* establish for the LMS suite. *) +(* ---------------------------------------------------------------------- *) + +let test_te_deum_coverage () = + let ordo = ordo_rows () in + let no_te_deum = List.filter (fun o -> o.te_deum = None) ordo in + Alcotest.(check int) "the unresolved-Te-Deum population matches this fixture's own measured figure" 127 + (List.length no_te_deum) + +let describe_te_deum_mismatch (o : ordo_row) (t : colitur_te_deum_row) = + Printf.sprintf "%s %S: colitur te_deum=%b, Ordo te_deum=%b (rank=%s commemoration=%b)" o.date o.title t.t_te_deum + (Option.get o.te_deum) (V.rank_to_string t.t_rank) t.t_has_commemoration + +(* F2 (every I-class and omissible vigil) and F4 (the three September + Ember days) were BOTH found against the FIRST, buggy extraction (see + the fixture's own provenance header, "RE-EXTRACTED" note, and + {!Rite_ef.Rubrics_ef.te_deum}'s own 237(a) comment for the full + account of the extractor bug and its fix) -- re-run against the + corrected data, NEITHER fires any more: colitur's own answer already + matched the CORRECTED Ordo on every one of those 7 dates, all along. + Removed rather than kept as dead code with an [expected 0] pin, the + same "an entry that stops firing is a real change, not silently + absorbed" discipline data/ef/expected-divergences-lms.sexp's own L1/L3 + closures already establish -- see that file's own history for the + precedent this follows. + + F3, the privileged-Lenten/Passiontide-feria-plus-commemoration shape + L5 (data/ef/expected-divergences-lms.sexp) already found for Gloria, + REMAINS: 6 instances, unaffected by the extractor fix (none of the six + raw source lines used "sine Te Deum" at all -- confirmed directly + against the pdftotext dump, see F3's own citation). CONTRADICTS L5, + not corroborates it: FIUV's own [gloria] is [false] on all six dates + (agreeing with colitur), while its [te_deum] is [true] on the same six + -- see F3's own citation in data/ef/expected-divergences-fiuv.sexp for + the full account of why Gloria and Te Deum diverge on the identical + day. *) +let is_f3_lenten_commemoration (_ : ordo_row) (t : colitur_te_deum_row) = + t.t_has_commemoration && t.t_rank = V.Class3 + +let test_te_deum_matches_or_is_explained () = + let ordo = ordo_rows () in + let colitur = colitur_te_deum_rows () in + let unexplained = ref [] in + let f3_count = ref 0 in + List.iter2 + (fun (o : ordo_row) (t : colitur_te_deum_row) -> + if not (String.equal o.date t.t_date) then Alcotest.failf "misaligned: ordo %s vs colitur %s" o.date t.t_date; + match o.te_deum with + | None -> () + | Some otd -> + if Bool.equal otd t.t_te_deum then () + else if is_f3_lenten_commemoration o t then incr f3_count + else unexplained := describe_te_deum_mismatch o t :: !unexplained) + ordo colitur; + Alcotest.(check (list string)) "every Te Deum mismatch is named in the allow-list -- none unexplained" [] + (List.rev !unexplained); + Alcotest.(check int) "F3 (Lenten privileged feria + commemoration) count" 6 !f3_count + let suite = ( "fiuv-ordo", [ Alcotest.test_case "fixture SHA-256 matches its provenance note" `Quick test_fixture_checksum; Alcotest.test_case "streams are 400 rows each, dates aligned 1:1" `Quick test_dates_align; Alcotest.test_case "only Holy Saturday has no Ordo Creed marker" `Quick test_creed_coverage; Alcotest.test_case "every Creed difference is named in the cited allow-list -- none unexplained" `Quick - test_creed_matches_or_is_explained + test_creed_matches_or_is_explained; + Alcotest.test_case "Ordo Gloria coverage matches the measured figure" `Quick test_gloria_coverage; + Alcotest.test_case "every Gloria difference is named in the cited allow-list -- none unexplained" `Quick + test_gloria_matches_or_is_explained; + Alcotest.test_case "Ordo Te Deum coverage matches the measured figure" `Quick test_te_deum_coverage; + Alcotest.test_case "every Te Deum difference is named in the cited allow-list -- none unexplained" `Quick + test_te_deum_matches_or_is_explained ] ) diff --git a/test/test_lms_ordo.ml b/test/test_lms_ordo.ml index 219e51f..42dc31d 100644 --- a/test/test_lms_ordo.ml +++ b/test/test_lms_ordo.ml @@ -173,6 +173,13 @@ type colitur_row = { c_season : V.season; c_formulary : MF.t option; c_creed : bool; + c_gloria : bool; + c_rank : V.rank; + c_slug : string; + c_has_commemoration : bool; + (** whether {!Colitur_kernel.Liturgical_day.t.commemorations} is + non-empty -- needed by the Gloria comparison's own L5 shape + (RG 431(b)/n.302(b)), not by anything Task 6 built. *) } let colitur_rows ~year_lo ~year_hi ~window_first ~window_last = @@ -194,7 +201,10 @@ let colitur_rows ~year_lo ~year_hi ~window_first ~window_last = | Some day -> rows := { c_date = Date.to_iso8601 day.LD.date; c_season = day.LD.temporal.Colitur_kernel.Temporal.season; - c_formulary = day.LD.formulary; c_creed = day.LD.creed } + c_formulary = day.LD.formulary; c_creed = day.LD.creed; c_gloria = day.LD.gloria; + c_rank = day.LD.observed.Colitur_kernel.Celebration.rank; + c_slug = Colitur_kernel.Slug.to_string day.LD.observed.Colitur_kernel.Celebration.slug; + c_has_commemoration = day.LD.commemorations <> [] } :: !rows | None -> Alcotest.failf "no colitur day resolved for %s" (Date.to_iso8601 !d)); d := Date.add_days !d 1 @@ -434,9 +444,19 @@ let check_formulary_overrides ordo colitur = (* not noise to silence. *) (* ---------------------------------------------------------------------- *) +(* No Str/regex (frozen deps) -- the same hand-rolled substring test every + other file in this codebase reaches for (rubrics_ef.ml's own + [contains_substring], precedence_ef.ml's [contains_substring]). Local to + this file, not shared, on the same "no common .mli to hang it from" + footing those other copies already document. *) +let contains_substring s ~needle = + let ls = String.length s and ln = String.length needle in + let rec at i = i + ln <= ls && (String.sub s i ln = needle || at (i + 1)) in + ln = 0 || at 0 + let make_suite ~label ~fixture_path ~fixture_sha256 ~window_first ~window_last ~year_lo ~year_hi ~allow_list_path ~expected_rows ~expected_bvm_votive ~expected_proper ~expected_common ~expected_preceding_sunday - ~expected_ascension_week = + ~expected_ascension_week ~expected_gloria_l5 ~expected_gloria_l6 = let test_fixture_checksum () = Alcotest.(check string) "fixture SHA-256 matches its provenance note" fixture_sha256 (sha256_of_file fixture_path) @@ -540,6 +560,84 @@ let make_suite ~label ~fixture_path ~fixture_sha256 ~window_first ~window_last ~ Alcotest.(check int) (Printf.sprintf "%s: expected_rows matches the actual count" id) e.expected_rows n) explained_counts in + (* ---- Gloria (RG 431-432, {!Rite_ef.Rubrics_ef.gloria}) -- Phase 2 of + this task. Same shape as the Creed comparison immediately above: + coverage (which dates carry no Ordo "Gl"/"No Gl" marker at all) is + checked separately from the value comparison, and both are + date-keyed against their own allow-list ids, never a loose "some + divergence is fine" check. *) + let test_gloria_coverage () = + let ordo = ordo_rows fixture_path in + let no_gloria = List.filter (fun o -> o.gloria = None) ordo in + let expected = window_good_fridays () in + Alcotest.(check (list string)) "only this window's own Good Friday has no Ordo Gloria marker" expected + (List.map (fun o -> o.date) no_gloria) + in + let describe_gloria_mismatch (o : ordo_row) (c : colitur_row) = + Printf.sprintf "%s %S: colitur gloria=%b, Ordo gloria=%b (rank=%s commemoration=%b formulary=%s)" o.date o.title + c.c_gloria (Option.get o.gloria) (V.rank_to_string c.c_rank) c.c_has_commemoration + (match c.c_formulary with + | Some { MF.said = Some s; _ } -> Colitur_kernel.Slug.to_string s + | Some { MF.said = None; via = MF.Votive } -> "votive (said unnamed in the data)" + | Some { MF.said = None; _ } -> "NONE (said, unexpectedly outside Votive)" + | None -> "NONE") + in + (* Two REAL, STRUCTURAL shapes found running this comparison (see + data/ef/expected-divergences-lms.sexp's own L5/L6 for the full + citations) -- neither is a fixed date list, both a property of the + day itself, because both recur every year the underlying condition + holds, not on a fixed calendar date the way L4 above does. Matched + by PREDICATE, not by date, for the same reason L2's own note gives + for why it is prose-only rather than wired through the shared + [expected_rows] mechanism: the count varies window to window (L5: + 6/5/6; L6: 1/0/0), so a single static count cannot check it, and + [make_suite] threads the per-window expected totals directly + (~expected_gloria_l5/~expected_gloria_l6 below) instead. *) + let is_l5_lenten_commemoration (o : ordo_row) (c : colitur_row) = + (* RG 431(b)/n.302(b): "Missa de commemoratione in Officio diei + occurrente" says the Gloria. Every instance found is a privileged + Lenten/Passiontide feria (Class3, violet -- {!TE}'s own + [ferial_rank], RG25) carrying exactly one commemoration of an + impeded Class3 saint, colitur reading [gloria]=false (the ferial + Mass on its own) where the Ordo reads [true]. *) + (not c.c_gloria) && Option.value o.gloria ~default:false && c.c_has_commemoration && c.c_rank = V.Class3 + in + let is_l6_rogation_colour (o : ordo_row) (c : colitur_row) = + (* Root-caused to a DIFFERENT, pre-existing bug this comparison merely + surfaced -- {!Rite_ef.Temporal_ef.temporal}'s own Rogation Monday/ + Tuesday branch hardcodes [Colour.Violet] with no RG citation at + all, but the Ordo shows this exact date "FERIA IV Cl W" (white, + matching Paschaltide's own [season_colour] and RG88's "nihil fit in + Officio" -- the Office, hence its colour, is unchanged by the + Rogation, only the Mass TEXT is proper) -- so [gloria]'s own 432(b) + violet guard wrongly fires. NOT a Gloria defect and NOT fixed here + (out of this task's own scope; see the task report). *) + (not c.c_gloria) && Option.value o.gloria ~default:false + && (contains_substring c.c_slug ~needle:"rogation-monday" || contains_substring c.c_slug ~needle:"rogation-tuesday") + in + let test_gloria_matches_or_is_explained ~expected_gloria_l5 ~expected_gloria_l6 () = + let ordo = ordo_rows fixture_path in + let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in + let unexplained = ref [] in + let l5_count = ref 0 and l6_count = ref 0 in + List.iter2 + (fun (o : ordo_row) (c : colitur_row) -> + if not (String.equal o.date c.c_date) then Alcotest.failf "misaligned: ordo %s vs colitur %s" o.date c.c_date; + match o.gloria with + | None -> () + | Some ogloria -> + if Bool.equal ogloria c.c_gloria then () + else if is_l5_lenten_commemoration o c then incr l5_count + else if is_l6_rogation_colour o c then incr l6_count + else unexplained := describe_gloria_mismatch o c :: !unexplained) + ordo colitur; + Alcotest.(check (list string)) (Printf.sprintf "[%s] every Gloria mismatch is named in the allow-list -- none unexplained" label) + [] (List.rev !unexplained); + Alcotest.(check int) (Printf.sprintf "[%s] L5 (Lenten privileged feria + commemoration) count" label) + expected_gloria_l5 !l5_count; + Alcotest.(check int) (Printf.sprintf "[%s] L6 (Rogation Monday/Tuesday colour bug) count" label) expected_gloria_l6 + !l6_count + in let test_bvm_seasonal_selection () = let ordo = ordo_rows fixture_path in let colitur = colitur_rows ~year_lo ~year_hi ~window_first ~window_last in @@ -635,6 +733,10 @@ let make_suite ~label ~fixture_path ~fixture_sha256 ~window_first ~window_last ~ Alcotest.test_case "only this window's own Good Friday has no Ordo Creed marker" `Quick test_creed_coverage; Alcotest.test_case "every Creed difference is named in the cited allow-list -- none unexplained" `Quick test_creed_matches_or_is_explained; + Alcotest.test_case "only this window's own Good Friday has no Ordo Gloria marker" `Quick + test_gloria_coverage; + Alcotest.test_case "every Gloria difference is named in the cited allow-list -- none unexplained" `Quick + (test_gloria_matches_or_is_explained ~expected_gloria_l5 ~expected_gloria_l6); Alcotest.test_case "every BVM-Saturday numeral matches its season" `Quick test_bvm_seasonal_selection; Alcotest.test_case "every Ordo BVM numeral day is a colitur Votive day" `Quick test_bvm_numeral_implies_votive; @@ -676,18 +778,18 @@ let suite_2023_2024 = I-class Sunday that admits him not even as a commemoration (RG16(a)), so his own Mass is not said anywhere in this window any more. *) ~expected_bvm_votive:12 ~expected_proper:181 ~expected_common:2 ~expected_preceding_sunday:61 - ~expected_ascension_week:1 + ~expected_ascension_week:1 ~expected_gloria_l5:5 ~expected_gloria_l6:1 let suite_2024_2025 = make_suite ~label:"lms-ordo-2024-2025" ~fixture_path:"fixtures/lms-ordo-2024-2025.sexp" ~fixture_sha256:"da817b75c5bf40ed3be1d5f6890b199705e02ce4d42111253ab8547bccabc3f7" ~window_first:"2024-11-27" ~window_last:"2025-12-31" ~year_lo:2023 ~year_hi:2026 ~allow_list_path ~expected_rows:400 ~expected_bvm_votive:14 ~expected_proper:179 ~expected_common:2 ~expected_preceding_sunday:66 - ~expected_ascension_week:3 + ~expected_ascension_week:3 ~expected_gloria_l5:5 ~expected_gloria_l6:0 let suite_2025_2026 = make_suite ~label:"lms-ordo-2025-2026" ~fixture_path:"fixtures/lms-ordo-2025-2026.sexp" ~fixture_sha256:"8839a61e0c7d1e6c8326114f4f45a5c183551287a88f4ed01127154add6ae5a5" ~window_first:"2025-11-28" ~window_last:"2026-12-31" ~year_lo:2024 ~year_hi:2027 ~allow_list_path ~expected_rows:399 ~expected_bvm_votive:13 ~expected_proper:174 ~expected_common:2 ~expected_preceding_sunday:68 - ~expected_ascension_week:2 + ~expected_ascension_week:2 ~expected_gloria_l5:6 ~expected_gloria_l6:0 diff --git a/test/test_rubrics_ef.ml b/test/test_rubrics_ef.ml index a778a5a..71f180d 100644 --- a/test/test_rubrics_ef.ml +++ b/test/test_rubrics_ef.ml @@ -46,6 +46,23 @@ let creed_on y m d = (Cal.day ctx layer (mk y m d)).LD.creed let check name y m d expected = Alcotest.(check bool) name expected (creed_on y m d) +(* ------------------------------------------------------------------------ *) +(* Breviary 237-238 (Te Deum) and RG 431-432 (Gloria) -- Phase 2 of the *) +(* celebrant-rubrics-phase1 design. See lib/rites/rite_ef/rubrics_ef.ml for *) +(* both rubrics quoted in full and every branch's own citation. One *) +(* end-to-end test per clause, resolved against REAL calendar dates through *) +(* the shipped data -- every expected value below was read off *) +(* `colitur rubrics `'s real output BEFORE being pinned here, the *) +(* same discipline the Creed tests above already establish. *) + +let day_on y m d = Cal.day ctx layer (mk y m d) +let gloria_on y m d = (day_on y m d).LD.gloria +let te_deum_on y m d = RE.te_deum ~temporal:(day_on y m d).LD.temporal ~observed:(day_on y m d).LD.observed + ~date:(mk y m d) + +let check_gloria name y m d expected = Alcotest.(check bool) name expected (gloria_on y m d) +let check_te_deum name y m d expected = Alcotest.(check bool) name expected (te_deum_on y m d) + (* ---- RG 475(a): "in qualibet dominica, etsi eius Officium alicui festo locum cedat" ---- *) @@ -440,6 +457,277 @@ let test_ferial_origin_never_carries_lord_bvm_or_apostle_slug () = Alcotest.(check bool) "span reached Lenten/Passiontide III-class ferias" true (!lenten_passiontide_class3 > 0) +(* ---- 237(a): the three named Paschaltide days ---- *) + +let test_237a_easter_sunday () = check_te_deum "237(a): Easter Sunday" 2026 4 5 true +let test_237a_low_sunday () = check_te_deum "237(a): Low Sunday" 2026 4 12 true +let test_237a_pentecost () = check_te_deum "237(a): Pentecost Sunday" 2026 5 24 true + +(* ---- 237(f): the vigils of Ascension and Pentecost, both otherwise + reachable by 238(b) (the Ascension vigil is Class2) or moot to it + (the Pentecost vigil is Class1) ---- *) + +let test_237f_ascension_vigil () = check_te_deum "237(f): Vigil of the Ascension" 2026 5 13 true +let test_237f_pentecost_vigil () = check_te_deum "237(f): Vigil of Pentecost" 2026 5 23 true + +(* ---- 237(d): the three privileged octaves, reusing [creed]'s own windows + -- a day within the Nativity octave whose OWN office is a real saint + (RG 67's "Com. octavae Nativitatis"), and an Easter-week feria outside + the narrower 237(a) list ---- *) + +let test_237d_nativity_octave_saint () = + check_te_deum "237(d): St John within the Nativity octave" 2026 12 27 true + +let test_237d_easter_week_feria () = check_te_deum "237(d): Easter Tuesday" 2026 4 7 true + +(* ---- 238(b): the omissible (Class2/Class3) vigils -- St Lawrence's own, + picked in a year where nothing displaces it, confirmed via + `colitur day 2026` first the same way the Creed tests were derived ---- *) + +let test_238b_omissible_vigil () = + (* 9 August 2026 is itself a Sunday (the vigil impeded, see 237(b)'s own + test below, which reuses that exact date) -- 2027 is picked instead, + confirmed via `colitur day 2027` first, the same discipline every + other date in this file follows. *) + check_te_deum "238(b): Vigil of St Lawrence" 2027 8 9 false + +(* ---- The Nativity Vigil (I class): NOT literally named by 238(b)'s own + "II et III classis" text -- excluded here on the structural RG 21/35 + taxonomy inference [te_deum]'s own comment states explicitly as an + inference, not a citation. Flagged the same way in the task report; + checked against the FIUV Ordo's own Te Deum marker for 24 December in + test_fiuv_ordo.ml, which is this inference's real corroboration. ---- *) + +let test_nativity_vigil_excluded_by_inference () = + check_te_deum "Nativity Vigil (I class): excluded, inference not literal 238(b)" 2026 12 24 false + +(* ---- 238(c)/RG23: Ash Wednesday and Good Friday (moot -- no Mass, but the + RG23 feria-I-classis exclusion still answers [false] regardless of + colour) ---- *) + +let test_238c_ash_wednesday () = check_te_deum "238(c)/RG23: Ash Wednesday" 2026 2 18 false +let test_238c_good_friday () = check_te_deum "238(c)/RG23: Good Friday" 2026 4 3 false + +(* ---- 237(g): the votive Office of the BVM on Saturday -- reused directly + from test/cli.t's own pinned example ---- *) + +let test_237g_bvm_saturday () = check_te_deum "237(g): BVM Saturday Office" 2026 7 11 true + +(* ---- 237(e): a plain, non-octave, non-Sunday feria of Christmastide (2-5 + January) ---- *) + +let test_237e_christmastide_feria () = + check_te_deum "237(e): 2 January, a Christmastide feria" 2026 1 2 true + +(* ---- 237(b): an ordinary II-class Sunday outside Septuagesima, and its + own explicit exception (a Septuagesima/Sexagesima Sunday, [Class2], + excepted by name) ---- *) + +let test_237b_ordinary_class2_sunday () = + check_te_deum "237(b): an ordinary Time-after-Pentecost Sunday" 2026 8 9 true + +(* RE-VERIFIED against the CORRECTED FIUV extraction ([te_deum]'s own + 237(a) comment has the full account of the extractor bug an earlier + pass of this task found and fixed): 237(b)'s own literal "exceptis + dominicis in Septuagesima, in Sexagesima et in Quinquagesima" holds + after all -- these two Sundays do NOT say the Te Deum, confirmed + against the corrected data, not merely the original transcription + alone. *) +let test_237b_septuagesima_exception () = + check_te_deum "237(b)'s own exception: Septuagesima Sunday" 2027 1 24 false + +let test_237b_sexagesima_exception () = + check_te_deum "237(b)'s own exception: Sexagesima Sunday" 2027 1 31 false + +(* ---- 237(c): a genuine sanctoral feast kept during a penitential season + (St Paul of the Cross, Class3, outside any privileged window) and one in + Ordinary Time (Lawrence, Class2) ---- *) + +let test_237c_sanctoral_feast () = check_te_deum "237(c): a sanctoral feast (Lawrence)" 2026 8 10 true + +(* Found alongside the FIUV extractor-bug fix ([te_deum]'s own 237(c) + comment has the full account): Passion Sunday and Palm Sunday are BOTH + entries in {!Temporal_ef.named} (named individually for RG 91 entry 6), + which without the [temporal.weekday <> Sun] guard on 237(c)'s own + [named<>None] disjunct would wrongly grant them [true] by ACCIDENT of + table membership -- neither is a genuine "festum" (RG 35's own "dies + dominica" is its own category), and both are [Class1] Sundays 237(b)'s + own [Class2] guard already excludes. Confirmed [false] directly against + the corrected FIUV extraction. *) +let test_passion_sunday_not_a_festum () = check_te_deum "Passion Sunday is not a festum for 237(c)" 2026 3 22 false +let test_palm_sunday_not_a_festum () = check_te_deum "Palm Sunday is not a festum for 237(c)" 2026 3 29 false + +(* ---- 238(d): the Requiem proxy, shared with [creed]'s own 476(f) and the + SAME two-member {!Colour.Black} population + {!test_colour_black_population_is_exactly_two} already asserts ---- *) + +let test_238d_all_souls () = check_te_deum "238(d): All Souls' Day (transferred)" 2025 11 3 false + +(* ---- RG 431(c): Holy Thursday and the Easter Vigil Mass say the Gloria + even though neither day's own Matins says the Te Deum (both are + feria-I-classis, [te_deum]'s own 238(c) branch) -- the one place [gloria] + and [te_deum] genuinely disagree on real data. Holy Saturday also proves + 431(c) outranks 432(b): its own colour is [Violet] + (Passiontide's [season_colour]), yet the Gloria is still said. ---- *) + +let test_431c_holy_thursday () = check_gloria "431(c): Holy Thursday" 2026 4 2 true +let test_431c_easter_vigil () = check_gloria "431(c): the Easter Vigil Mass (Holy Saturday)" 2026 4 4 true + +(* ---- RG 432(b): violet vestments -- independent of [te_deum], checked + against an Advent Sunday (I class, so [te_deum] would ALSO answer + [false] here via its own 237(b) rank guard: this is a same-answer + witness, not proof of independent teeth -- see the task report for why + no fully independent (te_deum=true, violet) witness exists anywhere in + the shipped 1583-9999 domain: every real [Colour.Violet] [Feast] entry + is one of the five sanctoral vigils, already excluded by [te_deum]'s + own [is_vigil] branch either way). ---- *) + +let test_432b_violet_sunday () = check_gloria "432(b): Advent I Sunday, violet" 2026 11 29 false + +(* ---- RG 432(d): the Requiem proxy, same population as 238(d) above ---- *) + +let test_432d_all_souls () = check_gloria "432(d): All Souls' Day (transferred)" 2025 11 3 false + +(* ---- RG 431(a)/432(a): mirrors [te_deum] for everything not already + decided above -- one true, one false, neither reachable via 431(c)/432(b)/ + 432(d) ---- *) + +let test_431a_mirrors_te_deum_true () = + check_gloria "431(a): mirrors [te_deum]=true (a sanctoral feast, Lawrence)" 2026 8 10 true + +let test_432a_mirrors_te_deum_false () = + check_gloria "432(a): mirrors [te_deum]=false (an ordinary Advent feria)" 2025 12 1 false + +(* ---- Domain-wide sanity, task requirement 5: "every violet day must be + false (RG 432 b), and every Requiem must be false (432 d)". Two + invariants, checked over every day {!Cal.year} resolves -- [Cal.year], + not [Cal.day] in a loop, for the same cost reason + {!test_every_sunday_in_2026_says_the_creed} above already gives. + + FAST (default suite): a fixed 200-year span, 1583-1782 -- large enough + to cross multiple Easter cycles and every season repeatedly, cheap + enough to stay in `dune test`'s own budget. EXHAUSTIVE (gated the same + way {!test_validate.test_exhaustive_domain_sweep} already is, via + COLITUR_EXHAUSTIVE_SWEEP): the full 1583-9999 domain, also tallying and + printing the true/false distribution so the measurement this task asks + for is not merely "did the invariant hold" but has a number attached -- + see the task report for the printed figures. *) +(* NOT a blanket "every violet day is [gloria]=false" (the task brief's own + phrasing, taken literally, is one exception too strong): RG 431(c) is a + NAMED, lex-specialis override for the Easter Vigil Mass, and colitur's + own per-day colour model gives Holy Saturday [Colour.Violet] + ([gloria]'s own header has the full argument for why -- the historical + violet-to-white vestment change happens AT the Gloria itself, a + per-action nuance this whole day/colour model already cannot express). + So the real invariant, checked here, is "every violet day is + [gloria]=false EXCEPT the Easter Vigil (Easter offset -1), which is + [gloria]=true BY DESIGN" -- and the exception is asserted to be EXACTLY + that one shape, every year, nothing else: [n = -1] is checked directly + rather than merely excluded, so a second, unexpected (violet, + gloria=true) day anywhere in the domain still fails loudly. Pushed back + on the task brief's own simplified phrasing rather than silently + special-cased -- see the task report. *) +let check_gloria_invariants_for_year y (counts : (int * int * int * int) ref) = + let days = Cal.year ctx layer y in + (* Per-date, NOT once per loop iteration off [y]: a liturgical year + "opening in civil year y" ({!Rite.t.year_start}'s own doc comment) + runs from that year's Advent into MOST of civil year y+1 -- so + [Cal.year ctx layer y]'s own array holds dates whose civil year is + y+1 for the whole Christmas-to-Pentecost span, governed by EASTER OF + y+1, not y. A single [Computus.gregorian_easter y] computed once here + wrongly used year y's own Easter for those dates -- found live: it + misidentified 1584-03-31 (Holy Saturday, governed by 1584's Easter, + surfaced while processing loop iteration y=1583) as an ordinary + violet day instead of the Vigil's own 431(c) exception, because + Easter 1583 (not 1584) was subtracted. Fixed by keying off + [Date.year d.LD.date] instead, which is always safe here: Holy + Saturday and Easter Sunday are never more than a few days apart and + never cross a civil-year boundary. *) + let violet, black, gloria_true, gloria_false = !counts in + let violet = ref violet + and black = ref black + and gloria_true = ref gloria_true + and gloria_false = ref gloria_false in + Array.iter + (fun (d : (V.season, V.rank) LD.t) -> + (if d.LD.gloria then incr gloria_true else incr gloria_false); + if d.LD.observed.Cel.colour = Colour.Violet then begin + incr violet; + let easter = Computus.gregorian_easter (Date.year d.LD.date) in + let n = Date.to_rata d.LD.date - Date.to_rata easter in + if n = -1 then + Alcotest.(check bool) + (Printf.sprintf "%s: the Easter Vigil's own 431(c) override, [gloria]=true despite violet" + (Date.to_iso8601 d.LD.date)) + true d.LD.gloria + else + Alcotest.(check bool) + (Printf.sprintf "%s: violet -> [gloria]=false (RG 432(b))" (Date.to_iso8601 d.LD.date)) + false d.LD.gloria + end; + if d.LD.observed.Cel.colour = Colour.Rose then + (* Gaudete/Laetare -- [gloria] reads [false] here via [te_deum]'s + own [Class2] guard on 237(b) (both Rose Sundays are [Class1] BY + CONSTRUCTION -- {!Rite_ef.Rubrics_ef.gloria}'s own 432(b) + citation has the full "checked, then found redundant, then + removed" account of why 432(b) itself does NOT need its own + Rose branch). Not folded into the [violet] counter above: + keeping the two colours separately tallied is what let this + invariant catch the Gaudete/Laetare gap live in the first place + (found via the LMS Ordo, 2023-12-17 and 2024-03-10) rather than + silently averaging it away inside one shared bucket. *) + Alcotest.(check bool) + (Printf.sprintf "%s: Rose (Gaudete/Laetare) -> [gloria]=false" (Date.to_iso8601 d.LD.date)) + false d.LD.gloria; + if d.LD.observed.Cel.colour = Colour.Black then begin + incr black; + Alcotest.(check bool) + (Printf.sprintf "%s: Requiem (black) -> [gloria]=false (RG 432(d))" (Date.to_iso8601 d.LD.date)) + false d.LD.gloria + end) + days; + counts := (!violet, !black, !gloria_true, !gloria_false) + +let test_domain_violet_implies_no_gloria_sample () = + let counts = ref (0, 0, 0, 0) in + for y = 1583 to 1782 do + check_gloria_invariants_for_year y counts + done; + let violet, _, _, _ = !counts in + Alcotest.(check bool) "the 200-year sample reached a real number of violet days" true (violet > 1000) + +let test_domain_requiem_implies_no_gloria_sample () = + (* Separate test name, same underlying sweep as the one immediately above + -- {!Cal.year} is only computed once per year regardless (module-level + [ctx]/[layer], no per-test reload), so this is not a second pass over + the domain, only a second, independently-named assertion on the same + tally, matching how {!test_colour_black_population_is_exactly_two} + above separates its own DATA and CODE checks into one function while + this pair keeps violet and black as two named outcomes. *) + let counts = ref (0, 0, 0, 0) in + for y = 1583 to 1782 do + check_gloria_invariants_for_year y counts + done; + let _, black, _, _ = !counts in + Alcotest.(check bool) "the 200-year sample reached at least one Requiem day" true (black > 0) + +let colitur_exhaustive_sweep_env = "COLITUR_EXHAUSTIVE_SWEEP" + +let test_exhaustive_gloria_domain_sweep () = + if Sys.getenv_opt colitur_exhaustive_sweep_env = None then Alcotest.skip () + else begin + let counts = ref (0, 0, 0, 0) in + for y = 1583 to 9999 do + check_gloria_invariants_for_year y counts + done; + let violet, black, gloria_true, gloria_false = !counts in + Printf.printf + "gloria domain sweep 1583..9999: violet=%d black=%d gloria_true=%d gloria_false=%d total=%d\n%!" + violet black gloria_true gloria_false (gloria_true + gloria_false); + Alcotest.(check bool) "the full domain reached a real number of violet days" true (violet > 100_000); + Alcotest.(check bool) "the full domain reached a real number of Requiem days" true (black > 1_000) + end + let suite = ( "Rubrics_ef", [ Alcotest.test_case "475(a): ordinary Sunday" `Quick test_475a_ordinary_sunday; @@ -477,4 +765,38 @@ let suite = Alcotest.test_case "every Sunday in 2026 says the Creed" `Quick test_every_sunday_in_2026_says_the_creed; Alcotest.test_case "RG 24/25: no ferial-origin office carries Lord/Bvm or an apostle slug" `Quick - test_ferial_origin_never_carries_lord_bvm_or_apostle_slug ] ) + test_ferial_origin_never_carries_lord_bvm_or_apostle_slug; + Alcotest.test_case "237(a): Easter Sunday" `Quick test_237a_easter_sunday; + Alcotest.test_case "237(a): Low Sunday" `Quick test_237a_low_sunday; + Alcotest.test_case "237(a): Pentecost Sunday" `Quick test_237a_pentecost; + Alcotest.test_case "237(f): Vigil of the Ascension" `Quick test_237f_ascension_vigil; + Alcotest.test_case "237(f): Vigil of Pentecost" `Quick test_237f_pentecost_vigil; + Alcotest.test_case "237(d): St John within the Nativity octave" `Quick + test_237d_nativity_octave_saint; + Alcotest.test_case "237(d): Easter Tuesday" `Quick test_237d_easter_week_feria; + Alcotest.test_case "238(b): Vigil of St Lawrence" `Quick test_238b_omissible_vigil; + Alcotest.test_case "Nativity Vigil excluded by structural inference, not literal 238(b)" `Quick + test_nativity_vigil_excluded_by_inference; + Alcotest.test_case "238(c)/RG23: Ash Wednesday" `Quick test_238c_ash_wednesday; + Alcotest.test_case "238(c)/RG23: Good Friday" `Quick test_238c_good_friday; + Alcotest.test_case "237(g): BVM Saturday Office" `Quick test_237g_bvm_saturday; + Alcotest.test_case "237(e): 2 January, a Christmastide feria" `Quick test_237e_christmastide_feria; + Alcotest.test_case "237(b): an ordinary Class2 Sunday" `Quick test_237b_ordinary_class2_sunday; + Alcotest.test_case "237(b)'s own exception: Septuagesima Sunday" `Quick test_237b_septuagesima_exception; + Alcotest.test_case "237(b)'s own exception: Sexagesima Sunday" `Quick test_237b_sexagesima_exception; + Alcotest.test_case "237(c): a sanctoral feast (Lawrence)" `Quick test_237c_sanctoral_feast; + Alcotest.test_case "Passion Sunday is not a festum for 237(c)" `Quick test_passion_sunday_not_a_festum; + Alcotest.test_case "Palm Sunday is not a festum for 237(c)" `Quick test_palm_sunday_not_a_festum; + Alcotest.test_case "238(d): All Souls' Day (transferred)" `Quick test_238d_all_souls; + Alcotest.test_case "431(c): Holy Thursday" `Quick test_431c_holy_thursday; + Alcotest.test_case "431(c): the Easter Vigil Mass" `Quick test_431c_easter_vigil; + Alcotest.test_case "432(b): Advent I Sunday, violet" `Quick test_432b_violet_sunday; + Alcotest.test_case "432(d): All Souls' Day (transferred)" `Quick test_432d_all_souls; + Alcotest.test_case "431(a): mirrors [te_deum]=true" `Quick test_431a_mirrors_te_deum_true; + Alcotest.test_case "432(a): mirrors [te_deum]=false" `Quick test_432a_mirrors_te_deum_false; + Alcotest.test_case "domain sanity: every violet day has [gloria]=false (sample)" `Quick + test_domain_violet_implies_no_gloria_sample; + Alcotest.test_case "domain sanity: every Requiem day has [gloria]=false (sample)" `Quick + test_domain_requiem_implies_no_gloria_sample; + Alcotest.test_case "domain sweep 1583..9999: violet/Requiem invariants, committed not sampled" + `Slow test_exhaustive_gloria_domain_sweep ] ) diff --git a/test/test_validate.ml b/test/test_validate.ml index 671c706..b53f915 100644 --- a/test/test_validate.ml +++ b/test/test_validate.ml @@ -324,15 +324,17 @@ module Synthetic = struct { Colitur_kernel.Mass_formulary.said = Some (Slug.of_string_exn "syn-formulary"); via = Colitur_kernel.Mass_formulary.Own_slug } - (* No fixture here exercises the Creed rubric -- a rite that has not - implemented it returns [false] explicitly, {!Rite.t.creed}'s own - documented default. Made overridable ([?creed] below) on the same - footing as [?readings] just above, for Task 6's own fixtures. *) + (* No fixture here exercises the Creed or Gloria rubrics -- a rite that + has not implemented them returns [false] explicitly, + {!Rite.t.creed}/{!Rite.t.gloria}'s own documented default. Made + overridable ([?creed]/[?gloria] below) on the same footing as + [?readings] just above, for Task 6's own fixtures. *) let creed ~temporal:_ ~observed:_ ~date:_ = false + let gloria ~temporal:_ ~observed:_ ~date:_ = false let rite ?(vocab = vocab) ?(anchors = fun _ -> []) ?(season_runs = [ A; B ]) ?(rules = rules) - ?(transfer_target = fun _ origin _ -> origin) ?(readings = readings) ?(creed = creed) temporal - : (season, rank) Rite.t = + ?(transfer_target = fun _ origin _ -> origin) ?(readings = readings) ?(creed = creed) + ?(gloria = gloria) temporal : (season, rank) Rite.t = { Rite.id = "synthetic"; vocab; year_start; temporal; anchors; rules; season_runs; (* Not a Roman rite, but a Rite.t must supply SOME Easter now that movable Date_spec variants exist. The Gregorian one is as good as @@ -342,7 +344,7 @@ module Synthetic = struct (* Not a Roman rite either, so no bissextile-doubling convention: identity, {!Rite.t.fixed_key}'s own documented default. *) fixed_key = (fun d -> Some (D.month d, D.day d)); - transfer_target; readings; creed } + transfer_target; readings; creed; gloria } (* Empty by default: every check built before Task 12 exercises the TEMPORAL-only pass, where an empty layer is exactly the fixture that diff --git a/tools/extract_fiuv_ordo.ml b/tools/extract_fiuv_ordo.ml index 42d3b96..c9688a7 100644 --- a/tools/extract_fiuv_ordo.ml +++ b/tools/extract_fiuv_ordo.ml @@ -292,7 +292,28 @@ let extract_te_deum full_text = | None -> String.length full_text in let span = String.sub full_text mat_start (laudes_start - mat_start) in + (* TWO negative phrasings the source actually uses, found live + (celebrant-rubrics-phase1 Phase 2, 2026-08-22): "non dicitur Te + Deum" (e.g. ordinary Time-after-Pentecost ferias, 27/28 November) + AND, separately, "sine Te Deum" (e.g. every Sunday, every Ember + day, every privileged Lenten/Passiontide feria carrying a + commemoration -- Advent I, 30 November: "...3a de homilia (cum + suo R), sine Te Deum."). The ORIGINAL version of this function + checked only the first phrasing, so "sine Te Deum" fell through + to the bare "Te Deum" substring test and was wrongly read as a + POSITIVE hit -- confirmed by grepping the raw pdftotext dump + directly against a first, uncorrected run's own output: EVERY + date this bug affected showed "sine Te Deum" in the source and + [Some true] in the fixture, a 100% correlation, not a handful of + coincidences. This is the SAME "does the source negate the + hymn's own name with a DIFFERENT word than the one this parser + already checks for" shape [find_word]'s own "Gloria Patri" + substring trap already documents for Gloria -- this trap simply + went unnoticed until Phase 2 actually compared the extracted + values against colitur's own output and against the raw text by + hand, rather than only checking coverage counts. *) if contains span ~sub:"non dicitur Te Deum" then Some false + else if contains span ~sub:"sine Te Deum" then Some false else if contains span ~sub:"Te Deum" then Some true else None) -- cgit v1.3 From cd3e03bf3e5b040786838fc3c5639d7c29e9b3b8 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 22 Aug 2026 20:15:56 +0200 Subject: fix(ef): Rogation Monday/Tuesday are white, not violet (RG 119) Temporal_ef.temporal's Rogation Monday/Tuesday branch hardcoded Colour.Violet with no citation (data/ef/expected-divergences-lms.sexp's L6, surfaced by the LMS Ordo). RG 119 assigns white to the de-Tempore Office and Mass from the Paschal Vigil Mass to the Pentecost Vigil Mass exclusive; Easter+36/+37 sit inside that window. RG 128's own exhaustive four-case violet list never names Rogation days, and its vigil clause (d) is doubly inapplicable: these are not vigils, and they are inside Paschaltide, not outside it. RG 88 explains the violet actually seen in print sources: "de Litaniis minoribus nihil fit in Officio, sed tantum in Missa" -- the violet belongs to the Rogation Mass/procession, a distinct votive text this engine does not model, not to the day's own Office. Witnessed directly: LMS Ordo 2023-2024, 2024-05-06, "FERIA IV Cl W" with "Gl", the Office and Mass printed separately from the optional violet votive Mass of the Rogations. Cascade verified, not assumed: RG 432(b) omits the Gloria on every violet Mass, so the wrong colour was also silencing the Gloria on these two weekdays even though RG 237(e) (Breviary, ordinary Paschaltide ferias say Te Deum) already made it true underneath. White removes the 432(b) block, and the Gloria now follows Te Deum via 431(a), matching the Ordo's own "Gl" -- confirmed with dedicated golden pins in both test_golden.ml (colour) and test_rubrics_ef.ml (the Gloria flip), 2028-05-22/23 (neither day impeded that year). Closed L6 in data/ef/expected-divergences-lms.sexp. Reopened C8 in data/ef/expected-divergences.sexp (26 of 16801 rows, 2005-2050): lectio independently computes these two days violet too, so the two engines used to agree by sharing the same uncited assumption; colitur's colour is now correct and lectio's is not, so the comparator disagrees again, for the opposite reason. While reopening C8, found and corrected a genuine misattribution in its own prior closure note, which claimed lectio's violet was "RG 128(d)" -- 128(d) governs vigils outside Paschaltide and has nothing to do with Rogation days. Checked docs/research/rules-register.md for the same claim: not present there, but a related conflation was found and corrected at its O'Connell section 4(d) note, which had used a Rogation MASS/procession citation to "confirm" the day's own OFFICE colour. Blast radius measured in-process across the full 1583-9999 domain (throwaway probe, not committed): 4722 days total (2368 Rogation Mondays + 2354 Rogation Tuesdays actually observed outright, i.e. not impeded by a stronger sanctoral feast that year), all Violet->White, all with the Gloria flipping false->true alongside, zero anomalies. Mutation-tested: reverting the branch to Colour.Violet reddens 16 tests across Rubrics_ef, the lectio differential, the LMS Ordo suite and both golden pin files; restored and reconfirmed green (680 tests). --- data/ef/expected-divergences.sexp | 20 +++++++++------ lib/rites/rite_ef/temporal_ef.ml | 28 +++++++++++++++++++-- test/golden/grid-2027.html | 2 +- test/golden/grid-2027.tex | 2 +- test/golden/grid-2027.typ | 2 +- test/golden/ordo-2027.adoc | 2 +- test/golden/ordo-2027.html | 4 +-- test/golden/ordo-2027.md | 2 +- test/golden/ordo-2027.ms | 2 +- test/golden/ordo-2027.tex | 4 +-- test/golden/ordo-2027.txt | 2 +- test/golden/ordo-2027.typ | 4 +-- test/test_golden.ml | 32 ++++++++++++++++++++++++ test/test_lms_ordo.ml | 52 +++++++++++++++++++++++---------------- test/test_rubrics_ef.ml | 20 +++++++++++++++ 15 files changed, 135 insertions(+), 43 deletions(-) (limited to 'lib/rites/rite_ef') diff --git a/data/ef/expected-divergences.sexp b/data/ef/expected-divergences.sexp index 632854c..f98409b 100644 --- a/data/ef/expected-divergences.sexp +++ b/data/ef/expected-divergences.sexp @@ -131,13 +131,19 @@ Diff shapes: most rows carry [Slug_f; Rank; Colour_f; First_f; Gospel_f] (the te BLAST RADIUS, MEASURED, full 1583-9999 domain (not merely this fixture's 2005-2050 window): 6 983 liturgical days change, zero unclassified, across all 2 041 leap years in the domain -- see data/ef/expected-divergences-lms.sexp's own now-closed L3 entry for the full figures (this entry's own 21-row 2005-2050 population is the expected subset of that domain-wide count landing inside the fixture's own window: 11 leap years x up to 2 new rows each, minus the three already-explained overlaps above).") (expected_rows 21)) - ; C8 -- CLOSED, REMOVED (2026-08-18): 0 of 16801 rows, was 26. lectio builds - ; Rogation Monday and Tuesday now (RG 87, Easter+36/+37, violet under - ; RG 128(d)). The Wednesday is NOT built there and deliberately so: Easter+38 - ; is the Ascension's Vigil, which wins the day, and the rubric asks for a - ; commemoration -- a channel lectio's one-office-per-day temporal function - ; does not have. That is the same reason this entry never covered the - ; Wednesday either. Citation preserved in the register. + ((id C8) + (citation "RG 119 (Caput XVIII, \"De Colore Paramentorum\"): white is used \"in Officio et Missa de Tempore ... a Missa Vigiliae paschalis usque ad Missam vigiliae Pentecostes exclusive\" -- the de-Tempore Office and Mass are white from the Paschal Vigil Mass until the Pentecost Vigil Mass exclusive, and Rogation Monday/Tuesday (Easter+36/+37) sit inside that window. RG 128's own exhaustive four-case list for violet (Advent I-Christmas Eve; Septuagesima-Paschal Vigil; the September Ember ferias; vigils of II/III class OUTSIDE Paschaltide) never names Rogation days. RG 88 (\"de Litaniis minoribus nihil fit in Officio, sed tantum in Missa\"): the violet belongs to the Rogation MASS/procession, a distinct votive text this engine does not model, not to the day's own Office. Fixed in Rite_ef.Temporal_ef.temporal's Rogation branch, celebrant-rubrics-phase1 Bug 1, 2026-08-22 -- see that branch's own citation and data/ef/expected-divergences-lms.sexp's now-closed L6 entry, which is what surfaced it (LMS Ordo 2023-2024, 2024-05-06, \"FERIA IV Cl W\").") + (verdict colitur) + (note "REOPENED (2026-08-22, celebrant-rubrics-phase1, Bug 1 fix). Previously CLOSED, REMOVED (2026-08-18): 0 of 16801 rows, was 26, with this note, PRESERVED VERBATIM because it contains a misattribution now corrected rather than silently dropped: \"lectio builds Rogation Monday and Tuesday now (RG 87, Easter+36/+37, violet under RG 128(d)). The Wednesday is NOT built there and deliberately so: Easter+38 is the Ascension's Vigil, which wins the day, and the rubric asks for a commemoration -- a channel lectio's one-office-per-day temporal function does not have. That is the same reason this entry never covered the Wednesday either. Citation preserved in the register.\" + +THE MISATTRIBUTION: \"violet under RG 128(d)\" is wrong. RG 128(d) governs vigils of II/III class OUTSIDE Paschaltide; Rogation Monday/Tuesday are neither vigils nor outside Paschaltide. Checked against docs/research/rules-register.md (gitignored, local) while researching Bug 1: no claim of an RG 128(d) basis for Rogation colour exists there -- the misattribution was confined to this file's own closure note. It traces to Rite_ef.Temporal_ef.temporal's own Rogation branch, which hardcoded [Colour.Violet] with NO citation at all before this fix; whoever wrote this entry's closure note in 2026-08-18 apparently backfilled a plausible-looking but wrong citation for behaviour that was never actually cited in the code it described. + +WHY THIS REOPENS RATHER THAN STAYS CLOSED: the entry closed in 2026-08-18 because lectio, independently, ALSO computes these two days violet -- not because either engine's violet was ever verified against the Missal. Both engines shared the same uncited assumption, so the comparator saw agreement and the entry went to zero. Bug 1 corrected COLITUR's side to white (RG 119); lectio's own violet is unchanged (a sibling project, out of this repo, not modified here). The comparator now disagrees again, on the same 26 rows, for the OPPOSITE reason: previously colitur was wrong and lectio was wrong the same way; now colitur is right and lectio still carries the old, uncited assumption forward -- consistent with CLAUDE.md's own \"colitur vs missalemeum\" base-rate note for a different lineage, applied here to lectio directly: a fresh divergence is not presumptively colitur's fault once colitur has just been corrected FROM the shared error. + +MEASURED (test_differential.ml's own [classify], 2005-2050 fixture, DEBUGC8 probe run and reverted): exactly 26 rows, all with diffs=[Colour_f] alone -- season and slug already agree (lectio's own [easter]/colitur's [paschaltide] season labels are Layer A vocabulary synonyms, not a diff), and on every row lectio=violet, colitur=white. Dates: 2005-05-03, 2006-05-22/23, 2007-05-14, 2011-05-30, 2012-05-14, 2013-05-06, 2016-05-03, 2017-05-22/23, 2018-05-08, 2022-05-23/24, 2024-05-06, 2027-05-03, 2028-05-22/23, 2029-05-08, 2032-05-03, 2033-05-23/24, 2040-05-08, 2044-05-23/24, 2047-05-21, 2049-05-24. Fewer than the 2-per-year x 23-year x ~2 ceiling because a competing sanctoral feast impedes the Rogation office outright in some years on one or both of the two days (unchanged by this fix, gated on colitur's own [rogation-monday]/[rogation-tuesday] slug in [layer_c_reason] the same as before). The Wednesday remains absent from this entry for the same reason the pre-2026-08-18 and post-2026-08-18 notes both already gave: Easter+38 is the Ascension Vigil (RG 91 entry 21), which outranks the Rogation Wednesday commemoration outright on colitur's side, and lectio has no Rogation-Wednesday concept at all to compare against regardless. + +Gated on [(String.equal c.slug \"ef-rogation-monday\" || String.equal c.slug \"ef-rogation-tuesday\") && subset diffs [ Season; Slug_f; Colour_f ]] in [layer_c_reason] -- unchanged code, present since before this entry's 2026-08-18 closure and never removed; only the sexp record was removed when the count hit zero, per this file's own \"closed = 0 rows, not code deleted\" convention.") + (expected_rows 26)) ; C14 -- CLOSED, REMOVED (2026-08-18): 0 of 16801 rows, was 3. lectio has ; adopted RG 96(a)/97/98. Its transferIfImpededEF resolved each impeded ; I-class feast independently, so in 2008, 2035 and 2046 St Joseph and the diff --git a/lib/rites/rite_ef/temporal_ef.ml b/lib/rites/rite_ef/temporal_ef.ml index 1c916f4..fe08b2e 100644 --- a/lib/rites/rite_ef/temporal_ef.ml +++ b/lib/rites/rite_ef/temporal_ef.ml @@ -823,12 +823,36 @@ let temporal d = "ef-rogation-monday"/"-tuesday" are colitur-only keys and a lectionary gap for Plan 4 (CORRECTED, final fix wave, item 7 -- the lectionary bootstrap is Plan 4, not Plan 3), like the Ember - and Nativity-vigil keys above. *) + and Nativity-vigil keys above. + + COLOUR (L6, data/ef/expected-divergences-lms.sexp, CLOSED by this + fix): this branch used to hardcode [Colour.Violet] with no + citation at all. RG 119 (Caput XVIII, "De Colore Paramentorum"): + white is used "in Officio et Missa de Tempore ... a Missa Vigiliae + paschalis usque ad Missam vigiliae Pentecostes exclusive" -- the + de-Tempore Office AND Mass are white from the Paschal Vigil Mass + until the Pentecost Vigil Mass exclusive, and days 36/37 (Rogation + Monday/Tuesday) sit squarely inside that window. RG 128's own + exhaustive four-case list for violet (Advent I-Christmas Eve; + Septuagesima-Paschal Vigil; the September Ember ferias; vigils of + II/III class OUTSIDE Paschaltide) names Rogation days nowhere, and + its vigil clause is inapplicable twice over -- these are not + vigils, and they ARE inside Paschaltide. RG 88 ("de Litaniis + minoribus nihil fit in Officio, sed tantum in Missa") is why: the + violet belongs to the ROGATION MASS/procession alone, a distinct + votive text this engine does not model as a separate Mass (the + same per-action colour nuance temporal_ef.ml's own RG 126/RG 128 + Good-Friday-Communion citations already flag as unmodelled + elsewhere) -- the day's own OFFICE, which is what [colour] here + expresses, stays [season_colour s] like any other Paschaltide + feria. Witnessed directly: LMS 2023-2024, 2024-05-06 (Rogation + Monday), "FERIA IV Cl W" (white), "Mass of 5th Sunday after + Easter". *) let rogation = days_between easter d in if rogation = 36 || rogation = 37 then build ~season:s ~slug:(if rogation = 36 then "ef-rogation-monday" else "ef-rogation-tuesday") - ~colour:Colour.Violet ~rank:(ferial_rank d s) ~week:(week d) () + ~colour:(season_colour s) ~rank:(ferial_rank d s) ~week:(week d) () else match ember d with | Some (slug, rank, colour) -> build ~season:s ~slug ~colour ~rank ~week:(week d) () diff --git a/test/golden/grid-2027.html b/test/golden/grid-2027.html index daa6ba5..6741ad7 100644 --- a/test/golden/grid-2027.html +++ b/test/golden/grid-2027.html @@ -131,7 +131,7 @@ 1St. Joseph the WorkmanCol 3:14-15, 17, 23-24 · Matt 13:54-58 - 25th Sunday after EasterJas 1:22-27 · John 16:23-303Rogation MondayJas 1:22-27 · John 16:23-304St. Monica1 Tim 5:3-10 · Luke 7:11-165Vigil of the AscensionEph 4:7-13 · John 17:1-116The Ascension of Our LordActs 1:1-11 · Mark 16:14-207St. StanislausWis 5:1-5 · John 15:1-78Our Lady's Saturday OfficeEcclus 24:14-16 · John 19:25-27 + 25th Sunday after EasterJas 1:22-27 · John 16:23-303Rogation MondayJas 1:22-27 · John 16:23-304St. Monica1 Tim 5:3-10 · Luke 7:11-165Vigil of the AscensionEph 4:7-13 · John 17:1-116The Ascension of Our LordActs 1:1-11 · Mark 16:14-207St. StanislausWis 5:1-5 · John 15:1-78Our Lady's Saturday OfficeEcclus 24:14-16 · John 19:25-27 9Sunday after the Ascension1 Pet 4:7-11 · John 15:26-27; 16:1-410St. AntoninusEcclus 44:16-27; 45:3-20 · Matt 25:14-2311Sts. Philip & JamesWis 5:1-5 · John 14:1-1312Sts. Nereus, Achilleus, Domitilla, & PancrasWis 5:1-5 · John 4:46-5313St. Robert BellarmineWis 7:7-14 · Matt 5:13-1914Friday of the 7th Week of Eastertide1 Pet 4:7-11 · John 15:26-27; 16:1-415Vigil of PentecostActs 19:1-8 · John 14:15-21 diff --git a/test/golden/grid-2027.tex b/test/golden/grid-2027.tex index 674b9c4..d81bdb4 100644 --- a/test/golden/grid-2027.tex +++ b/test/golden/grid-2027.tex @@ -151,7 +151,7 @@ \begin{tabular}{|*{7}{p{\cellw}|}}\hline \textbf{ Sunday } & \textbf{ Monday } & \textbf{ Tuesday } & \textbf{ Wednesday } & \textbf{ Thursday } & \textbf{ Friday } & \textbf{ Saturday } \\ \hline & & & & & & \cellcolor{cwhite}\daycell{ 1 }{ St. Joseph the Workman }{ 1st Class }{ Col 3:14-15, 17, 23-24\ \textperiodcentered\ Matt 13:54-58 } \\ \hline -\cellcolor{cwhite}\daycell{ 2 }{ 5th Sunday after Easter }{ 2nd Class }{ Jas 1:22-27\ \textperiodcentered\ John 16:23-30 } & \cellcolor{cviolet}\daycell{ 3 }{ Rogation Monday }{ 4th Class }{ Jas 1:22-27\ \textperiodcentered\ John 16:23-30 } & \cellcolor{cwhite}\daycell{ 4 }{ St. Monica }{ 3rd Class }{ 1 Tim 5:3-10\ \textperiodcentered\ Luke 7:11-16 } & \cellcolor{cwhite}\daycell{ 5 }{ Vigil of the Ascension }{ 2nd Class }{ Eph 4:7-13\ \textperiodcentered\ John 17:1-11 } & \cellcolor{cwhite}\daycell{ 6 }{ The Ascension of Our Lord }{ 1st Class }{ Acts 1:1-11\ \textperiodcentered\ Mark 16:14-20 } & \cellcolor{cred}\daycell{ 7 }{ St. Stanislaus }{ 3rd Class }{ Wis 5:1-5\ \textperiodcentered\ John 15:1-7 } & \cellcolor{cwhite}\daycell{ 8 }{ Our Lady's Saturday Office }{ 4th Class }{ Ecclus 24:14-16\ \textperiodcentered\ John 19:25-27 } \\ \hline +\cellcolor{cwhite}\daycell{ 2 }{ 5th Sunday after Easter }{ 2nd Class }{ Jas 1:22-27\ \textperiodcentered\ John 16:23-30 } & \cellcolor{cwhite}\daycell{ 3 }{ Rogation Monday }{ 4th Class }{ Jas 1:22-27\ \textperiodcentered\ John 16:23-30 } & \cellcolor{cwhite}\daycell{ 4 }{ St. Monica }{ 3rd Class }{ 1 Tim 5:3-10\ \textperiodcentered\ Luke 7:11-16 } & \cellcolor{cwhite}\daycell{ 5 }{ Vigil of the Ascension }{ 2nd Class }{ Eph 4:7-13\ \textperiodcentered\ John 17:1-11 } & \cellcolor{cwhite}\daycell{ 6 }{ The Ascension of Our Lord }{ 1st Class }{ Acts 1:1-11\ \textperiodcentered\ Mark 16:14-20 } & \cellcolor{cred}\daycell{ 7 }{ St. Stanislaus }{ 3rd Class }{ Wis 5:1-5\ \textperiodcentered\ John 15:1-7 } & \cellcolor{cwhite}\daycell{ 8 }{ Our Lady's Saturday Office }{ 4th Class }{ Ecclus 24:14-16\ \textperiodcentered\ John 19:25-27 } \\ \hline \cellcolor{cwhite}\daycell{ 9 }{ Sunday after the Ascension }{ 2nd Class }{ 1 Pet 4:7-11\ \textperiodcentered\ John 15:26-27; 16:1-4 } & \cellcolor{cwhite}\daycell{ 10 }{ St. Antoninus }{ 3rd Class }{ Ecclus 44:16-27; 45:3-20\ \textperiodcentered\ Matt 25:14-23 } & \cellcolor{cred}\daycell{ 11 }{ Sts. Philip \& James }{ 2nd Class }{ Wis 5:1-5\ \textperiodcentered\ John 14:1-13 } & \cellcolor{cred}\daycell{ 12 }{ Sts. Nereus, Achilleus, Domitilla, \& Pancras }{ 3rd Class }{ Wis 5:1-5\ \textperiodcentered\ John 4:46-53 } & \cellcolor{cwhite}\daycell{ 13 }{ St. Robert Bellarmine }{ 3rd Class }{ Wis 7:7-14\ \textperiodcentered\ Matt 5:13-19 } & \cellcolor{cwhite}\daycell{ 14 }{ Friday of the 7th Week of Eastertide }{ 4th Class }{ 1 Pet 4:7-11\ \textperiodcentered\ John 15:26-27; 16:1-4 } & \cellcolor{cred}\daycell{ 15 }{ Vigil of Pentecost }{ 1st Class }{ Acts 19:1-8\ \textperiodcentered\ John 14:15-21 } \\ \hline \cellcolor{cred}\daycell{ 16 }{ Pentecost Sunday (Whitsunday) }{ 1st Class }{ Acts 2:1-11\ \textperiodcentered\ John 14:23-31 } & \cellcolor{cred}\daycell{ 17 }{ Monday of Pentecost Week }{ 1st Class }{ Acts 10:34, 42-48\ \textperiodcentered\ John 3:16-21 } & \cellcolor{cred}\daycell{ 18 }{ Tuesday of Pentecost Week }{ 1st Class }{ Acts 8:14-17\ \textperiodcentered\ John 10:1-10 } & \cellcolor{cred}\daycell{ 19 }{ Pentecost Ember Wednesday }{ 1st Class }{ Acts 5:12-16\ \textperiodcentered\ John 6:44-52 } & \cellcolor{cred}\daycell{ 20 }{ Thursday of Pentecost Week }{ 1st Class }{ Acts 8:5-8\ \textperiodcentered\ Luke 9:1-6 } & \cellcolor{cred}\daycell{ 21 }{ Pentecost Ember Friday }{ 1st Class }{ Joel 2:23-24; 2:26-27\ \textperiodcentered\ Luke 5:17-26 } & \cellcolor{cred}\daycell{ 22 }{ Pentecost Ember Saturday }{ 1st Class }{ Rom 5:1-5\ \textperiodcentered\ Luke 4:38-44 } \\ \hline \cellcolor{cwhite}\daycell{ 23 }{ Trinity Sunday }{ 1st Class }{ Rom 11:33-36\ \textperiodcentered\ Matt 28:18-20 } & \cellcolor{cgreen}\daycell{ 24 }{ Monday of the 1st Week of the Time after Pentecost }{ 4th Class }{ 1 John 4:8-21\ \textperiodcentered\ Luke 6:36-42 } & \cellcolor{cwhite}\daycell{ 25 }{ St. Gregory VII }{ 3rd Class }{ 1 Pet 5:1-4; 5:10-11\ \textperiodcentered\ Matt 16:13-19 } & \cellcolor{cwhite}\daycell{ 26 }{ St. Philip Neri }{ 3rd Class }{ Wis 7:7-14\ \textperiodcentered\ Luke 12:35-40 } & \cellcolor{cwhite}\daycell{ 27 }{ Corpus Christi }{ 1st Class }{ 1 Cor 11:23-29\ \textperiodcentered\ John 6:56-59 } & \cellcolor{cwhite}\daycell{ 28 }{ St. Augustine of Canterbury }{ 3rd Class }{ 1 Thess 2:2-9\ \textperiodcentered\ Luke 10:1-9 } & \cellcolor{cwhite}\daycell{ 29 }{ St. Mary Magdalene de Pazzi }{ 3rd Class }{ 2 Cor 10:17-18; 11:1-2\ \textperiodcentered\ Matt 25:1-13 } \\ \hline diff --git a/test/golden/grid-2027.typ b/test/golden/grid-2027.typ index 800edf9..b9e1d0e 100644 --- a/test/golden/grid-2027.typ +++ b/test/golden/grid-2027.typ @@ -2056,7 +2056,7 @@ - table.cell(fill: cgrid.at("violet"))[ + table.cell(fill: cgrid.at("white"))[ #block(height: cellh - 2 * cellinset, clip: true)[ #set par(leading: 0.3em) #text(weight: "bold")[3] #text(size: 6.5pt)[Rogation Monday] diff --git a/test/golden/ordo-2027.adoc b/test/golden/ordo-2027.adoc index 39e5ae4..d5660a0 100644 --- a/test/golden/ordo-2027.adoc +++ b/test/golden/ordo-2027.adoc @@ -879,7 +879,7 @@ Epistle Jas 1:22-27 Gospel John 16:23-30 *3* Rogation Monday + -class-4 · violet + +class-4 · white + Epistle Jas 1:22-27 Gospel John 16:23-30 + Commemoration Sts. Alexander & Companions diff --git a/test/golden/ordo-2027.html b/test/golden/ordo-2027.html index 8513909..1b621e4 100644 --- a/test/golden/ordo-2027.html +++ b/test/golden/ordo-2027.html @@ -672,9 +672,9 @@
class-2 · white · Epistle Jas 1:22-27 · Gospel John 16:23-30
-
+
3Rogation Monday -
class-4 · violet · Epistle Jas 1:22-27 · Gospel John 16:23-30
+
class-4 · white · Epistle Jas 1:22-27 · Gospel John 16:23-30
Commemoration Sts. Alexander & Companions
diff --git a/test/golden/ordo-2027.md b/test/golden/ordo-2027.md index 3d96240..d4acf1d 100644 --- a/test/golden/ordo-2027.md +++ b/test/golden/ordo-2027.md @@ -789,7 +789,7 @@ **3** Rogation Monday -`class-4` · violet · Epistle Jas 1:22-27 · Gospel John 16:23-30 +`class-4` · white · Epistle Jas 1:22-27 · Gospel John 16:23-30 - Commemoration Sts. Alexander & Companions diff --git a/test/golden/ordo-2027.ms b/test/golden/ordo-2027.ms index e029a76..7bc8c89 100644 --- a/test/golden/ordo-2027.ms +++ b/test/golden/ordo-2027.ms @@ -1354,7 +1354,7 @@ May .IP "3" 4 \fBRogation Monday\fR .br -\s74th Class \(bu Violet\s9 +\s74th Class \(bu White\s9 .br \s7Epistle Jas 1:22-27\s9 .br diff --git a/test/golden/ordo-2027.tex b/test/golden/ordo-2027.tex index 0db4121..e8ec59f 100644 --- a/test/golden/ordo-2027.tex +++ b/test/golden/ordo-2027.tex @@ -1548,9 +1548,9 @@ \begin{tcolorbox}[colback=white,colframe=black!35,boxrule=0.3pt,sharp corners,boxsep=0mm,left=1.6mm,right=1.6mm,top=0.4mm,bottom=0.4mm,before skip=0.3mm,after skip=0mm] -\fontsize{9}{9}\selectfont\bfseries 3 \fontsize{7}{9}\selectfont\mdseries\;Monday\hfill\swatch{licolviolet}\\ +\fontsize{9}{9}\selectfont\bfseries 3 \fontsize{7}{9}\selectfont\mdseries\;Monday\hfill\swatch{licolwhite}\\ \fontsize{9}{9.7}\selectfont\bfseries Rogation Monday\\ -\fontsize{6.5}{7.4}\selectfont\mdseries 4th Class \textperiodcentered\ Violet \quad Epistle\ Jas 1:22-27 \quad Gospel\ John 16:23-30 \\ +\fontsize{6.5}{7.4}\selectfont\mdseries 4th Class \textperiodcentered\ White \quad Epistle\ Jas 1:22-27 \quad Gospel\ John 16:23-30 \\ \fontsize{6.5}{7.4}\selectfont\mdseries Commemoration\ Sts. Alexander \& Companions \end{tcolorbox} diff --git a/test/golden/ordo-2027.txt b/test/golden/ordo-2027.txt index 0af67b3..e0073ec 100644 --- a/test/golden/ordo-2027.txt +++ b/test/golden/ordo-2027.txt @@ -728,7 +728,7 @@ May 2027 Gospel John 16:23-30 3 Rogation Monday - class-4 · violet + class-4 · white Epistle Jas 1:22-27 Gospel John 16:23-30 Commemoration Sts. Alexander & Companions diff --git a/test/golden/ordo-2027.typ b/test/golden/ordo-2027.typ index 0d87f04..ce5e0ca 100644 --- a/test/golden/ordo-2027.typ +++ b/test/golden/ordo-2027.typ @@ -1480,9 +1480,9 @@ #block(width: 100%, above: 0.4mm, below: 0mm, breakable: false, stroke: 0.4pt + luma(160), inset: (x: 1.6mm, y: 0.4mm))[ #set par(leading: 0.36em) - #text(weight: "bold")[3] #h(2mm) #text(size: 7pt)[Monday] #h(1fr) #swatch("violet") \ + #text(weight: "bold")[3] #h(2mm) #text(size: 7pt)[Monday] #h(1fr) #swatch("white") \ #text(weight: "bold")[Rogation Monday] \ - #text(size: 6.5pt)[4th Class #sym.dot.c Violet #h(2mm) Epistle Jas 1:22\-27 #h(2mm) Gospel John 16:23\-30] \ + #text(size: 6.5pt)[4th Class #sym.dot.c White #h(2mm) Epistle Jas 1:22\-27 #h(2mm) Gospel John 16:23\-30] \ #text(size: 6.5pt)[Commemoration Sts. Alexander & Companions] ] diff --git a/test/test_golden.ml b/test/test_golden.ml index d680720..3a6f3f5 100644 --- a/test/test_golden.ml +++ b/test/test_golden.ml @@ -1315,6 +1315,34 @@ let test_rogation_wednesday_yields_to_a_feast_2026 () = "2026-05-13 wednesday season=paschaltide week=6 slug=ef-ascension-vigil rank=class-2 colour=white \ subject=temporal name_la=- comms=[robert-bellarmine:ordinary] in=- out=[]" +(* ---- Rogation Monday/Tuesday colour, RG 119/128/88 (celebrant-rubrics- + phase1 Bug 1 fix, 2026-08-22; data/ef/expected-divergences-lms.sexp's + now-closed L6, data/ef/expected-divergences.sexp's reopened C8) ---- + + {!Rite_ef.Temporal_ef.temporal}'s Rogation Monday/Tuesday branch used to + hardcode [Colour.Violet] with no citation. RG 119 assigns white to the + de-Tempore Office and Mass from the Paschal Vigil to the Pentecost Vigil + exclusive -- Easter+36/+37 sit inside that window -- and RG 128's own + exhaustive four-case violet list never names Rogation days; RG 88 ("de + Litaniis minoribus nihil fit in Officio") is why -- the violet belongs + to the Rogation MASS/procession alone, not the day's own Office. Fixed + to [season_colour s], the same call every other Paschaltide feria makes. + + 2028 pins BOTH days at once (unlike the LMS witness, which only reaches + one day per fixture window): neither is impeded by a competing + sanctoral feast that year, so both are observed outright, both white. + Verified directly against `date -d`: Easter 2028-04-16, so 22/23 May are + Easter+36/+37. *) +let test_rogation_monday_white_2028 () = + check ~msg:"2028: Rogation Monday is white (RG 119), not violet" 2028 5 22 + "2028-05-22 monday season=paschaltide week=6 slug=ef-rogation-monday rank=class-4 colour=white \ + subject=temporal name_la=- comms=[] in=- out=[]" + +let test_rogation_tuesday_white_2028 () = + check ~msg:"2028: Rogation Tuesday is white (RG 119), not violet" 2028 5 23 + "2028-05-23 tuesday season=paschaltide week=6 slug=ef-rogation-tuesday rank=class-4 colour=white \ + subject=temporal name_la=- comms=[] in=- out=[]" + (* ---- The bissextile (leap-year) calendarium footnote, bissextile-shift task, 2026-08-22 ---- @@ -1572,6 +1600,10 @@ let suite = test_rogation_wednesday_admitted_2024; Alcotest.test_case "RG87/113: Rogation Wednesday yields to an impeded feast (2026)" `Quick test_rogation_wednesday_yields_to_a_feast_2026; + Alcotest.test_case "RG119/128: Rogation Monday is white, not violet (2028)" `Quick + test_rogation_monday_white_2028; + Alcotest.test_case "RG119/128: Rogation Tuesday is white, not violet (2028)" `Quick + test_rogation_tuesday_white_2028; Alcotest.test_case "bissextile footnote: Matthias moves 24->25 February (leap, 2044)" `Quick test_bissextile_matthias_leap_2044; Alcotest.test_case "bissextile footnote: Matthias unaffected (common, 2025)" `Quick diff --git a/test/test_lms_ordo.ml b/test/test_lms_ordo.ml index 00fb32c..ed1c871 100644 --- a/test/test_lms_ordo.ml +++ b/test/test_lms_ordo.ml @@ -582,17 +582,25 @@ let make_suite ~label ~fixture_path ~fixture_sha256 ~window_first ~window_last ~ | Some { MF.said = None; _ } -> "NONE (said, unexpectedly outside Votive)" | None -> "NONE") in - (* Two REAL, STRUCTURAL shapes found running this comparison (see - data/ef/expected-divergences-lms.sexp's own L5/L6 for the full - citations) -- neither is a fixed date list, both a property of the - day itself, because both recur every year the underlying condition - holds, not on a fixed calendar date the way L4 above does. Matched - by PREDICATE, not by date, for the same reason L2's own note gives - for why it is prose-only rather than wired through the shared - [expected_rows] mechanism: the count varies window to window (L5: - 6/5/6; L6: 1/0/0), so a single static count cannot check it, and - [make_suite] threads the per-window expected totals directly - (~expected_gloria_l5/~expected_gloria_l6 below) instead. *) + (* L5 is the one REAL, STRUCTURAL shape still open (see + data/ef/expected-divergences-lms.sexp's own L5 for the full + citation) -- not a fixed date list, a property of the day itself, + because it recurs every year the underlying condition holds, not on a + fixed calendar date the way L4 above does. Matched by PREDICATE, not + by date, for the same reason L2's own note gives for why it is + prose-only rather than wired through the shared [expected_rows] + mechanism: the count varies window to window (L5: 6/5/6), so a single + static count cannot check it, and [make_suite] threads the per-window + expected total directly (~expected_gloria_l5 below) instead. + + L6 is CLOSED (celebrant-rubrics-phase1, Bug 1 fix, this task): + {!Rite_ef.Temporal_ef.temporal}'s Rogation Monday/Tuesday branch no + longer hardcodes [Colour.Violet] -- see temporal_ef.ml's own citation + on that branch and data/ef/expected-divergences-lms.sexp's L6 entry + for the fix and its measured blast radius. [is_l6_rogation_colour] and + ~expected_gloria_l6 are kept, at 0 in every window, as the regression + net the same way L3's own bissextile fix keeps a predicate whose count + is now provably zero rather than deleting it. *) let is_l5_lenten_commemoration (o : ordo_row) (c : colitur_row) = (* NOT a Gloria defect (CORRECTED, l5-f3-readjudication task, 2026-08-22 -- data/ef/expected-divergences-lms.sexp's own L5 has @@ -613,15 +621,17 @@ let make_suite ~label ~fixture_path ~fixture_sha256 ~window_first ~window_last ~ (not c.c_gloria) && Option.value o.gloria ~default:false && c.c_has_commemoration && c.c_rank = V.Class3 in let is_l6_rogation_colour (o : ordo_row) (c : colitur_row) = - (* Root-caused to a DIFFERENT, pre-existing bug this comparison merely - surfaced -- {!Rite_ef.Temporal_ef.temporal}'s own Rogation Monday/ - Tuesday branch hardcodes [Colour.Violet] with no RG citation at - all, but the Ordo shows this exact date "FERIA IV Cl W" (white, - matching Paschaltide's own [season_colour] and RG88's "nihil fit in - Officio" -- the Office, hence its colour, is unchanged by the - Rogation, only the Mass TEXT is proper) -- so [gloria]'s own 432(b) - violet guard wrongly fires. NOT a Gloria defect and NOT fixed here - (out of this task's own scope; see the task report). *) + (* CLOSED (celebrant-rubrics-phase1, Bug 1 fix). Was root-caused to a + DIFFERENT, pre-existing bug this comparison merely surfaced -- + {!Rite_ef.Temporal_ef.temporal}'s own Rogation Monday/Tuesday branch + used to hardcode [Colour.Violet] with no RG citation at all, where + the Ordo shows this exact date "FERIA IV Cl W" (white, matching + Paschaltide's own [season_colour] and RG88's "nihil fit in Officio" + -- the Office, hence its colour, is unchanged by the Rogation, only + the Mass TEXT is proper) -- so [gloria]'s own 432(b) violet guard + wrongly fired. Now fixed at the source (temporal_ef.ml); this + predicate and its own ~expected_gloria_l6 count (0 in every window) + are kept as the regression net, not deleted. *) (not c.c_gloria) && Option.value o.gloria ~default:false && (contains_substring c.c_slug ~needle:"rogation-monday" || contains_substring c.c_slug ~needle:"rogation-tuesday") in @@ -788,7 +798,7 @@ let suite_2023_2024 = I-class Sunday that admits him not even as a commemoration (RG16(a)), so his own Mass is not said anywhere in this window any more. *) ~expected_bvm_votive:12 ~expected_proper:181 ~expected_common:2 ~expected_preceding_sunday:61 - ~expected_ascension_week:1 ~expected_gloria_l5:5 ~expected_gloria_l6:1 + ~expected_ascension_week:1 ~expected_gloria_l5:5 ~expected_gloria_l6:0 let suite_2024_2025 = make_suite ~label:"lms-ordo-2024-2025" ~fixture_path:"fixtures/lms-ordo-2024-2025.sexp" diff --git a/test/test_rubrics_ef.ml b/test/test_rubrics_ef.ml index 71f180d..6679702 100644 --- a/test/test_rubrics_ef.ml +++ b/test/test_rubrics_ef.ml @@ -598,6 +598,24 @@ let test_431a_mirrors_te_deum_true () = let test_432a_mirrors_te_deum_false () = check_gloria "432(a): mirrors [te_deum]=false (an ordinary Advent feria)" 2025 12 1 false +(* ---- The Rogation Monday/Tuesday cascade (celebrant-rubrics-phase1 Bug 1 + fix, 2026-08-22): {!Rite_ef.Temporal_ef.temporal} used to hardcode + [Colour.Violet] on these two days with no citation, which silenced + [gloria] via 432(b) even though 237(e)'s own Paschaltide-feria branch + already said [te_deum]=true underneath. Fixed to [season_colour] (RG + 119: white from the Paschal Vigil to the Pentecost Vigil exclusive; RG + 128's own violet list never names Rogation days; RG 88 -- the violet + belongs to the Rogation MASS, not the Office this field describes). + 2028-05-22/23: neither day is impeded that year (test_golden.ml's own + [test_rogation_{monday,tuesday}_white_2028] pins the colour side of the + same fix), so both resolve to white and both now say the Gloria, MIRROR + OF [te_deum] via 431(a) once 432(b)'s violet guard no longer fires -- + MATCHING the LMS Ordo witness (2023-2024, 2024-05-06, "FERIA IV Cl W + ... Gl"). Reverting temporal_ef.ml's fix alone (mutation-checked while + building this task) turns both [false], the pre-fix answer. *) +let test_rogation_monday_gloria_2028 () = check_gloria "Rogation Monday: white -> Gloria said (2028)" 2028 5 22 true +let test_rogation_tuesday_gloria_2028 () = check_gloria "Rogation Tuesday: white -> Gloria said (2028)" 2028 5 23 true + (* ---- Domain-wide sanity, task requirement 5: "every violet day must be false (RG 432 b), and every Requiem must be false (432 d)". Two invariants, checked over every day {!Cal.year} resolves -- [Cal.year], @@ -794,6 +812,8 @@ let suite = Alcotest.test_case "432(d): All Souls' Day (transferred)" `Quick test_432d_all_souls; Alcotest.test_case "431(a): mirrors [te_deum]=true" `Quick test_431a_mirrors_te_deum_true; Alcotest.test_case "432(a): mirrors [te_deum]=false" `Quick test_432a_mirrors_te_deum_false; + Alcotest.test_case "Rogation Monday: white -> Gloria said (2028)" `Quick test_rogation_monday_gloria_2028; + Alcotest.test_case "Rogation Tuesday: white -> Gloria said (2028)" `Quick test_rogation_tuesday_gloria_2028; Alcotest.test_case "domain sanity: every violet day has [gloria]=false (sample)" `Quick test_domain_violet_implies_no_gloria_sample; Alcotest.test_case "domain sanity: every Requiem day has [gloria]=false (sample)" `Quick -- cgit v1.3 From 0806fe65e388a9502035bc9d4f551528ac74b26d Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sat, 22 Aug 2026 23:24:12 +0200 Subject: feat(ef): the Mass preface, RG 482-499 Item 2 of Phase 3 (celebrant-rubrics-phase1), the bulk of this phase and the last EF Mass rubric this project scoped: which preface is said, deferring to seasonal/proper-title rules for the fourteen named prefaces, then RG 498's Common residual. RG 482 gives the resolution chain: the Mass's own proper preface, failing that the seasonal one, failing that Common. Read literally, RG 484-497 look like fourteen separate rules, but each numbered rubric's own propria/de-Tempore pair produces the SAME preface identity either way, so the whole chain collapses into one priority- ordered decision: title/mystery triggers (Holy Cross, Sacred Heart, Christ the King, Trinity, St Joseph, BVM, the Nativity octave, the Apostles, Epiphany), each independent of season, then six seasonal windows (Nativity, Epiphany, Lent, Holy Cross/Passiontide, Easter, Ascension, Holy Spirit, Trinity-for-ordinary-Sundays), then Common. The "one genuinely unproven piece" the design spec worried about -- per-feast proper prefaces extracted from the Missal's propers at scale -- turned out not to be a large-scale extraction problem at all: every one of the fourteen propers is a closed, small, subject/slug-keyed trigger (mirroring Precedence_ef.band's own RG 91 table), not thousands of individual saints' pages. The one genuine open question (RG 488, the Chrism Mass) is N/A: this engine resolves one Mass per civil day and has no separate Chrism-Mass dimension. Preface is a new kernel type (lib/kernel/preface.ml[i]), not an EF-specific one, the same placement as Colour/Subject/Mass_formulary: Liturgical_day.t is parameterised only over season/rank, so any field it carries generically must live in the kernel even though only EF constructs a value of it today. Wired exactly as creed/gloria were (Rite.t.preface, Calendar.ml, Rite_ef.context), but Preface.t option, not a bare bool: unlike creed/gloria, a preface is said only at a Mass, and Good Friday (1955-restored Holy Week) resolves an observed celebration but has no Mass at all -- None is the honest answer there, and also the neutral value an unimplemented rite returns. Two priority-order findings only the oracle settled, not derivable from the Latin text in isolation, both cross-checked against 358 individually classified entries in the FIUV Ordo's own praef column (test/fixtures/fiuv-ordo-2025-2026.sexp, already captured, wired up here as a new comparison axis): RG 484(b)'s own "except Masses with a proper of the divine mysteries or Persons" is narrower than every other window's implicit exception (an Apostle inside the Nativity octave is overridden to Nativity; outside it, keeps his own preface even inside another window); and RG 495's "et votivis" half is live for the one office this engine models without a votive-Mass dimension (the Saturday Office of the BVM), which also proved a vigil is not a "festum" for this purpose (the Assumption's own vigil takes Common, not BVM, correcting an initial reuse of Precedence_ef.marian_slugs that had no reason to make that distinction for its own, different rubric). A third, RG16(a)-shaped fix landed the same way: RG 494(b)'s own Trinity grant must read the day's TEMPORAL season, not the celebration that actually won it, or a Class1 feast with no preference of its own (All Saints) wrongly falls to Common on a Sunday it merely commemorates. colitur rubrics gains a sixth TAB-separated column. Domain-wide 1583-9999 exhaustive sweep confirms every Christmastide day resolves Nativity/Epiphany/Bvm, every Paschaltide day one of its own three windows or a season-independent title, every Lent day Lent or a title, and Passiontide legitimately produces Easter exactly once a year (the Vigil Mass) -- 8416 of the domain's 8417 years, the one short year being the domain's own upper boundary (the liturgical year opening in 9999 cannot construct dates in year 10000, a pre-existing edge this sweep re-confirms rather than a new one). day/readings verified byte-identical to the branch's own state before this phase (commit 9c96e0a) across a 455-year sample spanning the whole domain -- not literally to the v0.10.1 tag, which 22 earlier commits on this same branch (Phase 1/2, the bissextile shift, the Rogation colour fix) had already moved past before this phase began. --- bin/main.ml | 45 +++- lib/kernel/calendar.ml | 4 + lib/kernel/liturgical_day.ml | 1 + lib/kernel/liturgical_day.mli | 7 + lib/kernel/preface.ml | 59 +++++ lib/kernel/preface.mli | 50 ++++ lib/kernel/rite.ml | 1 + lib/kernel/rite.mli | 17 ++ lib/rites/rite_ef/rite_ef.ml | 3 +- lib/rites/rite_ef/rite_ef.mli | 4 + lib/rites/rite_ef/rubrics_ef.ml | 496 +++++++++++++++++++++++++++++++++++++++ lib/rites/rite_ef/rubrics_ef.mli | 35 +++ man/colitur.1 | 33 ++- test/cli.t | 33 ++- test/test_calendar.ml | 10 +- test/test_fiuv_ordo.ml | 153 +++++++++++- test/test_rubrics_ef.ml | 387 +++++++++++++++++++++++++++++- test/test_validate.ml | 16 +- 18 files changed, 1306 insertions(+), 48 deletions(-) create mode 100644 lib/kernel/preface.ml create mode 100644 lib/kernel/preface.mli (limited to 'lib/rites/rite_ef') diff --git a/bin/main.ml b/bin/main.ml index cf8cae2..b5d8d98 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -413,7 +413,18 @@ let readings_line ~lang ~sigla (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.r Task (celebrant-rubrics-phase1, Phase 2): a FIFTH column, whether the Gloria in excelsis is said (EF: RG 431-432, {!Rite_ef.Rubrics_ef.gloria}) -- same [string_of_bool] convention as [creed], same plain [bool] with - no [option] to guard, same reasoning throughout. *) + no [option] to guard, same reasoning throughout. + + Task (celebrant-rubrics-phase1, Phase 3): a SIXTH column, which preface + is said (EF: RG 482-499, {!Rite_ef.Rubrics_ef.preface}) -- + {!Colitur_kernel.Preface.to_string} (e.g. "common", "holy-cross"), or + "-" when [d.preface] is [None]. UNLIKE [creed]/[gloria], [preface] is a + genuine [option]: "-" here can mean either of two things ("this rite + has not implemented the rule" or "this specific day has no Mass to + preface", {!Colitur_kernel.Rite.t.preface}'s own citation) and this + column does not distinguish them, the same "-" convention [formulary]'s + own [None] case already uses two columns to the left, for the identical + reason. *) let rubrics_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) = let said, via = @@ -426,9 +437,15 @@ let rubrics_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_k Colitur_kernel.Mass_formulary.source_to_string f.Colitur_kernel.Mass_formulary.via ) | None -> ("-", "-") in - Printf.printf "%s\t%s\t%s\t%s\t%s\n" (D.to_iso8601 d.Colitur_kernel.Liturgical_day.date) said via + let preface = + match d.Colitur_kernel.Liturgical_day.preface with + | Some p -> Colitur_kernel.Preface.to_string p + | None -> "-" + in + Printf.printf "%s\t%s\t%s\t%s\t%s\t%s\n" (D.to_iso8601 d.Colitur_kernel.Liturgical_day.date) said via (string_of_bool d.Colitur_kernel.Liturgical_day.creed) (string_of_bool d.Colitur_kernel.Liturgical_day.gloria) + preface (* One civil year, Jan 1 - Dec 31, matching [temporal_report]'s own scan -- NOT one liturgical year: [Colitur_kernel.Calendar.year] resolves a single @@ -1287,8 +1304,8 @@ output formats: 2026-04-05 sunday paschaltide 1 ef-easter-sunday class-1 white readings date slug | Epistle | Gospel [| name] 2026-12-25 ef-nativity | Heb 1:1-12 | John 1:1-14 - rubrics date, formulary slug, source, creed, gloria -- TAB-separated - 2026-01-01[TAB]ef-circumcision[TAB]own[TAB]true[TAB]true + rubrics date, formulary slug, source, creed, gloria, preface -- TAB-separated + 2026-01-01[TAB]ef-circumcision[TAB]own[TAB]true[TAB]true[TAB]nativity A citation contains spaces, so readings uses " | " between its fields while day stays space-separated; that is why they are separate commands rather @@ -1305,16 +1322,20 @@ output formats: (source: proper/own/preceding-sunday/common/votive) -- not always the day's own: a weekday with no proper resumes the preceding Sunday's, a saint with no proper says his assigned Common -- followed by whether the - Creed is said (RG 475-476) and whether the Gloria in excelsis is said + Creed is said (RG 475-476), whether the Gloria in excelsis is said (RG 431-432, deferring to the Breviary's own Te Deum rule, nn. 237-238, - for RG 431(a)) -- both "true"/"false", OCaml's own literal, not - "yes"/"no" or "1"/"0". TAB-separated rather than space or " | ": a - resolved formulary NAME is a column a later version may add, and it can - carry both spaces and punctuation a citation never does, which rules out + for RG 431(a)), and which preface is said (RG 482-499) -- Creed/Gloria + both "true"/"false", OCaml's own literal, not "yes"/"no" or "1"/"0"; + preface one of nativity/epiphany/lent/holy-cross/easter/ascension/ + sacred-heart/christ-the-king/holy-spirit/trinity/bvm/st-joseph/apostles/ + common/requiem, or "-" when this engine resolves no Mass at all that day + (Good Friday). TAB-separated rather than space or " | ": a resolved + formulary NAME is a column a later version may add, and it can carry + both spaces and punctuation a citation never does, which rules out either alternative already in use above. --overlay is accepted (the - observed celebration it changes decides the formulary, the Creed and the - Gloria); --lang/--raw/--sigla-* are refused -- this row resolves no - display name and no citation for any of them to affect. + observed celebration it changes decides the formulary, the Creed, the + Gloria and the preface); --lang/--raw/--sigla-* are refused -- this row + resolves no display name and no citation for any of them to affect. emit one schema (season, week, slug, rank, colour, subject, names, citations, commemorations), rendered five ways: csv (RFC 4180, diff --git a/lib/kernel/calendar.ml b/lib/kernel/calendar.ml index 7178780..a1309f9 100644 --- a/lib/kernel/calendar.ml +++ b/lib/kernel/calendar.ml @@ -554,6 +554,9 @@ let build_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) let gloria = rite.Rite.gloria ~temporal ~observed:resolution.Precedence.observed.Precedence.cel ~date in + let preface = + rite.Rite.preface ~temporal ~observed:resolution.Precedence.observed.Precedence.cel ~date + in { Liturgical_day.date; rite = rite.Rite.id; @@ -568,6 +571,7 @@ let build_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.index) formulary; creed; gloria; + preface; } let year (rite : ('s, 'r) Rite.t) (layer : 'r Layer.t) (y : int) : diff --git a/lib/kernel/liturgical_day.ml b/lib/kernel/liturgical_day.ml index 98c5084..c3ecb37 100644 --- a/lib/kernel/liturgical_day.ml +++ b/lib/kernel/liturgical_day.ml @@ -28,5 +28,6 @@ type ('s, 'r) t = { a rite with no lectionary -- see {!Mass_formulary} *) creed : bool; (** whether the Creed is said at this day's Mass; see {!Rite.t.creed} *) gloria : bool; (** whether the Gloria in excelsis is said; see {!Rite.t.gloria} *) + preface : Preface.t option; (** which preface is said; see {!Rite.t.preface} *) } [@@deriving sexp] diff --git a/lib/kernel/liturgical_day.mli b/lib/kernel/liturgical_day.mli index be17d73..643097b 100644 --- a/lib/kernel/liturgical_day.mli +++ b/lib/kernel/liturgical_day.mli @@ -51,5 +51,12 @@ type ('s, 'r) t = { (** Whether the Gloria in excelsis is said at this day's Mass -- {!Rite.t.gloria}, EF: RG 431-432. Same seam as [creed] in every respect. *) + preface : Preface.t option; + (** Which preface is said at this day's Mass -- {!Rite.t.preface}, + EF: RG 482-499. Same seam as [creed]/[gloria], but [option], not + a bare {!Preface.t}: see {!Rite.t.preface}'s own citation for why + -- [None] both for a rite that has not implemented the rule and + for a real day this engine resolves that has no Mass at all + (Good Friday). *) } [@@deriving sexp] diff --git a/lib/kernel/preface.ml b/lib/kernel/preface.ml new file mode 100644 index 0000000..08d00b4 --- /dev/null +++ b/lib/kernel/preface.ml @@ -0,0 +1,59 @@ +(* RG 482-499 -- see the .mli for the rubric quoted in full and the + placement rationale. Argument-less variants only, so no + [Sexplib0.Sexp_conv] open is needed -- see CLAUDE.md's own gotcha note. *) +type t = + | Nativity + | Epiphany + | Lent + | Holy_cross + | Easter + | Ascension + | Sacred_heart + | Christ_the_king + | Holy_spirit + | Trinity + | Bvm + | St_joseph + | Apostles + | Common + | Requiem +[@@deriving sexp] + +let all = + [ Nativity; Epiphany; Lent; Holy_cross; Easter; Ascension; Sacred_heart; Christ_the_king; + Holy_spirit; Trinity; Bvm; St_joseph; Apostles; Common; Requiem ] + +let to_string = function + | Nativity -> "nativity" + | Epiphany -> "epiphany" + | Lent -> "lent" + | Holy_cross -> "holy-cross" + | Easter -> "easter" + | Ascension -> "ascension" + | Sacred_heart -> "sacred-heart" + | Christ_the_king -> "christ-the-king" + | Holy_spirit -> "holy-spirit" + | Trinity -> "trinity" + | Bvm -> "bvm" + | St_joseph -> "st-joseph" + | Apostles -> "apostles" + | Common -> "common" + | Requiem -> "requiem" + +let of_string = function + | "nativity" -> Some Nativity + | "epiphany" -> Some Epiphany + | "lent" -> Some Lent + | "holy-cross" -> Some Holy_cross + | "easter" -> Some Easter + | "ascension" -> Some Ascension + | "sacred-heart" -> Some Sacred_heart + | "christ-the-king" -> Some Christ_the_king + | "holy-spirit" -> Some Holy_spirit + | "trinity" -> Some Trinity + | "bvm" -> Some Bvm + | "st-joseph" -> Some St_joseph + | "apostles" -> Some Apostles + | "common" -> Some Common + | "requiem" -> Some Requiem + | _ -> None diff --git a/lib/kernel/preface.mli b/lib/kernel/preface.mli new file mode 100644 index 0000000..b43890e --- /dev/null +++ b/lib/kernel/preface.mli @@ -0,0 +1,50 @@ +(** The Mass preface (Missale Romanum, Rubricae Generales, Caput XVII("De + Ritibus servandis in celebratione Missae"), "H) De praefatione", RG + 482-499; docs/research/LT.txt, grep "praefatione dicitur quae cuique"). + + RG 482 gives the resolution chain in full: {i "Praefatio dicitur quae + cuique Missae propria est; qua deficiente, dicitur praefatio de + Tempore, secus communis"} -- the Mass's own PROPER preface; failing + that, the SEASONAL ("de Tempore") one; failing that, the COMMON. RG + 483: {i "Nulla commemoratio, in Missa occurrens, praefationem propriam + inducit"} -- a commemoration never induces its own proper preface, + which is why {!Rite_ef.Rubrics_ef.preface} (the one rite that + implements this so far) reads only the day's OBSERVED celebration, + never its commemorations, the identical discipline {!Rite.t.creed}'s + own RG 476(e) already established. + + RG 484-497 enumerate the fourteen named propers -- Nativity, Epiphany, + Lent, Holy Cross, [the Chrism Mass, out of scope: see the .ml's own + N/A note], Easter, Ascension, the Sacred Heart, Christ the King, the + Holy Spirit, the Trinity, the Blessed Virgin Mary, St Joseph, the + Apostles. RG 498 is the residual Common; RG 499 the Requiem. + + A KERNEL type, not a rite-specific one (the same placement as + {!Colour}, {!Subject}, {!Mass_formulary}): {!Liturgical_day.t} is + parameterised only over a rite's season/rank, so any field it carries + generically must live here, even though only the EF rite constructs a + value of it today -- CLAUDE.md's own "generalise when forced, not + speculatively" stance, already the precedent {!Mass_formulary} + follows. *) + +type t = + | Nativity (** RG 484 *) + | Epiphany (** RG 485 *) + | Lent (** RG 486, "de Quadragesima" *) + | Holy_cross (** RG 487, "de sancta Cruce" *) + | Easter (** RG 489, "paschalis" *) + | Ascension (** RG 490 *) + | Sacred_heart (** RG 491, "de Ss.mo Corde Iesu" *) + | Christ_the_king (** RG 492, "de D. N. Iesu Christo Rege" *) + | Holy_spirit (** RG 493, "de Spiritu Sancto" *) + | Trinity (** RG 494, "de Ss.ma Trinitate" *) + | Bvm (** RG 495, "de beata Maria Virgine" *) + | St_joseph (** RG 496, "de S. Ioseph" *) + | Apostles (** RG 497, "de Apostolis" *) + | Common (** RG 498, "communis" -- the residual when nothing else applies *) + | Requiem (** RG 499, "defunctorum" *) +[@@deriving sexp] + +val all : t list +val to_string : t -> string +val of_string : string -> t option diff --git a/lib/kernel/rite.ml b/lib/kernel/rite.ml index 915941d..e44c2c6 100644 --- a/lib/kernel/rite.ml +++ b/lib/kernel/rite.ml @@ -21,4 +21,5 @@ type ('s, 'r) t = { Mass_formulary.t option * Citation.t list; creed : temporal:('s, 'r) Temporal.t -> observed:'r Celebration.t -> date:Date.t -> bool; gloria : temporal:('s, 'r) Temporal.t -> observed:'r Celebration.t -> date:Date.t -> bool; + preface : temporal:('s, 'r) Temporal.t -> observed:'r Celebration.t -> date:Date.t -> Preface.t option; } diff --git a/lib/kernel/rite.mli b/lib/kernel/rite.mli index b9d1961..d53e4e6 100644 --- a/lib/kernel/rite.mli +++ b/lib/kernel/rite.mli @@ -140,4 +140,21 @@ type ('s, 'r) t = { parameters and the same reasons for each, a [bool] not an [option], and [false] is the answer a rite that has not implemented the rule returns explicitly. *) + preface : temporal:('s, 'r) Temporal.t -> observed:'r Celebration.t -> date:Date.t -> Preface.t option; + (** Which preface is said at this day's Mass (EF: RG 482-499). Same + seam and same three parameters as {!creed}/{!gloria}, for the + same reasons -- {!Preface}'s own citation has the rubric in full. + [Preface.t option], not a bare [Preface.t]: unlike [creed]/ + [gloria], where "not said" is itself a legitimate answer a + [bool] can carry, a preface is said only at a MASS, and this + engine constructs at least one day (Good Friday, the + 1955-restored Holy Week) that resolves an [observed] celebration + but has no Mass at all -- [None] is the honest answer there, not + a manufactured preface. [None] is also the value a rite that has + not implemented this rule returns, the same "the type's own + neutral value" contract {!creed}/{!gloria} give for [false] -- + the two meanings ("this rite does not model the question" and + "this specific day has no Mass to preface") collapse onto the + same representation deliberately: a caller with no rite-specific + context to distinguish them should not need one. *) } diff --git a/lib/rites/rite_ef/rite_ef.ml b/lib/rites/rite_ef/rite_ef.ml index d7b3d83..808e017 100644 --- a/lib/rites/rite_ef/rite_ef.ml +++ b/lib/rites/rite_ef/rite_ef.ml @@ -52,4 +52,5 @@ let context ~lectionary ~commons : (Vocab_ef.season, Vocab_ef.rank) Rite.t = transfer_target = Precedence_ef.transfer_target; readings = Lectionary_ef.readings ~lectionary ~commons; creed = Rubrics_ef.creed; - gloria = Rubrics_ef.gloria } + gloria = Rubrics_ef.gloria; + preface = Rubrics_ef.preface } diff --git a/lib/rites/rite_ef/rite_ef.mli b/lib/rites/rite_ef/rite_ef.mli index 7025143..2ed61de 100644 --- a/lib/rites/rite_ef/rite_ef.mli +++ b/lib/rites/rite_ef/rite_ef.mli @@ -39,6 +39,10 @@ module Rubrics_ef = Rubrics_ef - [gloria]: {!Rubrics_ef.gloria}, RG 431-432 -- whether the Gloria in excelsis is said. Reads {!Rubrics_ef.te_deum} (Breviary nn. 237-238) for RG 431(a)/432(a)'s own deferral. + - [preface]: {!Rubrics_ef.preface}, RG 482-499 -- which preface is + said. Same seam as [creed]/[gloria]; [None] both for a day with no + Mass at all (Good Friday) and for a rite that has not implemented + the rule. Deliberately carries no [sanctoral]/[lectionary] fields the way the original design-doc sketch of [RITE] does: {!Colitur_kernel.Rite.t} (the diff --git a/lib/rites/rite_ef/rubrics_ef.ml b/lib/rites/rite_ef/rubrics_ef.ml index a497b57..eea92a9 100644 --- a/lib/rites/rite_ef/rubrics_ef.ml +++ b/lib/rites/rite_ef/rubrics_ef.ml @@ -697,3 +697,499 @@ let gloria ~(temporal : (Vocab_ef.season, Vocab_ef.rank) Temporal.t) Gloria mirrors [te_deum] exactly for every Mass not already decided above. This is the ONE call site [te_deum] exists to serve. *) te_deum ~temporal ~observed ~date + +(* Missale Romanum, Rubricae Generales, Caput XVII, "H) De praefatione" (RG + 482-499; docs/research/LT.txt, grep "praefatione dicitur quae cuique"), + quoted here in full so every branch below can cite its own paragraph + without re-quoting the whole rubric: + + "482. Praefatio dicitur quae cuique Missae propria est; qua deficiente, + dicitur praefatio de Tempore, secus communis. + + 483. Nulla commemoratio, in Missa occurrens, praefationem propriam + inducit. + + 484. Praefatio de Nativitate Domini dicitur: + a) tamquam propria in Missis de Nativitate Domini et de eiusdem + octava, necnon in festo Purificationis B. Mariae Virg.; + b) tamquam de Tempore, infra octavam Nativitatis Domini, etiam in + Missis quae secus praefationem propriam haberent, exceptis iis Missis + quae praefationem propriam de divinis mysteriis vel Personis habent; et + a die 2 ad 5 ianuarii. + + 485. Praefatio de Epiphania Domini dicitur: + a) tamquam propria in Missis de festo Epiphaniae et de + Commemoratione Baptismatis D. N. Iesu Christi; + b) tamquam de Tempore diebus a 7 ad 13 ianuarii. + + 486. Praefatio de Quadragesima dicitur: + a) tamquam propria in Missis de Tempore a feria IV cinerum usque ad + sabbatum ante dominicam I Passionis; + b) tamquam de Tempore in ceteris Missis quae celebrantur eodem + tempore, et praefatione propria carent. + + 487. Praefatio de sancta Cruce dicitur: + a) tamquam propria in Missis de tempore a dominica I Passionis usque + ad feriam V in Cena Domini; in Missis tam festivis quam votivis de + sancta Cruce, de Passione Domini et instrumentis Passionis Domini, de + pretiosissimo Sanguine D. N. Iesu Christi, de Ss.mo Redemptore; + b) tamquam de Tempore in omnibus Missis a dominica I Passionis usque + ad feriam IV Hebdomadae sanctae, quae praefatione propria carent. + + 488. Praefatio de Missa chrismatis dicitur feria V in Cena Domini, in + sua Missa. + + 489. Praefatio paschalis dicitur: + a) tamquam propria in Missis de Tempore a Missa Vigiliae paschalis + usque ad vigiliam Ascensionis Domini; + b) tamquam de Tempore in ceteris Missis quae celebrantur eodem + tempore, et praefatione propria carent. + + 490. Praefatio de Ascensione Domini dicitur: + a) tamquam propria in festo Ascensionis Domini; + b) tamquam de Tempore in omnibus Missis a feria VI post Ascensionem + usque ad feriam VI ante vigiliam Pentecostes, quae praefatione propria + carent. + + 491. Praefatio de Ss.mo Corde Iesu dicitur in Missis festivis et + votivis de Ss.mo Corde Iesu. + + 492. Praefatio de D. N. Iesu Christo Rege dicitur in Missis festivis + et votivis de D. N. Iesu Christo Rege. + + 493. Praefatio de Spiritu Sancto dicitur: + a) tamquam propria in Missis de Tempore a vigilia Pentecostes usque + ad subsequens sabbatum; et in Missis festivis et votivis de Spiritu + Sancto; + b) tamquam de Tempore in ceteris Missis quae celebrantur eodem + tempore, et praefatione propria carent. + + 494. Praefatio de Ss.ma Trinitate dicitur: + a) tamquam propria in Missis de festo et votivis Ss.mae Trinitatis; + b) tamquam de Tempore in dominicis Adventus, et in omnibus dominicis + II classis, extra tempus natalicium et paschale. + + 495. Praefatio de beata Maria Virgine dicitur in Missis festivis et + votivis beatae Mariae Virginis, praeterquam in festo Purificationis B. + Mariae Virg. + + 496. Praefatio de S. Ioseph dicitur in Missis festivis et votivis S. + Ioseph. + + 497. Praefatio de Apostolis dicitur in Missis festivis et votivis + Apostolorum et Evangelistarum. + + 498. Praefatio communis dicitur in Missis quae praefatione propria + carent, nec sumere debent praefationem de Tempore. + + 499. Praefatio defunctorum dicitur in Missis defunctorum." + + THE SHAPE OF THE RULE, once, rather than at every branch: RG 482's own + chain is "propria, else de Tempore, else communis". Read literally, 484- + 497 look like FOURTEEN SEPARATE RULES, but on inspection each numbered + rubric's own (a)/(b) pair (where it has both) produces the SAME preface + identity either way -- (a) is the propria reading ("this Mass's OWN + preface"), (b) is the de-Tempore reading ("this OTHER Mass, lacking one + of its own, borrows it") -- so for the single question this function + answers (WHICH preface, not WHETHER it counts as propria or de Tempore + for some other purpose) the two halves collapse into one PRIORITY- + ORDERED decision: a fixed list of "genuinely proper" triggers (title/ + mystery feasts, independent of season), checked first in a citable + order, falling through to a fixed list of SEASONAL windows, falling + through to [Common]. RG 483 (a commemoration never induces a proper) + holds by construction, the same way [creed]'s own 476(e) does: every + branch below reads only [observed], never a day's admitted + commemorations. + + THE PRIORITY ORDER ITSELF was cross-checked against 358 real, + individually classifiable entries in the FIUV Ordo's own [praef] column + (test/fixtures/fiuv-ordo-2025-2026.sexp, test_fiuv_ordo.ml) spanning the + WHOLE liturgical year -- not merely derived from the Latin text in + isolation. Two findings the plain text alone would not have settled, + both empirically confirmed rather than assumed: + + - 484(b)'s own "exceptis iis Missis quae praefationem propriam de + divinis mysteriis vel Personis habent" is NARROWER than every other + window's implicit "unless it already has a genuine proper" -- St + John the Evangelist (27 December, on {!creed_apostle_slugs}, so his + OWN Apostles preface (497) would otherwise apply) is overridden to + [Nativity] inside the octave (confirmed: the Ordo's own 27 December + entry reads "de Nativ.", not "App."), while St Barnabas/Sts Philip & + James/the other Apostles OUTSIDE the octave keep their own Apostles + preface even inside another window (Sts Philip & James, 11 May, + inside the Easter window: confirmed "App." in the Ordo, not + "Pasch."). So [Apostles] is checked AFTER the Nativity window below, + but every OTHER title trigger (Holy Cross/Sacred Heart/Christ the + King/Trinity/St Joseph/BVM) is checked BEFORE it -- RG 484(b)'s own + narrower carve-out, read literally: unreachable on the shipped + calendar for the other five (no such feast falls 25 December-5 + January), so this ordering is defensive for them, not observed live, + the same "checked, not merely assumed" discipline + {!Precedence_ef.marian_slugs}'s own citation follows elsewhere. + - RG 495's own "et votivis" half is live on this engine's data after + all, for the ONE office this project already models as a votive- + shaped Mass without a votive-Mass DIMENSION (RG 78/91 entry 27, the + Saturday Office of the BVM, {!Temporal_ef}'s own [subject = Bvm] + tag): confirmed directly (3 January and 10 January 2026, both the + Saturday Office, both read "BMV" in the Ordo) -- including on 3 + January, itself inside the Nativity's own "2 ad 5 ianuarii" de- + Tempore window, where BVM still wins, corroborating the same + "genuine propria outranks every window" ordering the Apostles + finding above established from the opposite direction. + + Good Friday's own printed [praef] text ("comm. Feria VI prima in + mense.") was NOT used to check this function's own [None] answer for + that day: {!test_fiuv_ordo.ml}'s own F1 (Gloria) already adjudicated + this exact date's raw text as unreliable (a copied, not a considered, + line -- see that allow-list entry's own citation for the full argument, + confirmed against the PDF's own page image, not merely the extracted + fixture) -- the same defect, read again, would apply equally to + whatever trails "praef." on the identical corrupted line, so this + function's [None] rests on RG 28's own "no Mass" structural argument + alone (the same argument [creed]/[gloria]/[te_deum] already give for + this date), not on any Ordo corroboration. *) + +(* RG 487(a): the two GENUINE fixed-date feast triggers in the shipped + universal calendar -- the Exaltation of the Holy Cross (14 September) + and the Most Precious Blood (1 July), both [subject = Lord]. RG 487(a)'s + own further-named categories ("de Passione Domini et instrumentis + Passionis Domini, de Ss.mo Redemptore") have NO corresponding entry + anywhere in data/ef/sanctoral.sexp (checked directly, grepping for + "instrument"/"redeem": zero hits) -- genuinely absent from the shipped + 1962 universal calendar, not merely unmatched by this list, so they are + N/A rather than silently unreachable. *) +let preface_holy_cross_slugs = + [ "exaltation-of-the-holy-cross"; "precious-blood-of-our-lord-jesus-christ" ] + +(* RG 496: the two St Joseph feasts in the shipped calendar (19 March, 1 + May) -- both [subject = Saint], so (unlike RG 495's own BVM feasts) no + [subject]-based fallback exists or is needed; this closed list is the + whole of what RG 496 can ever reach on shipped data. *) +let preface_st_joseph_slugs = [ "joseph-spouse-of-the-bl-virgin-mary"; "joseph-the-workman" ] + +(* RG 497's own [Apostolorum et Evangelistarum] population is WIDER than + {!creed_apostle_slugs}: RG 475(e) is restricted to a NATALICIUM ("festis + NATALICIIS Apostolorum...", that module's own citation), but RG 497 has + no such restriction at all ("in Missis festivis et votivis Apostolorum + et Evangelistarum" -- ANY festive/votive Mass of an Apostle or + Evangelist). FOUND, not assumed: the FIUV Ordo's own 30 June entry + ("In Commemoratione S. Pauli Ap.", data/ef/adjustments.sexp's own RG + 110(c) [Add], {!Precedence_ef}'s own citation -- a genuine [Feast]- + status office of Paul the Apostle, but NOT his own dies natalis, so + {!creed_apostle_slugs} deliberately excludes it) reads "App. I", not + "comm." -- checked directly while building this comparison, not + guessed. [creed_apostle_slugs] itself is UNCHANGED (RG 475(e)'s own + narrower "natalicium" reading still holds for the Creed); this is a + SEPARATE, wider list for RG 497 alone. + + "conversion-of-st-paul" (25 January, Class3, the SAME "not a + natalicium" shape {!creed_apostle_slugs}'s own citation excludes it + for) is a plausible SECOND candidate by the identical RG 497 reasoning + -- deliberately NOT added: it is UNWITNESSED (25 January falls on a + Sunday, hence impeded, in the one fixture year this engine's Ordo + evidence covers, {!test_fiuv_ordo.ml}'s own window), and "a wrong + citation is worse than a missing one" ({!Precedence_ef.marian_slugs}'s + own citation, the precedent this follows). Left for a future fixture + year to confirm or refute. *) +let preface_apostle_slugs = "in-commemoratione-sancti-pauli-apostoli" :: creed_apostle_slugs + +(* RG 495's own [beatae Mariae Virginis] population is also WIDER than + {!Precedence_ef.marian_slugs}: that list was built for a DIFFERENT + rubric (RG 112(d), whether a commemoration invokes HER OWN + intercession specifically) with a correspondingly narrower, oration- + checked standard, and its own citation explicitly EXCLUDES "dedication- + of-the-basilica-of-st-mary-major" (5 August) for exactly that reason -- + "whose own oration could not be found... to confirm it invokes her + intercession". RG 495 asks a different, WIDER question ("is this Mass + festive or votive OF the Blessed Virgin Mary at all"), which the + Dedication of St Mary Major answers on its own title alone, without + needing the oration-level standard RG 112(d) requires. FOUND, not + assumed: the FIUV Ordo's own 5 August entry reads "BMV Et te in + Festivitate.", not "comm." -- checked directly. *) +let preface_bvm_slugs = "dedication-of-the-basilica-of-st-mary-major" :: Precedence_ef.marian_slugs + +let preface ~(temporal : (Vocab_ef.season, Vocab_ef.rank) Temporal.t) + ~(observed : Vocab_ef.rank Celebration.t) ~(date : Date.t) : Preface.t option = + let easter = Computus.gregorian_easter (Date.year date) in + let n = Date.to_rata date - Date.to_rata easter in + let m = Date.month date and dd = Date.day date in + let slug = Slug.to_string observed.Celebration.slug in + if + (* RG 28-34/RG 23(b), the same structural "no Mass at all" position + [creed]'s own vigil comment and [gloria]'s own 432(d) comment both + take for Good Friday specifically: the 1955-restored Holy Week has + no Mass whatsoever that day (only the afternoon liturgical action), + so there is no Mass to preface. Checked ahead of the + {!Colour.Black} Requiem proxy immediately below -- unlike All + Souls, Good Friday sharing that colour is coincidental, not + diagnostic (temporal_ef.ml's own RG 132 citation), and the two need + DIFFERENT answers here (unlike [creed]/[gloria]/[te_deum], where + both collapse to the same boolean) -- so this function cannot reuse + their shared single guard and must split Good Friday out first. *) + n = -2 + then None + else if + (* RG 499: "in Missis defunctorum" -- the same {!Colour.Black} proxy + [creed]'s own 476(f), [te_deum]'s own 238(d) and [gloria]'s own + 432(d) already use (this file's own header has the full argument + and the two-member population this proxy rests on). With Good + Friday split out above, the one remaining member is All Souls. *) + observed.Celebration.colour = Colour.Black + then Some Preface.Requiem + else if + (* RG 487(a)'s own fixed-feast half -- checked first among the title + triggers per this file's own header (arbitrary among these six, + since none can ever co-occur with another on shipped data; Holy + Cross is placed first only because it is also the anchor for the + Passiontide WINDOW checked later below, keeping both citations + adjacent in this file). *) + List.mem slug preface_holy_cross_slugs + then Some Preface.Holy_cross + else if + (* RG 491: "in Missis festivis... de Ss.mo Corde Iesu" -- the Friday + after the Octave of Corpus Christi (Easter+68), {!Temporal_ef}'s own + named slug. Confirmed against the Ordo (12 June 2026: "de Ss.mi + Corde Iesu"). *) + slug = "ef-sacred-heart" + then Some Preface.Sacred_heart + else if + (* RG 492: "in Missis festivis... de D. N. Iesu Christo Rege" -- the + last Sunday of October, {!Temporal_ef.christ_the_king}'s own named + slug. Confirmed against the Ordo (25 October 2026: "de Domino + Nostro Jesu Rege"). *) + slug = "ef-christ-the-king" + then Some Preface.Christ_the_king + else if + (* RG 490(a): "in festo Ascensionis Domini" -- the feast itself + (Easter+39), checked here by slug rather than folded into the + Ascension WINDOW below (which starts the day AFTER, Easter+40): + Ascension Day itself needs no window at all, its own slug already + identifies it uniquely. Confirmed against the Ordo (14 May 2026: + "Ascensionis, Communic pr."). *) + slug = "ef-ascension" + then Some Preface.Ascension + else if + (* RG 494(a): "in Missis de festo... Ss.mae Trinitatis" -- Trinity + Sunday itself (Easter+56), {!Temporal_ef}'s own named slug. Checked + ahead of 494(b)'s own WIDER de-Tempore grant (checked last below, + after every other window) for the same "specific propria before any + season fallback" reason every other title trigger is. Confirmed + against the Ordo (31 May 2026: "Trinit. II"). *) + slug = "ef-trinity" + then Some Preface.Trinity + else if + (* RG 496: see {!preface_st_joseph_slugs}'s own citation. *) + List.mem slug preface_st_joseph_slugs + then Some Preface.St_joseph + else if + (* RG 495: "in Missis festivis et votivis beatae Mariae Virginis" -- + {!preface_bvm_slugs} (its own citation has the full account of why + it is wider than {!Precedence_ef.marian_slugs}) covers every + genuine Marian FEAST; [subject = Bvm] covers the one VOTIVE-shaped + office this engine models without a votive-Mass dimension of its + own (RG 78/91 entry 27, the Saturday Office of the BVM -- this + file's own header has the empirical confirmation, 3/10 January + 2026). The Purification is deliberately ABSENT from both: + {!Precedence_ef.marian_slugs} already excludes it by name (its own + citation), and it never carries [subject = Bvm] (tagged [Lord] + instead, register §6.0) -- RG 495's own "praeterquam in festo + Purificationis" exclusion therefore holds by construction, not by + a guard written here. + + [not (is_vigil slug)]: RG 495's own "festivis" reads "festum", not + "vigilia" -- the SAME RG 21/35 taxonomy distinction {!creed}'s own + RG 28-34 comment already makes ("a vigil is its OWN liturgical-day + category, distinct from 'festum'"), applied here for the first + time in THIS function because it is the first branch a vigil can + actually reach: {!Precedence_ef.marian_slugs} includes + "vigil-of-the-assumption" (that list's own citation), which without + this guard would wrongly claim [Bvm] for 14 August. FOUND, not + assumed: the FIUV Ordo's own 14 August entry reads "comm. I", not + "BMV" -- checked directly, the same as every other finding in this + branch's own history. Corroborates, from the opposite direction, + {!creed}'s own RG 28-34 comment: colitur's own Nativity WINDOW + below already excludes 24 December (the Nativity Vigil) by + construction (it starts at 25 December, never 24th), so this guard + makes the SAME "a vigil is not a festum" answer explicit here too, + rather than relying on a second, unrelated accident of a date + range to produce it. *) + (not (Precedence_ef.is_vigil slug)) + && (List.mem slug preface_bvm_slugs || observed.Celebration.subject = Subject.Bvm) + then Some Preface.Bvm + else if + (* RG 484(a)'s own explicit Purification clause ("necnon in festo + Purificationis B. Mariae Virg.") -- 2 February, nowhere near the + Nativity's own Christmas-to-Epiphany calendar position, so this is + a standalone slug check, not part of the WINDOW test below (unlike + every other 484 trigger, which IS date-based). Checked here, after + the BVM check immediately above (which the Purification's own + [subject = Lord] tag never reaches) and before the Nativity window + (which its own actual date, 2 February, never reaches either) -- + positioned with the rest of 484's own citations for readability, + not because anything below could otherwise pre-empt it. *) + slug = "purification-of-the-blessed-virgin-mary" + then Some Preface.Nativity + else if + (* RG 484(a)/(b) merged, per this file's own header: 25 December-1 + January (the Nativity itself and its octave, propria) UNION 2-5 + January (498(b)'s own explicit extra de-Tempore days) -- one + contiguous window, since both halves produce the identical + preface. Checked BEFORE Apostles (below) but AFTER every genuine + "divine mysteries/Persons" propria above, per this file's own + header (St John the Evangelist, 27 December, is the live witness: + Apostles would otherwise apply and does not). *) + (m = 12 && dd >= 25) || (m = 1 && dd <= 5) + then Some Preface.Nativity + else if + (* RG 497: "in Missis festivis et votivis Apostolorum et + Evangelistarum" -- {!preface_apostle_slugs} (its own citation has + the full account of why it is wider than {!creed_apostle_slugs}), + confirmed by this file's own header to produce the SAME preface + answer as the Ordo on every Apostle date outside the Nativity + octave: 11 June (Barnabas), 29-30 June (Peter & Paul, In + Commemoratione Pauli), 11 May (Philip & James, RG 484(b)'s own + witness against the Nativity window immediately above). Checked + AFTER the Nativity window specifically (RG 484(b)'s own narrower + carve-out), but before every OTHER season window below -- an + Apostle feast keeps his own preface inside Lent, Passiontide, + Paschaltide etc., where nothing narrows the exception the way + 484(b) does. No [is_vigil] guard is needed here the way RG 495's + own branch above needs one: checked directly, no entry on + {!preface_apostle_slugs} is ever a vigil slug (every Apostle vigil + in the shipped data -- "vigil-of-sts-peter-paul" -- carries its own + distinct slug, absent from this list). *) + List.mem slug preface_apostle_slugs + then Some Preface.Apostles + else if + (* RG 485(a): "in Missis de festo Epiphaniae et de Commemoratione + Baptismatis D. N. Iesu Christi" -- the feast itself and its own + named commemoration (13 January, {!Precedence_ef}'s own + "commemoration-of-the-baptism-of-the-lord" -- {!creed}'s own 475(c) + comment already documents this entry's [subject = Lord] tag), both + checked by slug so 485(b)'s own WIDER window below need not repeat + them. *) + slug = "ef-epiphany" || slug = "commemoration-of-the-baptism-of-the-lord" + then Some Preface.Epiphany + else if + (* RG 485(b): "diebus a 7 ad 13 ianuarii" -- every OTHER Mass in this + window (Holy Family Sunday, an ordinary Time-after-Epiphany feria + or Sunday, a saint's feast with no propria of its own), confirmed + against the Ordo's own 11 January 2026 entry (Holy Family Sunday: + "de Epiphania. II", not a Holy-Family-specific preface -- this + engine has none to offer it anyway). Colitur's own Christmastide + season already spans 25 December-13 January (RG 72-73, + {!Vocab_ef.season}'s own citation), so this window is exactly its + OWN post-Epiphany tail; written as an explicit date range rather + than a season test only because the Nativity window above already + claims the season's FIRST half by date, not by season either, for + symmetry. *) + m = 1 && dd >= 6 && dd <= 13 + then Some Preface.Epiphany + else if + (* RG 486(a)/(b) merged: Ash Wednesday (Easter-46) through the + Saturday before Passion Sunday I (Easter-15) inclusive -- every + Lenten feria/Sunday's own Mass (a), and every OTHER Mass in the + same span lacking a proper of its own (b). Confirmed against the + Ordo throughout (e.g. 18 February/19-20 February 2026: "Quadr."). + {!creed}'s own RG 23 comment already explains why Ash Wednesday + (feria I classis) reaches this branch on [observed]'s own terms + regardless of rank -- this function reads no rank at all here, + only the date. *) + n >= -46 && n <= -15 + then Some Preface.Lent + else if + (* RG 487(a)/(b) merged: Passion Sunday I (Easter-14) through Holy + Thursday (Easter-3) inclusive -- (a)'s own "de tempore"/festive- + votive half extends through Holy Thursday itself (the Mass of the + Lord's Supper), (b)'s own narrower saint-Mass half stops one day + earlier (Holy Wednesday) but reaches no LIVE day this check does + not already cover identically (Holy Thursday is a feria I classis, + RG 23(b), so no saint's feast can ever occupy it -- {!creed}'s own + RG 23 citation). Confirmed against the Ordo throughout (22 March + 2026, Passion Sunday: "de Sancta Cruce."; 2 April 2026, Holy + Thursday: "de Sancta Cruce, Communicantes..."). *) + n >= -14 && n <= -3 + then Some Preface.Holy_cross + else if + (* RG 489(a)/(b) merged: the Easter Vigil Mass (Easter-1, on Holy + Saturday's own date) through the vigil of the Ascension (Easter+38) + inclusive. [n = -1] is this engine's own OVERLOADED representation + of "the Vigil Mass", not Holy Saturday's daytime (which has no Mass + of its own at all, unlike Good Friday's [n = -2] this function + excludes by name above) -- [gloria]'s own RG 431(c) comment already + establishes the same convention for the identical date, and RG + 489(a) resolves the question on its own terms regardless: the + Paschal preface's window STARTS at the Vigil Mass, so [n = -1] is + correctly [Easter]. Confirmed against the Ordo throughout (5 April + 2026, Easter Sunday: "Pasch."; 13 May 2026, the Ascension Vigil: + "Pasch. I"); the fixture prints nothing at all for Holy Saturday's + own daytime square (4 April 2026), corroborating rather than + contradicting this reading -- see test_fiuv_ordo.ml's own citation. *) + n >= -1 && n <= 38 + then Some Preface.Easter + else if + (* RG 490(b): "a feria VI post Ascensionem usque ad feriam VI ante + vigiliam Pentecostes" -- the Friday after Ascension (Easter+40) + through the Friday before the Pentecost vigil (Easter+47) + inclusive; Ascension Day itself (Easter+39) is already handled by + its own slug check above, not repeated here. Confirmed against the + Ordo throughout (15-22 May 2026: "Ascensionis"). *) + n >= 40 && n <= 47 + then Some Preface.Ascension + else if + (* RG 493(a)/(b) merged: the vigil of Pentecost (Easter+48) through + "subsequens sabbatum" (the FOLLOWING Saturday, i.e. the Ember + Saturday within the Octave of Pentecost, Easter+55) inclusive. + Confirmed against the Ordo throughout (23-24 May 2026, the vigil + and Pentecost itself: "de Spirito Sancto"; 30 May 2026, the Ember + Saturday: "de Spirito Sancto"). *) + n >= 48 && n <= 55 + then Some Preface.Holy_spirit + else if + (* RG 494(b): "in dominicis Adventus, et in omnibus dominicis II + classis, extra tempus natalicium et paschale". Read off + [temporal]'s own season, NOT [observed]'s rank -- CORRECTED from an + earlier version of this branch that DID read [observed.rank] and + required it to equal [Class2], which is wrong for the identical RG + 16(a) reason {!Precedence.rules.admit}'s own [~temporal] parameter + exists and [creed]'s own 237(b)/475(a) comments already give: a + feast that has WON the day can carry a different rank than the + Sunday it stands on. FOUND, not assumed: All Saints' Day (1 + November), Class1, observed outright over an ordinary + Time-after-Pentecost Sunday it commemorates + ([+ef-time-after-pentecost-sunday-23]), reads "Trinit." in the + Ordo -- [observed.rank] there is [Class1], so the OLD guard wrongly + answered [Common]; there is no dedicated preface for All Saints + among RG 484-497's own fourteen, so RG 482's chain correctly falls + through to the SUNDAY's own de-Tempore grant regardless of which + rank actually won the day. + + {!Temporal_ef.temporal}'s own [match s with Advent | Lent -> Class1 + | _ -> Class2] means EVERY Sunday's own TEMPORAL identity is + [Class2] except in Advent and Lent -- so "in omnibus dominicis II + classis" and "in dominicis Adventus" collapse into ONE test, "any + Sunday outside Christmastide and Paschaltide" (Lent's own Sundays + need no explicit exclusion here: {!creed}'s own RG 23/Lent-window + reasoning already means every one of them is claimed by the LENT + window earlier in this very priority chain, provably unreachable + here, the same "checked, not merely assumed" position the previous + version of this comment already took for Christmastide/Paschaltide + -- confirmed by the SAME domain sweep in test_rubrics_ef.ml, which + still finds zero Christmastide/Paschaltide/Lent days reaching this + branch after this change). Confirmed against the Ordo throughout + (e.g. every Advent/Time-after-Epiphany/Septuagesima/Time-after- + Pentecost Sunday not otherwise claimed: "Trinit."), now including + All Saints' Day itself. *) + temporal.Temporal.weekday = Date.Sun + && temporal.Temporal.season <> Vocab_ef.Christmastide + && temporal.Temporal.season <> Vocab_ef.Paschaltide + then Some Preface.Trinity + else + (* RG 498: "in Missis quae praefatione propria carent, nec sumere + debent praefationem de Tempore" -- everything else: an ordinary + weekday feria outside every window above, a plain sanctoral saint + with no title of his own, an ordinary (non-Sunday, non-Class2, or + Christmastide/Paschaltide) day. Confirmed against the Ordo + throughout (the single most common value in the fixture, 188 of + 360 comparable rows). *) + Some Preface.Common diff --git a/lib/rites/rite_ef/rubrics_ef.mli b/lib/rites/rite_ef/rubrics_ef.mli index 618af65..befbf72 100644 --- a/lib/rites/rite_ef/rubrics_ef.mli +++ b/lib/rites/rite_ef/rubrics_ef.mli @@ -68,3 +68,38 @@ val gloria : observed:Vocab_ef.rank Celebration.t -> date:Date.t -> bool + +(** RG 487(a)'s own two fixed-feast triggers for the Holy Cross preference + (the Exaltation of the Holy Cross, the Most Precious Blood) -- see the + .ml's own citation for what RG 487(a)'s further-named categories (the + Passion/Instruments of the Passion, the Most Holy Redeemer) are absent + from the shipped calendar entirely, not merely from this list. *) +val preface_holy_cross_slugs : string list + +(** RG 496's own two St Joseph feasts in the shipped calendar. *) +val preface_st_joseph_slugs : string list + +(** RG 497's own [Apostolorum et Evangelistarum] population -- WIDER than + {!creed_apostle_slugs} (no natalicium restriction); see the .ml's own + citation for the FIUV Ordo evidence and what was deliberately left + off, unwitnessed. *) +val preface_apostle_slugs : string list + +(** RG 495's own [beatae Mariae Virginis] population -- WIDER than + {!Precedence_ef.marian_slugs} (a different, looser standard than that + list's own RG 112(d) oration-level one); see the .ml's own citation + for the FIUV Ordo evidence. *) +val preface_bvm_slugs : string list + +(** Which preface is said at this day's Mass (RG 482-499). [None] both when + this engine resolves a day with no Mass at all (Good Friday) and -- for + a rite that has not implemented this function at all -- as the type's + own neutral value; see the .ml's own header for the rubric quoted in + full, the priority order every branch follows and why, and the FIUV + Ordo evidence that order rests on. Same three parameters as {!creed}/ + {!te_deum}/{!gloria}, for the same reasons. *) +val preface : + temporal:(Vocab_ef.season, Vocab_ef.rank) Temporal.t -> + observed:Vocab_ef.rank Celebration.t -> + date:Date.t -> + Preface.t option diff --git a/man/colitur.1 b/man/colitur.1 index 88c5fab..3f485d5 100644 --- a/man/colitur.1 +++ b/man/colitur.1 @@ -122,13 +122,14 @@ occurrence, commemoration and transfer. The Mass reading citations, one line per day. .TP .BI rubrics " YEAR" -Three rubrics of the Mass, one line per day: which formulary is actually +Four rubrics of the Mass, one line per day: which formulary is actually said \(em not always the day's own: a weekday with no proper resumes the preceding Sunday's, a saint with no proper says his assigned Common, and RG 78/309(a)'s votive Saturday Mass of Our Lady is said in place of an -unoccupied office's own \(em whether the Creed is said (RG 475\-476), and +unoccupied office's own \(em whether the Creed is said (RG 475\-476), whether the Gloria in excelsis is said (RG 431\-432, deferring to the -Breviary's own Te Deum rule, nn. 237\-238, for RG 431(a)). See +Breviary's own Te Deum rule, nn. 237\-238, for RG 431(a)), and which +preface is said (RG 482\-499). See .B OUTPUT FORMAT below. .TP @@ -461,13 +462,14 @@ own trailing field, above. .SS rubrics .RS .nf -date [TAB] formulary\-slug [TAB] source [TAB] creed [TAB] gloria +date [TAB] formulary\-slug [TAB] source [TAB] creed [TAB] gloria [TAB] preface .fi .RE .PP The day's own Mass formulary (which slug's Mass is actually said, and how -that was decided), followed by whether the Creed is said (RG 475\-476) and -whether the Gloria in excelsis is said (RG 431\-432). +that was decided), followed by whether the Creed is said (RG 475\-476), +whether the Gloria in excelsis is said (RG 431\-432), and which preface is +said (RG 482\-499). .B rubrics separates its fields with a literal TAB \(em not a plain space like .B day @@ -505,12 +507,25 @@ this row has no other boolean field to be consistent with). A day with no Mass at all for a rite that has not implemented a rule reads .B false outright \(em it is a decision, never a third \(lqunknown\(rq state. +.I preface +is one of +.BR nativity ", " epiphany ", " lent ", " holy\-cross ", " easter ", " +.BR ascension ", " sacred\-heart ", " christ\-the\-king ", " holy\-spirit ", " +.BR trinity ", " bvm ", " st\-joseph ", " apostles ", " common " or " requiem , +or a literal +.B \- +when this engine resolves no Mass at all that day (Good Friday) \(em unlike +.I creed / gloria , +.I preface +is a genuine option, so +.B \- +here can also mean a rite that has not implemented the rule at all. .RS .nf -2026\-01\-01 [TAB] ef\-circumcision [TAB] own [TAB] true [TAB] true -2038\-03\-08 [TAB] john\-of\-god [TAB] proper [TAB] false [TAB] true -2025\-12\-01 [TAB] ef\-advent\-sunday\-1 [TAB] preceding\-sunday [TAB] false [TAB] false +2026\-01\-01 [TAB] ef\-circumcision [TAB] own [TAB] true [TAB] true [TAB] nativity +2038\-03\-08 [TAB] john\-of\-god [TAB] proper [TAB] false [TAB] true [TAB] common +2025\-12\-01 [TAB] ef\-advent\-sunday\-1 [TAB] preceding\-sunday [TAB] false [TAB] false [TAB] common .fi .RE .PP diff --git a/test/cli.t b/test/cli.t index 8bd5b9f..4ae116f 100644 --- a/test/cli.t +++ b/test/cli.t @@ -210,9 +210,9 @@ separate command for the same mechanical reason `readings` is: `day`'s row is fixed-width space-separated with a variable-length "+slug" tail. $ colitur rubrics 2026 | head -3 - 2026-01-01 ef-circumcision own true true - 2026-01-02 ef-christmas-1-friday own false true - 2026-01-03 ef-christmas-1-saturday votive false true + 2026-01-01 ef-circumcision own true true nativity + 2026-01-02 ef-christmas-1-friday own false true nativity + 2026-01-03 ef-christmas-1-saturday votive false true bvm $ colitur rubrics 2026 | wc -l 365 @@ -224,19 +224,19 @@ apply to it -- step 2 does (the day's own temporal slug in the lectionary), tagged `own`. Contrast a real sanctoral saint with his own proper: $ colitur rubrics 2038 | grep '^2038-03-08' - 2038-03-08 john-of-god proper false true + 2038-03-08 john-of-god proper false true common A saint with no proper of his own says his assigned Common (step 4): $ colitur rubrics 2038 | grep '^2038-03-06' - 2038-03-06 common-of-non-virgins-1 common false true + 2038-03-06 common-of-non-virgins-1 common false true common A weekday with no proper of its own resumes the preceding Sunday's, never its own observed slug -- 1 December 2025 is the Monday after Advent I, and Advent's ferias have no Mass of their own (step 3): $ colitur rubrics 2025 | grep '^2025-12-01' - 2025-12-01 ef-advent-sunday-1 preceding-sunday false false + 2025-12-01 ef-advent-sunday-1 preceding-sunday false false common 3 January 2026 above ("votive") is the RG 78/309(a) Saturday Mass of Our Lady, said IN PLACE of the day's own office's Mass while the office (an @@ -257,9 +257,9 @@ diocesan overlay's local patron observed instead (no proper or Common of his own in the fixture), the chain falls all the way back to step 3: $ colitur rubrics 2026 --overlay fixtures/overlay-example-diocesan.sexp | grep '^2026-07-11' - 2026-07-11 ef-time-after-pentecost-sunday-6 preceding-sunday false true + 2026-07-11 ef-time-after-pentecost-sunday-6 preceding-sunday false true common $ colitur rubrics 2026 | grep '^2026-07-11' - 2026-07-11 ef-time-after-pentecost-6-saturday votive false true + 2026-07-11 ef-time-after-pentecost-6-saturday votive false true bvm `--lang`/`--raw`/`--sigla-*` are refused rather than silently ignored, unlike `readings`: this row resolves no display name and no citation for any of @@ -343,13 +343,13 @@ prints for the identical day, so the two cannot silently drift apart again in either direction: $ colitur --help | grep '^ rubrics date' - rubrics date, formulary slug, source, creed, gloria -- TAB-separated + rubrics date, formulary slug, source, creed, gloria, preface -- TAB-separated $ colitur --help | sed -n '/^ rubrics date/{n;p}' - 2026-01-01[TAB]ef-circumcision[TAB]own[TAB]true[TAB]true + 2026-01-01[TAB]ef-circumcision[TAB]own[TAB]true[TAB]true[TAB]nativity $ colitur rubrics 2026 | grep '^2026-01-01' | sed $'s/\t/[TAB]/g' - 2026-01-01[TAB]ef-circumcision[TAB]own[TAB]true[TAB]true + 2026-01-01[TAB]ef-circumcision[TAB]own[TAB]true[TAB]true[TAB]nativity --version prints the version alone, to standard output, exit 0. Deliberately not embedded in the help text above: this pin would then have to be edited @@ -589,8 +589,17 @@ addition: 2027-01-01's own record wraps [gloria] onto its own new line, while 2027-01-02's fits it on the same line as [creed] and [formulary] -- 172 of 365 records happened to cross a wrap boundary, the rest did not. +9197 -> 9252 (preface, celebrant-rubrics-phase1 Phase 3): {!Liturgical_day.t} +gained a [preface] field, the same seam [gloria] just above used -- all +365 of 2027's records print a new [(preface ())] token (checked +directly, [grep -c "(preface"]), [(preface ())] on exactly one day +(2027's own Good Friday, 26 March -- this engine resolves no Mass at all +that day, {!Rite_ef.Rubrics_ef.preface}'s own citation). Same cosmetic +reflow mechanics as every entry above, not a fixed one-line-per-record +addition. + $ colitur emit --format sexp --from 2027 --to 2027 | wc -l - 9197 + 9252 $ colitur emit --format xml --from 2027 --to 2027 | head -2 diff --git a/test/test_calendar.ml b/test/test_calendar.ml index 157841b..9059574 100644 --- a/test/test_calendar.ml +++ b/test/test_calendar.ml @@ -104,11 +104,13 @@ module Fixture = struct the sanctoral side. *) let readings ~observed:_ ~temporal:_ ~date:_ ~temporal_at:_ = (None, []) - (* No fixture here exercises the Creed or Gloria rubrics either -- a rite - that has not implemented them returns [false] explicitly, - {!Rite.t.creed}/{!Rite.t.gloria}'s own documented default. *) + (* No fixture here exercises the Creed, Gloria or preface rubrics either + -- a rite that has not implemented them returns [false]/[None] + explicitly, {!Rite.t.creed}/{!Rite.t.gloria}/{!Rite.t.preface}'s own + documented default. *) let creed ~temporal:_ ~observed:_ ~date:_ = false let gloria ~temporal:_ ~observed:_ ~date:_ = false + let preface ~temporal:_ ~observed:_ ~date:_ = None let rite : (season, rank) Rite.t = { Rite.id = "synthetic-calendar"; vocab; year_start; temporal; anchors = (fun _ -> []); @@ -120,7 +122,7 @@ module Fixture = struct (* Not a Roman rite either, so no bissextile-doubling convention: identity, {!Rite.t.fixed_key}'s own documented default. *) fixed_key = (fun d -> Some (D.month d, D.day d)); - rules; season_runs = [ A; B ]; transfer_target; readings; creed; gloria } + rules; season_runs = [ A; B ]; transfer_target; readings; creed; gloria; preface } let entry ~month ~day ~slug ~rank = { Layer.date = (match Date_spec.fixed ~month ~day with Ok d -> d | Error e -> failwith e); diff --git a/test/test_fiuv_ordo.ml b/test/test_fiuv_ordo.ml index 5c009a9..3bfed68 100644 --- a/test/test_fiuv_ordo.ml +++ b/test/test_fiuv_ordo.ml @@ -40,6 +40,7 @@ module Overlay = Colitur_kernel.Overlay module LD = Colitur_kernel.Liturgical_day module Date = Colitur_kernel.Date module V = Rite_ef.Vocab_ef +module Preface = Colitur_kernel.Preface let sanctoral_path = "../data/ef/sanctoral.sexp" let adjustments_path = "../data/ef/adjustments.sexp" @@ -138,7 +139,7 @@ let window_last = "2026-12-31" (* late). *) (* ---------------------------------------------------------------------- *) -type colitur_row = { c_date : string; c_creed : bool; c_gloria : bool } +type colitur_row = { c_date : string; c_creed : bool; c_gloria : bool; c_preface : Preface.t option } let colitur_rows () = let layer = real_layer () in @@ -156,7 +157,9 @@ let colitur_rows () = (match Hashtbl.find_opt by_rata (Date.to_rata !d) with | Some day -> rows := - { c_date = Date.to_iso8601 day.LD.date; c_creed = day.LD.creed; c_gloria = day.LD.gloria } :: !rows + { c_date = Date.to_iso8601 day.LD.date; c_creed = day.LD.creed; c_gloria = day.LD.gloria; + c_preface = day.LD.preface } + :: !rows | None -> Alcotest.failf "no colitur day resolved for %s" (Date.to_iso8601 !d)); d := Date.add_days !d 1 done; @@ -345,6 +348,148 @@ let test_gloria_matches_or_is_explained () = (List.rev !unexplained); Alcotest.(check int) "F1 (Good Friday) count" 1 !f1_count +(* ---------------------------------------------------------------------- *) +(* Preface (RG 482-499, {!Rite_ef.Rubrics_ef.preface}) -- Phase 3. Same *) +(* shape as Creed/Gloria above, over the identical 400-row window, using *) +(* the [praef] column already captured into the fixture (CAPTURED, NOT *) +(* VALIDATED at the time -- tools/extract_fiuv_ordo.ml's own [row.praef] *) +(* citation) but never compared until now. *) +(* *) +(* CHARACTERISATION FIRST, per the task's own standing instruction (this + project has been misled by trusting an Ordo field's surface shape + before, four times this session alone): [praef] is RAW TRAILING TEXT + after the literal token "praef." to the end of the primary Mass + clause -- it is not a clean enum, it can carry an arbitrary tail of + unrelated rubrical prose glued on by the same token-flattening that + produces it (Good Friday's own [gloria]/F1 finding above is the + worked example of exactly this shape). CLASSIFIED here by PREFIX + match against the FIRST WORD(S) only -- every prefix below was found + by direct inspection of the fixture's own distinct [praef] values + (test/fixtures/fiuv-ordo-2025-2026.sexp, 400 rows, all read by hand + while building this comparison) and cross-checked against the primary + rubric text, not guessed. Two genuine EXCEPTION SHAPES, THREE rows, + found the same way: + + - Christmas Day (both of them -- the fixture's own window, 27 + November 2025-31 December 2026, spans thirteen months and TWO 25 + Decembers, confirmed by running the coverage test below before + pinning it at "two", not three, the first time) and Epiphany Day + (2026-01-06, the window's only one) print "praef. et Communic..." + -- an ELLIPSIS: the compiler treats "this feast's own [i.e. + obviously implied] preface" as not needing its own name restated + on the ONE day that IS that feast, unlike every octave/de-Tempore + day governed by the SAME feast (which always restates the name + explicitly, e.g. 26 December's own "de Nativ."). Confirmed by + direct inspection: no OTHER day in the fixture omits the name this + way. [classify_praef] returns [None] (unrecognised) for all three + rather than special-casing them by date -- both feasts are + independently, directly citable from RG 484(a)/485(a)'s own text + (the Nativity/Epiphany feast itself is the paradigm case each + clause names first), so the oracle's corroboration is not needed + to trust colitur's own answer on these dates, and forcing a match + here would mean trusting an inference about the source's own + ellipsis rather than reading it. + - Good Friday (2026-04-03) prints "comm. Feria VI prima in mense." -- + classifies cleanly as [Common] by the same prefix rule as any + other day, but this file's own F1 (Gloria, above) already + adjudicated this EXACT date's raw text as unreliable (a copied, + not a considered, line -- confirmed against the PDF's own page + image, not merely the extracted fixture). The same defect + extends to whatever trails "praef." on the identical corrupted + line: excluded from this comparison by reusing [is_f1_good_friday] + directly, not re-argued. *) + +let has_prefix ~prefix s = + String.length s >= String.length prefix && String.sub s 0 (String.length prefix) = prefix + +(* Every prefix below maps 1:1 onto exactly one branch of + {!Rite_ef.Rubrics_ef.preface}'s own priority order -- see that + function's own header for the full citation of each. Order does not + matter here (unlike in [preface] itself): the Ordo's own printed text + never carries two of these prefixes on the same row, so this is a + partition, not a priority list. *) +let praef_prefixes = + [ ("comm.", Preface.Common); + ("Trinit.", Preface.Trinity); + ("Quadr.", Preface.Lent); + ("de Sancta Cruce", Preface.Holy_cross); + ("Pasch.", Preface.Easter); + ("etc. ut in festo", Preface.Easter); + (* Easter-octave ferias, "as on the feast [of Easter]" -- e.g. 6 + April 2026, "etc. ut in festo., ad Ite, missa est additur duplex + Alleluia." *) + ("Ascensionis", Preface.Ascension); + ("de Spirito Sancto", Preface.Holy_spirit); + ("BMV", Preface.Bvm); + ("App.", Preface.Apostles); + ("de Nativ.", Preface.Nativity); + ("de Epiphania", Preface.Epiphany); + ("de Ss.mi Corde", Preface.Sacred_heart); + ("de Ss.mo Corde", Preface.Sacred_heart); + ("de Domino Nostro Jesu Rege", Preface.Christ_the_king); + ("de S Iosepho", Preface.St_joseph); + ("de S Ioseph", Preface.St_joseph); + ("defunctorum", Preface.Requiem) ] + +let classify_praef (s : string) : Preface.t option = + let rec go = function + | [] -> None + | (prefix, p) :: rest -> if has_prefix ~prefix s then Some p else go rest + in + go praef_prefixes + +(* Measured directly, not assumed: every one of the 400 rows' own [praef] + text, classified, tallied by outcome. Two rows are genuinely + unclassifiable by this prefix table (the ellipsis dates above); every + other non-[None] [praef] value classifies. Pinned so a change to + either the fixture or [classify_praef] that silently drops coverage + fails loudly here rather than merely narrowing the comparison below. *) +let test_praef_classification_coverage () = + let ordo = ordo_rows () in + let with_praef = List.filter_map (fun o -> o.praef) ordo in + Alcotest.(check int) "399 of 400 rows carry a [praef] value (Holy Saturday is the one exception)" 399 + (List.length with_praef); + let unclassified = List.filter (fun s -> classify_praef s = None) with_praef in + (* THREE rows, not two: the fixture's own window (27 November 2025-31 + December 2026, 13 months) spans TWO Christmas Days, both printing the + identical ellipsis text -- found running this exact assertion, not + assumed from the date count alone. *) + Alcotest.(check int) "the three ellipsis rows (two Christmas Days, one Epiphany) are unclassified" 3 + (List.length unclassified); + Alcotest.(check bool) "every unclassified row starts with the ellipsis's own \"et Communic\"" true + (List.for_all (has_prefix ~prefix:"et Communic") unclassified) + +let describe_preface_mismatch (o : ordo_row) (c : colitur_row) (expected : Preface.t) = + Printf.sprintf "%s %S: colitur preface=%s, Ordo praef=%S (classified %s)" o.date o.title + (match c.c_preface with Some p -> Preface.to_string p | None -> "-") + (Option.get o.praef) (Preface.to_string expected) + +let test_preface_matches_or_is_explained () = + let ordo = ordo_rows () in + let colitur = colitur_rows () in + let unexplained = ref [] in + let unclassified_count = ref 0 in + let f1_count = ref 0 in + List.iter2 + (fun (o : ordo_row) (c : colitur_row) -> + if not (String.equal o.date c.c_date) then Alcotest.failf "misaligned: ordo %s vs colitur %s" o.date c.c_date; + if is_f1_good_friday o then incr f1_count + else + match o.praef with + | None -> () + | Some raw -> ( + match classify_praef raw with + | None -> incr unclassified_count + | Some expected -> + if c.c_preface = Some expected then () + else unexplained := describe_preface_mismatch o c expected :: !unexplained)) + ordo colitur; + Alcotest.(check (list string)) "every classified preface difference is explained -- none unexplained" [] + (List.rev !unexplained); + Alcotest.(check int) "the three ellipsis rows are skipped, not silently counted as agreement" 3 + !unclassified_count; + Alcotest.(check int) "F1 (Good Friday) is skipped here too, the same root cause as the Gloria axis" 1 !f1_count + (* ---------------------------------------------------------------------- *) (* Te Deum (Breviary 237-238, {!Rite_ef.Rubrics_ef.te_deum}) -- Phase 2, *) (* the mitigation the task brief names for this source's own stated *) @@ -440,6 +585,10 @@ let suite = Alcotest.test_case "Ordo Gloria coverage matches the measured figure" `Quick test_gloria_coverage; Alcotest.test_case "every Gloria difference is named in the cited allow-list -- none unexplained" `Quick test_gloria_matches_or_is_explained; + Alcotest.test_case "praef classification coverage matches the measured figure" `Quick + test_praef_classification_coverage; + Alcotest.test_case "every classified preface difference is explained -- none unexplained" `Quick + test_preface_matches_or_is_explained; Alcotest.test_case "Ordo Te Deum coverage matches the measured figure" `Quick test_te_deum_coverage; Alcotest.test_case "every Te Deum difference is named in the cited allow-list -- none unexplained" `Quick test_te_deum_matches_or_is_explained diff --git a/test/test_rubrics_ef.ml b/test/test_rubrics_ef.ml index d533670..bb3bfed 100644 --- a/test/test_rubrics_ef.ml +++ b/test/test_rubrics_ef.ml @@ -746,6 +746,333 @@ let test_exhaustive_gloria_domain_sweep () = Alcotest.(check bool) "the full domain reached a real number of Requiem days" true (black > 1_000) end +(* ---- RG 482-499, the preface -- see lib/rites/rite_ef/rubrics_ef.ml for + the rubric quoted in full and every branch's own citation. One + end-to-end test per branch, resolved against REAL calendar dates + through the shipped data, the same discipline every other section of + this file already follows -- most of these dates were cross-checked + directly against the FIUV Ordo's own [praef] column + (test/fixtures/fiuv-ordo-2025-2026.sexp) before being pinned here, not + merely derived from the Latin text in isolation; see [preface]'s own + header for the full account of what that cross-check settled. ---- *) + +module Pref = Colitur_kernel.Preface + +let preface_on y m d = (day_on y m d).LD.preface + +let preface_string_on y m d = + match preface_on y m d with Some p -> Pref.to_string p | None -> "-" + +let check_preface name expected y m d = + Alcotest.(check string) name (Pref.to_string expected) (preface_string_on y m d) + +let check_no_preface name y m d = Alcotest.(check string) name "-" (preface_string_on y m d) + +(* ---- Good Friday: no Mass at all, [None] -- checked ahead of the + Requiem/Black-colour branch (this file's own [preface] header explains + why the two need different answers, unlike creed/gloria/te_deum). ---- *) +let test_preface_good_friday_no_mass () = check_no_preface "Good Friday: no Mass, no preface" 2026 4 3 + +(* ---- RG 499: the Requiem proxy, the SAME two-member {!Colour.Black} + population {!test_colour_black_population_is_exactly_two} already + asserts, with Good Friday split out above. ---- *) +let test_preface_499_all_souls () = + check_preface "RG 499: All Souls' Day (transferred)" Pref.Requiem 2025 11 3 + +(* ---- RG 487(a): the two fixed Holy Cross feast triggers. ---- *) +let test_preface_487a_exaltation () = + check_preface "RG 487(a): the Exaltation of the Holy Cross" Pref.Holy_cross 2026 9 14 + +let test_preface_487a_precious_blood () = + check_preface "RG 487(a): the Most Precious Blood" Pref.Holy_cross 2026 7 1 + +(* ---- RG 491/492: Sacred Heart, Christ the King -- single named days. ---- *) +let test_preface_491_sacred_heart () = check_preface "RG 491: the Sacred Heart" Pref.Sacred_heart 2026 6 12 +let test_preface_492_christ_the_king () = + check_preface "RG 492: Christ the King" Pref.Christ_the_king 2026 10 25 + +(* ---- RG 490(a): Ascension Day itself. ---- *) +let test_preface_490a_ascension_day () = check_preface "RG 490(a): Ascension Day" Pref.Ascension 2026 5 14 + +(* ---- RG 494(a): Trinity Sunday itself. ---- *) +let test_preface_494a_trinity_sunday () = check_preface "RG 494(a): Trinity Sunday" Pref.Trinity 2026 5 31 + +(* ---- RG 496: St Joseph's two feasts. ---- *) +let test_preface_496_joseph_spouse () = check_preface "RG 496: St Joseph, Spouse of the BVM" Pref.St_joseph 2026 3 19 +let test_preface_496_joseph_workman () = check_preface "RG 496: St Joseph the Workman" Pref.St_joseph 2027 5 1 + +(* ---- RG 495: a genuine Marian FEAST via [marian_slugs] (the Assumption), + and the VOTIVE-shaped BVM Saturday Office via [subject = Bvm] -- the + latter is the live witness for RG 495's own "et votivis" half on this + engine's data (this file's own [preface] header has the full account, + corroborated against the Ordo on 3/10 January 2026). ---- *) +let test_preface_495_assumption () = check_preface "RG 495: the Assumption (marian_slugs)" Pref.Bvm 2026 8 15 +let test_preface_495_bvm_saturday_office () = + check_preface "RG 495: the BVM Saturday Office (subject=Bvm, votive-shaped)" Pref.Bvm 2026 7 11 + +(* ---- RG 484(a)'s own explicit Purification clause: 2 February, checked + as a standalone slug trigger regardless of season -- see [preface]'s + own header for why this needed to be independent of the Nativity + WINDOW below (2 February is nowhere near it). ---- *) +let test_preface_484a_purification () = check_preface "RG 484(a): the Purification" Pref.Nativity 2026 2 2 + +(* ---- RG 484(a)/(b) merged: the Nativity octave window, 25 December-1 + January, PLUS RG 484(b)'s own extra "2 ad 5 ianuarii" days -- one + contiguous window. St Stephen (26 December) is the live witness that + this OUTRANKS an ordinary saint's own otherwise-Common preface; + {!creed}'s own 475(d) test picks the identical date for the identical + "occurring inside a privileged window" shape. ---- *) +let test_preface_484_nativity_day () = check_preface "RG 484(a): Christmas Day itself" Pref.Nativity 2026 12 25 +let test_preface_484_stephen_in_octave () = + check_preface "RG 484(b): St Stephen, occurring within the Nativity octave" Pref.Nativity 2026 12 26 + +let test_preface_484_jan1_octave_day () = + check_preface "RG 484(a): 1 January, the Octave Day" Pref.Nativity 2026 1 1 + +let test_preface_484b_jan2to5 () = + (* 2 January 2026 (a plain Christmastide feria, "ef-christmas-1-friday", + confirmed via `colitur day 2026`) -- 3 January that year is the BVM + Saturday Office instead (RG 495's own "et votivis" outranking this + window, this file's own [test_preface_495_bvm_saturday_office] and + [preface]'s own header have the full account), so this date is picked + specifically to witness the PLAIN de-Tempore grant, uncomplicated by + that override. *) + check_preface "RG 484(b): 2 January, the extra de-Tempore days" Pref.Nativity 2026 1 2 + +(* ---- RG 484(b)'s own NARROWER exception: an Apostle/Evangelist inside + the Nativity octave is STILL overridden to [Nativity] (unlike every + other window, which an Apostle's own preface outranks -- the next test + below). John the Evangelist, 27 December, is the live witness this + file's own [preface] header cites. ---- *) +let test_preface_484b_overrides_apostle_in_octave () = + check_preface "RG 484(b): St John the Evangelist, inside the Nativity octave, still [Nativity]" + Pref.Nativity 2026 12 27 + +(* ---- RG 497: the Apostle/Evangelist natalicia list, reused from + {!creed_apostle_slugs} -- OUTSIDE the Nativity octave, an Apostle keeps + his own preface even inside another window (Sts Philip & James, 11 May, + inside the Paschaltide/Easter window -- the FIUV Ordo's own witness + this file's own [preface] header cites for RG 484(b)'s narrower + carve-out, checked from the other direction). ---- *) +let test_preface_497_barnabas () = check_preface "RG 497: St Barnabas" Pref.Apostles 2026 6 11 +let test_preface_497_peter_paul () = check_preface "RG 497: Sts Peter & Paul" Pref.Apostles 2026 6 29 +let test_preface_497_philip_james_inside_easter_window () = + check_preface "RG 497: Sts Philip & James, inside the Easter window, still [Apostles]" Pref.Apostles 2026 5 11 + +(* ---- RG 485(a)/(b): Epiphany itself and its own Baptism commemoration + (a), the wider 7-13 January window (b) -- Holy Family Sunday (11 + January 2026) is the live witness that a temporal-origin Sunday inside + this window gets [Epiphany], not some Holy-Family-specific preface this + engine has none of. ---- *) +let test_preface_485a_epiphany_day () = check_preface "RG 485(a): Epiphany Day" Pref.Epiphany 2026 1 6 +let test_preface_485a_baptism_commemoration () = + check_preface "RG 485(a): the Commemoration of the Baptism of the Lord" Pref.Epiphany 2026 1 13 + +let test_preface_485b_holy_family_sunday () = + check_preface "RG 485(b): Holy Family Sunday, inside the 7-13 January window" Pref.Epiphany 2026 1 11 + +(* ---- RG 486(a)/(b): the Lenten window, Ash Wednesday through the + Saturday before Passion Sunday I. ---- *) +let test_preface_486_ash_wednesday () = check_preface "RG 486(a): Ash Wednesday" Pref.Lent 2026 2 18 +let test_preface_486_lent_saturday_boundary () = + check_preface "RG 486: the Saturday immediately before Passion Sunday I" Pref.Lent 2026 3 21 + +(* ---- RG 487(a)/(b): the Passiontide window, Passion Sunday I through + Holy Thursday inclusive. ---- *) +let test_preface_487_passion_sunday () = check_preface "RG 487: Passion Sunday I" Pref.Holy_cross 2026 3 22 +let test_preface_487_palm_sunday () = check_preface "RG 487: Palm Sunday" Pref.Holy_cross 2026 3 29 +let test_preface_487_holy_thursday () = check_preface "RG 487: Holy Thursday" Pref.Holy_cross 2026 4 2 + +(* ---- RG 489(a)/(b): the Easter window, the Vigil Mass through the vigil + of the Ascension. ---- *) +let test_preface_489_easter_vigil_mass () = check_preface "RG 489(a): the Easter Vigil Mass" Pref.Easter 2026 4 4 +let test_preface_489_easter_sunday () = check_preface "RG 489(a): Easter Sunday" Pref.Easter 2026 4 5 +let test_preface_489_ascension_vigil () = check_preface "RG 489(b): the vigil of the Ascension" Pref.Easter 2026 5 13 + +(* ---- RG 490(b): the post-Ascension window. ---- *) +let test_preface_490b_post_ascension_feria () = + check_preface "RG 490(b): the Friday after Ascension" Pref.Ascension 2026 5 15 + +let test_preface_490b_sunday_after_ascension () = + check_preface "RG 490(b): the Sunday after Ascension" Pref.Ascension 2026 5 17 + +(* ---- RG 493(a)/(b): the Pentecost-octave window, the vigil through the + following Saturday (the Ember Saturday). ---- *) +let test_preface_493_pentecost_vigil () = check_preface "RG 493(a): the vigil of Pentecost" Pref.Holy_spirit 2026 5 23 +let test_preface_493_pentecost_sunday () = check_preface "RG 493(a): Pentecost Sunday" Pref.Holy_spirit 2026 5 24 +let test_preface_493_ember_saturday () = + check_preface "RG 493(a): the Ember Saturday within the Pentecost octave" Pref.Holy_spirit 2026 5 30 + +(* ---- RG 494(b): the Trinity de-Tempore Sundays -- an Advent Sunday + ([temporal.season = Advent], [Class1] by construction) and an ordinary + Time-after-Pentecost Sunday. Both now read off [temporal]'s own season, + not [observed]'s rank -- see the next test for why the distinction is + live, not merely stylistic. ---- *) +let test_preface_494b_advent_sunday () = check_preface "RG 494(b): Advent I Sunday" Pref.Trinity 2026 11 29 +let test_preface_494b_ordinary_class2_sunday () = + check_preface "RG 494(b): an ordinary Time-after-Pentecost Sunday" Pref.Trinity 2026 8 9 + +(* ---- RG 494(b), the RG 16(a)-shaped fix round: All Saints' Day (1 + November), [Class1], observed OUTRIGHT over the ordinary Sunday it + commemorates -- found via the FIUV Ordo (its own entry reads "Trinit. + vel de Omnibus Sanctis et Patronis", the "vel..." half an alternate + ORATIO reference, the SAME pattern every other "X vel Y" [praef] value + in that fixture already follows, never a second genuine preface). No + dedicated preface exists for All Saints among RG 484-497's own + fourteen, so RG 482's chain falls to the SUNDAY's own de-Tempore grant + regardless of which rank actually won the day -- exactly why this + branch must read [temporal]'s season, not [observed]'s [Class1] rank, + which an EARLIER version of [preface] wrongly required to equal + [Class2] and so answered [Common] here instead. + + 1 November 2026 is itself a Sunday (confirmed via `colitur day 2026`: + "all-saints class-1 white +ef-time-after-pentecost-sunday-23"), the + exact shape this fix concerns -- picked for that reason, not merely + because it is All Saints' Day. *) +let test_preface_494b_all_saints_class1_sunday () = + check_preface "RG 494(b): All Saints' Day, Class1, still Trinity (no dedicated preface exists)" Pref.Trinity + 2026 11 1 + +(* ---- RG 498: the Common residual -- Corpus Christi is the deliberately + chosen witness (NOT simply "any ordinary weekday"): the 1962 Missal + gives it no preface of its own at all, only an OPTIONAL alternative + (Sacred Heart's), so it takes the plain Common, confirmed against the + Ordo directly (4 June 2026: "comm. vel de Cor Sacratissimo") -- a real + trap this engine's own tier1 list does NOT fall into (no + "ef-corpus-christi" entry anywhere in it). ---- *) +let test_preface_498_corpus_christi () = check_preface "RG 498: Corpus Christi (no proper of its own)" Pref.Common 2026 6 4 + +let test_preface_498_plain_saint () = check_preface "RG 498: a plain sanctoral saint (Lawrence)" Pref.Common 2026 8 10 + +(* ---- Corroborating real-data invariants, task requirement 5: "every day + in Paschaltide should take the Easter preface unless it has a proper + one; every Lenten feria the Lent preface". Checked as a SWEEP, not a + single date, over a fixed sample span (1583-2200, matching the century+ + spans other domain checks in this file already use) -- FAST in the + default suite, EXHAUSTIVE (1583-9999) gated behind + COLITUR_EXHAUSTIVE_SWEEP the same way {!test_exhaustive_gloria_domain_sweep} + above already is. + + The invariant actually checked is NOT the brief's own literal phrasing + (which is one exception too strong, the identical "pushed back, not + silently special-cased" stance {!check_gloria_invariants_for_year}'s own + header already takes for [gloria]/432(b)): every Paschaltide-season day + takes [Easter], [Ascension] or [Holy_spirit] (its own three seasonal + windows), OR one of the season-independent title triggers this engine + can construct inside Paschaltide's real date range ([Bvm]/[St_joseph]/ + [Apostles]/[Requiem]) -- but NEVER [Nativity]/[Epiphany]/[Lent]/[Trinity]/ + [Sacred_heart]/[Christ_the_king]/[Common], none of which any real date + inside Paschaltide can trigger. Symmetrically for Lent: [Lent] or one of + [Bvm]/[St_joseph]/[Apostles]/[Requiem], never a preface belonging to a + date-disjoint window. Two further invariants the sweep found and kept, + not originally asked for but load-bearing: EVERY Christmastide-season + day resolves [Nativity], [Epiphany] or [Bvm] (RG 484/485 between them + leave no gap at all in that season on their own -- confirmed + exhaustively -- but RG 495's own votive-Mass half, live for the BVM + Saturday Office, can and does fall on a Christmastide Saturday too; + found live at 1584-01-07 while first running this exact sweep, kept as + a real witness rather than narrowed away); and Passiontide, uniquely, + legitimately + ALSO produces [Easter] exactly once a year (Holy Saturday's own Vigil + Mass, [n = -1] -- {!Rite_ef.Rubrics_ef.preface}'s own RG 489(a) comment; + this engine's day/colour model keeps that date [Passiontide] by season + even though the Vigil Mass's own preface has already moved to Easter's, + the same acknowledged per-action nuance [gloria]'s own RG 431(c) comment + already documents) -- asserted to be EXACTLY once per year, not merely + allowed, so a second, unexplained (Passiontide, Easter) day anywhere in + the domain still fails loudly. *) +let check_preface_season_invariants_for_year y (counts : (int * int * int * int) ref) = + let days = Cal.year ctx layer y in + let christmastide_gaps = ref 0 + and paschaltide_gaps = ref 0 + and lent_gaps = ref 0 + and passiontide_easter = ref 0 in + let christmastide_gaps0, paschaltide_gaps0, lent_gaps0, passiontide_easter0 = !counts in + christmastide_gaps := christmastide_gaps0; + paschaltide_gaps := paschaltide_gaps0; + lent_gaps := lent_gaps0; + passiontide_easter := passiontide_easter0; + Array.iter + (fun (d : (V.season, V.rank) LD.t) -> + let label = Printf.sprintf "%s (preface=%s)" (Date.to_iso8601 d.LD.date) + (match d.LD.preface with Some p -> Pref.to_string p | None -> "NONE") + in + (match d.LD.temporal.Temporal.season with + | V.Christmastide -> ( + match d.LD.preface with + | Some (Pref.Nativity | Pref.Epiphany | Pref.Bvm) -> () + | _ -> + incr christmastide_gaps; + Alcotest.failf "%s: Christmastide day with neither Nativity, Epiphany nor Bvm" label) + | V.Paschaltide -> ( + match d.LD.preface with + | Some (Pref.Easter | Pref.Ascension | Pref.Holy_spirit | Pref.Bvm | Pref.St_joseph + | Pref.Apostles | Pref.Requiem) -> + () + | _ -> + incr paschaltide_gaps; + Alcotest.failf "%s: Paschaltide day with an out-of-window preface" label) + | V.Lent -> ( + match d.LD.preface with + | Some (Pref.Lent | Pref.Bvm | Pref.St_joseph | Pref.Apostles | Pref.Requiem) -> () + | _ -> + incr lent_gaps; + Alcotest.failf "%s: Lent day with an out-of-window preface" label) + | V.Passiontide -> + if d.LD.preface = Some Pref.Easter then begin + incr passiontide_easter; + let easter = Computus.gregorian_easter (Date.year d.LD.date) in + Alcotest.(check int) + (Printf.sprintf "%s: the ONLY Passiontide/Easter day is the Vigil Mass (Easter-1)" label) + (-1) (Date.to_rata d.LD.date - Date.to_rata easter) + end + | _ -> ())) + days; + counts := (!christmastide_gaps, !paschaltide_gaps, !lent_gaps, !passiontide_easter) + +let test_preface_season_invariants_sample () = + let counts = ref (0, 0, 0, 0) in + for y = 1583 to 1782 do + check_preface_season_invariants_for_year y counts + done; + let _, _, _, passiontide_easter = !counts in + Alcotest.(check bool) "the 200-year sample found the once-a-year Passiontide/Easter exception" true + (passiontide_easter >= 200) + +let test_exhaustive_preface_season_domain_sweep () = + if Sys.getenv_opt colitur_exhaustive_sweep_env = None then Alcotest.skip () + else begin + let counts = ref (0, 0, 0, 0) in + for y = 1583 to 9999 do + check_preface_season_invariants_for_year y counts + done; + let christmastide_gaps, paschaltide_gaps, lent_gaps, passiontide_easter = !counts in + Printf.printf + "preface season sweep 1583..9999: christmastide_gaps=%d paschaltide_gaps=%d lent_gaps=%d \ + passiontide_easter=%d\n%!" + christmastide_gaps paschaltide_gaps lent_gaps passiontide_easter; + Alcotest.(check int) "zero Christmastide gaps anywhere in the domain" 0 christmastide_gaps; + Alcotest.(check int) "zero Paschaltide out-of-window prefaces anywhere in the domain" 0 paschaltide_gaps; + Alcotest.(check int) "zero Lent out-of-window prefaces anywhere in the domain" 0 lent_gaps; + (* 8416, not 8417 (the domain's own year count): a genuine, pre-existing + DOMAIN-BOUNDARY edge, not a preface defect -- {!Cal.year}'s own + liturgical year "opening in civil year 9999" cannot construct any + date past 1583-9999 ({!Date.make}'s own validated range), so it + returns only 34 days (28 November-31 December), never reaching its + own Easter/Holy Saturday (which would fall in year 10000). Found by + running this exact sweep: it returned 8416 first, not asserted + blindly at 8417 -- checked directly ([Cal.year ctx layer 9999] + alone, printed length 34) before writing this comment, the same + "measure before adjudicating" discipline every allow-list in this + project follows. The identical edge is why + docs/research/rules-register.md's own Layer.index citation already + reads "both edges, 1582 and 10000, bit during development" -- + this sweep is a second witness to the same known boundary, not a + new one. *) + Alcotest.(check int) "one Passiontide/Easter day per year, every year but the domain's own last" 8416 + passiontide_easter + end + (* ---- ITEM 1: RG 111(a), the sung-Mass commemoration cap ({!Colitur_kernel.Precedence.sung_mass_commemorations}) -- two real calendar days, one of each shape, resolved through the identical @@ -855,4 +1182,62 @@ let suite = Alcotest.test_case "RG 111(a): an ordinary-only Low-Mass set is empty at Sung Mass" `Quick test_rg111a_ordinary_only_dropped; Alcotest.test_case "RG 111(a): a privileged commemoration survives at Sung Mass" `Quick - test_rg111a_privileged_kept ] ) + test_rg111a_privileged_kept; + Alcotest.test_case "preface: Good Friday, no Mass" `Quick test_preface_good_friday_no_mass; + Alcotest.test_case "RG 499: All Souls' Day (transferred)" `Quick test_preface_499_all_souls; + Alcotest.test_case "RG 487(a): the Exaltation of the Holy Cross" `Quick test_preface_487a_exaltation; + Alcotest.test_case "RG 487(a): the Most Precious Blood" `Quick test_preface_487a_precious_blood; + Alcotest.test_case "RG 491: the Sacred Heart" `Quick test_preface_491_sacred_heart; + Alcotest.test_case "RG 492: Christ the King" `Quick test_preface_492_christ_the_king; + Alcotest.test_case "RG 490(a): Ascension Day" `Quick test_preface_490a_ascension_day; + Alcotest.test_case "RG 494(a): Trinity Sunday" `Quick test_preface_494a_trinity_sunday; + Alcotest.test_case "RG 496: St Joseph, Spouse of the BVM" `Quick test_preface_496_joseph_spouse; + Alcotest.test_case "RG 496: St Joseph the Workman" `Quick test_preface_496_joseph_workman; + Alcotest.test_case "RG 495: the Assumption (marian_slugs)" `Quick test_preface_495_assumption; + Alcotest.test_case "RG 495: the BVM Saturday Office (subject=Bvm)" `Quick + test_preface_495_bvm_saturday_office; + Alcotest.test_case "RG 484(a): the Purification" `Quick test_preface_484a_purification; + Alcotest.test_case "RG 484(a): Christmas Day itself" `Quick test_preface_484_nativity_day; + Alcotest.test_case "RG 484(b): St Stephen, inside the Nativity octave" `Quick + test_preface_484_stephen_in_octave; + Alcotest.test_case "RG 484(a): 1 January, the Octave Day" `Quick test_preface_484_jan1_octave_day; + Alcotest.test_case "RG 484(b): 3 January, the extra de-Tempore days" `Quick test_preface_484b_jan2to5; + Alcotest.test_case "RG 484(b): St John the Evangelist, inside the Nativity octave" `Quick + test_preface_484b_overrides_apostle_in_octave; + Alcotest.test_case "RG 497: St Barnabas" `Quick test_preface_497_barnabas; + Alcotest.test_case "RG 497: Sts Peter & Paul" `Quick test_preface_497_peter_paul; + Alcotest.test_case "RG 497: Sts Philip & James, inside the Easter window" `Quick + test_preface_497_philip_james_inside_easter_window; + Alcotest.test_case "RG 485(a): Epiphany Day" `Quick test_preface_485a_epiphany_day; + Alcotest.test_case "RG 485(a): the Commemoration of the Baptism of the Lord" `Quick + test_preface_485a_baptism_commemoration; + Alcotest.test_case "RG 485(b): Holy Family Sunday, inside the 7-13 January window" `Quick + test_preface_485b_holy_family_sunday; + Alcotest.test_case "RG 486(a): Ash Wednesday" `Quick test_preface_486_ash_wednesday; + Alcotest.test_case "RG 486: the Saturday before Passion Sunday I" `Quick + test_preface_486_lent_saturday_boundary; + Alcotest.test_case "RG 487: Passion Sunday I" `Quick test_preface_487_passion_sunday; + Alcotest.test_case "RG 487: Palm Sunday" `Quick test_preface_487_palm_sunday; + Alcotest.test_case "RG 487: Holy Thursday" `Quick test_preface_487_holy_thursday; + Alcotest.test_case "RG 489(a): the Easter Vigil Mass" `Quick test_preface_489_easter_vigil_mass; + Alcotest.test_case "RG 489(a): Easter Sunday" `Quick test_preface_489_easter_sunday; + Alcotest.test_case "RG 489(b): the vigil of the Ascension" `Quick test_preface_489_ascension_vigil; + Alcotest.test_case "RG 490(b): the Friday after Ascension" `Quick test_preface_490b_post_ascension_feria; + Alcotest.test_case "RG 490(b): the Sunday after Ascension" `Quick + test_preface_490b_sunday_after_ascension; + Alcotest.test_case "RG 493(a): the vigil of Pentecost" `Quick test_preface_493_pentecost_vigil; + Alcotest.test_case "RG 493(a): Pentecost Sunday" `Quick test_preface_493_pentecost_sunday; + Alcotest.test_case "RG 493(a): the Ember Saturday within the Pentecost octave" `Quick + test_preface_493_ember_saturday; + Alcotest.test_case "RG 494(b): Advent I Sunday" `Quick test_preface_494b_advent_sunday; + Alcotest.test_case "RG 494(b): an ordinary Time-after-Pentecost Sunday" `Quick + test_preface_494b_ordinary_class2_sunday; + Alcotest.test_case "RG 494(b): All Saints' Day, Class1, still Trinity" `Quick + test_preface_494b_all_saints_class1_sunday; + Alcotest.test_case "RG 498: Corpus Christi (no proper of its own)" `Quick test_preface_498_corpus_christi; + Alcotest.test_case "RG 498: a plain sanctoral saint (Lawrence)" `Quick test_preface_498_plain_saint; + Alcotest.test_case "preface domain sanity: Christmastide/Paschaltide/Lent window invariants (sample)" + `Quick test_preface_season_invariants_sample; + Alcotest.test_case + "preface domain sweep 1583..9999: window invariants, committed not sampled" `Slow + test_exhaustive_preface_season_domain_sweep ] ) diff --git a/test/test_validate.ml b/test/test_validate.ml index b53f915..3da8fbb 100644 --- a/test/test_validate.ml +++ b/test/test_validate.ml @@ -324,17 +324,19 @@ module Synthetic = struct { Colitur_kernel.Mass_formulary.said = Some (Slug.of_string_exn "syn-formulary"); via = Colitur_kernel.Mass_formulary.Own_slug } - (* No fixture here exercises the Creed or Gloria rubrics -- a rite that - has not implemented them returns [false] explicitly, - {!Rite.t.creed}/{!Rite.t.gloria}'s own documented default. Made - overridable ([?creed]/[?gloria] below) on the same footing as - [?readings] just above, for Task 6's own fixtures. *) + (* No fixture here exercises the Creed, Gloria or preface rubrics -- a + rite that has not implemented them returns [false]/[None] explicitly, + {!Rite.t.creed}/{!Rite.t.gloria}/{!Rite.t.preface}'s own documented + default. Made overridable ([?creed]/[?gloria]/[?preface] below) on + the same footing as [?readings] just above, for Task 6's own + fixtures. *) let creed ~temporal:_ ~observed:_ ~date:_ = false let gloria ~temporal:_ ~observed:_ ~date:_ = false + let preface ~temporal:_ ~observed:_ ~date:_ = None let rite ?(vocab = vocab) ?(anchors = fun _ -> []) ?(season_runs = [ A; B ]) ?(rules = rules) ?(transfer_target = fun _ origin _ -> origin) ?(readings = readings) ?(creed = creed) - ?(gloria = gloria) temporal : (season, rank) Rite.t = + ?(gloria = gloria) ?(preface = preface) temporal : (season, rank) Rite.t = { Rite.id = "synthetic"; vocab; year_start; temporal; anchors; rules; season_runs; (* Not a Roman rite, but a Rite.t must supply SOME Easter now that movable Date_spec variants exist. The Gregorian one is as good as @@ -344,7 +346,7 @@ module Synthetic = struct (* Not a Roman rite either, so no bissextile-doubling convention: identity, {!Rite.t.fixed_key}'s own documented default. *) fixed_key = (fun d -> Some (D.month d, D.day d)); - transfer_target; readings; creed; gloria } + transfer_target; readings; creed; gloria; preface } (* Empty by default: every check built before Task 12 exercises the TEMPORAL-only pass, where an empty layer is exactly the fixture that -- cgit v1.3 From 1252ffa57778331a67d75c2f69722b8ec1a7dbcc Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Sun, 23 Aug 2026 23:01:56 +0200 Subject: feat(ef): RG 497, the Conversion of St Paul takes the Apostles preface RG 497 ("in Missis festivis et votivis Apostolorum et Evangelistarum") carries no natalicium restriction, unlike RG 475(e)'s Creed clause -- preface_apostle_slugs already includes one non-natalicium sibling (In Commemoratione S. Pauli, 30 June) on this exact reasoning. conversion-of-st-paul (25 January) was left off pending a witness: the FIUV Ordo's own single window has 25 January impeded by a Sunday. Now witnessed four times, two independent publishers, four civil years: the Latin Mass Society Ordo's 2023-2024 and 2024-2025 editions both read "Pr of the Apostles" on their own unimpeded 25 January (2024, 2025); extraordinaryform.org's 2024-2025Ordo.pdf and 2026-2027Ordo.pdf independently agree on their own unimpeded years (2025, 2027, spot-checked, not wired in as a fixture). Every source's 2026 edition agrees 25 January is impeded that year and produces no data point at all. No source disagreed with the textual reading. Surveyed every other Apostle/Evangelist-named slug in data/ef/sanctoral.sexp before concluding the list needed exactly this one addition: the Dedication of the Basilicas of Sts Peter & Paul (18 November) is confirmed NOT an RG 497 trigger (all three LMS editions read "Pr of the Dedication of a Church", not "Apostles" -- it is about the buildings, not the Apostles in person); the Vigil of Sts Peter & Paul (28 June) is confirmed "Common Pr", corroborating the pre-existing "no vigil ever reaches this branch" comment. Blast radius, measured exhaustively (1583-9999, new permanent sweep in test_rubrics_ef.ml): 7217 domain years have 25 January unimpeded and observed; all 7217 now read Apostles, zero exceptions. Claude-Session: https://claude.ai/code/session_017ZBxCCRM2ojnBupp3SBxV9 --- lib/rites/rite_ef/rubrics_ef.ml | 65 +++++++++++++++++++++++++++++++----- lib/rites/rite_ef/rubrics_ef.mli | 8 +++-- test/test_rubrics_ef.ml | 71 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 132 insertions(+), 12 deletions(-) (limited to 'lib/rites/rite_ef') diff --git a/lib/rites/rite_ef/rubrics_ef.ml b/lib/rites/rite_ef/rubrics_ef.ml index eea92a9..ce2cfcc 100644 --- a/lib/rites/rite_ef/rubrics_ef.ml +++ b/lib/rites/rite_ef/rubrics_ef.ml @@ -884,14 +884,63 @@ let preface_st_joseph_slugs = [ "joseph-spouse-of-the-bl-virgin-mary"; "joseph-t "conversion-of-st-paul" (25 January, Class3, the SAME "not a natalicium" shape {!creed_apostle_slugs}'s own citation excludes it - for) is a plausible SECOND candidate by the identical RG 497 reasoning - -- deliberately NOT added: it is UNWITNESSED (25 January falls on a - Sunday, hence impeded, in the one fixture year this engine's Ordo - evidence covers, {!test_fiuv_ordo.ml}'s own window), and "a wrong - citation is worse than a missing one" ({!Precedence_ef.marian_slugs}'s - own citation, the precedent this follows). Left for a future fixture - year to confirm or refute. *) -let preface_apostle_slugs = "in-commemoratione-sancti-pauli-apostoli" :: creed_apostle_slugs + for) is a SECOND member, by the identical RG 497 reasoning -- + NOW WITNESSED (Preface-witnesses task, 2026-08-23): unlike + {!test_fiuv_ordo.ml}'s own single-year window (where 25 January falls + on a Sunday, hence impeded), the Latin Mass Society Ordo's own THREE + editions (test/test_lms_ordo.ml) each carry an UNIMPEDED 25 January -- + two of them fall outside a Sunday (2024, 2025) and both read "Pr of + the Apostles" for the Conversion of St Paul, confirmed directly + against two INDEPENDENT civil years, not merely two printings of the + same one (the third, 2026, is impeded by a Sunday exactly like FIUV's + own witness year -- the same date, the same reason, an entirely + different underlying calendar fact confirmed twice over, not a + coincidence). Added on this evidence. + + A FOURTH and FIFTH informal confirmation, from a SECOND, independent + publisher (extraordinaryform.org's own three annual PDFs, + docs/research/ordo/2024-2025Ordo.pdf / 2025-2026Ordo.pdf / + 2026-2027Ordo.pdf -- characterised, not wired in as an automated layer; + see the Preface-witnesses task's own report for why): its own per-day + PREFACE table column reads "Apostles" for the Conversion of St Paul on + BOTH of its own unimpeded years, 2025-01-25 and 2027-01-25 (its middle + edition's 2026-01-25 is impeded by a Sunday, the identical fact the + other two sources already independently establish) -- a spot check, + not a fixture, but a real cross-publisher agreement on the exact + question this list answers. + + SURVEYED, not merely patched: every other Apostle/Evangelist-named + slug in data/ef/sanctoral.sexp was checked against this same + three-edition LMS witness before deciding this list needed exactly + one addition, not more: + - "dedication-of-the-basilicas-of-sts-peter-paul" (18 November, + Class3) is Peter and Paul's own BASILICAS, not the Apostles + themselves -- confirmed NOT an RG 497 trigger in all three LMS + editions, identical across all three ("Pr of the Dedication of a + Church or Common Pr"), corroborating {!preface_bvm_slugs}'s own + "Dedication of a Church" finding rather than RG 497: this feast's + own subject is the building, governed by the SAME non-RG-482 + "extra" preface every other church dedication uses (RG 91 entry + 27's own header has the fuller account of this Ordo's option-list + shape). + - "vigil-of-sts-peter-paul" (28 June) reads "Common Pr" in the LMS + Ordo (2024-2025 edition, checked directly) -- confirming, not + merely assuming, this branch's own long-standing "no [is_vigil] + guard needed, no entry here is ever a vigil slug" comment below: + a vigil is not a "festum... votiva" either, the identical RG 21/35 + taxonomy {!preface_bvm_slugs}'s own [is_vigil] guard already + states for the BVM branch above. + - No other Peter/Paul/John/Andrew/James/Philip/Bartholomew/Thomas/ + Matthew/Jude/Simon/Matthias/Mark/Luke/Barnabas-named slug in the + shipped data names an Apostle or Evangelist at all (checked by + grepping every slug in data/ef/sanctoral.sexp against those ten + names): the rest are unrelated same-named saints (e.g. "mark-i", a + Pope, {!creed_apostle_slugs}'s own citation; "sts-john-paul", two + Roman martyrs unrelated to the Evangelist and the Apostle) or + genuinely unreachable RG 110 companions ("peter"/"paul", + [Commemoration_only], {!creed_apostle_slugs}'s own citation). *) +let preface_apostle_slugs = + "in-commemoratione-sancti-pauli-apostoli" :: "conversion-of-st-paul" :: creed_apostle_slugs (* RG 495's own [beatae Mariae Virginis] population is also WIDER than {!Precedence_ef.marian_slugs}: that list was built for a DIFFERENT diff --git a/lib/rites/rite_ef/rubrics_ef.mli b/lib/rites/rite_ef/rubrics_ef.mli index befbf72..c005aaf 100644 --- a/lib/rites/rite_ef/rubrics_ef.mli +++ b/lib/rites/rite_ef/rubrics_ef.mli @@ -80,9 +80,11 @@ val preface_holy_cross_slugs : string list val preface_st_joseph_slugs : string list (** RG 497's own [Apostolorum et Evangelistarum] population -- WIDER than - {!creed_apostle_slugs} (no natalicium restriction); see the .ml's own - citation for the FIUV Ordo evidence and what was deliberately left - off, unwitnessed. *) + {!creed_apostle_slugs} (no natalicium restriction); includes + "conversion-of-st-paul" (Preface-witnesses task, 2026-08-23, four + independent unimpeded-year confirmations across two publishers -- see + the .ml's own citation for the full evidence and the survey of every + other Apostle/Evangelist-named slug that was checked and NOT added. *) val preface_apostle_slugs : string list (** RG 495's own [beatae Mariae Virginis] population -- WIDER than diff --git a/test/test_rubrics_ef.ml b/test/test_rubrics_ef.ml index bb3bfed..77654c0 100644 --- a/test/test_rubrics_ef.ml +++ b/test/test_rubrics_ef.ml @@ -859,6 +859,22 @@ let test_preface_497_peter_paul () = check_preface "RG 497: Sts Peter & Paul" Pr let test_preface_497_philip_james_inside_easter_window () = check_preface "RG 497: Sts Philip & James, inside the Easter window, still [Apostles]" Pref.Apostles 2026 5 11 +(* ---- RG 497 also covers "conversion-of-st-paul" (25 January, Class3, + NOT his own natalicium -- {!creed_apostle_slugs} deliberately excludes + it from the CREED for exactly that reason, RG 475(e)'s own natalicium + restriction). RG 497 carries no such restriction, and unlike + {!test_fiuv_ordo.ml}'s own single window (where 25 January falls on a + Sunday, hence impeded), the Latin Mass Society Ordo's three editions + (test/test_lms_ordo.ml, Preface-witnesses task, 2026-08-23) each carry + an unimpeded 25 January in two of their three civil years, 2024 and + 2025, and BOTH independently read "Pr of the Apostles" -- 2024-01-25 is + the date pinned here, matching the FIRST of those two witnessed + years directly (day names elsewhere in this file default to 2026 for + uniformity with the FIUV Ordo's own window, but 2026-01-25 is itself + impeded by a Sunday, so it cannot witness this branch at all). ---- *) +let test_preface_497_conversion_of_st_paul () = + check_preface "RG 497: the Conversion of St Paul, not his own natalicium, still [Apostles]" Pref.Apostles 2024 1 25 + (* ---- RG 485(a)/(b): Epiphany itself and its own Baptism commemoration (a), the wider 7-13 January window (b) -- Holy Family Sunday (11 January 2026) is the live witness that a temporal-origin Sunday inside @@ -1073,6 +1089,53 @@ let test_exhaustive_preface_season_domain_sweep () = passiontide_easter end +(* ---- BLAST RADIUS, Preface-witnesses task (2026-08-23): adding + "conversion-of-st-paul" to {!Rite_ef.Rubrics_ef.preface_apostle_slugs} + changes exactly one thing -- the preface on every year's own 25 January + WHEN [conversion-of-st-paul] is the day actually observed (impeded, most + often by a Sunday, in every other year). Measured directly rather than + estimated: every observed occurrence must now read [Apostles], never + anything else, and the domain total is printed so the SIZE of this + change is on the record, not only its correctness. *) +let check_conversion_of_st_paul_for_year y (counts : (int * int) ref) = + let days = Cal.year ctx layer y in + let observed0, wrong0 = !counts in + let observed = ref observed0 and wrong = ref wrong0 in + Array.iter + (fun (d : (V.season, V.rank) LD.t) -> + if String.equal (Slug.to_string d.LD.observed.Cel.slug) "conversion-of-st-paul" then begin + incr observed; + if d.LD.preface <> Some Pref.Apostles then begin + incr wrong; + Alcotest.failf "%s: conversion-of-st-paul is observed but preface=%s, not Apostles" + (Date.to_iso8601 d.LD.date) + (match d.LD.preface with Some p -> Pref.to_string p | None -> "NONE") + end + end) + days; + counts := (!observed, !wrong) + +let test_conversion_of_st_paul_preface_sample () = + let counts = ref (0, 0) in + for y = 1583 to 1782 do + check_conversion_of_st_paul_for_year y counts + done; + let observed, wrong = !counts in + Alcotest.(check int) "zero wrong-preface days in the 200-year sample" 0 wrong; + Alcotest.(check bool) "the 200-year sample found real conversion-of-st-paul occurrences" true (observed > 100) + +let test_exhaustive_conversion_of_st_paul_preface_sweep () = + if Sys.getenv_opt colitur_exhaustive_sweep_env = None then Alcotest.skip () + else begin + let counts = ref (0, 0) in + for y = 1583 to 9999 do + check_conversion_of_st_paul_for_year y counts + done; + let observed, wrong = !counts in + Printf.printf "conversion-of-st-paul preface blast radius 1583..9999: observed=%d wrong=%d\n%!" observed wrong; + Alcotest.(check int) "zero wrong-preface days anywhere in the domain" 0 wrong + end + (* ---- ITEM 1: RG 111(a), the sung-Mass commemoration cap ({!Colitur_kernel.Precedence.sung_mass_commemorations}) -- two real calendar days, one of each shape, resolved through the identical @@ -1208,6 +1271,8 @@ let suite = Alcotest.test_case "RG 497: Sts Peter & Paul" `Quick test_preface_497_peter_paul; Alcotest.test_case "RG 497: Sts Philip & James, inside the Easter window" `Quick test_preface_497_philip_james_inside_easter_window; + Alcotest.test_case "RG 497: the Conversion of St Paul, not his own natalicium" `Quick + test_preface_497_conversion_of_st_paul; Alcotest.test_case "RG 485(a): Epiphany Day" `Quick test_preface_485a_epiphany_day; Alcotest.test_case "RG 485(a): the Commemoration of the Baptism of the Lord" `Quick test_preface_485a_baptism_commemoration; @@ -1240,4 +1305,8 @@ let suite = `Quick test_preface_season_invariants_sample; Alcotest.test_case "preface domain sweep 1583..9999: window invariants, committed not sampled" `Slow - test_exhaustive_preface_season_domain_sweep ] ) + test_exhaustive_preface_season_domain_sweep; + Alcotest.test_case "conversion-of-st-paul preface blast radius (sample)" `Quick + test_conversion_of_st_paul_preface_sample; + Alcotest.test_case "conversion-of-st-paul preface blast radius 1583..9999, committed not sampled" `Slow + test_exhaustive_conversion_of_st_paul_preface_sweep ] ) -- cgit v1.3