aboutsummaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-15 01:40:54 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-15 01:40:54 +0200
commit1299815ea03c2b1e4ff19d92d32e386fec24f4e4 (patch)
tree7fe91b561af482d7079fb4808ec52552e3228d08 /test
parentfe94cd9c4150fd6285dab49b06e23b6db42d131b (diff)
downloadcolitur-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.
Diffstat (limited to 'test')
-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
8 files changed, 409 insertions, 9 deletions
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