1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
|
(* Task 2 (2026-08-25-colitur-of-phases-3-5): the post-2002 decree overlays.
data/of/calendar-2002.sexp (Task 1) transcribes the 2002 typical edition
and is never edited again -- every subsequent Calendarium Romanum Generale
change is its own overlay file under data/of/amendments/, one per decree,
per docs/research/of/PROVENANCE-amendments.md (13 decrees, 16
celebration-level changes -- the research inventory's own "15 items,
numbered 1-15, where items 10-12 are three entries carried by a single
decree" collapses to 13 distinct decrees once items 2/3, both dated 28
September 2002 but carrying DIFFERENT protocol numbers, are counted as the
two separate juridical instruments they are).
This suite asserts: every file parses; each has a pinned SHA-256;
applying all 13 in their own decree-chronological order to the base
calendar yields a pinned entry count; Overlay.merge reports NO diagnostic
(a diagnostic would mean a directive was transcribed against a slug that
does not exist in the base -- the exact silent failure one-file-per-decree
exists to prevent); and Mary, Mother of the Church resolves to the Monday
after Pentecost in three real, independently-computed years. *)
module L = Colitur_kernel.Layer
module O = Colitur_kernel.Overlay
module Cel = Colitur_kernel.Celebration
module S = Colitur_kernel.Slug
module DS = Colitur_kernel.Date_spec
module D = Colitur_kernel.Date
module Computus = Colitur_kernel.Computus
module Names = Colitur_kernel.Names
module Lang = Colitur_kernel.Lang
module V = Rite_of.Vocab_of
let base_path = "../data/of/calendar-2002.sexp"
let amendments_dir = "../data/of/amendments/"
(* Decree-chronological order, matching docs/research/of/
PROVENANCE-amendments.md's own "Ordered by decree date" -- the numeric
filename prefixes already encode this, spelled out here explicitly so the
intended application order is visible in the test itself, not merely
inferred from a directory listing. Each paired with its SHA-256, pinned
2026-08-26 against the shipped files themselves (`sha256sum
data/of/amendments/*.sexp`), not copied from any tool's own stdout. *)
let files =
[ ("001-padre-pio.sexp",
"8ddd92f1820a736c7a4f3c27b3181579d6280ed82f0983f80219d523782731f6");
("002-juan-diego-cuauhtlatoatzin.sexp",
"01000fe5615894ff82b89a099e1539d6556beccfe1d07d5664362b0126724b09");
("003-our-lady-of-guadalupe.sexp",
"a19100769786d110d462805cc4533867c9d56308f8a058f46b257b2b0d6cb3ef");
("004-john-xxiii-john-paul-ii.sexp",
"ae24ac41935600d0d11a81072bedce2ba9790b79e2f8192c5d4a08a9a7db4e6e");
("005-mary-magdalene-rank.sexp",
"08a19050c21391ee565bc4d5a3ba228e3162875188db4bbc3b7f4b066a60a0c1");
("006-mary-mother-of-the-church.sexp",
"8a1dda10c1014bc59c22779f3686c6cce0bf9ff1ec4e504cb912cf2c89319ff9");
("007-paul-vi.sexp",
"d7b442b6b6dd6c8e7aca5f7126f7b3e65104191ae84c158e07e9afd0f70edcfc");
("008-our-lady-of-loreto.sexp",
"772366fb6c04d1c779109d8b56fa1c0a71ff0129f92349564e1a353b253c2e13");
("009-faustina-kowalska.sexp",
"a6bf8a1ab8e3f4aa65bb22d4ff9ba863e5f65865180444f4b143f36aa99e45b3");
("010-narek-avila-hildegard.sexp",
"6ca11a7c2e02491b9b96349282c7b1d6fc28c9e4729f678bd54d11a70e43f568");
("011-martha-mary-lazarus.sexp",
"c4ede10b24f22def5fd557a3627d1ecaeb5de583f263372a67bccf9ab838f023");
("012-teresa-of-calcutta.sexp",
"5646597472f6170a2b56e1f1c4a2ef7e7917cb5c87eb5e3d1f98b8ee1aaae646");
("013-john-henry-newman.sexp",
"f6c12d1ead043a609b0086a130ae1a9fc680a8926fe77ccd97756ee747105d7c") ]
let sha256_of_file path =
let tmp = Filename.temp_file "colitur_amendments_of_sha256" ".txt" in
let cmd = Printf.sprintf "sha256sum %s > %s" (Filename.quote path) (Filename.quote tmp) in
(match Sys.command cmd with
| 0 -> ()
| n -> Alcotest.failf "sha256sum exited %d" n);
let ic = open_in tmp in
let line = input_line ic in
close_in ic;
Sys.remove tmp;
match String.split_on_char ' ' line with
| hash :: _ -> hash
| [] -> Alcotest.fail "sha256sum produced no output"
let load_overlay name =
let path = amendments_dir ^ name in
match O.load V.rank_of_sexp path with
| Ok o -> o
| Error e -> Alcotest.failf "%s: failed to load: %s" path e
let load_base () =
match L.load V.rank_of_sexp base_path with
| Ok l -> l
| Error e -> Alcotest.failf "%s: failed to load: %s" base_path e
(* Every file parses. *)
let test_files_parse () =
List.iter
(fun (name, _) ->
let (_ : V.rank O.t) = load_overlay name in
())
files
(* Each has a pinned SHA-256. *)
let test_sha256_pinned () =
List.iter
(fun (name, expected) ->
Alcotest.(check string) (name ^ " SHA-256") expected
(sha256_of_file (amendments_dir ^ name)))
files
(* Applying all 13, in decree-chronological order, to the base calendar
yields a pinned entry count, AND Overlay.merge reports no diagnostic --
the silent-no-op check the whole one-file-per-decree discipline exists
to enforce (a diagnostic here would mean a directive was transcribed
against a slug the base calendar does not actually have). *)
let test_apply_in_order () =
let base = load_base () in
let overlays = List.map (fun (name, _) -> load_overlay name) files in
let merged, diagnostics = O.merge base overlays in
Alcotest.(check (list string)) "no diagnostics -- every directive matched something" []
(List.map O.diagnostic_to_string diagnostics);
(* 208 base entries + 14 Adds (1+1+1+2+1+1+1+1+3+1+1, files
001/002/003/004/006/007/008/009/010x3/012/013) + 2 Edits (005, 011,
no count change) = 222. *)
Alcotest.(check int) "208 base + 14 adds = 222 entries" 222 (List.length merged.L.entries)
(* Every new/edited slug this task introduces is actually present after
the merge, and the two Edits did not accidentally become Adds (no
"slug not present; edit ignored" diagnostic already ruled that out
above, but a direct spot-check on the merged layer is cheap insurance
against a slug typo that happened to also dodge the diagnostic, e.g. by
matching a DIFFERENT, wrong existing slug). *)
let test_new_slugs_present () =
let base = load_base () in
let overlays = List.map (fun (name, _) -> load_overlay name) files in
let merged, _ = O.merge base overlays in
let has slug = L.mem merged (S.of_string_exn slug) in
List.iter
(fun slug -> Alcotest.(check bool) (slug ^ " present after merge") true (has slug))
[ "pio-of-pietrelcina-padre-pio-priest"; "juan-diego"; "our-lady-of-guadalupe";
"john-xxiii-pope"; "john-paul-ii-pope"; "mary-mother-of-the-church"; "paul-vi-pope";
"our-lady-of-loreto"; "faustina-kowalska-virgin"; "gregory-of-narek-abbot-and-doctor";
"john-of-avila-priest-and-doctor"; "hildegard-of-bingen-virgin-and-doctor";
"teresa-of-calcutta-virgin"; "john-henry-newman-priest-and-doctor" ];
(* The two Edits kept their original slugs. *)
match L.find merged (S.of_string_exn "mary-magdalene") with
| None -> Alcotest.fail "mary-magdalene: slug lost after Edit"
| Some e -> Alcotest.(check bool) "mary-magdalene is now Festum" true (e.L.cel.Cel.rank = V.Festum)
let test_martha_mary_lazarus_renamed () =
let base = load_base () in
let overlays = List.map (fun (name, _) -> load_overlay name) files in
let merged, _ = O.merge base overlays in
match L.find merged (S.of_string_exn "martha") with
| None -> Alcotest.fail "martha: slug lost after Edit"
| Some e ->
Alcotest.(check (option string)) "en name widened" (Some "Saints Martha, Mary and Lazarus")
(Names.find e.L.cel.Cel.names (Lang.of_string_exn "en"));
Alcotest.(check bool) "date unchanged, 29 July" true
(e.L.date = (match DS.fixed ~month:7 ~day:29 with Ok d -> d | Error err -> failwith err));
Alcotest.(check bool) "rank unchanged, Memoria_obligatoria" true
(e.L.cel.Cel.rank = V.Memoria_obligatoria)
(* Mary, Mother of the Church resolves to the Monday after Pentecost --
Easter+50 -- in three real, independently-computed years, not merely
trusted from the decree's own arithmetic. Pentecost is Easter+49 (the
50th day of the Paschal season inclusive of Easter); the day after is
Easter+50. Checked structurally (weekday = Mon, and the resolved date is
exactly 50 days after that year's real Gregorian Easter, computed via
Colitur_kernel.Computus.gregorian_easter), not by hand-typing expected
calendar dates. *)
let test_mater_ecclesiae_monday_after_pentecost () =
let base = load_base () in
let overlays = List.map (fun (name, _) -> load_overlay name) files in
let merged, _ = O.merge base overlays in
let entry =
match L.find merged (S.of_string_exn "mary-mother-of-the-church") with
| Some e -> e
| None -> Alcotest.fail "mary-mother-of-the-church: not found after merge"
in
Alcotest.(check bool) "date spec is Easter_offset 50" true (entry.L.date = DS.Easter_offset 50);
Alcotest.(check bool) "Memoria_obligatoria" true (entry.L.cel.Cel.rank = V.Memoria_obligatoria);
Alcotest.(check bool) "white" true (entry.L.cel.Cel.colour = Colitur_kernel.Colour.White);
Alcotest.(check bool) "subject Bvm" true (entry.L.cel.Cel.subject = Colitur_kernel.Subject.Bvm);
List.iter
(fun year ->
let easter = Computus.gregorian_easter year in
let pentecost = D.add_days easter 49 in
let expected = D.add_days easter 50 in
Alcotest.(check bool)
(Printf.sprintf "%d: Pentecost (Easter+49) is a Sunday" year)
true (D.weekday pentecost = D.Sun);
let resolved =
match DS.resolve entry.L.date ~year ~easter with
| Some d -> d
| None -> Alcotest.failf "%d: Easter_offset 50 did not resolve" year
in
Alcotest.(check bool)
(Printf.sprintf "%d: resolves to exactly Easter+50" year) true (resolved = expected);
Alcotest.(check bool)
(Printf.sprintf "%d: resolved date is a Monday" year) true (D.weekday resolved = D.Mon);
Alcotest.(check bool)
(Printf.sprintf "%d: resolved date is the day after Pentecost" year) true
(resolved = D.add_days pentecost 1))
[ 2026; 2027; 2035 ]
let suite =
( "Amendments_of (data/of/amendments/*.sexp)",
[ Alcotest.test_case "every overlay file parses" `Quick test_files_parse;
Alcotest.test_case "SHA-256 pinned per file" `Quick test_sha256_pinned;
Alcotest.test_case "apply in decree-chronological order: 222 entries, no diagnostics" `Quick
test_apply_in_order;
Alcotest.test_case "every new/edited slug is present after merge" `Quick
test_new_slugs_present;
Alcotest.test_case "Martha/Mary/Lazarus: renamed in place, date/rank unchanged" `Quick
test_martha_mary_lazarus_renamed;
Alcotest.test_case "Mary, Mother of the Church: Monday after Pentecost, 3 real years" `Quick
test_mater_ecclesiae_monday_after_pentecost ] )
|