aboutsummaryrefslogtreecommitdiff
path: root/test/test_sanctoral_ef.ml
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-21 14:42:30 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-21 14:42:30 +0200
commit12b97761019cfa02ca0da8a5fb50ef815d07685c (patch)
tree11e7306a1a5de2f5226eea06282515ff38b226d5 /test/test_sanctoral_ef.ml
parent0eae32f67145065b0e4960650ae06c293fd9f272 (diff)
downloadcolitur-12b97761019cfa02ca0da8a5fb50ef815d07685c.tar.gz
colitur-12b97761019cfa02ca0da8a5fb50ef815d07685c.zip
feat(ef): implement RG 33's third omission trigger
RG 33 omits a II/III-class vigil in three cases: it falls on a Sunday, it falls on a I-class feast, "vel si festum cui praemittitur in alium diem transferri aut ad commemorationem reduci contingat". Only the first two were built; the third was recorded in precedence_ef.ml as unimplemented on the grounds that no witness existed in the shipped data. That reasoning was wrong, and the rule fires on 1 744 days across 1583-9999. Both halves of the clause reduce to one observable question -- is the feast the OBSERVED office on the following day (RG 34 puts it there) -- so the kernel asks it once per candidate, after place_transfers has settled the year. No fixed point is needed: a vigil is a candidate only on its own day, never on its feast's, so suppressing it cannot change what the next day observes. Precedence.rules gains vigil_feast, which returns the slug of the feast a vigil precedes; the kernel cannot infer that itself, because only two of the five vigil/feast pairs share a slug stem. Blast radius, measured pre-change binary vs HEAD over the whole domain and classified: 1 744 days, three shapes, zero unexplained. 1 199 are the feast reduced to a commemoration (10 August on a Sunday, St Lawrence); 478 and 67 are the feast transferred under RG 96 after the Sacred Heart or Corpus Christi takes its day. The Assumption's and the Ascension's vigils never qualify -- their I-class feasts always keep their own day. Independently witnessed, which is unusual here. The published Ordo -- the only witness outside the Divinum Officium -> missalemeum -> lectio lineage -- omits St Lawrence's vigil on 2025-08-09, agreeing with colitur against both engines. That date had been read earlier as an Ordo gap; the Ordo was right, and correcting the misreading is what surfaced this clause. On 2027-08-09 the feast does keep its day and the Ordo omits a vigil colitur correctly keeps, which is a genuine Ordo gap. Allow-lists: C39 (lectio, 10 rows) and a 2038 oracle class citing the register, the 2026-2027 window having no instance. The golden pin asserting St Lawrence's vigil is violet moved 2025 -> 2027; its own comment had reasoned about the vigil's weekday and missed that RG 33 also looks at the feast's. Two new pins cover both shapes of the clause. The vigil/feast table is built with Slug.of_string_exn: mutation testing showed that of_string plus Result.to_option turns a typo into None, which this hook's contract reads as "not a vigil", switching the rule off in silence. Two tests assert the table against the shipped data in both directions.
Diffstat (limited to 'test/test_sanctoral_ef.ml')
-rw-r--r--test/test_sanctoral_ef.ml72
1 files changed, 71 insertions, 1 deletions
diff --git a/test/test_sanctoral_ef.ml b/test/test_sanctoral_ef.ml
index 5c46edf..b237f25 100644
--- a/test/test_sanctoral_ef.ml
+++ b/test/test_sanctoral_ef.ml
@@ -165,6 +165,72 @@ let test_commemoration_only_has_no_readings () =
Alcotest.(check int)
"a commemoration contributes an oration, not a reading" 0 (List.length bad)
+(* RG 33's third omission trigger reads a hand-written table in
+ [Precedence_ef.vigil_feast_table] pairing each vigil with the feast it
+ precedes. That table is the one part of the rule no type can check: a slug
+ that matches nothing in the data makes the rule silently inert for that
+ entry, and a vigil ADDED to the data but not to the table is never omitted
+ at all. Both directions are asserted here against the shipped file.
+
+ The Ascension's vigil is excluded from the data-side checks because it is
+ TEMPORAL (Temporal_ef builds it off an Easter offset) and correctly absent
+ from the sanctoral layer -- asserted explicitly below rather than skipped,
+ so that the exclusion cannot quietly grow to cover a real miss. *)
+let ascension_vigil = "ef-ascension-vigil"
+
+let test_vigil_feast_table_slugs_all_exist () =
+ let l = load () in
+ let mem slug =
+ match Colitur_kernel.Slug.of_string slug with
+ | Error e -> Alcotest.failf "%s: not a well-formed slug: %s" slug e
+ | Ok s -> L.mem l s
+ in
+ List.iter
+ (fun (vigil, feast) ->
+ let feast = Colitur_kernel.Slug.to_string feast in
+ if String.equal vigil ascension_vigil then begin
+ (* BOTH halves of this pair are temporal -- Temporal_ef builds the
+ vigil at Easter+38 and the feast at Easter+39 -- so neither belongs
+ in the sanctoral layer. Asserted in the negative rather than
+ skipped, so the exclusion cannot quietly widen to hide a real miss;
+ the pair's positive witness is test_temporal_ef's own Ascension
+ cases and the 2026-05-13 golden pin. *)
+ Alcotest.(check bool) "ef-ascension-vigil is temporal, not sanctoral" false (mem vigil);
+ Alcotest.(check bool) "ef-ascension is temporal, not sanctoral" false (mem feast)
+ end
+ else begin
+ Alcotest.(check bool)
+ (Printf.sprintf "%s is present in the shipped sanctoral" vigil)
+ true (mem vigil);
+ Alcotest.(check bool)
+ (Printf.sprintf "%s (the feast %s precedes) is present in the shipped sanctoral"
+ feast vigil)
+ true (mem feast)
+ end)
+ PE.vigil_feast_table
+
+let test_vigil_feast_table_covers_every_shipped_vigil () =
+ let l = load () in
+ let tabled = List.map fst PE.vigil_feast_table in
+ let shipped_vigils =
+ l.L.entries
+ |> List.filter (fun (e : V.rank L.entry) ->
+ let slug = Colitur_kernel.Slug.to_string e.L.cel.Colitur_kernel.Celebration.slug in
+ PE.is_vigil slug && PE.is_omissible_vigil e.L.cel.Colitur_kernel.Celebration.rank)
+ |> List.map (fun (e : V.rank L.entry) ->
+ Colitur_kernel.Slug.to_string e.L.cel.Colitur_kernel.Celebration.slug)
+ |> List.sort compare
+ in
+ let missing = List.filter (fun s -> not (List.mem s tabled)) shipped_vigils in
+ Alcotest.(check (list string))
+ "every II/III-class vigil in the shipped sanctoral is in vigil_feast_table -- a new one \
+ added to the data without a table row would never be omitted under RG 33"
+ [] missing;
+ (* Pinned so that a vigil VANISHING from the data is caught too: an empty
+ [shipped_vigils] would satisfy the subset check above vacuously. *)
+ Alcotest.(check int) "four sanctoral vigils are subject to RG 33" 4
+ (List.length shipped_vigils)
+
let suite =
( "Sanctoral_ef (data/ef/sanctoral.sexp)",
[ Alcotest.test_case "load succeeds and counts match the source INI" `Quick
@@ -176,4 +242,8 @@ let suite =
Alcotest.test_case "sanctoral entries carry their proper readings" `Quick
test_sanctoral_carries_propers;
Alcotest.test_case "Commemoration_only entries carry no readings" `Quick
- test_commemoration_only_has_no_readings ] )
+ test_commemoration_only_has_no_readings;
+ Alcotest.test_case "RG33: every vigil_feast_table slug exists in the shipped data" `Quick
+ test_vigil_feast_table_slugs_all_exist;
+ Alcotest.test_case "RG33: vigil_feast_table covers every shipped II/III-class vigil" `Quick
+ test_vigil_feast_table_covers_every_shipped_vigil ] )