diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-15 01:40:54 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-15 01:40:54 +0200 |
| commit | 1299815ea03c2b1e4ff19d92d32e386fec24f4e4 (patch) | |
| tree | 7fe91b561af482d7079fb4808ec52552e3228d08 | |
| parent | fe94cd9c4150fd6285dab49b06e23b6db42d131b (diff) | |
| download | colitur-1299815ea03c2b1e4ff19d92d32e386fec24f4e4.tar.gz colitur-1299815ea03c2b1e4ff19d92d32e386fec24f4e4.zip | |
ef(lectionary): the Commons, chain step 4
Fifteen class-3 saints had no readings anywhere in colitur -- the 6 March -
5 April cluster whose RANKS lectio's generator fix of 2026-08-12 restored
without backfilling readings, and lectio has no Commons concept to have
backfilled them from. They are exactly the fifteen `status Feast` entries in
data/ef/sanctoral.sexp carrying `(citations ())`.
The Missal splits them eight/seven, and NOT as the plan predicted. It
expected propers for Benedict, Patrick, Thomas Aquinas and Gregory the
Great; only Thomas Aquinas has one. Benedict ("Missa Os iusti, de Communi
Abbatum"), Patrick ("Missa Statuit, de Communi Confessoris Pontificis I
loco") and Gregory the Great ("Missa Si diligis me, de Communi unius aut
plurium Summorum Pontificum") are all sent to a Common, with only Oratio,
Secreta and Postcommunio printed at the date. All three reversals verified
on the page images (pp. 488, 492) as well as in both OCR text layers.
PROPERS (data/ef/adjustments.sexp, `Edit ... Set_citation`):
thomas-aquinas 7 Mar Sap 7:7-14 / Mt 5:13-19
john-of-god 8 Mar Eccli 31:8-11 / Mt 22:34-46
forty-holy-martyrs 10 Mar Hebr 11:33-39 / Luc 6:17-23
cyril-of-jerusalem 18 Mar Eccli 39:6-14 / Mt 10:23-28
gabriel-the-archangel 24 Mar Dan 9:21-26 / Luc 1:26-38
john-damascene 27 Mar Sap 10:10-17 / Luc 6:6-11
john-of-capistrano 28 Mar Sap 10:10-14 / Luc 9:1-6
francis-of-paola 2 Apr Philipp 3:7-12 / Luc 12:32-34
COMMONS (data/ef/commons.sexp), each quoting the Missal's own instruction at
the saint's date:
sts-felicitas-perpetua 6 Mar non Virginum I Eccli 51:1-8,12 / Mt 13:44-52
frances-rome 9 Mar non Virginum II Prov 31:10-31 / Mt 13:44-52
gregory-the-great 12 Mar Summ. Pontificum 1 Petri 5:1-4,10-11 / Mt 16:13-19
patrick 17 Mar Conf. Pont. I Eccli 44:16-27;45:3-20 / Mt 25:14-23
benedict 21 Mar Abbatum Eccli 45:1-6 / Mt 19:27-29
isidore-of-seville 4 Apr Doctorum 2 Tim 4:1-8 / Mt 5:13-19
vincent-ferrer 5 Apr Conf. non Pont. I Eccli 31:8-11 / Luc 12:35-40
Every value read in three independent places -- both OCR text layers (two
DIFFERENT printings, whose Commune Sanctorum pagination differs by 1-2, so
the data keys on heading plus Mass incipit and never on the bracketed page
number) and the rendered page images. That caught a genuine typographical
error in one printing: it gives St Gabriel's Gospel as "Luc. 2, 26-38" (on
the page image, so not an OCR artefact). Luke 1:26-38 is settled by the
other printing, by the pericope text in both, and by the SAME edition citing
the identical pericope correctly two pages later at the Annunciation.
Step 4 EXECUTES SECOND (order 1, 4, 2, 3), not last as the plan sketched.
Measured: every day on which one of the fifteen is actually the observed
office already has a non-empty step-2 or step-3 answer waiting, so a step 4
placed after the ferial fallback is unreachable on every date in 1583-9999 --
and wrong where it would fire (2038-03-06 would emit Septuagesima II
Saturday's Mass on a day whose office is Sts Perpetua and Felicity). Step 4
is also the only step in the chain with a direct primary-source warrant: the
Missal names the Mass at each such saint's own date, while steps 2 and 3 rest
only on lectio's observed behaviour. Guarded so it can never divert a day
whose observed office IS its temporal office -- ferias, Sundays, the Triduum
and the RG 78 Saturday Office of the BVM (which deliberately shares the
ferial slug) are structurally excluded. Step NUMBERS are unchanged so every
"step 3" already written down still names the same branch.
The Commons are caller-supplied, like the lectionary: `Rite_ef.context` gains
a REQUIRED `~commons`, not an optional one -- a silently-defaulted empty
table would give a rite whose class-3 saints quietly lose their Mass, and
nothing in layers 3-5 compares citations, so that loss would be invisible.
`Commons.load` rejects the four defects indistinguishable downstream from
"this saint has no Common": duplicate common id, duplicate assignment, empty
formulary, assignment naming a common that does not exist.
KNOWN LIMITS, recorded rather than papered over. There is no oracle: lectio
has no readings for these feasts and missalemeum's 2026-2027 window never
observes one of the fifteen. Eight of the fifteen are NEVER the observed
office anywhere in the domain (10-28 March always falls to a Lenten or
Passiontide feria that outranks a III-class feast), so they have no
end-to-end witness and are covered by dedicated table tests instead. The
Commune Sanctorum's own opening rubric permits any Epistle or Gospel of a
Common in any Mass of that Common unless one is assigned, so what is emitted
is the reading PRINTED WITH the named formulary, not the only lawful one;
labelled alternatives (e.g. the widow's Epistle 1 Tim 5:3-10, squarely
available to St Frances of Rome) are noted in the data and deliberately not
encoded. The BVM Common is NOT authored: the RG 78 Saturday Office shares the
ordinary ferial slug, so assigning a Common to it would hand the BVM Mass to
every feria sharing that slug; routing it needs a subject-keyed lookup and a
season-keyed choice among the Missal's five "Missae de sancta Maria in
sabbato" -- new behaviour, not new data. It currently emits its feria's Mass,
now pinned as such.
Blast radius, measured (git archive HEAD binary vs HEAD, full 1583-9999
`colitur day` sweep, diffed): ZERO differing lines, 3 074 246 lines each
side, byte-identical -- the CLI prints no citations, so this change is
invisible to layers 2-5 by construction, and the sanctoral overlay perturbed
nothing else.
357 tests green (344 before), 358 with the exhaustive sweep. Mutation-tested
six ways, all reverted after confirming: relegating step 4 to last reddens
exactly the four step-4 pins and nothing else; corrupting a Common's citation,
dropping an assignment, dropping a proper Edit, and corrupting either an
unreachable proper or an unreachable Common each redden their own pins. The
load-bearing assertion is stated over the loaded layer rather than a
hard-coded list: every sanctoral entry that can ever BE the observed office
now has either a proper or an assigned Common -- fifteen failures before,
zero after, and a future re-bootstrap adding a readingless class-3 feast
fails there instead of silently emitting its feria's Mass.
| -rw-r--r-- | bin/main.ml | 107 | ||||
| -rw-r--r-- | data/ef/adjustments.sexp | 144 | ||||
| -rw-r--r-- | data/ef/commons.sexp | 257 | ||||
| -rw-r--r-- | lib/rites/rite_ef/lectionary_ef.ml | 259 | ||||
| -rw-r--r-- | lib/rites/rite_ef/lectionary_ef.mli | 96 | ||||
| -rw-r--r-- | lib/rites/rite_ef/rite_ef.ml | 29 | ||||
| -rw-r--r-- | lib/rites/rite_ef/rite_ef.mli | 17 | ||||
| -rw-r--r-- | test/dune | 8 | ||||
| -rw-r--r-- | test/test_calendar.ml | 15 | ||||
| -rw-r--r-- | test/test_differential.ml | 14 | ||||
| -rw-r--r-- | test/test_golden.ml | 15 | ||||
| -rw-r--r-- | test/test_lectionary_ef.ml | 323 | ||||
| -rw-r--r-- | test/test_oracle.ml | 14 | ||||
| -rw-r--r-- | test/test_rite_ef.ml | 14 | ||||
| -rw-r--r-- | test/test_validate.ml | 15 |
15 files changed, 1195 insertions, 132 deletions
diff --git a/bin/main.ml b/bin/main.ml index bc02654..16daa49 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -122,6 +122,19 @@ let load_ef_lectionary () = | Error e -> Error (Printf.sprintf "failed to load %s: %s" path e) | Ok lectionary -> Ok lectionary +(* Sibling to [load_ef_lectionary] above, same reasoning and the same + [result] failure path: data/ef/commons.sexp holds the Commons of the + 1962 Missal plus the per-saint assignments that route a readingless + class-3 feast to one, and [Rite_ef.context] takes it as [~commons] + rather than reading it itself. Its own loader validates the file + (duplicate ids, empty formularies, assignments naming a common that does + not exist) and reports every failure as [Error]. *) +let load_ef_commons () = + let path = Filename.concat (data_dir ()) "commons.sexp" in + match Rite_ef.Lectionary_ef.Commons.load path with + | Error e -> Error (Printf.sprintf "failed to load %s: %s" path e) + | Ok commons -> Ok commons + let day_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) = let t = d.Colitur_kernel.Liturgical_day.temporal in @@ -155,50 +168,64 @@ let day_line (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kerne for the days sharing one liturgical year (calendar.mli's own "pays it once" cost model assumes exactly this usage: call [year], not [day] in a loop). *) -let day_report y = +(* The three data files this subcommand needs, loaded once and reported + through ONE failure path. Flattened out of the nested [match] this used + to be when a third loader (the Commons, Task 6) joined the first two: + each additional caller-supplied table would otherwise add a level of + indentation and a third verbatim copy of the same two-line error-and-exit + block. Every loader already returns [(_, string) result] (never raises, + never reads at module-initialisation time -- see [load_ef_lectionary]), + so chaining them costs nothing and keeps that promise intact. *) +let load_ef_data () = match load_ef_layer () with + | Error msg -> Error msg + | Ok layer -> ( + match load_ef_lectionary () with + | Error msg -> Error msg + | Ok lectionary -> ( + match load_ef_commons () with + | Error msg -> Error msg + | Ok commons -> Ok (layer, lectionary, commons))) + +let day_report y = + match load_ef_data () with | Error msg -> Printf.eprintf "colitur: %s\n" msg; exit 2 - | Ok layer -> ( - match load_ef_lectionary () with - | Error msg -> - Printf.eprintf "colitur: %s\n" msg; - exit 2 - | Ok lectionary -> - let context = Rite_ef.context ~lectionary in - let module Cal = Colitur_kernel.Calendar in - let by_rata : (int, (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) Hashtbl.t = - Hashtbl.create 400 - in - let index days = - Array.iter - (fun (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) -> - Hashtbl.replace by_rata (D.to_rata d.Colitur_kernel.Liturgical_day.date) d) - days - in - index (Cal.year context layer (y - 1)); - index (Cal.year context layer y); - let jan1 = match D.make ~year:y ~month:1 ~day:1 with Ok t -> t | Error e -> failwith e in - let dec31 = match D.make ~year:y ~month:12 ~day:31 with Ok t -> t | Error e -> failwith e in - let d = ref jan1 in - while D.compare !d dec31 <= 0 do - (match Hashtbl.find_opt by_rata (D.to_rata !d) with - | Some day -> day_line day - | None -> - (* Unreachable for any [y] in 1583..9999: the two indexed - liturgical years jointly cover [year_start (y-1), year_start - (y+1)), which contains all of civil year [y] - (calendar.mli). Not a [failwith] -- an out-of-domain [d] - inside this loop is impossible by construction (jan1/dec31 - are themselves validated in range, and [add_days] only ever - advances within the same civil year here) -- but a silent - skip would violate the same "never silently dropped" - standard the kernel holds itself to, so a gap surfaces - loudly on stderr rather than as a quietly short year. *) - Printf.eprintf "colitur: internal error: no resolved day for %s\n" (D.to_iso8601 !d)); - d := D.add_days !d 1 - done) + | Ok (layer, lectionary, commons) -> + let context = Rite_ef.context ~lectionary ~commons in + let module Cal = Colitur_kernel.Calendar in + let by_rata : (int, (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) Hashtbl.t = + Hashtbl.create 400 + in + let index days = + Array.iter + (fun (d : (Rite_ef.Vocab_ef.season, Rite_ef.Vocab_ef.rank) Colitur_kernel.Liturgical_day.t) -> + Hashtbl.replace by_rata (D.to_rata d.Colitur_kernel.Liturgical_day.date) d) + days + in + index (Cal.year context layer (y - 1)); + index (Cal.year context layer y); + let jan1 = match D.make ~year:y ~month:1 ~day:1 with Ok t -> t | Error e -> failwith e in + let dec31 = match D.make ~year:y ~month:12 ~day:31 with Ok t -> t | Error e -> failwith e in + let d = ref jan1 in + while D.compare !d dec31 <= 0 do + (match Hashtbl.find_opt by_rata (D.to_rata !d) with + | Some day -> day_line day + | None -> + (* Unreachable for any [y] in 1583..9999: the two indexed + liturgical years jointly cover [year_start (y-1), year_start + (y+1)), which contains all of civil year [y] + (calendar.mli). Not a [failwith] -- an out-of-domain [d] + inside this loop is impossible by construction (jan1/dec31 + are themselves validated in range, and [add_days] only ever + advances within the same civil year here) -- but a silent + skip would violate the same "never silently dropped" + standard the kernel holds itself to, so a gap surfaces + loudly on stderr rather than as a quietly short year. *) + Printf.eprintf "colitur: internal error: no resolved day for %s\n" (D.to_iso8601 !d)); + d := D.add_days !d 1 + done let usage () = prerr_endline "colitur: usage: colitur easter <year> | colitur temporal <year> | colitur day <year>"; diff --git a/data/ef/adjustments.sexp b/data/ef/adjustments.sexp index bd6fe60..f41d0d1 100644 --- a/data/ef/adjustments.sexp +++ b/data/ef/adjustments.sexp @@ -395,4 +395,146 @@ (Edit alphonsus-liguori ((Set_colour White))) (Edit augustine ((Set_colour White))) (Edit rose-of-lima ((Set_colour White))) - (Edit mark-i ((Set_colour White)))))) + (Edit mark-i ((Set_colour White))) + + ; --- ef-lectionary Task 6: the eight class-3 saints whose Mass the + ; 1962 Missal prints IN FULL, with its own Epistle and Gospel. + ; + ; Fifteen class-3 saints reached this branch with no readings at all + ; (data/ef/sanctoral.sexp, `(citations ())` -- they are exactly the + ; fifteen `status Feast` entries in that file that have none, checked + ; not assumed). They are the 6 March - 5 April cluster whose RANKS + ; lectio's own generator fix of 2026-08-12 restored without + ; backfilling readings, and lectio has no Commons concept to have + ; backfilled them from. + ; + ; The Missal splits them cleanly in two, and the split is NOT the one + ; the plan predicted (it expected propers for Benedict, Patrick, + ; Thomas Aquinas and Gregory the Great; only Thomas Aquinas has one). + ; The seven that the Missal sends to a Common are routed through + ; data/ef/commons.sexp instead -- see that file's header for the whole + ; method, the Commune Sanctorum's own substitution rubric, and the + ; per-Common evidence. The eight below are the ones the Missal prints + ; a full Mass for at the date itself, so the readings are ASSIGNED + ; ("certa Epistola aut certum Evangelium in Missali ... assignata", + ; the Commune's own proviso) and belong on the celebration. + ; + ; They are Edits here, not corrections to data/ef/sanctoral.sexp, + ; because that file is GENERATED (tools/bootstrap_sanctoral.ml) and + ; its source is upstream in lectio -- the same reasoning the colour + ; corrections above already carry. If lectio ever gains these + ; readings, `Set_citation` is idempotent (overlay.ml's own + ; [apply_field_edit] replaces the matching part), so the Edits stay + ; harmless and stand as a regression guard. + ; + ; EVERY value below was read in three independent places before being + ; written: docs/research/scan1.txt (CMAA scan), docs/research/scan2.txt + ; (Internet Archive scan -- a DIFFERENT printing), and the rendered + ; page images of missale-romanum-1962.pdf. Both text layers interleave + ; the two-column page, so no value rests on a single reading. There is + ; NO oracle for any of this: layer 3 (lectio) has no readings for these + ; feasts at all, and layer 4 (missalemeum, 2026-2027) never observes + ; one of them in either year. + ; + ; OBSERVABILITY, measured against the real resolver over 1950-2200: + ; only three of the eight are EVER the observed office -- + ; thomas-aquinas (6 days, e.g. 2000-03-07, 2011-03-07), john-of-god + ; (5 days, e.g. 2011-03-08, 2038-03-08), francis-of-paola (2 days, + ; 2008-04-02, 2160-04-02). The other five fall on dates always held by + ; a Lenten or Passiontide feria that outranks a III-class feast, so + ; they are only ever COMMEMORATIONS, and colitur emits readings for + ; the observed office only. Their citations are correct data with no + ; live witness today; they are recorded rather than omitted so that a + ; diocesan overlay or a rank change finds them already sourced. + + ; 7 March, "S. Thomae de Aquino, Conf. et Eccl. Doct., III classis": + ; "Lectio libri Sapientiae. Sap. 7, 7-14" / "Sequentia sancti + ; Evangelii secundum Matthaeum. Mt. 5, 13-19". + ; scan1:27701-27704 + 27699-27700; scan2:30588-30589 + 30643-30646; + ; page image p. 484. (scan2's OCR renders the Gospel chapter as "6"; + ; scan1 and the page image both print 5, and the pericope is "Vos + ; estis sal terrae", Mt 5:13-19.) + (Edit thomas-aquinas + ((Set_citation First "Wis 7:7-14") (Set_citation Gospel "Matt 5:13-19"))) + + ; 8 March, "S. Ioannis a Deo Conf., III classis": + ; "Lectio libri Sapientiae. Eccli. 31, 8-11" / "Sequentia sancti + ; Evangelii secundum Matthaeum. Mt. 22, 34-46". + ; scan1:27732-27734 + 27769-27770; scan2:30698-30699 + 30733-30736; + ; page image p. 485. NOTE: the Epistle is the Common of a Confessor + ; not a Bishop's own, but the GOSPEL is not (that Common has Luke + ; 12:35-40) -- the Missal prints him a full Mass with an assigned + ; Gospel, so this is a proper, not a Common. (scan2's OCR renders the + ; Gospel's last verse as 48; scan1 and the page image print 46, and + ; the pericope ends at "neque ausus fuit quisquam ex illa die eum + ; amplius interrogare" = Mt 22:46.) + (Edit john-of-god + ((Set_citation First "Ecclus 31:8-11") (Set_citation Gospel "Matt 22:34-46"))) + + ; 10 March, "Ss. Quadraginta Martyrum, III classis": + ; "Lectio Epistolae beati Pauli Apostoli ad Hebraeos. Hebr. 11, 33-39" + ; / "Sequentia sancti Evangelii secundum Lucam. Luc. 6, 17-23". + ; scan1:27841-27842 + 27832-27834; scan2:30766-30768 + 30821-30823. + (Edit forty-holy-martyrs-of-sebaste + ((Set_citation First "Heb 11:33-39") (Set_citation Gospel "Luke 6:17-23"))) + + ; 18 March, "S. Cyrilli Ep. Hierosolymitani, Conf. et Eccl. Doct., + ; III classis": "Lectio libri Sapientiae. Eccli. 39, 6-14" / + ; "Sequentia sancti Evangelii secundum Matthaeum. Mt. 10, 23-28". + ; scan1:27948-27949 + 27953-27955; scan2:30890-30896 + 30948-30951; + ; page image p. 489. This Epistle is the Common of Doctors' own + ; labelled ALTERNATIVE ("Item altera Epistola pro Doctoribus") -- here + ; it is assigned outright at the date, which is what makes it his. + (Edit cyril-of-jerusalem + ((Set_citation First "Ecclus 39:6-14") (Set_citation Gospel "Matt 10:23-28"))) + + ; 24 March, "S. Gabrielis Archangeli, III classis": + ; "Lectio Danielis Prophetae. Dan. 9, 21-26" / "Sequentia sancti + ; Evangelii secundum Lucam. Luc. 1, 26-38". + ; scan1:28149-28150 + 28175-28176; scan2:31070-31074 + 31158-31160; + ; page images pp. 493-494. + ; + ; THE ONE GENUINE CONFLICT BETWEEN THE TWO PRINTINGS, and the reason + ; this task's cross-check discipline exists. scan1's edition prints + ; the Gospel citation as "Luc. 2, 26-38" -- confirmed on the page + ; image, so it is a TYPOGRAPHICAL ERROR IN THAT PRINTING, not an OCR + ; artefact. scan2's edition prints "Luc. 1, 26-38". Three independent + ; things settle it for Luke 1: (a) scan2's own text; (b) the pericope + ; printed under the citation in BOTH editions is "Missus est Angelus + ; Gabriel ... Ecce ancilla Domini", which is Luke 1:26-38 -- Luke + ; 2:26-38 is Simeon and Anna, a different passage entirely; (c) the + ; SAME edition that misprints it here prints the identical pericope + ; two pages later for the Annunciation (25 March, page image p. 496) + ; and cites it correctly as "Luc. 1, 26-38". + (Edit gabriel-the-archangel + ((Set_citation First "Dan 9:21-26") (Set_citation Gospel "Luke 1:26-38"))) + + ; 27 March, "S. Ioannis Damasceni, Conf. et Eccl. Doct., III classis": + ; "Lectio libri Sapientiae. Sap. 10, 10-17" / "Sequentia sancti + ; Evangelii secundum Lucam. Luc. 6, 6-11". + ; scan1:28305-28306 + 28319-28320; scan2:31326-31328 + 31344-31346; + ; page image p. 497. The 10-17 (here) versus 10-14 (John of + ; Capistrano, next entry) split is real and agreed by all three + ; sources: this pericope runs on through "Et reddidit iustis mercedem + ; laborum suorum" (Wis 10:17), his does not. + (Edit john-damascene + ((Set_citation First "Wis 10:10-17") (Set_citation Gospel "Luke 6:6-11"))) + + ; 28 March, "S. Ioannis de Capistrano Conf., III classis": + ; "Lectio libri Sapientiae. Sap. 10, 10-14" / "Sequentia sancti + ; Evangelii secundum Lucam. Luc. 9, 1-6". + ; scan1:28346-28348 + 28412-28413; scan2:31382-31387 + 31445-31447; + ; page image p. 498 (the pericope there ends at "et in vinculis non + ; dereliquit illum" = Wis 10:14). + (Edit john-of-capistrano + ((Set_citation First "Wis 10:10-14") (Set_citation Gospel "Luke 9:1-6"))) + + ; 2 April, "S. Francisci de Paula Conf., III classis": + ; "Lectio Epistolae beati Pauli Apostoli ad Philippenses. Philipp. 3, + ; 7-12" / "Sequentia sancti Evangelii secundum Lucam. Luc. 12, 32-34". + ; scan1:28585-28586 + 28587-28588; scan2:31560-31562 + 31625-31630. + ; (scan2's OCR of the Gospel citation is shredded to "Lac. U, 32-34"; + ; scan1 prints it cleanly and the pericope is "Nolite timere, + ; pusillus grex", Luke 12:32-34.) + (Edit francis-of-paola + ((Set_citation First "Phil 3:7-12") (Set_citation Gospel "Luke 12:32-34")))))) diff --git a/data/ef/commons.sexp b/data/ef/commons.sexp new file mode 100644 index 0000000..c488fa2 --- /dev/null +++ b/data/ef/commons.sexp @@ -0,0 +1,257 @@ +; data/ef/commons.sexp -- the Commons of the 1962 Missal (Epistle + Gospel +; citations only), plus the per-saint assignments that route a readingless +; class-3 feast to one. +; +; HAND-AUTHORED from the 1962 Missale Romanum. There is no bootstrap source: +; lectio has no Commons concept at all, and its own generator left these +; feasts with no readings (the 6 March - 5 April cluster whose RANKS its +; 2026-08-12 fix restored without backfilling readings). Every entry below +; names the Missal heading and the exact place it was read from. +; +; METHOD (the discipline this file's trustworthiness rests on). Every value +; was read in THREE independent places before being written here: +; 1. docs/research/scan1.txt -- OCR text layer of missale-romanum-1962.pdf +; (Church Music Association of America scan, 1088 pp). +; 2. docs/research/scan2.txt -- OCR text layer of +; "Missale Romanum 1962_text.pdf" (Internet Archive scan, 1140 pp). +; A DIFFERENT PRINTING: its Commune Sanctorum pagination runs 1-2 pages +; ahead of scan1's, so the bracketed page numbers the Proprium quotes +; ("de Communi ... I loco [24]" vs "(25)") differ between them. The +; Common's IDENTITY -- heading plus Mass incipit -- is what is stable, +; and is what this file keys on. Never the page number. +; 3. The rendered page images of missale-romanum-1962.pdf, read directly, +; for every value where the two OCR layers disagreed and for a +; spot-audit of the rest (pp. 484-485, 488-489, 492, 493-494, 496-498). +; Both text layers interleave the two-column page, so a line can carry text +; from two different columns; that is precisely why nothing here rests on a +; single reading. See the task report for the per-value evidence table. +; +; WHAT THE MISSAL ACTUALLY GUARANTEES -- read this before trusting a value +; here as "the" reading. The Commune Sanctorum opens with its own rubric +; (scan1:40799-40802, scan2:45519-45523, word for word in both): +; "In singulis Communibus, Epistolae et Evangelia quae habentur, sive in +; ipsis Missis, sive ad calcem totius Communis, sumi possunt in qualibet +; Missa de eodem Communi, dummodo tamen certa Missa, dicenda in casu +; prouti iacet, vel certa Epistola aut certum Evangelium in Missali non +; fuerit assignata." +; That is: within one Common, ANY of its Epistles and Gospels may be used in +; ANY of its Masses, unless a particular Mass/Epistle/Gospel is assigned. +; So a Common does NOT determine a unique Epistle+Gospel pair. What this +; file records is the narrower, fully verifiable fact: the Epistle and +; Gospel PRINTED WITH the named Mass formulary that the saint's own day +; sends him to. Several Commons additionally print explicitly-labelled +; alternatives ("Item aliae Epistolae et alia Evangelia..."); those are +; noted per entry and deliberately NOT encoded -- choosing among them is a +; celebrant's option, not a computation. +; +; ORACLE COVERAGE: none. Layer 3 (lectio, 2005-2050) has no readings for any +; of these feasts -- that is why this task exists. Layer 4 (missalemeum, +; 2026-2027) has no witness either: not one of the fifteen is the OBSERVED +; office in 2026 or 2027 (checked, not assumed). The only live witnesses are +; this repository's own unit tests and golden pins, on the four Commons that +; a real year ever reaches (see OBSERVABILITY below). +; +; ASSIGNMENT IS EXPLICIT PER SAINT, NOT INFERRED. Subject.t is +; Temporal|Saint|Bvm|Lord and Celebration.t carries no +; martyr/confessor/virgin/bishop/abbot classification at all; those words +; appear only inside display names. Inventing that taxonomy is a larger +; change than this plan carries, and a wrong inference here is invisible -- +; there is no oracle to catch it. A saint with no proper and no assignment +; gets no Common; that is a recorded gap, never a guess. +; +; SCRIPTURE-REFERENCE STYLE. Book abbreviations follow the forms already +; dominant in colitur's own data, one form per book, applied consistently +; here (the bootstrapped data is inconsistent upstream -- it carries both +; "Sir" and "Ecclus" for Ecclesiasticus, both "Wis" and "Wis." for Wisdom. +; Nothing parses these strings, so the inconsistency is cosmetic, but a +; hand-authored file should not add to it). "Ecclus" is used for +; Ecclesiasticus throughout, matching the Missal's own "Eccli." -- this file +; is transcribed from that book, so its abbreviations track the source that +; was actually read. +; +; OBSERVABILITY -- how much of this file a real year can ever reach. +; Measured over 1950-2200 against the real resolver (Calendar + Precedence +; over the shipped data), not estimated: +; * common-of-non-virgins-1 (sts-felicitas-perpetua) -- REACHED, e.g. +; 1962-03-06, 2000-03-06, 2038-03-06 (13 days in 1950-2200). +; * common-of-non-virgins-2 (frances-rome) -- REACHED, e.g. +; 2038-03-09, 2190-03-09 (2 days). +; * common-of-doctors (isidore-of-seville) -- REACHED, e.g. +; 1951-04-04, 2008-04-04, 2035-04-04 (8 days). +; * common-of-a-confessor-not-a-bishop-1 (vincent-ferrer) -- REACHED, e.g. +; 1951-04-05, 2005-04-05, 2035-04-05 (21 days). +; * common-of-supreme-pontiffs (gregory-the-great) -- NEVER reached. +; * common-of-a-confessor-bishop-1 (patrick) -- NEVER reached. +; * common-of-abbots (benedict) -- NEVER reached. +; The last three saints' dates (12, 17, 21 March) always fall in Lent or +; Passiontide, where the feria outranks a III-class feast, so they are only +; ever COMMEMORATIONS -- and colitur emits readings for the observed office +; only. Their assignments are correct data with no live witness today; a +; diocesan overlay raising one of them, or any rank change, makes them live +; immediately. They are kept rather than dropped precisely so that day +; arrives with the reading already sourced. + +((commons + ; --- Commune unius aut plurium Summorum Pontificum, Missa "Si diligis me" + ; scan1 [1]-[3], lines 40814-40886; scan2 (1)-(3), lines 45539-45656. + ; This Common has ONE Mass formulary only (it serves both the singular and + ; the plural, varying the orations, not the readings). + ; Epistle heading: "Lectio Epistolae beati Petri Apostoli. 1 Petri 5, + ; 1-4 et 10-11" (scan1:40872-40873, scan2:45578-45579). + ; Gospel heading: "Sequentia sancti Evangelii secundum Matthaeum. + ; Mt. 16, 13-19" (scan1:40877-40878, scan2:45653-45654) -- scan1's OCR + ; of this citation is shredded ("Mi. x6> 13*19"); scan2 prints it + ; cleanly, and the pericope printed under it in both is unmistakably + ; Mt 16:13-19 ("Venit Iesus in partes Caesareae Philippi ... tibi dabo + ; claves regni caelorum"). + ((common-of-supreme-pontiffs + (((part First) (reference "1 Pet 5:1-4, 10-11")) + ((part Gospel) (reference "Matt 16:13-19")))) + + ; --- Commune Confessoris Pontificis, I loco, Missa "Statuit" + ; scan1 [19]-[20], lines 41717-41773; scan2 (20)-(21), lines 46590-46642. + ; Epistle: "Lectio libri Sapientiae. Eccli. 44, 16-27; 45, 3-20" + ; (scan1:41744-41745, scan2:46621-46622). + ; Gospel: "Sequentia sancti Evangelii secundum Matthaeum. Mt. 25, 14-23" + ; (scan1:41757-41758, scan2:46632-46633). + ; The Missal prints a SECOND Mass for a Confessor Bishop ("Sacerdotes + ; tui", II loco: Hebr 7:23-27 / Mt 24:42-47) and then four further named + ; alternatives ("Item aliae Epistolae et alia Evangelia pro Confessore + ; Pontifice", scan1:41865-41877). Neither is encoded: 17 March sends + ; St Patrick to the FIRST Mass by name, which is the assigned case the + ; opening rubric's own proviso covers. + (common-of-a-confessor-bishop-1 + (((part First) (reference "Ecclus 44:16-27; 45:3-20")) + ((part Gospel) (reference "Matt 25:14-23")))) + + ; --- Commune Doctorum, Missa "In medio" + ; scan1 [22]-[23], lines 41878-41979; scan2 (23)-(24), lines 46789-46879. + ; Epistle: "Lectio Epistolae beati Pauli Apostoli ad Timotheum. + ; 2 Tim. 4, 1-8" (scan1:41902-41903, scan2:46809-46810). + ; Gospel: "Sequentia sancti Evangelii secundum Matthaeum. Mt. 5, 13-19" + ; (scan1:41917-41918, scan2:46845-46847). + ; One labelled alternative Epistle follows ("Item altera Epistola pro + ; Doctoribus": Eccli 39, 6-14, scan1:41950-41952) -- not encoded. Note + ; that this same Eccli 39:6-14 is the Epistle ASSIGNED outright to + ; St Cyril of Jerusalem's own proper Mass on 18 March, which is why it + ; appears in adjustments.sexp as his proper rather than here. + (common-of-doctors + (((part First) (reference "2 Tim 4:1-8")) + ((part Gospel) (reference "Matt 5:13-19")))) + + ; --- Commune Confessoris non Pontificis, I loco, Missa "Os iusti" + ; scan1 [24]-[25], lines 41980-42034; scan2 (25)-(26), lines 46900-46955. + ; Epistle: "Lectio libri Sapientiae. Eccli. 31, 8-11" + ; (scan1:42007-42008, scan2:46927-46928). + ; Gospel: "Sequentia sancti Evangelii secundum Lucam. Luc. 12, 35-40" + ; (scan1:42017-42018, scan2:46953-46956). + ; A second Mass (II loco, 1 Cor 4:9-14 / Luc 12:32-34) and a labelled + ; alternative Epistle/Gospel pair follow -- not encoded; 5 April sends + ; St Vincent Ferrer to the FIRST Mass by name. + ; NOTE the near-miss: St John of God (8 March) shares this Common's + ; Epistle (Eccli 31:8-11) but the Missal prints him a DIFFERENT, + ; assigned Gospel (Mt 22:34-46), so he is a proper, not this Common. + (common-of-a-confessor-not-a-bishop-1 + (((part First) (reference "Ecclus 31:8-11")) + ((part Gospel) (reference "Luke 12:35-40")))) + + ; --- Commune Abbatum, Missa "Os iusti" + ; scan1 [27]-[28], lines 42134-42202; scan2 (28)-(29), lines 47057-47114. + ; Epistle: "Lectio libri Sapientiae. Eccli. 45, 1-6" + ; (scan1:42163-42164, scan2:47089-47093). + ; Gospel: "Sequentia sancti Evangelii secundum Matthaeum. Mt. 19, 27-29" + ; (scan1:42172-42173, scan2:47100-47102). + ; Corroborated independently by the Oratio: this Common's own + ; "Intercessio nos, quaesumus, Domine, beati N. Abbatis commendet" + ; (scan1:42154) is verbatim the Oratio printed for St Benedict on + ; 21 March with "N." resolved to "Benedicti" (scan1:28093, page image + ; p. 492) -- the same Mass, seen from both ends. + (common-of-abbots + (((part First) (reference "Ecclus 45:1-6")) + ((part Gospel) (reference "Matt 19:27-29")))) + + ; --- Commune non Virginum, I loco ("Pro Martyre non Virgine"), + ; Missa "Me exspectaverunt" + ; scan1 [35]-[36], lines 42560-42657; scan2 (37)-(38), lines 47583-47676. + ; Epistle: "Lectio libri Sapientiae. Eccli. 51, 1-8 et 12" + ; (scan1:42589-42590, scan2:47591-47592). + ; Gospel: "Sequentia sancti Evangelii secundum Matthaeum. Mt. 13, 44-52" + ; (scan1:42606-42607, scan2:47663-47665). + ; This Common covers the PLURAL case with the same Mass and different + ; orations, in its own rubric: "Pro pluribus Martyribus, quae non sint + ; Virgines, dicatur Missa ut supra, cum orationibus ut infra" + ; (scan1:42619-42621) -- and the orations it then prints ("Da nobis, + ; quaesumus, Domine Deus noster, sanctarum Martyrum tuarum N. et N. + ; palmas...", scan1:42624) are verbatim the ones printed for Sts + ; Perpetua and Felicity on 6 March with N. et N. resolved + ; (scan1:27637-27642). That is what makes their "cum orationibus ut + ; infra" reference unambiguous: same Mass, hence these readings. + (common-of-non-virgins-1 + (((part First) (reference "Ecclus 51:1-8, 12")) + ((part Gospel) (reference "Matt 13:44-52")))) + + ; --- Commune non Virginum, II loco ("Pro nec Virgine nec Martyre"), + ; Missa "Cognovi" + ; scan1 [37]-[38], lines 42657-42770; scan2 (39)-(40), lines 47677-47790. + ; Epistle: "Lectio libri Sapientiae. Prov. 31, 10-31" -- the heading + ; says Sapientiae, the citation printed under it is Proverbs, in both + ; scans (scan1:42679-42680, scan2:47678-47680); Prov 31:10-31 is what + ; the pericope text actually is ("Mulierem fortem quis inveniet?"). + ; Gospel: "Sequentia sancti Evangelii secundum Matthaeum. Mt. 13, 44-52" + ; (scan1:42718-42720, scan2:47772-47774) -- the same Gospel as I loco. + ; A labelled alternative Epistle follows, "Item altera Epistola pro + ; Vidua: 1 Tim. 5, 3-10" (scan1:42736-42740, scan2:47792-47795). St + ; Frances of Rome IS a widow ("S. Franciscae Romanae Vid."), so that + ; alternative is squarely available to her -- which is exactly why it is + ; NOT encoded: the Missal offers it as an option beside the printed + ; Epistle, and choosing between two lawful options is a celebrant's act, + ; not a computation. What is emitted is the Epistle printed with the + ; Mass her own day names. + (common-of-non-virgins-2 + (((part First) (reference "Prov 31:10-31")) + ((part Gospel) (reference "Matt 13:44-52")))))) + + ; Each assignment below is the Missal's own instruction at that saint's own + ; date, quoted in the comment -- not a classification colitur derived. + (assigned + ; 21 March: "S. Benedicti Abbatis / III classis / Missa Os iusti, de + ; Communi Abbatum [27]." No Epistle or Gospel is printed at the date; + ; only Oratio, Secreta, Postcommunio. scan1:28080-28085, + ; scan2:31054-31060, page image p. 492. (The plan expected a proper here; + ; the Missal says otherwise, three times.) + ((benedict common-of-abbots) + + ; 9 March: "S. Franciscae Romanae Vid. / III classis / Missa Cognovi, de + ; Communi non Virginum II loco [37], praeter orationem sequentem." + ; scan1:27781-27787, scan2:30754-30762. + (frances-rome common-of-non-virgins-2) + + ; 12 March: "S. Gregorii I, Papae, Conf. et Eccl. Doct. / III classis / + ; Missa Si diligis me, de Communi unius aut plurium Summorum Pontificum + ; [1], cum orationibus ut infra." scan1:27893-27901, scan2:30832-30840, + ; page image p. 488. (The plan expected a proper here; the Missal prints + ; only the three orations.) + (gregory-the-great common-of-supreme-pontiffs) + + ; 4 April: "S. Isidori Ep., Conf. et Eccl. Doct. / III classis / Missa + ; In medio, de Communi Doctorum [22]." scan1:28650-28660, + ; scan2:31637-31646. + (isidore-of-seville common-of-doctors) + + ; 17 March: "S. Patricii Ep. et Conf. / III classis / Missa Statuit, de + ; Communi Confessoris Pontificis I loco [19], praeter orationem + ; sequentem." scan1:27881-27888, scan2:30874-30883, page image p. 488. + ; (The plan expected a proper here; the Missal says otherwise.) + (patrick common-of-a-confessor-bishop-1) + + ; 6 March: "Ss. Perpetuae et Felicitatis Martyrum / III classis / Missa + ; Me exspectaverunt, de Communi non Virginum I loco [35], cum + ; orationibus ut infra." scan1:27629-27635, scan2:30567-30576. The + ; orations that follow are the Common's own plural set -- see + ; common-of-non-virgins-1 above. + (sts-felicitas-perpetua common-of-non-virgins-1) + + ; 5 April: "S. Vincentii Ferrerii Conf. / III classis / Missa Os iusti, + ; de Communi Confessoris non Pontificis I loco [24], praeter orationem + ; sequentem." scan1:28627-28632, scan2:31682-31689. + (vincent-ferrer common-of-a-confessor-not-a-bishop-1)))) diff --git a/lib/rites/rite_ef/lectionary_ef.ml b/lib/rites/rite_ef/lectionary_ef.ml index 37ec1d8..2658cd3 100644 --- a/lib/rites/rite_ef/lectionary_ef.ml +++ b/lib/rites/rite_ef/lectionary_ef.ml @@ -1,12 +1,116 @@ open Colitur_kernel +(* The Commons of the 1962 Missal, plus the per-saint assignments that route + a readingless class-3 feast to one. Data only -- every value in the + shipped file is transcribed from the Missal and carries its own source + citation there (data/ef/commons.sexp). This module knows the SHAPE and + the invariants, never the values. + + Caller-supplied, exactly as [lectionary] is, and for the same reason + (see this module's own .mli): a rite module that reads the filesystem as + a side effect of being linked breaks every caller that touches none of + its data. *) +module Commons = struct + open Sexplib0.Sexp_conv + + (* [commons]: a Common's id -> the Epistle and Gospel PRINTED WITH the + named Mass formulary. [assigned]: a saint's slug -> the Common his own + day sends him to. Two tables, not one, because the same Common serves + several saints and the two facts have different warrants -- the + formulary is read from the Commune Sanctorum, the assignment from the + saint's own date in the Proprium Sanctorum. *) + type t = { + commons : (Slug.t * Citation.t list) list; + assigned : (Slug.t * Slug.t) list; + } + [@@deriving sexp] + + let empty = { commons = []; assigned = [] } + let formularies t = t.commons + let assignments t = t.assigned + + (* Same discipline as [Colitur_kernel.Lectionary.of_entries]: canonically + sorted, and a duplicate key is an [Error] naming it rather than a + silently-shadowed second answer. *) + let sorted_by_slug xs = List.stable_sort (fun (a, _) (b, _) -> Slug.compare a b) xs + + let first_dup xs = + let rec go = function + | (a, _) :: ((b, _) :: _ as rest) -> if Slug.equal a b then Some a else go rest + | _ -> None + in + go xs + + let of_tables ~commons ~assigned = + let commons = sorted_by_slug commons and assigned = sorted_by_slug assigned in + match first_dup commons with + | Some s -> Error (Printf.sprintf "commons: duplicate common %S" (Slug.to_string s)) + | None -> ( + match first_dup assigned with + | Some s -> Error (Printf.sprintf "commons: duplicate assignment for %S" (Slug.to_string s)) + | None -> ( + (* A formulary with no citations is indistinguishable at the call + site from "this saint has no Common" -- [commons_for] would + return [Some []] and [readings] would emit [] either way. That + is exactly the silent hole this project does not allow, so it + is rejected here where it is still nameable. *) + match List.find_opt (fun (_, cs) -> cs = []) commons with + | Some (s, _) -> + Error (Printf.sprintf "commons: common %S has no citations" (Slug.to_string s)) + | None -> ( + (* An assignment pointing at a Common that does not exist + would otherwise degrade to [None] -- i.e. to "this saint + has no Common", the same answer as no assignment at all -- + so a typo in the data file would be invisible. Named + loudly instead. *) + match + List.find_opt (fun (_, common) -> not (List.mem_assoc common commons)) assigned + with + | Some (saint, common) -> + Error + (Printf.sprintf "commons: %S is assigned to unknown common %S" + (Slug.to_string saint) (Slug.to_string common)) + | None -> Ok { commons; assigned }))) + + let find t saint = + match List.assoc_opt saint t.assigned with + | None -> None + | Some common -> List.assoc_opt common t.commons + + (* Byte-for-byte the failure discipline of [Lectionary.load] (see its own + comments for why each catch-all is placed where it is): every parse and + validation failure comes back as [Error], never as an exception, and + never at module-initialisation time. *) + let load path = + match Sexplib.Sexp.load_sexp path with + | exception Sexplib.Sexp.Parse_error e -> + Error (Printf.sprintf "commons: %s: %s" path e.err_msg) + | exception Sys_error e -> Error (Printf.sprintf "commons: %s" e) + | exception exn -> Error (Printf.sprintf "commons: %s: %s" path (Printexc.to_string exn)) + | sexp -> ( + match t_of_sexp sexp with + | exception Sexplib0.Sexp_conv_error.Of_sexp_error (exn, _) -> + Error (Printf.sprintf "commons: %s: %s" path (Printexc.to_string exn)) + | exception exn -> Error (Printf.sprintf "commons: %s: %s" path (Printexc.to_string exn)) + | parsed -> of_tables ~commons:parsed.commons ~assigned:parsed.assigned) +end + +let commons_for ~commons saint = Commons.find commons saint + (* Step 1: the observed celebration's own proper. + Step 4: the observed SAINT's assigned Common -- see the branch comment in + [readings] for why it sits here, second, and not last. Step 2: the day's own temporal slug. Step 3: a weekday whose own slug has no entry says the preceding Sunday's Mass -- see the implementation comment on that branch in [readings] for the termination argument and why it is the Sunday's TEMPORAL, not observed, identity. + The steps keep their original NUMBERS (the plan's, and every existing + test's and comment's) even though step 4 now runs second: renumbering + would silently invalidate every "step 3" reference already written down. + Execution order is 1, 4, 2, 3. + Nothing here encodes "Lent has daily propers": the presence of an entry in [lectionary] is the sole discriminator -- this function does not branch on season, rank, or any other field to decide whether a temporal slug "ought" @@ -20,7 +124,9 @@ open Colitur_kernel rules ... Have lectio's behaviour; confirm against the Missal's ferial-Mass rubrics when coding") -- that confirmation has not been done; do not read this comment as citing RG/the Missal for the SELECTION rule - itself, only [Lectionary.find]'s presence-or-absence as the mechanism. *) + itself, only [Lectionary.find]'s presence-or-absence as the mechanism. + Step 4 is the one step of the four that DOES have a direct primary-source + warrant; its own comment gives it. *) (* Days from a given weekday back to the preceding Sunday. Sunday itself yields 0, which is why step 3 must guard on it -- see [readings] below. *) let days_since_sunday : Date.weekday -> int = function @@ -32,61 +138,110 @@ let days_since_sunday : Date.weekday -> int = function | Date.Fri -> 5 | Date.Sat -> 6 -let readings ~lectionary ~observed ~temporal ~date ~temporal_at = +let readings ~lectionary ~commons ~observed ~temporal ~date ~temporal_at = match observed.Celebration.citations with | _ :: _ as cs -> cs | [] -> ( - match Lectionary.find lectionary temporal.Temporal.office.Celebration.slug with + (* Step 4: a saint who is the day's observed office and has no proper + says his assigned Common. The assignment is explicit, never + inferred -- see data/ef/commons.sexp. + + ORDER. This runs SECOND, before the temporal fallbacks, not last as + the task brief sketched. The brief's ordering was tried first and is + provably dead code: measured against the real resolver over + 1950-2200, EVERY day on which one of the fifteen readingless + class-3 saints is actually the observed office also has a + non-empty step-2 or step-3 answer waiting (a Septuagesima or + Paschaltide feria resolves through its own slug or its preceding + Sunday's), so a step 4 placed after them is never reached on any + date in the domain. It would also be WRONG where it did fire: on + 2038-03-06 the observed office is Sts Perpetua and Felicity, a + III-class feast that beat the feria, and the Mass said that day is + theirs -- not Septuagesima II Saturday's 2 Cor 11:19-33 / Luke + 8:4-15, which is what the brief's ordering emits. + + WARRANT, and it is the strongest in this chain: the Missal itself, + at each of these saints' own dates, names the Mass to be said -- + "Missa Cognovi, de Communi non Virginum II loco, praeter orationem + sequentem" (9 March), "Missa Os iusti, de Communi Abbatum" + (21 March), and so on. That is a direct instruction about what is + read when the feast is the office of the day, quoted per saint in + data/ef/commons.sexp. Steps 2 and 3, by contrast, rest only on + lectio's observed behaviour (above). So the one step with a primary + source outranks the two without -- which is also simply what the + steps MEAN: steps 2 and 3 answer "what does this day's TEMPORAL + office read", a question that only governs when the temporal office + is the one being celebrated. + + The guard makes that precondition structural rather than a property + of the data file: the Commons are consulted only when the observed + celebration is not itself the day's temporal office. Without it, a + future overlay that assigned a Common to a temporal slug by mistake + would silently replace a feria's Mass; with it, ferias, Sundays, the + Triduum and the RG 78 Saturday Office of the BVM (whose observed + celebration IS its temporal office, deliberately sharing the ferial + slug) can never be diverted here at all. [Validate] already asserts + slug uniqueness per liturgical year, so a sanctoral feast can never + collide with a temporal slug and be wrongly excluded by it. *) + let sanctoral_office = + not (Slug.equal observed.Celebration.slug temporal.Temporal.office.Celebration.slug) + in + match + if sanctoral_office then commons_for ~commons observed.Celebration.slug else None + with | Some cs -> cs | None -> ( - (* Step 3: a feria with no proper of its own says the preceding - Sunday's Mass. WARRANT is the same as step 2's -- lectio's own - observed behaviour, not a confirmed Missal citation: this is the - rule lectio hard-codes as data on the four Advent ferias - (Advent II's readings copied verbatim onto the following - Monday-Saturday) and leaves absent on the other slugs this step - now also reaches; docs/research/rules-register.md already - records the ferial-Mass selection rule itself as unconfirmed - against the primary source. + match Lectionary.find lectionary temporal.Temporal.office.Celebration.slug with + | Some cs -> cs + | None -> ( + (* Step 3: a feria with no proper of its own says the preceding + Sunday's Mass. WARRANT is the same as step 2's -- lectio's own + observed behaviour, not a confirmed Missal citation: this is the + rule lectio hard-codes as data on the four Advent ferias + (Advent II's readings copied verbatim onto the following + Monday-Saturday) and leaves absent on the other slugs this step + now also reaches; docs/research/rules-register.md already + records the ferial-Mass selection rule itself as unconfirmed + against the primary source. - Guarded on weekday, but NOT because a Sunday reaching this - branch would loop (fix round 1, coordinator review: the - original comment here claimed exactly that, and it was wrong). - [readings] is not recursive -- step 3's fallback is one flat - [Lectionary.find], never a re-entrant call into [readings] -- - so without the guard, [days_since_sunday Sun = 0] would just - repeat the SAME [Lectionary.find] step 2 already ran and - already got [None] from (same pure inputs, same date), and - return [] once, normally. The chain as a whole terminates - because every step either consults data (a lookup) or, here, - a strictly EARLIER date via [temporal_at] -- no step ever calls - back into [readings] itself, so there is no recursion anywhere - in this function for a cycle to form in the first place. The - real reason for the guard is simpler: a Sunday has no - PRECEDING Sunday to resume -- consulting itself would be - meaningless (it would re-ask the question step 2 just - answered), not dangerous, so the guard exists to make that - intent explicit rather than to prevent a runaway loop that was - never actually possible. + Guarded on weekday, but NOT because a Sunday reaching this + branch would loop (fix round 1, coordinator review: the + original comment here claimed exactly that, and it was wrong). + [readings] is not recursive -- step 3's fallback is one flat + [Lectionary.find], never a re-entrant call into [readings] -- + so without the guard, [days_since_sunday Sun = 0] would just + repeat the SAME [Lectionary.find] step 2 already ran and + already got [None] from (same pure inputs, same date), and + return [] once, normally. The chain as a whole terminates + because every step either consults data (a lookup) or, here, + a strictly EARLIER date via [temporal_at] -- no step ever calls + back into [readings] itself, so there is no recursion anywhere + in this function for a cycle to form in the first place. The + real reason for the guard is simpler: a Sunday has no + PRECEDING Sunday to resume -- consulting itself would be + meaningless (it would re-ask the question step 2 just + answered), not dangerous, so the guard exists to make that + intent explicit rather than to prevent a runaway loop that was + never actually possible. - The preceding Sunday's TEMPORAL slug, never its observed one: - the rubric is the preceding Sunday's Mass even in a year when a - feast displaced that Sunday from being observed (see - test_step3_uses_temporal_not_observed). [temporal_at] gives the - temporal identity of any date, so the Sunday is reached by date - arithmetic and a fresh call to the temporal cycle -- never by - string surgery on [own_slug]: the slug shapes are genuinely - inconsistent across seasons (e.g. [ef-advent-sunday-1] versus - [ef-advent-1-monday], the week number on opposite sides of the - season name), so deriving one from the other textually would be - a latent bug the moment a season's naming convention differs. *) - let offset = days_since_sunday temporal.Temporal.weekday in - if offset = 0 then [] - else - let sunday = Date.add_days date (-offset) in - let sunday_temporal = temporal_at sunday in - match - Lectionary.find lectionary sunday_temporal.Temporal.office.Celebration.slug - with - | Some cs -> cs - | None -> [])) + The preceding Sunday's TEMPORAL slug, never its observed one: + the rubric is the preceding Sunday's Mass even in a year when a + feast displaced that Sunday from being observed (see + test_step3_uses_temporal_not_observed). [temporal_at] gives the + temporal identity of any date, so the Sunday is reached by date + arithmetic and a fresh call to the temporal cycle -- never by + string surgery on [own_slug]: the slug shapes are genuinely + inconsistent across seasons (e.g. [ef-advent-sunday-1] versus + [ef-advent-1-monday], the week number on opposite sides of the + season name), so deriving one from the other textually would be + a latent bug the moment a season's naming convention differs. *) + let offset = days_since_sunday temporal.Temporal.weekday in + if offset = 0 then [] + else + let sunday = Date.add_days date (-offset) in + let sunday_temporal = temporal_at sunday in + match + Lectionary.find lectionary sunday_temporal.Temporal.office.Celebration.slug + with + | Some cs -> cs + | None -> []))) diff --git a/lib/rites/rite_ef/lectionary_ef.mli b/lib/rites/rite_ef/lectionary_ef.mli index e54a0b1..baf25d4 100644 --- a/lib/rites/rite_ef/lectionary_ef.mli +++ b/lib/rites/rite_ef/lectionary_ef.mli @@ -3,12 +3,13 @@ open Colitur_kernel (** The EF lectionary resolution chain. All rubric knowledge about what a day with no proper falls back to lives here, not in the kernel. - [lectionary] is caller-supplied, not loaded by this module -- the same - reasoning rite_ef.mli's own [context] doc comment already gives for why - the sanctoral {!Colitur_kernel.Layer.t} stays a separate argument rather - than an embedded field: it lets a caller load data/ef/lectionary.sexp - however suits it, and leaves room for a future diocesan/proper - lectionary overlay to attach without this module changing at all. + [lectionary] and [commons] are both caller-supplied, not loaded by this + module -- the same reasoning rite_ef.mli's own [context] doc comment + already gives for why the sanctoral {!Colitur_kernel.Layer.t} stays a + separate argument rather than an embedded field: it lets a caller load + data/ef/lectionary.sexp and data/ef/commons.sexp however suits it, and + leaves room for a future diocesan/proper lectionary overlay to attach + without this module changing at all. An eager filesystem read at module initialisation was tried first and reverted (fix round 1, coordinator review): [readings] used to close @@ -18,17 +19,84 @@ open Colitur_kernel missing from a bare `dune build`'s own default target (it was only present because test/dune's own deps happened to materialise it, masking the gap in every test run). See the task report for the - reproduction. + reproduction. The Commons follow the same path for the same reason. *) - Steps 1-3 (Tasks 4-5): the observed celebration's own proper, else the - day's own temporal slug in the lectionary, else -- 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). A day matching none of - the three gets [] for now -- the Commons (Task 6) are not built here. *) +(** The Commons of the 1962 Missal (Epistle and Gospel citations only) and + the per-saint assignments that route a readingless class-3 feast to one. + + Two tables rather than one, because the two facts have different + warrants and different lifetimes: a FORMULARY is read from the Commune + Sanctorum and is the same for every saint sent to it, while an + ASSIGNMENT is read from one saint's own date in the Proprium Sanctorum. + A diocesan overlay adds assignments; it rarely adds formularies. + + Assignment is explicit per saint, never inferred: {!Colitur_kernel.Subject.t} + is [Temporal|Saint|Bvm|Lord] and {!Colitur_kernel.Celebration.t} carries + no martyr/confessor/virgin/bishop/abbot classification at all, so there + is nothing to infer one from -- those words appear only inside display + names. A saint with no proper and no assignment gets no Common. *) +module Commons : sig + type t + + (** No formularies and no assignments -- the identity for this table, and + what a caller that genuinely has no Commons data should pass. Every + lookup returns [None]; nothing is silently invented. *) + val empty : t + + (** Loads from a sexp file. Parse and validation failures come back as + [Error], never as an exception, and never at module-initialisation + time -- the same contract {!Colitur_kernel.Lectionary.load} makes. + + [Error] (never a silently-degraded lookup) on: a duplicate common id; + a duplicate assignment for one saint; a formulary with no citations + (indistinguishable downstream from "no Common at all"); and an + assignment naming a common that does not exist (likewise). *) + val load : string -> (t, string) result + + (** The Commons themselves, canonically sorted by id. *) + val formularies : t -> (Slug.t * Citation.t list) list + + (** Saint slug -> common id, canonically sorted by saint. *) + 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. + + 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 + +(** The day's Epistle and Gospel citations, or []. + + 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): + + - {b Step 1} -- the observed celebration's own proper. + - {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 + primary-source warrant (the Missal names the Mass at each such saint's + own date), and placing it last makes it unreachable on every date in + 1583-9999 as well as wrong on the days it would fire. The full + argument, with the measurement behind it, is on the branch itself. + Guarded so it can only ever apply to a SANCTORAL observed office -- + 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. + - {b Step 2} -- the day's own temporal slug in the lectionary. + - {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). + + A day matching none of the four gets []. *) val readings : lectionary:Lectionary.t -> + commons:Commons.t -> observed:Vocab_ef.rank Celebration.t -> temporal:(Vocab_ef.season, Vocab_ef.rank) Temporal.t -> date:Date.t -> diff --git a/lib/rites/rite_ef/rite_ef.ml b/lib/rites/rite_ef/rite_ef.ml index ded960b..60065b7 100644 --- a/lib/rites/rite_ef/rite_ef.ml +++ b/lib/rites/rite_ef/rite_ef.ml @@ -11,16 +11,23 @@ module Lectionary_ef = Lectionary_ef open Colitur_kernel -(* [~lectionary], not a value closed over an internal load: fix round 1 - (coordinator review) found the previous version -- [context] as a plain - value, [Lectionary_ef] loading data/ef/lectionary.sexp as a side effect - of being linked -- made `colitur easter <year>` (no lectionary data - touched at all) die at startup the moment that file was absent from a - bare `dune build`'s own default target. A function mirrors how the - sanctoral [Layer.t] already travels: caller-supplied, not embedded (see - this module's own .mli doc comment on [context] for the fuller - rationale, shared with data/ef/sanctoral.sexp). *) -let context ~lectionary : (Vocab_ef.season, Vocab_ef.rank) Rite.t = +(* [~lectionary] and [~commons], not values closed over an internal load: + fix round 1 (coordinator review) found the previous version -- [context] + as a plain value, [Lectionary_ef] loading data/ef/lectionary.sexp as a + side effect of being linked -- made `colitur easter <year>` (no + lectionary data touched at all) die at startup the moment that file was + absent from a bare `dune build`'s own default target. A function mirrors + how the sanctoral [Layer.t] already travels: caller-supplied, not + embedded (see this module's own .mli doc comment on [context] for the + fuller rationale, shared with data/ef/sanctoral.sexp). + + [~commons] is REQUIRED, not optional-with-a-default: an omitted + [?commons] would silently give a caller [Commons.empty], i.e. a rite + whose class-3 saints quietly lose their Mass, and nothing in the suite + compares citations across layers 3-5, so that loss would be invisible. + A caller that genuinely has no Commons data passes + [Lectionary_ef.Commons.empty] and says so. *) +let context ~lectionary ~commons : (Vocab_ef.season, Vocab_ef.rank) Rite.t = { Rite.id = Temporal_ef.id; vocab = Vocab_ef.vocab; year_start = Temporal_ef.year_start; @@ -32,4 +39,4 @@ let context ~lectionary : (Vocab_ef.season, Vocab_ef.rank) Rite.t = admit = Precedence_ef.admit }; season_runs = Vocab_ef.seasons; transfer_target = Precedence_ef.transfer_target; - readings = Lectionary_ef.readings ~lectionary } + readings = Lectionary_ef.readings ~lectionary ~commons } diff --git a/lib/rites/rite_ef/rite_ef.mli b/lib/rites/rite_ef/rite_ef.mli index 12f5609..2183799 100644 --- a/lib/rites/rite_ef/rite_ef.mli +++ b/lib/rites/rite_ef/rite_ef.mli @@ -26,10 +26,12 @@ module Lectionary_ef = Lectionary_ef value's own documentation for the termination and forward-progress argument {!Colitur_kernel.Rite.t.transfer_target}'s contract requires). - [readings]: {!Lectionary_ef.readings} partially applied to the caller's - own [~lectionary] -- the observed celebration's own proper, else the - day's own temporal slug, else (a weekday with no entry of its own) the - preceding Sunday's temporal slug, in data/ef/lectionary.sexp (chain - steps 1-3; the Commons are later work). + own [~lectionary] and [~commons] -- the observed celebration's own + proper, else (for a saint who is the day's observed office) his + assigned Common from data/ef/commons.sexp, else the day's own temporal + 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. Deliberately carries no [sanctoral]/[lectionary] fields the way the original design-doc sketch of [RITE] does: {!Colitur_kernel.Rite.t} (the @@ -44,7 +46,12 @@ module Lectionary_ef = Lectionary_ef that never touches lectionary data at all the moment that file was missing from a bare build -- see the task report). A future diocesan/proper lectionary overlay has a caller-side seam to attach to - for the same reason the sanctoral overlay already does. *) + for the same reason the sanctoral overlay already does. [~commons] + travels the same seam, and is deliberately REQUIRED rather than + defaulted -- see this module's .ml for why a silently-defaulted + {!Lectionary_ef.Commons.empty} would be undetectable by any of the five + validation layers. *) val context : lectionary:Colitur_kernel.Lectionary.t -> + commons:Lectionary_ef.Commons.t -> (Vocab_ef.season, Vocab_ef.rank) Colitur_kernel.Rite.t @@ -7,10 +7,16 @@ ../data/ef/expected-divergences.sexp ../data/ef/expected-divergences-missalemeum.sexp ../data/ef/lectionary.sexp + ../data/ef/commons.sexp fixtures/lectio-ef-2005-2050.txt fixtures/missalemeum-ef-2026-2027.txt) (preprocess (pps ppx_sexp_conv))) (cram - (deps %{bin:colitur} ../data/ef/sanctoral.sexp ../data/ef/adjustments.sexp)) + (deps + %{bin:colitur} + ../data/ef/sanctoral.sexp + ../data/ef/adjustments.sexp + ../data/ef/lectionary.sexp + ../data/ef/commons.sexp)) diff --git a/test/test_calendar.ml b/test/test_calendar.ml index aeaeea0..a533ab2 100644 --- a/test/test_calendar.ml +++ b/test/test_calendar.ml @@ -471,6 +471,18 @@ let real_ef_lectionary_for_transfer_probes = | Error e -> failwith ("../data/ef/lectionary.sexp: " ^ e) | Ok l -> l +(* The Commons (data/ef/commons.sexp) travel the same caller-supplied seam + as the lectionary above, and [~commons] is required rather than defaulted + so that no caller can silently run with none -- nothing in layers 3-5 + compares reading citations, so a rite quietly missing its Commons would + be invisible. Loaded here even where this file asserts nothing about + readings, so that the rite under test is the same one bin/main.ml + assembles. *) +let real_ef_commons_for_transfer_probes = + match Rite_ef.Lectionary_ef.Commons.load "../data/ef/commons.sexp" with + | Error e -> failwith ("../data/ef/commons.sexp: " ^ e) + | Ok c -> c + let test_transferred_commemoration_only_capped_out_at_target_settles_cleanly () = let probe_date = match Colitur_kernel.Date_spec.fixed ~month:4 ~day:26 with Ok d -> d | Error e -> failwith e @@ -488,7 +500,8 @@ let test_transferred_commemoration_only_capped_out_at_target_settles_cleanly () 25 and 26 April 2011. *) let year = C.year - (Rite_ef.context ~lectionary:real_ef_lectionary_for_transfer_probes) + (Rite_ef.context ~lectionary:real_ef_lectionary_for_transfer_probes + ~commons:real_ef_commons_for_transfer_probes) augmented_layer 2010 in let find_date target = diff --git a/test/test_differential.ml b/test/test_differential.ml index 0b68f90..acbb559 100644 --- a/test/test_differential.ml +++ b/test/test_differential.ml @@ -214,6 +214,18 @@ let real_lectionary () = | Ok l -> l | Error e -> Alcotest.failf "../data/ef/lectionary.sexp: failed to load: %s" e +(* The Commons (data/ef/commons.sexp) travel the same caller-supplied seam + as the lectionary above, and [~commons] is required rather than defaulted + so that no caller can silently run with none -- nothing in layers 3-5 + compares reading citations, so a rite quietly missing its Commons would + be invisible. Loaded here even where this file asserts nothing about + readings, so that the rite under test is the same one bin/main.ml + assembles. *) +let real_commons () = + match Rite_ef.Lectionary_ef.Commons.load "../data/ef/commons.sexp" with + | Ok c -> c + | Error e -> Alcotest.failf "../data/ef/commons.sexp: failed to load: %s" e + (* --- The seven-column row both streams share (commemorations excluded, --- *) (* limit 1 above). *) type row = { @@ -262,7 +274,7 @@ let lectio_rows () = List.map row_of_line (read_lines fixture_path) test_rite_ef.ml already made for [real_layer] above. *) let colitur_rows_2005_2050 () = let layer = real_layer () in - let rite = Rite_ef.context ~lectionary:(real_lectionary ()) 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 20000 in for y = 2004 to 2050 do let days = Cal.year rite layer y in diff --git a/test/test_golden.ml b/test/test_golden.ml index 188e37f..2f8930f 100644 --- a/test/test_golden.ml +++ b/test/test_golden.ml @@ -64,6 +64,7 @@ module V = Rite_ef.Vocab_ef let sanctoral_path = "../data/ef/sanctoral.sexp" let adjustments_path = "../data/ef/adjustments.sexp" let lectionary_path = "../data/ef/lectionary.sexp" +let commons_path = "../data/ef/commons.sexp" (* Loaded once at module init, same convention test_validate.ml's own [real_ef_layer] uses (not test_oracle.ml/test_differential.ml's @@ -91,7 +92,19 @@ let real_ef_lectionary = | Error e -> failwith (Printf.sprintf "%s: failed to load: %s" lectionary_path e) | Ok l -> l -let real_ef_rite = Rite_ef.context ~lectionary:real_ef_lectionary +(* The Commons (data/ef/commons.sexp) travel the same caller-supplied seam + as the lectionary above, and [~commons] is required rather than defaulted + so that no caller can silently run with none -- nothing in layers 3-5 + compares reading citations, so a rite quietly missing its Commons would + be invisible. Loaded here even where this file asserts nothing about + readings, so that the rite under test is the same one bin/main.ml + assembles. *) +let real_ef_commons = + match Rite_ef.Lectionary_ef.Commons.load commons_path with + | Error e -> failwith (Printf.sprintf "%s: failed to load: %s" commons_path e) + | Ok c -> c + +let real_ef_rite = Rite_ef.context ~lectionary:real_ef_lectionary ~commons:real_ef_commons let mk y m d = match Date.make ~year:y ~month:m ~day:d with Ok t -> t | Error e -> failwith e diff --git a/test/test_lectionary_ef.ml b/test/test_lectionary_ef.ml index 8af57f6..e28476d 100644 --- a/test/test_lectionary_ef.ml +++ b/test/test_lectionary_ef.ml @@ -11,6 +11,7 @@ open Rite_ef let sanctoral_path = "../data/ef/sanctoral.sexp" let adjustments_path = "../data/ef/adjustments.sexp" let lectionary_path = "../data/ef/lectionary.sexp" +let commons_path = "../data/ef/commons.sexp" let real_layer () = let layer = @@ -40,6 +41,17 @@ let real_lectionary () = | Ok l -> l | Error e -> Alcotest.failf "%s: failed to load: %s" lectionary_path e +(* The Commons travel the same caller-supplied seam (Task 6). Note that + [Commons.load]'s own validation runs here on the committed file: a + duplicate common id, a duplicate assignment, an empty formulary, or an + assignment naming a common that does not exist all come back as [Error] + and fail every test in this file rather than degrading silently to "this + saint has no Common". *) +let real_commons () = + match Lectionary_ef.Commons.load commons_path with + | Ok c -> c + | Error e -> Alcotest.failf "%s: failed to load: %s" commons_path e + (* [Calendar.day] (not [year]): the liturgical year "opening in civil year y" is Advent-anchored (RG 61), so [Calendar.year _ _ 2030] covers Advent 2030 through November 2031 -- it would never contain 13 January 2030, which @@ -48,7 +60,9 @@ let real_lectionary () = let day y m d = let date = match Date.make ~year:y ~month:m ~day:d with | Ok x -> x | Error e -> Alcotest.fail e in - Calendar.day (Rite_ef.context ~lectionary:(real_lectionary ())) (real_layer ()) date + Calendar.day + (Rite_ef.context ~lectionary:(real_lectionary ()) ~commons:(real_commons ())) + (real_layer ()) date let refs (ld : _ Liturgical_day.t) = List.map (fun c -> c.Citation.reference) ld.citations @@ -164,6 +178,289 @@ let test_step3_sunday_does_not_recurse () = let d = day 2026 6 14 in Alcotest.(check int) "a Sunday resolves without looping" 2 (List.length (refs d)) +(* ---------------------------------------------------------------------- *) +(* Chain step 4: the Commons (Task 6). *) +(* *) +(* Step 4 EXECUTES SECOND, between step 1 and step 2, not last as the task *) +(* brief sketched -- see lectionary_ef.ml's own branch comment for the *) +(* measurement and the primary-source warrant. The step NUMBER is kept as *) +(* the plan wrote it so that every "step 3" already recorded elsewhere *) +(* still names the same branch. *) +(* *) +(* Every expected value below is quoted from data/ef/commons.sexp, which in *) +(* turn cites the Missal page it was read from. There is NO oracle for any *) +(* of it: lectio has no readings for these feasts (that is why Task 6 *) +(* exists) and missalemeum's 2026-2027 window never observes one of them. *) +(* These pins and the golden ones are the entire regression net. *) +(* ---------------------------------------------------------------------- *) + +(* 2038-03-06 observes Sts Perpetua and Felicity: a III-class feast that + beats the Septuagesima II Saturday feria. The Missal sends 6 March to + "Missa Me exspectaverunt, de Communi non Virginum I loco", whose printed + Epistle and Gospel are these. + + This test is also the ORDERING pin, and it is the reason it uses explicit + values rather than a length check: with step 4 placed last (the brief's + sketch) this day resolves through step 2 to the feria's own + "2 Cor. 11:19-33; 12:1-9" / "Luke 8:4-15" instead -- a green + two-citation answer that a [List.length = 2] assertion would not + distinguish from the right one. *) +let test_step4_commons_perpetua_and_felicity () = + Alcotest.(check (list string)) + "6 March 2038: Sts Perpetua and Felicity take the Common of non-Virgins I" + [ "Ecclus 51:1-8, 12"; "Matt 13:44-52" ] + (refs (day 2038 3 6)) + +(* 2038-03-09, St Frances of Rome: "Missa Cognovi, de Communi non Virginum + II loco". Same year, three days later, and a DIFFERENT Common -- so this + pin also proves the assignment table is consulted per saint rather than + one Common being handed to everything that reaches step 4. Its temporal + slug (ef-septuagesima-3-tuesday) resolves to 1 Cor. 13:1-13 / Luke + 18:31-43, so the wrong-order failure is again a plausible-looking green. *) +let test_step4_commons_frances_of_rome () = + Alcotest.(check (list string)) + "9 March 2038: St Frances of Rome takes the Common of non-Virgins II" + [ "Prov 31:10-31"; "Matt 13:44-52" ] + (refs (day 2038 3 9)) + +(* 2008-04-04, St Isidore: "Missa In medio, de Communi Doctorum". A + Paschaltide witness, where step 2 would otherwise have supplied the + Easter-week feria's 1 John 5:4-10 / John 20:19-31. *) +let test_step4_commons_isidore () = + Alcotest.(check (list string)) + "4 April 2008: St Isidore takes the Common of Doctors" + [ "2 Tim 4:1-8"; "Matt 5:13-19" ] + (refs (day 2008 4 4)) + +(* 2005-04-05, St Vincent Ferrer: "Missa Os iusti, de Communi Confessoris + non Pontificis I loco". *) +let test_step4_commons_vincent_ferrer () = + Alcotest.(check (list string)) + "5 April 2005: St Vincent Ferrer takes the Common of a Confessor not a Bishop I" + [ "Ecclus 31:8-11"; "Luke 12:35-40" ] + (refs (day 2005 4 5)) + +(* Step 1 still wins over step 4 for a saint the Missal prints a full Mass + for. St John of God (8 March) is the sharp case: his Epistle IS the + Common of a Confessor not a Bishop's own (Ecclus 31:8-11), but the Missal + assigns him a different GOSPEL (Mt 22:34-46, not that Common's Luke + 12:35-40), so a chain that reached for a Common here would be caught by + the Gospel alone. He has no assignment in commons.sexp at all -- this + asserts the proper arrived via adjustments.sexp and step 1. *) +let test_step1_proper_beats_any_common_john_of_god () = + Alcotest.(check (list string)) + "8 March 2038: St John of God's own printed Mass, not the Common that shares its Epistle" + [ "Ecclus 31:8-11"; "Matt 22:34-46" ] + (refs (day 2038 3 8)); + Alcotest.(check bool) + "and he is deliberately absent from the Commons assignment table" true + (Lectionary_ef.commons_for ~commons:(real_commons ()) + (Slug.of_string_exn "john-of-god") + = None) + +(* The other two proper-Mass saints a real year ever observes. Both would + otherwise silently emit their feria's Mass. *) +let test_step1_proper_thomas_aquinas () = + Alcotest.(check (list string)) + "7 March 2011: St Thomas Aquinas's own proper" + [ "Wis 7:7-14"; "Matt 5:13-19" ] + (refs (day 2011 3 7)) + +let test_step1_proper_francis_of_paola () = + Alcotest.(check (list string)) + "2 April 2008: St Francis of Paola's own proper" + [ "Phil 3:7-12"; "Luke 12:32-34" ] + (refs (day 2008 4 2)) + +(* Step 4 must never divert a day whose observed office IS its temporal + office. The guard in [readings] makes that structural; this pins it + behaviourally on the case most at risk -- the RG 78 Saturday Office of + the BVM, which deliberately REUSES the ordinary ferial slug (see + temporal_ef.ml's [bvm_saturday_names], "Slug" paragraph). If that shared + slug were ever assigned a Common, every feria sharing it would change + Mass. 4 July 2026 is such a Saturday; it keeps its own ferial Mass. *) +let test_step4_never_diverts_a_temporal_office () = + Alcotest.(check (list string)) + "4 July 2026 (a BVM Saturday) keeps its ferial Mass, unaffected by step 4" + [ "1 Pet 3:8-15."; "Matt 5:20-24." ] + (refs (day 2026 7 4)) + +(* THE DATA ASSERTION, and the one that cannot drift: after Task 6, every + sanctoral entry that can ever BE the observed office -- i.e. every + [status Feast] entry -- either carries its own proper or has a Common + assigned. Stated over the real loaded layer rather than as a hard-coded + list of fifteen names, so that a future re-bootstrap adding a + readingless class-3 feast fails here instead of silently emitting its + feria's Mass. + + [Commemoration_only] entries are excluded deliberately and not as an + oversight: they are never the observed celebration (Precedence never + returns one as [observed]), and [readings] only ever consults + [observed], so their empty citations are unreachable. 104 of the 119 + entries with no citations are of that kind. *) +let test_every_observable_sanctoral_feast_has_readings () = + let commons = real_commons () in + let layer = real_layer () in + let gaps = + List.filter_map + (fun (entry : Vocab_ef.rank Layer.entry) -> + let cel = entry.Layer.cel in + if cel.Celebration.status <> Celebration.Feast then None + else if cel.Celebration.citations <> [] then None + else if Lectionary_ef.commons_for ~commons cel.Celebration.slug <> None then None + else Some (Slug.to_string cel.Celebration.slug)) + layer.Layer.entries + in + Alcotest.(check (list string)) + "every sanctoral Feast has either a proper or an assigned Common" [] + (List.sort_uniq compare gaps) + +(* The seven saints the Missal sends to a Common, and the exact Common each + one is sent to -- quoted per saint, with its Missal citation, in + data/ef/commons.sexp. Named explicitly (in addition to the generic + invariant above) because three of them -- Benedict, Gregory the Great, + Patrick -- are NEVER the observed office anywhere in 1583-9999 (12, 17 + and 21 March always fall to a Lenten or Passiontide feria that outranks a + III-class feast), so no end-to-end pin can reach them and this table is + their only coverage. + + The plan expected Benedict, Patrick and Gregory the Great to have PROPERS + and Thomas Aquinas likewise; the Missal gives a proper only to Thomas + Aquinas. All three reversals were verified on the page images + (pp. 488, 492) as well as in both OCR text layers. *) +let test_step4_assignment_table () = + let commons = real_commons () in + let assigned = + List.map + (fun (saint, common) -> (Slug.to_string saint, Slug.to_string common)) + (Lectionary_ef.Commons.assignments commons) + in + Alcotest.(check (list (pair string string))) + "the Commons assignment table, exactly" + [ ("benedict", "common-of-abbots"); + ("frances-rome", "common-of-non-virgins-2"); + ("gregory-the-great", "common-of-supreme-pontiffs"); + ("isidore-of-seville", "common-of-doctors"); + ("patrick", "common-of-a-confessor-bishop-1"); + ("sts-felicitas-perpetua", "common-of-non-virgins-1"); + ("vincent-ferrer", "common-of-a-confessor-not-a-bishop-1") ] + assigned + +(* The three Commons no real year reaches, resolved through [commons_for] so + that the assignment AND the formulary behind it are both exercised. *) +let test_step4_unreachable_commons_still_resolve () = + let commons = real_commons () in + let for_saint s = Lectionary_ef.commons_for ~commons (Slug.of_string_exn s) in + let refs_of = function + | None -> [ "<no common>" ] + | Some cs -> List.map (fun c -> c.Citation.reference) cs + in + Alcotest.(check (list string)) + "St Benedict (21 March), Common of Abbots" + [ "Ecclus 45:1-6"; "Matt 19:27-29" ] + (refs_of (for_saint "benedict")); + Alcotest.(check (list string)) + "St Gregory the Great (12 March), Common of Supreme Pontiffs" + [ "1 Pet 5:1-4, 10-11"; "Matt 16:13-19" ] + (refs_of (for_saint "gregory-the-great")); + Alcotest.(check (list string)) + "St Patrick (17 March), Common of a Confessor Bishop I" + [ "Ecclus 44:16-27; 45:3-20"; "Matt 25:14-23" ] + (refs_of (for_saint "patrick")) + +(* The five proper-Mass saints no real year reaches, asserted on the layer + rather than end-to-end, for the same reason. *) +let test_step4_unreachable_propers_are_present () = + let layer = real_layer () in + let refs_of slug = + match + List.find_opt + (fun (e : Vocab_ef.rank Layer.entry) -> + Slug.equal e.Layer.cel.Celebration.slug (Slug.of_string_exn slug)) + layer.Layer.entries + with + | None -> [ "<absent from the layer>" ] + | Some e -> List.map (fun c -> c.Citation.reference) e.Layer.cel.Celebration.citations + in + Alcotest.(check (list string)) + "Forty Holy Martyrs of Sebaste (10 March)" + [ "Heb 11:33-39"; "Luke 6:17-23" ] + (refs_of "forty-holy-martyrs-of-sebaste"); + Alcotest.(check (list string)) + "St Cyril of Jerusalem (18 March)" + [ "Ecclus 39:6-14"; "Matt 10:23-28" ] + (refs_of "cyril-of-jerusalem"); + (* The one genuine disagreement between the two printings: scan1's edition + misprints this Gospel as "Luc. 2, 26-38" (confirmed on its own page + image, so a typographical error, not OCR). Luke 1:26-38 is settled by + scan2's edition, by the pericope text in both, and by the same edition + citing the identical pericope correctly two pages later at the + Annunciation. See data/ef/adjustments.sexp for the full account. *) + Alcotest.(check (list string)) + "St Gabriel the Archangel (24 March)" + [ "Dan 9:21-26"; "Luke 1:26-38" ] + (refs_of "gabriel-the-archangel"); + (* Wis 10:10-17 here versus 10:10-14 for John of Capistrano below: a real + difference, agreed by both scans and both page images. *) + Alcotest.(check (list string)) + "St John Damascene (27 March)" + [ "Wis 10:10-17"; "Luke 6:6-11" ] + (refs_of "john-damascene"); + Alcotest.(check (list string)) + "St John of Capistrano (28 March)" + [ "Wis 10:10-14"; "Luke 9:1-6" ] + (refs_of "john-of-capistrano") + +(* [Commons.load] must reject, not silently degrade, the four data defects + that are indistinguishable downstream from "this saint has no Common". + Written against strings rather than the committed file so the committed + file stays clean. *) +let test_commons_load_rejects_bad_data () = + let write name contents = + let path = Filename.concat (Filename.get_temp_dir_name ()) name in + let oc = open_out path in + output_string oc contents; + close_out oc; + path + in + let err name contents = + match Lectionary_ef.Commons.load (write name contents) with + | Ok _ -> Alcotest.failf "%s: expected Error, got Ok" name + | Error e -> e + in + let good_common = "(c-a (((part First) (reference \"A 1:1\")) ((part Gospel) (reference \"B 2:2\"))))" in + let contains needle haystack = + let n = String.length needle and h = String.length haystack in + let rec go i = i + n <= h && (String.sub haystack i n = needle || go (i + 1)) in + go 0 + in + let check_msg label needle msg = + Alcotest.(check bool) (Printf.sprintf "%s names the culprit (%s)" label needle) true + (contains needle msg) + in + check_msg "duplicate common" "duplicate common" + (err "commons-dup-common.sexp" + (Printf.sprintf "((commons (%s %s)) (assigned ()))" good_common good_common)); + check_msg "duplicate assignment" "duplicate assignment" + (err "commons-dup-assign.sexp" + (Printf.sprintf "((commons (%s)) (assigned ((s c-a) (s c-a))))" good_common)); + check_msg "empty formulary" "no citations" + (err "commons-empty.sexp" "((commons ((c-a ()))) (assigned ()))"); + check_msg "unknown common" "unknown common" + (err "commons-unknown.sexp" + (Printf.sprintf "((commons (%s)) (assigned ((s c-missing))))" good_common)); + (* A missing file is an [Error] too, never an exception -- the contract + [Lectionary.load] already makes and the reason neither is read at + module-initialisation time. *) + (match Lectionary_ef.Commons.load "../data/ef/no-such-commons.sexp" with + | Ok _ -> Alcotest.fail "a missing commons file must not load" + | Error _ -> ()); + (* [empty] is the identity: every lookup [None], nothing invented. *) + Alcotest.(check bool) "Commons.empty resolves nothing" true + (Lectionary_ef.commons_for ~commons:Lectionary_ef.Commons.empty + (Slug.of_string_exn "benedict") + = None) + let suite = [ ("step 1: sanctoral proper", `Quick, test_step1_sanctoral_proper); ("step 2: own temporal proper", `Quick, test_step2_lenten_feria_has_its_own); @@ -178,4 +475,26 @@ let suite = ("step 3: 3 Feb 2025 takes the Sunday's temporal Mass, not the Purification's", `Quick, test_step3_uses_temporal_not_observed); ("step 3: a Sunday does not recurse into itself", `Quick, - test_step3_sunday_does_not_recurse) ] + test_step3_sunday_does_not_recurse); + ("step 4: Perpetua and Felicity take the Common of non-Virgins I", `Quick, + test_step4_commons_perpetua_and_felicity); + ("step 4: Frances of Rome takes the Common of non-Virgins II", `Quick, + test_step4_commons_frances_of_rome); + ("step 4: Isidore takes the Common of Doctors", `Quick, test_step4_commons_isidore); + ("step 4: Vincent Ferrer takes the Common of a Confessor not a Bishop I", `Quick, + test_step4_commons_vincent_ferrer); + ("step 1 beats step 4: John of God's proper, not the Common sharing its Epistle", `Quick, + test_step1_proper_beats_any_common_john_of_god); + ("step 1: Thomas Aquinas's proper", `Quick, test_step1_proper_thomas_aquinas); + ("step 1: Francis of Paola's proper", `Quick, test_step1_proper_francis_of_paola); + ("step 4 never diverts a temporal office (BVM Saturday)", `Quick, + test_step4_never_diverts_a_temporal_office); + ("every observable sanctoral Feast has a proper or a Common", `Quick, + test_every_observable_sanctoral_feast_has_readings); + ("the Commons assignment table, exactly", `Quick, test_step4_assignment_table); + ("the three Commons no real year reaches still resolve", `Quick, + test_step4_unreachable_commons_still_resolve); + ("the five propers no real year reaches are present", `Quick, + test_step4_unreachable_propers_are_present); + ("Commons.load rejects the four silent-degradation defects", `Quick, + test_commons_load_rejects_bad_data) ] diff --git a/test/test_oracle.ml b/test/test_oracle.ml index e1e4184..f1ab9f1 100644 --- a/test/test_oracle.ml +++ b/test/test_oracle.ml @@ -227,6 +227,18 @@ let real_lectionary () = | Ok l -> l | Error e -> Alcotest.failf "../data/ef/lectionary.sexp: failed to load: %s" e +(* The Commons (data/ef/commons.sexp) travel the same caller-supplied seam + as the lectionary above, and [~commons] is required rather than defaulted + so that no caller can silently run with none -- nothing in layers 3-5 + compares reading citations, so a rite quietly missing its Commons would + be invisible. Loaded here even where this file asserts nothing about + readings, so that the rite under test is the same one bin/main.ml + assembles. *) +let real_commons () = + match Rite_ef.Lectionary_ef.Commons.load "../data/ef/commons.sexp" with + | Ok c -> c + | Error e -> Alcotest.failf "../data/ef/commons.sexp: failed to load: %s" e + (* ---------------------------------------------------------------------- *) (* The oracle side: one line per day, as tools/extract_missalemeum_oracle *) (* .py's own header documents. *) @@ -328,7 +340,7 @@ let en = Lang.of_string_exn "en" let colitur_rows_2026_2027 () = let layer = real_layer () in - let rite = Rite_ef.context ~lectionary:(real_lectionary ()) 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 = 2025 to 2027 do let days = Cal.year rite layer y in diff --git a/test/test_rite_ef.ml b/test/test_rite_ef.ml index 7680545..a014977 100644 --- a/test/test_rite_ef.ml +++ b/test/test_rite_ef.ml @@ -56,7 +56,19 @@ let real_lectionary () = | Ok l -> l | Error e -> Alcotest.failf "../data/ef/lectionary.sexp: failed to load: %s" e -let context () = Rite_ef.context ~lectionary:(real_lectionary ()) +(* The Commons (data/ef/commons.sexp) travel the same caller-supplied seam + as the lectionary above, and [~commons] is required rather than defaulted + so that no caller can silently run with none -- nothing in layers 3-5 + compares reading citations, so a rite quietly missing its Commons would + be invisible. Loaded here even where this file asserts nothing about + readings, so that the rite under test is the same one bin/main.ml + assembles. *) +let real_commons () = + match Rite_ef.Lectionary_ef.Commons.load "../data/ef/commons.sexp" with + | Ok c -> c + | Error e -> Alcotest.failf "../data/ef/commons.sexp: failed to load: %s" e + +let context () = Rite_ef.context ~lectionary:(real_lectionary ()) ~commons:(real_commons ()) let slug_of (c : V.rank Cel.t) = Slug.to_string c.Cel.slug diff --git a/test/test_validate.ml b/test/test_validate.ml index 8e63a1b..8c96f26 100644 --- a/test/test_validate.ml +++ b/test/test_validate.ml @@ -16,6 +16,7 @@ module T = Rite_ef.Temporal_ef let sanctoral_path = "../data/ef/sanctoral.sexp" let adjustments_path = "../data/ef/adjustments.sexp" let lectionary_path = "../data/ef/lectionary.sexp" +let commons_path = "../data/ef/commons.sexp" (* Loaded once at module init, not per call: [run] below is called by every test and by the 200-sample property, and Calendar.year's own resolution @@ -42,7 +43,19 @@ let real_ef_lectionary = | Error e -> failwith (Printf.sprintf "%s: failed to load: %s" lectionary_path e) | Ok l -> l -let real_ef_rite = Rite_ef.context ~lectionary:real_ef_lectionary +(* The Commons (data/ef/commons.sexp) travel the same caller-supplied seam + as the lectionary above, and [~commons] is required rather than defaulted + so that no caller can silently run with none -- nothing in layers 3-5 + compares reading citations, so a rite quietly missing its Commons would + be invisible. Loaded here even where this file asserts nothing about + readings, so that the rite under test is the same one bin/main.ml + assembles. *) +let real_ef_commons = + match Rite_ef.Lectionary_ef.Commons.load commons_path with + | Error e -> failwith (Printf.sprintf "%s: failed to load: %s" commons_path e) + | Ok c -> c + +let real_ef_rite = Rite_ef.context ~lectionary:real_ef_lectionary ~commons:real_ef_commons let run year = Val.run real_ef_rite real_ef_layer ~year |
