aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--bin/main.ml107
-rw-r--r--data/ef/adjustments.sexp144
-rw-r--r--data/ef/commons.sexp257
-rw-r--r--lib/rites/rite_ef/lectionary_ef.ml259
-rw-r--r--lib/rites/rite_ef/lectionary_ef.mli96
-rw-r--r--lib/rites/rite_ef/rite_ef.ml29
-rw-r--r--lib/rites/rite_ef/rite_ef.mli17
-rw-r--r--test/dune8
-rw-r--r--test/test_calendar.ml15
-rw-r--r--test/test_differential.ml14
-rw-r--r--test/test_golden.ml15
-rw-r--r--test/test_lectionary_ef.ml323
-rw-r--r--test/test_oracle.ml14
-rw-r--r--test/test_rite_ef.ml14
-rw-r--r--test/test_validate.ml15
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
diff --git a/test/dune b/test/dune
index fe5e4cc..9ca2cf6 100644
--- a/test/dune
+++ b/test/dune
@@ -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