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
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
|
(* of-litcal-layer task (2026-08-25): external validation of the OF rite
module's Phase 1 temporal cycle (lib/rites/rite_of/temporal_of.ml) --
specifically the two-block Ordinary Time week arithmetic Normae n. 44
requires, named in .superpowers/sdd/of-phase1-report.md's own
"Concerns" section as "the single highest-risk function in this phase",
with NO external witness anywhere in Phase 1's own test suite.
Witness: litcal (github.com/Liturgical-Calendar/LiturgicalCalendarAPI),
Apache-2.0, already archived and SHA-256-pinned by the sibling project
lectio. See test/fixtures/litcal-temporal-2024-2035.sexp's own header
for the full provenance, the row-selection rules, and the
SECOND-IMPLEMENTATION-NOT-SECOND-PUBLICATION caveat this layer is
built under -- NOT repeated here.
`rite_of` is deliberately not wired into `Colitur_kernel.Rite.t` or the
CLI yet (Phase 2's own work) -- this file calls
{!Rite_of.Temporal_of.temporal} directly, exactly as test_temporal_of.ml
already does, over the SAME civil-date window the fixture covers
(2023-12-03..2035-12-01), no {!Colitur_kernel.Calendar}/{!Colitur_kernel
.Layer} involved.
SCOPE: season and Ordinary Time week only -- the two fields the fixture
carries. Rank/colour/named-day identity are out of scope for this
layer (Phase 1 has no sanctoral data to compare them against, and
litcal's own [grade_lcl]/[name] are read here only to CLASSIFY why a
week is unwitnessed, never compared against a colitur rank). *)
module V = Rite_of.Vocab_of
module T = Rite_of.Temporal_of
module D = Colitur_kernel.Date
let fixture_path = "fixtures/litcal-temporal-2024-2035.sexp"
let allow_list_path = "../data/of/expected-divergences-litcal.sexp"
(* Duplicated, not shared -- every other Ordo/oracle test file in this
project carries its own identical copy for the same reason
(test_fiuv_ordo.ml's own comment): no .mli any of them could share it
through. *)
let sha256_of_file path =
let tmp = Filename.temp_file "colitur_litcal_of_sha256" ".txt" in
Fun.protect
~finally:(fun () -> try Sys.remove tmp with Sys_error _ -> ())
(fun () ->
let cmd = Printf.sprintf "sha256sum %s > %s" (Filename.quote path) (Filename.quote tmp) in
let rc = Sys.command cmd in
if rc <> 0 then Alcotest.failf "sha256sum exited %d for %s (is it on PATH?)" rc path;
let ic = open_in tmp in
let line =
try input_line ic
with End_of_file ->
close_in ic;
Alcotest.failf "sha256sum produced no output for %s" path
in
close_in ic;
match String.index_opt line ' ' with
| Some i -> String.sub line 0 i
| None -> Alcotest.failf "unexpected sha256sum output for %s: %S" path line)
let fixture_sha256 = "f5d65b33ac1afd2a6d1d8e053d9c8897a24ffcc67136e73f0978189f02dae816"
(* ---------------------------------------------------------------------- *)
(* The litcal side: the sexp row, mirroring tools/extract_litcal_ordo.py's *)
(* own [build_rows] output row-for-row. *)
(* ---------------------------------------------------------------------- *)
open Sexplib0.Sexp_conv
type litcal_row = { date : string; season : string; ot_week : int option; event_key : string; grade_lcl : string }
[@@deriving sexp]
let litcal_rows () =
let sexp =
try Sexplib.Sexp.load_sexp fixture_path
with e -> Alcotest.failf "%s: failed to load: %s" fixture_path (Printexc.to_string e)
in
list_of_sexp litcal_row_of_sexp sexp
let window_first = "2023-12-03"
let window_last = "2035-12-01"
(* ---------------------------------------------------------------------- *)
(* The colitur side, over the identical window -- straight [Temporal_of. *)
(* temporal] calls, no Rite.t/Calendar involved (Phase 1 has neither). *)
(* ---------------------------------------------------------------------- *)
type colitur_row = { c_date : string; c_season : V.season; c_ot_week : int option }
let colitur_rows () =
let mk s = match D.of_iso8601 s with Ok d -> d | Error e -> Alcotest.failf "%s: %s" s e in
let rows = ref [] in
let d = ref (mk window_first) in
let stop = mk window_last in
while D.compare !d stop <= 0 do
let temporal = T.temporal !d in
rows :=
{ c_date = D.to_iso8601 !d; c_season = temporal.Colitur_kernel.Temporal.season;
c_ot_week = temporal.Colitur_kernel.Temporal.week }
:: !rows;
d := D.add_days !d 1
done;
List.rev !rows
(* litcal's own [liturgical_season] atom (lowercased by the generator) ->
the [Vocab_of.season] it should equal, or [None] for
["easter_triduum"] -- a season colitur's own vocabulary has NO value
for at all (temporal_of.ml's own top-of-module comment; see this task's
own fixture header). [None] is not "unmapped/unknown": it is the
signal that this row belongs to the Triduum allow-list class below,
never silently skipped. *)
let expected_season = function
| "advent" -> Some V.Advent
| "christmas" -> Some V.Christmas
| "lent" -> Some V.Lent
| "easter" -> Some V.Easter
| "ordinary_time" -> Some V.Ordinary_time
| "easter_triduum" -> None
| s -> Alcotest.failf "unrecognised litcal season atom in fixture: %S" s
(* ---------------------------------------------------------------------- *)
(* data/of/expected-divergences-litcal.sexp -- same shape as every other *)
(* allow-list in this project (test_fiuv_ordo.ml's own [allow_entry]). *)
(* ---------------------------------------------------------------------- *)
type allow_entry = { id : string; citation : string; verdict : string; note : string; expected_rows : int }
[@@deriving sexp]
let load_allow_list () =
let sexps =
try Sexplib.Sexp.load_sexps allow_list_path
with e -> Alcotest.failf "%s: failed to load: %s" allow_list_path (Printexc.to_string e)
in
List.map allow_entry_of_sexp sexps
(* L1 -- the only season-divergence class this layer expects: every
EASTER_TRIDUUM-tagged litcal row (Holy Thursday's own evening Mass,
Good Friday, Holy Saturday -- see the fixture header for why the
Chrism Mass row is excluded upstream, in the generator, rather than
here). *)
let is_l1_triduum (r : litcal_row) = String.equal r.season "easter_triduum"
(* ---------------------------------------------------------------------- *)
(* Tests *)
(* ---------------------------------------------------------------------- *)
let test_fixture_checksum () =
Alcotest.(check string) "fixture SHA-256 matches its provenance header" fixture_sha256 (sha256_of_file fixture_path)
let test_dates_align () =
let litcal = litcal_rows () in
let colitur = colitur_rows () in
Alcotest.(check int) "the fixture has 4382 rows (2023-12-03..2035-12-01)" 4382 (List.length litcal);
Alcotest.(check int) "colitur resolved the same number of days" (List.length litcal) (List.length colitur);
(match litcal with
| l :: _ -> Alcotest.(check string) "first date" window_first l.date
| [] -> Alcotest.fail "empty");
(match List.rev litcal with
| l :: _ -> Alcotest.(check string) "last date" window_last l.date
| [] -> Alcotest.fail "empty");
let mismatched =
List.filter_map
(fun (l, c) -> if String.equal l.date c.c_date then None else Some (l.date, c.c_date))
(List.combine litcal colitur)
in
Alcotest.(check (list (pair string string))) "no misaligned dates" [] mismatched
(* The season comparison: every litcal row either matches colitur's own
season outright, or is named in the allow-list (L1, the Triduum class)
-- zero unexplained anywhere in the 4382-day window. *)
let test_season_matches_or_is_explained () =
let litcal = litcal_rows () in
let colitur = colitur_rows () in
let allow_list = load_allow_list () in
let by_id = List.map (fun e -> (e.id, e)) allow_list in
let unexplained = ref [] in
let l1_count = ref 0 in
List.iter2
(fun (l : litcal_row) (c : colitur_row) ->
if not (String.equal l.date c.c_date) then Alcotest.failf "misaligned: litcal %s vs colitur %s" l.date c.c_date;
match expected_season l.season with
| None ->
(* easter_triduum *)
if is_l1_triduum l then incr l1_count
else Alcotest.failf "%s: unrecognised no-mapping season %S" l.date l.season
| Some expected ->
if expected = c.c_season then ()
else
unexplained :=
Printf.sprintf "%s: colitur season=%s, litcal season=%s (event_key=%S)" l.date
(V.season_to_string c.c_season) l.season l.event_key
:: !unexplained)
litcal colitur;
Alcotest.(check (list string)) "every season mismatch is named in the allow-list -- none unexplained" []
(List.rev !unexplained);
(match List.assoc_opt "L1" by_id with
| None -> Alcotest.failf "allow-list entry L1 is used by the comparator but not declared in %s" allow_list_path
| Some e -> Alcotest.(check int) "L1 (Easter Triduum, no colitur season value) expected_rows" e.expected_rows !l1_count);
List.iter
(fun e -> if not (String.equal e.id "L1") then Alcotest.failf "unknown allow-list entry %s (only L1 is used)" e.id)
allow_list
(* THE load-bearing test: the Ordinary Time week, on every day litcal
actually witnesses one (an Ord* event_key present on that date) --
Normae n. 44's own two-block resumption arithmetic, the risk this whole
layer exists to validate. Zero tolerance: every witnessed week must
match exactly, with no allow-list at all -- a real mismatch here would
mean colitur serves the WRONG MASS FORMULARY, not a cosmetic
difference, so there is no "expected divergence" shape for this half of
the comparison the way there is for season/Triduum. *)
let test_ordinary_time_week_matches () =
let litcal = litcal_rows () in
let colitur = colitur_rows () in
let mismatched = ref [] in
let witnessed = ref 0 in
List.iter2
(fun (l : litcal_row) (c : colitur_row) ->
if not (String.equal l.date c.c_date) then Alcotest.failf "misaligned: litcal %s vs colitur %s" l.date c.c_date;
match l.ot_week with
| None -> ()
| Some n ->
incr witnessed;
if c.c_ot_week <> Some n then
mismatched :=
Printf.sprintf "%s: colitur week=%s, litcal week=%d (event_key=%S, colitur season=%s)" l.date
(match c.c_ot_week with Some w -> string_of_int w | None -> "None")
n l.event_key (V.season_to_string c.c_season)
:: !mismatched)
litcal colitur;
Alcotest.(check (list string)) "every witnessed Ordinary Time week matches exactly -- zero tolerance" []
(List.rev !mismatched);
(* Pinned so a change in the fixture or the extraction rules is visible,
not merely "still zero mismatches" -- the same discipline every
count assertion in this project's other Ordo/oracle layers follows. *)
Alcotest.(check int) "1876 of the 4382 days witness an Ordinary Time week" 1876 !witnessed
(* The UNWITNESSED half of Ordinary Time, mirroring the EF module's own
[Comm_identity_unresolved] precedent (CLAUDE.md's "know what each layer
cannot see" section): NOT a mismatch (litcal has no answer to compare
against on these days, an obligatory celebration having suppressed its
own weekday/Sunday row), so NOT allow-listed -- but counted and
classified rather than silently skipped. Two shapes, both named in the
fixture's own header: (a) an obligatory celebration suppresses the
weekday outright (by far the majority); (b) a handful of dates (the
Immaculate-Heart-of-Mary window) where litcal's own data omits the
weekday despite showing only optional-memorial-grade rows -- both
collapse to the same "grade_lcl <> weekday" signal here, since the
ONLY grade litcal ever gives an Ord* row is "weekday"/"FEAST OF THE
LORD" (a Sunday) -- checked directly against the fixture's own content,
not assumed. *)
let test_unwitnessed_ordinary_time_is_counted () =
let litcal = litcal_rows () in
let unwitnessed = List.filter (fun (l : litcal_row) -> String.equal l.season "ordinary_time" && l.ot_week = None) litcal in
Alcotest.(check int) "859 Ordinary Time days have no litcal-witnessed week" 859 (List.length unwitnessed);
let by_grade = Hashtbl.create 8 in
List.iter
(fun (l : litcal_row) ->
Hashtbl.replace by_grade l.grade_lcl (1 + try Hashtbl.find by_grade l.grade_lcl with Not_found -> 0))
unwitnessed;
let total = Hashtbl.fold (fun _ n acc -> n + acc) by_grade 0 in
Alcotest.(check int) "grade_lcl buckets sum to the same 859" 859 total
let suite =
( "litcal-of",
[ Alcotest.test_case "fixture checksum" `Quick test_fixture_checksum;
Alcotest.test_case "dates align" `Quick test_dates_align;
Alcotest.test_case "season matches or is explained" `Quick test_season_matches_or_is_explained;
Alcotest.test_case "Ordinary Time week matches exactly where witnessed" `Quick test_ordinary_time_week_matches;
Alcotest.test_case "unwitnessed Ordinary Time is counted, not skipped" `Quick test_unwitnessed_ordinary_time_is_counted
] )
|