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). --- 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 +- 7 files changed, 684 insertions(+), 65 deletions(-) (limited to 'test') 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 -- cgit v1.3