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
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
|
(* Task 15: differential harness vs lectio (sibling project, Go), EF only,
2005-2050 -- validation layer 3 of the design spec's five (colitur CLAUDE.md
"Validation" section; layers 1-2, Types and Property, are already built and
green). lectio's own EF stream is committed as a fixture
(test/fixtures/lectio-ef-2005-2050.txt, provenance -- including the
SHA-256 this file's own [test_fixture_checksum] asserts, so a hand-edit or
partial re-copy of the fixture fails loudly rather than silently becoming
an unlabelled snapshot -- in the sibling .provenance file next to it);
colitur's side is recomputed fresh from this library on every run, through
the SAME pipeline `colitur day` uses (Colitur_kernel.Calendar over the
real data/ef/sanctoral.sexp + adjustments.sexp), not the compiled binary.
*** THREE STATED LIMITS THIS COMPARATOR DOES NOT PRETEND TO EXCEED ***
1. Commemorations are NOT compared. lectio's trailing "+other-slug" tokens
are the LOSING sanctoral candidates for the day (its own dumper's doc
comment says so), not an RG 111 admitted set -- lectio has no RG 111
admission logic at all. Comparing that column would compare colitur's
real admitted commemorations against lectio's rejects, which proves
nothing. Of the eleven fixed columns the fixture's own dumper now
prints, nine are read from either stream: the original seven (date
weekday season week slug rank colour) plus, since Task 8, the two
reading-citation columns (first, gospel) -- name_en/name_pl (the
remaining two) are parsed and immediately discarded, never compared
(this file's own [row_of_line] comment says why).
2. lectio's own EF oracle test (~/git/projects/lectio,
internal/calendar/oracle_ef_test.go) asserts ONLY season, strictly,
against missalemeum, and ONLY for 2025-2026. Rank and colour are logged
there, never asserted. So lectio's advertised "0-error vs references
2005-2050" covers season-exactness for the EF, not full-row-exactness.
A rank or colour difference below is therefore NOT presumptive evidence
that colitur is wrong -- every one is adjudicated against the Missal/RG
register directly (data/ef/expected-divergences.sexp), never against
lectio's say-so. (Layer 4, the oracle differential against missalemeum
itself, is what actually validates rank and colour; that is a later
task.)
3. The WEEK column is not compared, at all, on either side. Two reasons,
both confirmed by reading lectio's own source
(cmd/lectio-ef-dump/main.go's doc comment): first, lectio prints week
"0" (rendered "-") "where no season week applies, e.g. named I class
feasts and per annum green-season ferias" -- an internal DISPLAY
convention of lectio's, not a liturgical fact, so there is nothing to
cross-check it against. Second, even where both sides print a real
number, the two engines anchor Time-after-Epiphany week numbering
differently (colitur: the first Sunday on/after Epiphany; lectio: a
fixed offset from 6 January -- register §3c item 5) and the offset
between them is NOT constant (it depends on which weekday 6 January
falls on, and re-synchronises mid-season), so no clean formula-based
check is possible without reimplementing lectio's own algorithm here.
Slug identity, rank and colour -- where the real liturgical substance
lives -- remain fully and separately compared; only the bare integer
is out of scope. See the report for the concrete rows this weakens.
*** THE THREE-LAYER DESIGN (controller ruling, Task 15 dispatch) ***
REFRESHED (2026-08-12, task 2026-08-12-colitur-rg16a, branch ef-rebootstrap):
the fixture was regenerated from lectio's CURRENT HEAD (3b32c00), which has
fixed seven EF calendar defects since the fixture was first pinned (commit
2386a45) -- see the fixture's own provenance note for the exact commits and
defect list. Of 16801 day-pairs (2005-2050), 11826 now already match on the
raw seven columns (up from roughly 11201 against the stale fixture); of the
4975 that don't, only 10 distinct RAW (field-diff, including the
never-compared week column) signatures cover all of them, independently
recomputed against this fixture and colitur's current output, not copied
from the original task-15-class-summary.txt (which described the OLD
fixture and is stale). After Layers A and B below strip the week column and
the vocabulary/numbering artifacts, only 540 of those 4975 remain genuine
differences -- all 540 explained by Layer C (368 + 138 + 31 + 3, see each
entry's own citation), 0 unexplained. They resolve into three strictly
separate layers:
- Layer A (this file's [norm_season]/[norm_slug]): vocabulary. A
declarative, explicit, closed table of naming synonyms that carry no
liturgical substance -- lectio's "easter"/"christmas" ARE colitur's
"paschaltide"/"christmastide"; a handful of slugs are two different
engines' names for the identical office (Christmas Vigil, Holy Name
Sunday, the Pentecost-octave Ember days, the two Passiontide weeks).
Every entry is a literal string pair, never a pattern -- widening this
to a wildcard is exactly how a real bug would get hidden, so it is not
done even where it would shorten the table.
- Layer B ([strip_epiphany_index]): numbering. The ONE case in the whole
4975 where a slug's embedded index genuinely cannot be reconciled by a
literal table (Time-after-Epiphany's non-constant offset, limit 3
above) -- both sides' embedded week digit is stripped to a common
family+weekday form before comparing, while rank and colour (which
carry no week-index information for an ordinary green-season
feria/Sunday) remain fully compared, so a genuine identity bug in this
family would still be caught by everything except the digit itself.
- Layer C (data/ef/expected-divergences.sexp, matched by
[layer_c_reason] below): the CITED allow-list. Each entry cites its RG
paragraph and states which engine is right (always colitur, verified
against the Missal/register, never against lectio's own behaviour --
"lectio does it differently" is not itself a justification anywhere in
this file). This is the ONLY layer that may cover a difference in rank,
colour, or which celebration is observed; A and B never do (enforced
structurally below: A/B only ever touch the season/slug fields, and
Layer C's predicates each require an exact, narrow field-diff SET, not
"anything goes").
REFRESHED (2026-08-12, ef-rebootstrap fixture regeneration): lectio's own
fix wave (2386a45 -> 3b32c00, provenance note has the full commit list)
independently fixed the exact defects nine of the thirteen entries this
file used to carry were about -- C2, C3, C4, C5, C7, C10, C11, C12 and C13
all now match ZERO rows against the refreshed fixture and were CLOSED
(removed from data/ef/expected-divergences.sexp and from
[layer_c_reason] below), each with its citation preserved and the closure
recorded in docs/research/rules-register.md, not silently deleted -- see
the report for the full before/after account. C9's old shape ("St Joseph
observed ON a Lent Sunday", lectio's own defect 4) also closed the same
way, but a NARROWER, different divergence involving the same saint
survived the refresh and was re-cited as a new entry, C14 (below), not
folded back into the old C9 id (the two are different rules; conflating
them would be exactly the "count proving cardinality where identity was
required" vacuity flavour this project's harnesses are specifically
checked against). Three entries survive unchanged in kind: C1 (368 rows,
up from 361 -- the 7 rows C13 used to carve out fold back into C1's own
shape now that lectio's slug matches there too, see C1's own note), C6
(138 rows, unchanged count but a narrower shape -- lectio fixed the RANK
this entry used to also cover, leaving only the SLUG naming difference,
see C6's own note) and C8 (31 rows, wholly unchanged -- lectio still
computes no Rogation days at all).
13 January (register §6's long-open "Baptism of the Lord" item) is
EMPIRICALLY CONFIRMED FIXED, not separately allow-listed, in ALL 46 years
again as of this refresh: lectio's own fix wave independently fixed its
equivalent of the same RG16(a) defect the now-closed C13 used to allow-
list (its slug now matches colitur's `commemoration-of-the-baptism-of-
the-lord` on all 7 of the Sunday years too, not only the 39 non-Sunday
ones), so the residual difference on every one of the 46 rows is season
alone -- covered by C1, the Jan 6-13 boundary, with no separate entry
needed any more. *)
module Cal = Colitur_kernel.Calendar
module Layer = Colitur_kernel.Layer
module Overlay = Colitur_kernel.Overlay
module LD = Colitur_kernel.Liturgical_day
module Slug = Colitur_kernel.Slug
module Date = Colitur_kernel.Date
module Cel = Colitur_kernel.Celebration
module Colour = Colitur_kernel.Colour
module Subject = Colitur_kernel.Subject
module Citation = Colitur_kernel.Citation
module V = Rite_ef.Vocab_ef
(* Same relative paths test_rite_ef.ml/test_sanctoral_ef.ml use: dune test
runs from _build/default/test/. *)
let sanctoral_path = "../data/ef/sanctoral.sexp"
let adjustments_path = "../data/ef/adjustments.sexp"
let fixture_path = "fixtures/lectio-ef-2005-2050.txt"
let allow_list_path = "../data/ef/expected-divergences.sexp"
(* The fixture's own provenance note (test/fixtures/lectio-ef-2005-2050.provenance)
records this same digest, so it is discoverable by a reader who never runs
the suite. Recorded here too, and ASSERTED (fix round 1, finding 4): a
provenance note is enough to REGENERATE the fixture but says nothing about
whether the committed bytes still match the commit they claim to come
from -- an unnoticed hand-edit or partial re-copy would silently turn the
whole oracle into an unlabelled snapshot of whatever someone last ran.
Regenerate this constant (and the provenance note's copy) together,
deliberately, after re-running the exact command the provenance note
names -- never by copying the actual value back in to make a mismatch
pass, which would defeat the point of pinning it at all. *)
let fixture_sha256 = "2e9c5761e7e5ccee4b5be096e8727d07a2e67dd9523a897f9aa73407ddf1e350"
(* Same technique tools/bootstrap_sanctoral.ml already uses for this exact
purpose (that file's own comment: shelling out to the system's
[sha256sum], not an OCaml crypto library -- Task 15's deps are frozen).
Unlike that tool, this avoids even the (already-permitted, per that
file's own comment, "already in the switch") [unix] library: [Sys.command]
plus a redirected-to-file capture needs nothing beyond the Stdlib every
dune executable already links. Depends on [sha256sum] being on PATH,
which every environment this suite has actually run in (Debian, per
CLAUDE.md) provides via coreutils; a machine without it fails this check
with a command-not-found exit code rather than silently skipping it. *)
let sha256_of_file path =
let tmp = Filename.temp_file "colitur_differential_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 real_layer () =
let layer =
match Layer.load V.rank_of_sexp sanctoral_path with
| Ok l -> l
| Error e -> Alcotest.failf "%s: failed to load: %s" sanctoral_path e
in
let overlay =
match Overlay.load V.rank_of_sexp adjustments_path with
| Ok o -> o
| Error e -> Alcotest.failf "%s: failed to load: %s" adjustments_path e
in
let layer, diagnostics = Overlay.apply layer overlay in
Alcotest.(check (list string)) "the committed overlay applies cleanly, no diagnostics" []
(List.map Overlay.diagnostic_to_string diagnostics);
layer
(* [Rite_ef.context] takes [~lectionary] (fix round 1, coordinator review) --
caller-supplied, same as [real_layer] above. *)
let real_lectionary () =
match Colitur_kernel.Lectionary.load "../data/ef/lectionary.sexp" with
| 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 = {
date : string;
weekday : string;
season : string;
week : string;
slug : string;
rank : string;
colour : string;
(* Fix round 1 (coordinator finding F3): ONLY ever populated for colitur's
own row (see [colitur_rows_2005_2050] below) -- lectio's own fixture
format has no subject column at all, so [row_of_line] (the lectio-side
parser) sets this to "-", a placeholder never read on that side. Every
Layer C predicate that reads [subject] must therefore read it off the
COLITUR row [c], never [l] -- the same asymmetry [row]'s other fields
do not have, called out here rather than left implicit. *)
subject : string;
(* Task 8: the day's resolved Epistle/Lesson and Gospel citations, ALREADY
DECODED (lectio's own "_"-for-space convention reversed at parse time,
[decode_field] below; colitur's own [Citation.reference] never contains
an underscore in the first place, so decoding it is a no-op) -- both
sides therefore carry the same "-" sentinel for "no reading resolved"
that the fixture's own name/week columns already use, so a plain
[String.equal] is the whole comparison, matching every other field in
this record. *)
first : string;
gospel : string;
}
let read_lines path =
let ic = open_in path in
let rec loop acc =
match input_line ic with
| line -> loop (line :: acc)
| exception End_of_file ->
close_in ic;
List.rev acc
in
loop []
(* Reverses the fixture's own "_" -for-space, "-" -for-absent encoding
(shared by the name_en/name_pl columns and, since Task 7's
lectio-ef-dump refresh, by first/gospel too -- see the fixture's own
.provenance note). Verified against the real file, not assumed: a
citation like ["Rom_13:11-14"] round-trips to ["Rom 13:11-14"], and the
literal sentinel ["-"] (no underscore in it) passes through unchanged,
so one function serves both the "real value" and the "absent" case
without a special-cased branch. *)
let decode_field s = String.map (fun c -> if c = '_' then ' ' else c) s
let row_of_line line =
match String.split_on_char ' ' line with
| date :: weekday :: season :: week :: slug :: rank :: colour :: _name_en :: _name_pl :: first
:: gospel :: _others ->
{ date;
weekday;
season;
week;
slug;
rank;
colour;
subject = "-";
first = decode_field first;
gospel = decode_field gospel
}
| _ -> Alcotest.failf "malformed fixture line (fewer than 11 fields): %S" line
let lectio_rows () = List.map row_of_line (read_lines fixture_path)
(* Recomputes colitur's day-by-day output straight from the library --
exactly [Colitur_kernel.Calendar.year] + [Rite_ef.context] over the real
committed data, the same pipeline bin/main.ml's `colitur day` runs (that
file's own [day_report]/[day_line] comments explain the two-liturgical-
year-per-civil-year indexing this mirrors). Duplicated here rather than
shared with bin/main.ml (an executable, not a library) -- the same choice
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 ()) ~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
Array.iter (fun (d : (V.season, V.rank) LD.t) -> Hashtbl.replace by_rata (Date.to_rata d.LD.date) d) days
done;
let mk y m d = match Date.make ~year:y ~month:m ~day:d with Ok t -> t | Error e -> failwith e in
let rows = ref [] in
for y = 2005 to 2050 do
let d = ref (mk y 1 1) in
let stop = mk y 12 31 in
while Date.compare !d stop <= 0 do
(match Hashtbl.find_opt by_rata (Date.to_rata !d) with
| Some day ->
let t = day.LD.temporal in
let cel = day.LD.observed in
let week =
match t.Colitur_kernel.Temporal.week with Some n -> string_of_int n | None -> "-"
in
(* [Rite.readings] already resolved the day's citations (calendar.ml's
own [build_day]); this just projects the ONE reading of each
PART lectio's own dumper prints ("first"/"gospel" -- neither
engine's EF stream carries Psalm/Second/Tract/etc, task-7-report.md's
own note on why). "-" for a part with no resolved reading, the
same sentinel [row_of_line]'s [decode_field] leaves an absent
lectio field as, so the two sides compare with a plain
[String.equal] like every other field here. *)
let citation_ref part =
match List.find_opt (fun (c : Citation.t) -> c.Citation.part = part) day.LD.citations with
| Some c -> c.Citation.reference
| None -> "-"
in
rows :=
{ date = Date.to_iso8601 day.LD.date;
weekday = Date.weekday_to_string t.Colitur_kernel.Temporal.weekday;
season = V.season_to_string t.Colitur_kernel.Temporal.season;
week;
slug = Slug.to_string cel.Cel.slug;
rank = V.rank_to_string cel.Cel.rank;
colour = Colour.to_string cel.Cel.colour;
subject = Subject.to_string cel.Cel.subject;
first = citation_ref Citation.First;
gospel = citation_ref Citation.Gospel
}
:: !rows
| None -> Alcotest.failf "internal error: no resolved day for %s" (Date.to_iso8601 !d));
d := Date.add_days !d 1
done
done;
List.rev !rows
(* ---------------------------------------------------------------------- *)
(* Layer A: vocabulary. Explicit, closed tables; no wildcards, no pattern *)
(* matching against arbitrary content -- every case below is a literal *)
(* string compared against a literal string. *)
(* ---------------------------------------------------------------------- *)
(* lectio's season word -> colitur's season word. The ONLY two seasons that
are spelled differently; every other season word is shared verbatim
(both call Advent "advent", Lent "lent", etc.). *)
let norm_season = function
| "easter" -> "paschaltide"
| "christmas" -> "christmastide"
| s -> s
let weekdays = [ "monday"; "tuesday"; "wednesday"; "thursday"; "friday"; "saturday" ]
(* lectio's slug -> the colitur slug for the SAME office, where the two
projects simply chose different names. [month]/[lectio_rank] disambiguate
the two cases where lectio reuses one slug for what colitur names two
different ways (see each branch's own comment). *)
let rec norm_slug ~month ~lectio_rank slug =
if String.equal slug "vigil-of-christmas" then "ef-nativity-vigil"
(* register §6: lectio names its vigils "vigil-of-X" (prefix); colitur's
own convention is "X-vigil" (suffix). Same celebration -- confirmed a
genuine duplicate in Task 11, where colitur's overlay suppresses
lectio's key entirely from the sanctoral layer. *)
else if
(* "ef-christmas-sunday-0"/"ef-christmas-0-<weekday>" mean TWO different
things in lectio depending on which window they fall in: the Sunday
within Holy Name week (2-5 January, colitur's own named feast) or an
ordinary day within the Nativity Octave (26-31 December, where colitur
uses its own ef-nativity-octave-day-N naming instead, Layer C's C6 --
that window keeps a genuine RANK difference too, so it must not be
absorbed here). Gating on [month = 1] keeps this table from ever
touching the December occurrence. *)
month = 1
then
if String.equal slug "ef-christmas-sunday-0" then "ef-holy-name-sunday"
else
match List.find_opt (fun wd -> String.equal slug ("ef-christmas-0-" ^ wd)) weekdays with
| Some wd -> "ef-christmas-1-" ^ wd
| None -> norm_slug_rest ~lectio_rank slug
else norm_slug_rest ~lectio_rank slug
and norm_slug_rest ~lectio_rank slug =
(* Passiontide: lectio never distinguishes Passion week (colitur's week 1,
class-3 ferias) from Holy week (colitur's week 2, class-1 ferias) in the
slug -- both print "ef-passiontide-0-<weekday>". Rank, independently and
strictly compared elsewhere, is what actually tells the two weeks apart
(RG 91 entries 22 vs 2/7), so using it here to pick the expected colitur
digit does not launder away a genuine identity bug: a colitur bug that
mixed up the two weeks would, on the evidence available in this stream,
also very likely show up as a rank mismatch of its own. *)
match List.find_opt (fun wd -> String.equal slug ("ef-passiontide-0-" ^ wd)) weekdays with
| Some wd -> (
match lectio_rank with
| "class-3" -> "ef-passiontide-1-" ^ wd
| "class-1" -> "ef-passiontide-2-" ^ wd
| _ -> slug)
| None -> (
match slug with
| "ef-easter-8-wednesday" -> "ef-pentecost-ember-wed"
| "ef-easter-8-friday" -> "ef-pentecost-ember-fri"
| "ef-easter-8-saturday" -> "ef-pentecost-ember-sat"
| s -> s)
(* ---------------------------------------------------------------------- *)
(* Layer B: numbering (register §3c item 5). The Time-after-Epiphany *)
(* week-index embedded in a slug is stripped to a common form on BOTH *)
(* sides before comparing -- see limit 3 in this file's header comment *)
(* for why a table (Layer A's tool) cannot do this instead. *)
(* ---------------------------------------------------------------------- *)
let starts_with ~prefix s =
let lp = String.length prefix in
String.length s >= lp && String.equal (String.sub s 0 lp) prefix
let is_digit_string s = s <> "" && String.for_all (fun c -> c >= '0' && c <= '9') s
let strip_epiphany_index slug =
let prefix = "ef-time-after-epiphany-" in
if not (starts_with ~prefix slug) then slug
else
let rest = String.sub slug (String.length prefix) (String.length slug - String.length prefix) in
match String.index_opt rest '-' with
| None -> slug
| Some i ->
let left = String.sub rest 0 i in
let right = String.sub rest (i + 1) (String.length rest - i - 1) in
if is_digit_string left && List.mem right weekdays then prefix ^ right
else if String.equal left "sunday" && is_digit_string right then prefix ^ "sunday"
else slug
(* ---------------------------------------------------------------------- *)
(* Field-diff computation: applies Layers A and B, then reports exactly *)
(* which of the SEVEN substantive columns still differ (week is never *)
(* inspected at all -- limit 3). weekday is included defensively: dates *)
(* are checked 1:1 aligned before this runs, so it should never fire, and *)
(* if it ever does that is real signal, not noise to normalise away. *)
(* *)
(* Task 8: First_f/Gospel_f join the SAME diff set the other five fields *)
(* share -- deliberately, not a separate pass gated on "only when the day *)
(* otherwise matches". A citation difference on a day whose identity *)
(* ALSO differs is not a second, independent fact needing its own *)
(* citation -- it is the same divergence Layer C already explains *)
(* (different office observed, therefore a different Mass read); each *)
(* Layer C predicate below is widened to accept First_f/Gospel_f *)
(* alongside its own existing field set wherever that is what the real *)
(* fixture data shows, never blanket-permitted. *)
(* ---------------------------------------------------------------------- *)
type field = Weekday | Season | Slug_f | Rank | Colour_f | First_f | Gospel_f
let field_name = function
| Weekday -> "weekday"
| Season -> "season"
| Slug_f -> "slug"
| Rank -> "rank"
| Colour_f -> "colour"
| First_f -> "first"
| Gospel_f -> "gospel"
let month_of_date date = int_of_string (String.sub date 5 2)
let diff_fields (l : row) (c : row) =
let m = month_of_date l.date in
let l_season = norm_season l.season in
let l_slug = strip_epiphany_index (norm_slug ~month:m ~lectio_rank:l.rank l.slug) in
let c_slug = strip_epiphany_index c.slug in
List.filter_map
(fun x -> x)
[ (if String.equal l.weekday c.weekday then None else Some Weekday);
(if String.equal l_season c.season then None else Some Season);
(if String.equal l_slug c_slug then None else Some Slug_f);
(if String.equal l.rank c.rank then None else Some Rank);
(if String.equal l.first c.first then None else Some First_f);
(if String.equal l.gospel c.gospel then None else Some Gospel_f);
(if String.equal l.colour c.colour then None else Some Colour_f)
]
(* ---------------------------------------------------------------------- *)
(* Layer C: the cited allow-list (data/ef/expected-divergences.sexp). *)
(* Each predicate below names the [id] it matches; the sexp file carries *)
(* that id's citation, verdict and expected row count. A predicate fires *)
(* only on an EXACT, narrow field-diff set -- never "any difference at *)
(* all" -- so it cannot silently absorb a difference outside what its own *)
(* citation actually explains. *)
(* ---------------------------------------------------------------------- *)
let subset xs ys = List.for_all (fun x -> List.mem x ys) xs
let day_of_date date = int_of_string (String.sub date 8 2)
(* ef-rebootstrap fixture refresh (2026-08-12): C2, C3, C4, C5, C7, C10, C11,
C12 and C13's own predicates/helper bindings (sunday_iclass_slugs,
rose_sunday_slugs, advent_feria_slug, fixed_iii_class_reclassified_slugs,
jan13_lord_sunday_dates_2005_2050, and the inline C7/C10/C11 date/slug
checks) are REMOVED here, not left dead -- each matched zero rows against
the refreshed fixture (lectio's own fix wave, 2386a45 -> 3b32c00,
independently fixed the exact defects these entries were about), so their
citations moved to docs/research/rules-register.md (closure record, with
the RG paragraph preserved) and their sexp rows to nothing --
data/ef/expected-divergences.sexp no longer declares those ids at all.
Removing a predicate strengthens this harness the same way removing its
sexp row does: if any of these nine shapes ever reappears (a regression in
colitur, or lectio moving again), it now surfaces as UNEXPLAINED instead
of being silently re-absorbed by a citation whose own divergence no
longer exists. *)
(* Fix round 1, finding 1: C1 and C6 (below) originally gated on calendar
date alone, with no slug/slug-family check -- unlike every other entry
here. The reviewer constructed the failure this leaves open: if a future
sanctoral regeneration made some OTHER 29-31 December celebration win the
day (colliding coincidentally with C6's own [Slug_f; Rank] diff shape),
it would be silently absorbed under "RG 91 entry 17, Nativity Octave" --
a citation that has nothing to do with the real cause. [subset diffs
[...]] alone was never enough; the SLUG that actually won must also be
the one each citation is about. Both lists below are exact literals (the
Nativity Octave's three colitur-only day slugs; the closed set of
offices that can legitimately observe C1's Jan 6-13 window), not
patterns -- a slug outside them fails through to [None] instead of being
absorbed. *)
let jan_6_13_slug slug =
String.equal slug "ef-epiphany"
|| String.equal slug "commemoration-of-the-baptism-of-the-lord"
|| List.exists (fun wd -> String.equal slug ("ef-christmas-2-" ^ wd)) weekdays
let nativity_octave_day_slugs =
[ "ef-nativity-octave-day-5"; "ef-nativity-octave-day-6"; "ef-nativity-octave-day-7" ]
(* C18 (ef-sanctoral-audit, 2026-08-14): the closed set of colitur
[status = Feast] sanctoral slugs whose bootstrapped [colour] was
corrected against RG 124 (data/ef/adjustments.sexp's own audit-block
comment has the full citation and per-slug reasoning); every one of
these still carries lectio's OLD, uncorrected colour, so a row where
this is the ONLY colitur candidate observed for its own date diffs on
[Colour_f] alone whenever it wins the day (most years -- these are all
ordinary universal feasts, rarely impeded). Deliberately a literal list,
not a shape predicate, the same discipline C6/C14/C15/C16 already use:
a colour diff on any OTHER slug must still surface as unexplained. Eight
further audit corrections (prisca/peter/vitus/margaret/agapitus/liborii/
mark-i, all Commemoration_only, plus the new `barbara` Add) are NOT
listed here and need no predicate: [colitur_rows_2005_2050] emits only
the OBSERVED day's own record, and a [Commemoration_only] candidate can
never be observed (Precedence.resolve's own design), so their corrected
colours have no row in this comparison to explain. *)
let audit_colour_corrected_slugs =
[ "conversion-of-st-paul"; "chair-of-st-peter"; "john-of-san-fecundo"; "ephrem-of-syria";
"julia-of-falconieri"; "john-gualbert"; "camillus-de-lellis"; "jerome-emiliani"; "apollinaris";
"martha"; "alphonsus-liguori"; "augustine"; "rose-of-lima"; "josaphat" ]
(* C14 (ef-rebootstrap fixture refresh, 2026-08-12; replaces the closed C9,
see data/ef/expected-divergences.sexp's own C14 note for the full RG
citation): the exact 3 civil days, across the whole 2005-2050 window,
where St Joseph's (19 March, I class) RG 96 transfer walk is congested
enough by Passiontide/Holy Week/the Easter octave to cross Easter AND
collide with the Annunciation's own transferred "sedes propria" (RG 96
Attamen (a), Monday after Low Sunday) -- independently re-derived from
this fixture and colitur's own output (`grep joseph-spouse` both sides,
filtered to rows with a real post-Layer-A/B diff), not copied from the
register's prior "2008, 2035, 2046" prose. A LITERAL date list, not a
slug predicate, per the SAME discipline C12/C13 already used (a plain
"involves this slug" predicate is exactly what let the old C9 silently
cover two textually-unrelated divergences at once -- see C14's own sexp
note for the full account of why that was wrong). *)
let joseph_annunciation_collision_dates_2005_2050 = [ "2008-04-01"; "2035-04-03"; "2046-04-03" ]
(* C15 -- ef-rg112-rg110 task: SPLIT OUT of C1's own blanket 6-13 January
window, not a new kind of divergence -- the same discipline C13 (now
closed, see the header above) already established for this exact date,
and the SAME check the task brief asked for explicitly ("if C1 now
absorbs a different observed celebration, split it out"). Before this
task, colitur had no Holy Family office at all: on these seven dates (13
January, the one civil day it falls on a Sunday in the 2005-2050 window
-- 2008, 2013, 2019, 2030, 2036, 2041, 2047), colitur's own generic
Sunday fallback happened to lose outright to the fixed Commemoration of
the Baptism of the Lord (RG16(a)'s existing "festum Domini beats an
ordinary Sunday" mechanism, subject Lord already present in the
REBOOTSTRAPPED data), producing the SAME slug lectio's own tridentine-
calendar.ini shows for that date ("commemoration-of-the-baptism-of-the-
lord") -- only [Season] differed (RG 72-73 vs lectio's own 6 January
boundary), squarely inside C1's own shape. Now that Holy Family is built
(RG 17(b)) and correctly outranks the fixed Baptism (RG 91 entry 14,
"primum mobilia, deinde fixa"; RG 112(a) excludes the Baptism as a
commemoration too, both this task's own precedence_ef.ml changes),
colitur's own slug on these seven dates changes to
"ef-time-after-epiphany-sunday-1" (Holy Family's own, unchanged from the
ordinary-Sunday key it always carried, temporal_ef.ml's own comment on
why) -- a GENUINE identity divergence against lectio, which has no Holy
Family at all and still shows the fixed Baptism observed there. Gated on
the literal 7-date list AND colitur's own slug (the same guard C1/C6/C8/
C14 already apply, fix round 1's own finding 1: a predicate must pin
WHICH celebration it is about, not only the date). *)
let holy_family_baptism_collision_dates_2005_2050 =
[ "2008-01-13"; "2013-01-13"; "2019-01-13"; "2030-01-13"; "2036-01-13"; "2041-01-13"; "2047-01-13" ]
(* C16 -- ef-holyname-rg110 task: RG 17(a)'s own fallback (2 January, "secus
die 2 ianuarii"), for every civil year 2005-2050 with no Sunday 2-5
January -- independently re-derived against `date -d <year>-01-0{2..5}
+%u`, not transcribed from the register's domain-wide figure (see
data/ef/expected-divergences.sexp's own C16 note for the full citation
and the derivation). lectio has no equivalent fallback at all (checked
directly against tridentine-calendar.ini, which carries no 01-02 entry),
so it shows the plain Christmastide ferial slug on every one of these
dates, class-4 -- colitur's own `ef-holy-name`, class-2, is a genuine
[Slug_f; Rank] divergence, not a naming synonym Layer A could absorb. *)
let holy_name_fallback_dates_2005_2050 =
[ "2006-01-02"; "2007-01-02"; "2008-01-02"; "2012-01-02"; "2013-01-02"; "2017-01-02";
"2018-01-02"; "2019-01-02"; "2023-01-02"; "2024-01-02"; "2029-01-02"; "2030-01-02";
"2034-01-02"; "2035-01-02"; "2036-01-02"; "2040-01-02"; "2041-01-02"; "2045-01-02";
"2046-01-02"; "2047-01-02" ]
(* C17 -- ef-bvm-saturday task: RG 91 entry 27 ("Officium sanctae Mariae in
sabbato" -- see data/ef/expected-divergences.sexp's own C17 note for the
full RG 78/RG 120(b) citation). lectio builds no equivalent office at all
(checked directly against its own tridentine-calendar.ini/generator, the
same "genuine upstream gap, not a colitur bootstrap miss" shape C15/C16
already document for their own gaps) -- it shows the ordinary season
colour on every otherwise-unoccupied Class4 Saturday where colitur's own
RG 78 fix now shows white. Season, slug and rank all still agree --
temporal_ef.ml's own [bvm_saturday_names] citation ("Slug" paragraph)
deliberately REUSES the ordinary ferial slug rather than minting a new
one, precisely so this predicate's diff set stays [Colour_f] alone, never
[Slug_f] too. Gated on colitur's own [rank]/[weekday]/[colour] rather
than a date list, the same shape M2 (data/ef/expected-divergences-
missalemeum.sexp) already uses for the identical reason: this
population is large (a large fraction of all Saturdays domain-wide) and
entirely formulaic (RG 78's own condition, "otherwise unoccupied
IV-class Saturday", reduces exactly to this triple), not a short,
individually-interesting list of dates the way C14/C15/C16 above are. *)
let is_bvm_saturday_row (c : row) diffs =
(* Fix round 1 (coordinator finding F3): [c.subject] added -- the ONLY
field in this predicate that pins WHICH celebration is observed, not
merely its shape. Every other Layer C entry pins a colitur slug (C1's
own [jan_6_13_slug], C6's [nativity_octave_day_slugs], C8/C14/C15/C16's
own literal slug checks); this entry could not, because the office
deliberately REUSES the ordinary ferial slug (Rite_ef.Temporal_ef's own
[bvm_saturday_names] citation, "Slug" paragraph) -- there is no fixed
slug string to pin. [subject = "bvm"] is the field that DOES uniquely
identify the office (set nowhere else the differential's own
[colitur_rows_2005_2050] can produce a Saturday/Class4/white
combination for), closing the gap a shape-only predicate left open:
without this conjunct, a FUTURE bug that made some OTHER white,
Class4, Saturday candidate exist (Christmastide/Paschaltide, where
[season_colour] is already white, so a real bug there could slip
through unnoticed by colour alone) would be silently absorbed here
too. *)
(* WIDENED (2026-08-17): [First_f]/[Gospel_f] join [Colour_f], because the
CAUSE did not change, it grew. colitur builds RG 78's office (hence white
where lectio is green) and now also says RG 309(a)'s own Mass for it,
where lectio -- having no such office at all -- says the feria's. One
cause, one entry: splitting the colour from the citations would file two
ids against a single divergence.
At least one difference is required, not merely a subset, so this can
never match a day that agrees entirely. The colour difference is ABSENT
in Christmastide and Paschaltide (RG 119 already made those seasons
white), which is why those rows carry citations alone -- the
[c.colour = "white"] guard still holds there, being colitur's own value
either way. *)
subset diffs [ Colour_f; First_f; Gospel_f ]
&& (List.mem Colour_f diffs || List.mem First_f diffs || List.mem Gospel_f diffs)
&& String.equal c.weekday "saturday" && String.equal c.rank "class-4"
&& String.equal c.colour "white" && String.equal c.subject "bvm"
(* C19 (task 8, branch ef-lectionary): the Time-after-Epiphany WEEK-INDEX
offset (limit 3 in this file's own header comment, [strip_epiphany_index]
above) is not merely a slug-digit cosmetic difference -- it is a real
week-COUNT divergence between the two engines for the REST of the season
whenever Holy Family (RG 17(b)) has just displaced the fixed Baptism
commemoration (the same 7 years C15 above names): colitur's own week
numbering runs one week "behind" lectio's until Time after Epiphany ends
at Septuagesima (which re-synchronises both engines' counts from zero),
so the SAME calendar day's Sunday-derived reading (whichever Sunday's
Mass a feria repeats, or a Sunday's own Mass) is drawn from two
adjacent-but-different weeks on each side -- a citation divergence with
NO slug divergence at all (Layer B's own [strip_epiphany_index] already
normalises the embedded digit away, correctly: the office FAMILY is the
same, only which week's content is read differs). Confirmed empirically,
not assumed: every occurrence across the whole 2005-2050 fixture falls in
exactly these 7 years, and only on a [c.slug] in the
[ef-time-after-epiphany-] family -- checked, not merely gated, so a real
bug on an unrelated slug in one of these years still surfaces as
unexplained. [Colour_f] joins the accepted set for the Saturdays that
ALSO happen to carry C17's own BVM-Saturday office that week -- a second,
independent, already-cited cause layering onto the SAME row, not
something this predicate explains on its own; C17's own 416-row count is
untouched, since its own guard still requires diffs = EXACTLY
[Colour_f], which these rows (carrying [First_f]/[Gospel_f] too) never
satisfy. *)
let epiphany_week_shift_years_2005_2050 = [ 2008; 2013; 2019; 2030; 2036; 2041; 2047 ]
let year_of_date date = int_of_string (String.sub date 0 4)
(* C29-C34 -- Task 9 (branch ef-lectionary, layer-4 oracle): six colitur-side
citation fixes, all found via the SAME mechanism -- missalemeum (test/
test_oracle.ml, 2026-2027) disagreeing with colitur's OLD step-3
fallback/absent-entry answer, traced to an explicit primary-source
citation each time (tools/bootstrap_lectionary.ml's own
[holy_name_entries]/[epiphanytide_opening_entries]/
[movable_feast_entries] carry the full scan citations, not repeated
here). lectio has no equivalent data for any of the six (its own ini
either has no entry, matching colitur's OWN prior gap for
[ef-corpus-christi]/[ef-sacred-heart], or the SAME mis-borrowed value
colitur used to carry for the January families before this task's own
fix, see [colitur_keys]'s own CORRECTED note in that generator) -- so
every occurrence across the WHOLE 2005-2050 differential window newly
diverges, not only the 2026-2027 oracle window that found it. Each is
its own id, not folded into a neighbour, per this file's own standing
discipline (C14/C15/C16 above): a slug family's own citation fix is a
distinct fact from another family's, even where the underlying task
and citation shape are similar. *)
let holy_name_sunday_slug = "ef-holy-name-sunday"
(* C35 -- movable-date-specs follow-up (2026-08-17); this is test_oracle.ml's
own M26 shape 2(a), now FIXED rather than allow-listed there.
RG 299 (scan1.txt:1096-1098) is the general ferial rule: "In reliquis
feriis dicitur Missa dominicae praecedentis, NISI A RUBRICIS ALITER
PROVISUM SIT", restated in the propers (scan1.txt:4933-4935) as "...nisi
propria Missa assignetur". The Missal DOES assign one for this week: it
prints "Missa dominicae I post Pentecosten" (heading, scan1.txt:21758)
immediately after Trinity Sunday's Mass, precisely because Trinity has
taken that Sunday's own -- 1 Ioann. 4, 8-21 (scan1.txt:21801-21802) /
Luc. 6, 36-42 (scan1.txt:21813-21814).
colitur now resolves these ferias at step 2 from that formula. lectio walks
back to Trinity Sunday's Mass instead (Rom 11:33-36 / Matt 28:18-20) --
which is exactly what colitur did until this fix. Verdict colitur, on the
Missal's own printed heading.
No Thursday: Corpus Christi is Easter+60, which IS the Thursday of this
week every year, so that ferial slug never exists at all -- found by the
bootstrap's own reachability guard, which refuses to emit a key no
Temporal_ef slug can ever match. *)
let trinity_week_slugs =
[ "ef-time-after-pentecost-1-monday"; "ef-time-after-pentecost-1-tuesday";
"ef-time-after-pentecost-1-wednesday"; "ef-time-after-pentecost-1-friday";
"ef-time-after-pentecost-1-saturday" ]
let christmas_1_weekday_slugs =
List.map (fun wd -> "ef-christmas-1-" ^ wd) [ "monday"; "tuesday"; "wednesday"; "thursday"; "friday" ]
let christmas_2_weekday_slugs =
List.map (fun wd -> "ef-christmas-2-" ^ wd) [ "monday"; "tuesday"; "wednesday"; "thursday"; "friday" ]
let time_after_epiphany_1_weekday_slugs =
List.map
(fun wd -> "ef-time-after-epiphany-1-" ^ wd)
[ "monday"; "tuesday"; "wednesday"; "thursday"; "friday" ]
(* C31/C32's OWN Saturday ripple, checked for and confirmed real, not
theorised: [ef-christmas-1-saturday] and [ef-time-after-epiphany-1-
saturday]/[ef-christmas-2-saturday] deliberately have NO direct entry
(RG 78's BVM Saturday Office, above) -- but a Saturday still falls
through to step 3's OWN preceding-Sunday walkback, and on the (large
minority of) years where that walk lands on [ef-holy-name-sunday]
specifically, it now inherits THAT slug's newly-corrected citation as
a side effect, with no direct entry of its own involved at all.
[ef-christmas-1-saturday]'s own walkback structurally can never reach
that far forward (it only ever lands in December, C25's own unchanged
population, confirmed by the pinned test_lectionary_ef.ml case) --
only the OTHER two families' Saturdays are affected, so only they need
the wider slug list; a Saturday whose value is genuinely unaffected
this year simply produces no row needing an id at all ([diffs = []]),
not a false positive. *)
let christmas_2_weekday_slugs_with_saturday = christmas_2_weekday_slugs @ [ "ef-christmas-2-saturday" ]
let time_after_epiphany_1_weekday_slugs_with_saturday =
time_after_epiphany_1_weekday_slugs @ [ "ef-time-after-epiphany-1-saturday" ]
(* C20 (task 8): lectio genuinely computes NO reading at all -- "-"/"-" on
both parts -- for 14 civil days across 2005-2050, all seven of the
readingless class-3 saints Task 6 gave a proper or a Common
(data/ef/commons.sexp's own "OBSERVABILITY" note, task-6-report.md §1):
colitur is MORE COMPLETE here, not wrong -- every one of these 14 answers
is a direct Missal citation (data/ef/adjustments.sexp's own propers for
thomas-aquinas/john-of-god/francis-of-paola, or data/ef/commons.sexp's
own Common assignment for the other four), each read from three
independent witnesses (task-6-report.md's own account of its own
cross-check discipline), not invented. Gated on the literal 7-slug list
(the same identity-pinning discipline every other entry here uses) AND
on lectio's own field literally reading "-"/"-" -- never "any
first/gospel mismatch on these slugs", which would also swallow a
genuine future content disagreement on one of these same seven saints. *)
let lectio_no_reading_slugs =
[ "vincent-ferrer"; "francis-of-paola"; "isidore-of-seville"; "thomas-aquinas"; "john-of-god";
"sts-felicitas-perpetua"; "frances-rome" ]
(* C21 -- RETIRED, task 8 fix round 1 (coordinator review). Previously a
[verdict open] entry (107 rows) for "colitur's step 3 cannot reach a
correct answer for the fixed Christmastide slugs". That diagnosis has
been SUPERSEDED, not merely fixed around: Important 3(a)'s own direct
Missal formulary (tools/bootstrap_lectionary.ml's own
[nativity_octave_entries]) gives [ef-nativity-octave-day-{5,6,7}] a
real, correct answer via STEP 2, not step 3 at all, so "step 3 cannot
reach it" is no longer even true. What C21 used to cover has split
cleanly into two DIFFERENT, more precisely diagnosed populations:
colitur's own [ef-nativity-octave-day-N] rows now fold into C6 above
(widened, [verdict] re-opened -- see its own note); the
[ef-christmas-1-<weekday>] rows that resume THAT slug's own (partly
wrong, per RG 69) answer one hop later via step 3 are C25 below, a
genuinely different id because the mechanism is now precisely RG 69
propagating through step 3, not "step 3 has no answer". No predicate
or helper of this file's own still names "C21" -- removing the code
alongside the id is the same discipline this file's own header already
applies to C2-C5/C7/C9-C13 (closed, cited, never left as dead code). *)
(* C22 -- CLOSED, Task 9 (branch ef-lectionary), REMOVED not re-adjudicated
to a different verdict: 0 of 16801 rows now. It allow-listed the Lenten
Ember days (RG 91 entry 18, colitur's own ef-lent-ember-{wed,fri,sat}),
where lectio's own lectionary keyed the data under the generic
"ef-lent-1-<weekday>" family while its calendar computed the Ember
spelling, so its own [caldata.Readings] never reached it and it fell back
to Lent I Sunday's Mass. The entry named that as a genuine lectio bug for
upstream and declined to patch the sibling project (the brief's own triage
rule); it is now fixed there (lectio v0.46.1) and the fixture regenerated,
so both engines show the Ember day's own proper and there is nothing left
to allow-list. Full account in data/ef/expected-divergences.sexp's own
closure note. [lent_ember_slugs] is gone with it -- its only reader was
this predicate. *)
(* C23 (task 8, fix rounds 1-2, coordinator review, Critical 1): Holy
Week's own citations (tools/bootstrap_lectionary.ml's own
[holy_week_entries] comment has the full Missal citations and the two-
scan corroboration for each of the six days). lectio has no Holy Week
propers at all -- every "ef-passiontide-0-<weekday>" ini section is
Passion week's own Mass (or, for Tuesday specifically, Holy Tuesday's
own data outright -- C26 below), so lectio shows that reused/conflated
citation on every Holy Week weekday, every year, regardless of what the
real Missal prints there.
CORRECTED, fix round 2: the first pass split this into two entries --
C23 for the four days with one unambiguous reading in the Epistle
position (Monday, Tuesday, Thursday, Saturday), C24 for Wednesday and
Good Friday, left with NO citation at all on the reasoning that neither
has an unambiguous Epistle. That reasoning was right about the FIRST
slot but the conclusion was wrong and worse than what it replaced: an
absent lectionary key does not mean "no reading" to Lectionary_ef.
readings, it means step 3 silently resumes the preceding SUNDAY -- so
Good Friday, which has no Mass at all, was emitting Palm Sunday's own
Epistle and Passion narrative every single year. Fixed at the source
(tools/bootstrap_lectionary.ml's own [holy_week_entries]): the GOSPEL is
always authored (one unambiguous labelled Passion each day, nothing to
choose among); the FIRST slot is filled too, using the SAME "last
lesson before the Gospel" convention this file's own Lenten Ember
Wednesday entry already establishes for an identical two-peer-lesson
shape (neither lesson on either day is labelled "Epistola" the way Holy
Saturday's genuinely is, so this remains a stated convention, not a
textual fact -- but it is the SAME convention already load-bearing
elsewhere in this exact file, not invented to paper over this gap).
C24 is retired, not re-adjudicated: its own population (the two days)
now has a real citation and folds into C23, which covers all SIX days
uniformly.
Gated on the literal 6-slug list and [diffs] staying exactly
[First_f; Gospel_f] (identity already agrees on all six). Holy Week
recurs every year by construction, so this is not a coincidental count
the way a collision-driven entry's would be -- but it is NOT simply
46 x 6 = 276 either: Holy TUESDAY (`ef-passiontide-2-tuesday`) is the
one day of the six whose hand-authored citation is BYTE-IDENTICAL to
lectio's own value (both draw, independently, on the identical Missal
Mass -- C26 below has the full account of why lectio's ini carries
Holy Tuesday's data under a Passion-week key), so all 46 of its own
rows match outright and never reach this entry at all. Derived
directly from the OCaml comparator's own failure output, not
hand-counted first and cross-checked after: 230 (276 - 46, confirmed
by that same arithmetic, not merely asserted). *)
let holy_week_slugs =
[ "ef-passiontide-2-monday"; "ef-passiontide-2-tuesday"; "ef-passiontide-2-wednesday";
"ef-passiontide-2-thursday"; "ef-passiontide-2-friday"; "ef-passiontide-2-saturday" ]
(* C37 (ef-oconnell-rubrics): RG 128 gives violet to "II/III-class vigils
outside Paschaltide". These two carried Red and White respectively --
inherited from lectio, which inherited them from missalemeum, which is
generated from Divinum Officium. All three agree with each other and
disagree with the Missal, so no layer sharing that lineage could see it.
The Ascension's vigil is NOT here: it is the one II-class vigil INSIDE
Paschaltide and is correctly white already. *)
let violet_vigil_slugs = [ "vigil-of-st-lawrence"; "vigil-of-the-assumption" ]
(* C38 (ef-sanctoral-status): `ubaldus` (16 May) and `didacus` (13 November)
were promoted from Commemoration_only to Feast on the Missal's own
universal calendarium, which ranks both "III classis" outright. lectio
inherits the Commemoration_only status from missalemeum, so where colitur
now observes the saint, lectio still observes the feria -- hence the slug,
rank and both citations differ together. Two exact shapes, not one:
16 May falls in Paschaltide, whose ferial colour is ALREADY white, so no
colour difference arises there; 13 November is Time after Pentecost, green,
so the white confessor changes it. Both shapes are spelled out rather than
collapsed into a subset check, so a colour difference appearing on 16 May
(or vanishing on 13 November) would surface as unexplained. *)
let promoted_feast_slugs = [ "ubaldus"; "didacus" ]
(* C26 (task 8, fix round 2, coordinator review, Important): Passion
week's own real Tuesday Mass (tools/bootstrap_lectionary.ml's own
[passion_tuesday_entry] comment has the full Missal citation and both
scans' line references) -- Dan 14:27, 28-42 / John 7:1-13. lectio's own
ini section for this ONE weekday of Passion week is not Passion
Tuesday's Mass at all: it is Holy Tuesday's (confirmed independently,
footnoted in C23's own predecessor comment before this round acted on
it), and lectio's own COMPUTED reading for the civil date it labels
"Passion Tuesday" is drawn from that same wrong source -- so colitur's
now-correct citation genuinely diverges from lectio's on every single
occurrence this office is actually observed, not a data gap on either
side that could close. The other five Passion-week weekdays were
independently re-verified against the Missal and are correct as
lectio already had them -- this is the ONE exception, not a wider
pattern. Gated on the literal slug and [diffs] staying exactly
[First_f; Gospel_f]. Derived directly from the OCaml comparator's own
failure output, not hand-counted first and cross-checked after: 43,
not 46 -- traced, not merely accepted: in 2013, 2024 and 2042, Passion
Tuesday's own civil date is impeded outright by a competing I-class
feast (St Joseph, 19 March, in 2013 and 2024; the Annunciation, 25
March, in 2042), so colitur's own OBSERVED slug that day is the
saint's, not `ef-passiontide-1-tuesday` at all -- this entry correctly
does not fire there, and those three rows already match independently
(both engines agree on the saint's own citation, unrelated to this
entry). 43 + 3 = 46, the full domain, with no residue. *)
let passion_tuesday_slug = "ef-passiontide-1-tuesday"
(* C27/C28 (task 8, fix round 3, coordinator review, Important): two more
Ember Saturdays shipping a saint's Mass verbatim from lectio's ini --
the exact C26 shape, found by re-review after the fixes above had
already closed the shapes both this file's own reachability check and
the earlier rounds' fixes could catch. Both are multi-lesson Ember
Masses, but neither needed C23/C24's "last lesson before the Gospel"
CONVENTION: both readings are explicitly labelled "Lectio Epistolae" in
the Missal, exactly like Holy Saturday's own Epistle, distinct from the
several numbered prophecies that precede them -- a textual fact, not an
editorial choice (tools/bootstrap_lectionary.ml's own
[ember_saturday_corrections] comment has the full citations and both
scans' line references).
- C27, [ef-advent-ember-sat] ("Sabbato Quatuor Temporum Adventus"):
2 Thess 2:1-8 / Luke 3:1-6. lectio's own ini value (Eph 2:19-22 /
John 20:24-29) is St Thomas the Apostle's Mass (21 December).
- C28, [ef-september-ember-sat] ("Sabbato Quatuor Temporum
septembris"): Heb 9:2-12 / Luke 13:6-17. lectio's own ini value
(Ezek 1:10-14 / Matt 9:9-13) is St Matthew's Mass (21 September, the
calling of Matthew).
Both genuinely observed offices, confirmed against the real resolver,
not assumed. Gated on the literal slug and [diffs] staying exactly
[First_f; Gospel_f] (identity already agrees -- season/slug/rank/colour
were never the problem, only the reading). Derived directly from the
OCaml comparator's own failure output, not hand-counted first and
cross-checked after: C27 40, C28 40 -- not 46, and both traced, not
merely accepted, to the IDENTICAL 6 civil years (2013, 2019, 2024,
2030, 2041, 2047): 21 September and 21 December are always exactly 91
days apart, an exact multiple of 7, so they fall on the SAME weekday
every year -- confirmed directly, not merely arithmetic (`date -d`
agrees for all six). In precisely these 6 years both fall on a
Saturday, so Matthew's and Thomas's own FIXED feast days coincide with
their respective Ember Saturday, and the SAINT wins outright (a real,
correct occurrence result, RG 91's own table -- both engines agree, so
these rows never reach C27/C28 at all). This is also the likely origin
of lectio's own wrong ini values in the first place: bootstrapped from
whichever single calendar year lectio's own generator was run against,
which happened to be one of these coincidence years, generalising a
real but YEAR-SPECIFIC coincidence into a permanent template value. 40
+ 6 = 46, the full domain, no residue. *)
let advent_ember_sat_slug = "ef-advent-ember-sat"
let september_ember_sat_slug = "ef-september-ember-sat"
(* C25 (task 8, fix round 1, coordinator review, Important 3(b)) --
[verdict open], the SAME RG 69 gap C6 above re-opened, one hop removed.
RG 69 (docs/research/scan1.txt:625-631, word for word: "De dominica
infra octavam Nativitatis Domini... semper fit Officium cum
commemoratione festi forte occurrentis... nisi dominica incidat in
festum I classis") is unconditional: a Sunday landing 26-31 December
should keep ITS OWN Office (with the day's feast merely commemorated),
not the fixed weekday placeholder -- but colitur's [Temporal_ef] gives
both the SAME slug, undifferentiated by weekday (C6's own note, and
data/ef/expected-divergences.sexp's own C6 entry, have the full
account). Confirmed, not merely inferred, that THIS entry is the
direct, one-hop-removed consequence of that same gap: every single one
of these rows' colitur-side citation is "Titus 3:4-7" (the fixed
weekday formulary), because [Lectionary_ef.readings]' step 3 resumes
the SAME wrongly-undifferentiated Sunday C6 already names, not a
second, independent cause. A genuine, precisely-diagnosed [Temporal_ef]
defect -- out of this task's own scope (a behaviour change to a
shared, multi-round-reviewed kernel-adjacent function) -- recorded
here per the coordinator's own instruction ("record it prominently...
for a follow-up task"), not fixed and not left silently failing.
CORRECTED, fix round 2 (coordinator review): this entry's own reason
for the literal 4-slug list (Wednesday/Thursday/Friday/Saturday, never
Monday/Tuesday) previously claimed Monday and Tuesday "are always
claimed first by the Circumcision's or Holy Name's own direct
entries" -- FALSIFIED by 2028-01-03/04, real dates that genuinely exist
as their own `ef-christmas-1-{monday,tuesday}` slugs and genuinely
resume via step 3 (they are not "claimed away" from existing at all).
A first replacement of this reasoning, hand-derived from the weekday
arithmetic, was ALSO wrong on a second reading (an off-by-one: 2
January being a Sunday makes 3 January a MONDAY, not a Tuesday) and is
not repeated here -- this project's own discipline is to trust
measurement over a second unverified derivation. What is stated instead
is the DIRECTLY OBSERVED fact, from an exhaustive sweep of every
`ef-christmas-1-monday`/`ef-christmas-1-tuesday` occurrence across
2004-2051 against the real resolver (not merely the slug, the actual
resolved citation): `ef-christmas-1-monday` occurs 21 times in that
range and resolves to Holy Name Sunday's own citation (Gal 4:1-7/Luke
2:33-40) EVERY time; `ef-christmas-1-tuesday` occurs 22 times and
resolves to EITHER Holy Name Sunday's citation OR the Circumcision's
(Titus 2:11-15/Luke 2:21) -- both real, correct, directly-cited
entries, confirmed never the wrong Nativity-Octave-Sunday value C6
above names. Both weekdays fall close enough to 1-2 January that their
own preceding-Sunday walk stays within January; Wednesday/Thursday/
Friday/Saturday are far enough from the nearest Sunday that the SAME
walk can reach past 2 January into December when Holy Name Sunday
does not exist that year, landing on C6's own wrongly-undifferentiated
Sunday instead -- offered as the general shape of why the two groups
differ, not re-asserted as a proven arithmetic rule a third time.
Gated on the literal 4-slug family and [diffs] staying exactly
[First_f; Gospel_f] (identity already agrees -- this is a pure content
consequence).
CORRECTED, fix round 3 (coordinator review): the TOTAL, 57, comes
directly from the OCaml comparator's own failure output (its own
[expected_rows] pin, cross-checked against a full test run). The
PER-WEEKDAY split does not -- the comparator reports per-id totals
only, never a per-weekday breakdown -- so that finer split was
produced by a separate pass over the raw per-slug failure list, not
the comparator itself, and a prior version of this note wrongly
implied otherwise. That prior version also rotated the labels: it
read "19 + 19 + 13 + 6, Wednesday/Thursday/Friday/Saturday
respectively" (the right four numbers, the wrong weekday each was
attached to). Re-measured directly against the real resolver:
Wednesday 6, Thursday 19, Friday 19, Saturday 13 (6 + 19 + 19 + 13 =
57, no residue).
NARROWED, Task 9 (branch ef-lectionary, layer-4 oracle): three of the
four weekdays (Wednesday/Thursday/Friday) now have their OWN direct
lectionary entry (tools/bootstrap_lectionary.ml's own
[epiphanytide_opening_entries], "Diebus ferialibus a 2 ad 5 ianuarii
Missa dicitur ut die 1 ianuarii, cum Gloria et praefatione de
Nativitate, sine Credo et Communicantes proprio" -- scan1.txt:6523-6526,
corroborated scan2.txt:7216 -- CORRECTED, fix round 2: this cited the
rubric as RG 17(a), which it is not. RG 17(a) (LT.txt:843) says only
WHEN the Holy Name is kept ("celebrandum dominica quae occurrit a die 2
ad 5 ianuarii; secus die 2 ianuarii"). The text quoted here is a
MASS-PROPERS rubric, printed under the "Sanctissimi Nominis Iesu"
heading, governing what the ORDINARY FERIAS of 2-5 January say when
they are not the feast at all. Two different rules that happen to share
a date window; only the second one is what these three weekdays read) --
they no
longer reach step 3's one-hop walkback THIS entry's own citation
describes at all, so keeping them here would misdescribe what
actually happens on those three weekdays now. See [C30] below for
their own (DIFFERENT, larger) new population -- colitur is RIGHT
there, not merely differently wrong, so it is a separate id with its
own [verdict colitur], not a re-adjudication of this one. Only
[ef-christmas-1-saturday] remains here: deliberately NOT given a
direct entry (RG 78's BVM Saturday Office wins that day
unconditionally whenever nothing else does, {!Rite_ef.Temporal_ef}'s
own [bvm_saturday_names] -- giving it the "repeat 1 January" citation
would be wrong on exactly the days this entry's own citation already
describes as correct for the OTHER four weekdays), so this ONE
weekday's own step-3 walkback -- and this entry's own citation,
"Tit. 3, 4-7" -- is genuinely unchanged by this task. *)
let rg69_one_hop_slugs = [ "ef-christmas-1-saturday" ]
(* [layer_c_reason l c diffs] returns the [data/ef/expected-divergences.sexp]
[id] this row-pair's remaining (post Layer A/B) diff set belongs to, or
[None] if nothing here explains it (a genuine, uncovered failure). *)
let layer_c_reason (l : row) (c : row) diffs =
let m = month_of_date l.date and d = day_of_date l.date in
if diffs = [] then None
else if
m = 1 && d >= 6 && d <= 13
&& subset diffs [ Season; Colour_f; Slug_f ]
&& (not (List.mem Slug_f diffs) || jan_6_13_slug c.slug)
then Some "C1"
else if
m = 12
&& (d = 29 || d = 30 || d = 31)
(* NARROWED, fixture-refresh review: this was [subset diffs [ Slug_f; Rank ]].
Since lectio's own Christmas-octave rank was corrected (3b32c00) all 138
rows diff on [Slug_f] ALONE, and the note below says so -- but leaving
[Rank] in the accepted set did not merely over-permit, it made the entry
BLIND: every row already carries a Slug_f diff, so an added Rank diff
changed neither the diff-set membership nor the count. Demonstrated by
the reviewer: dropping the RG 91 entry 17 elevation in temporal_ef
(Class2 -> Class4 on octave days 5-7) left BOTH differential tests green
with C6 still reporting 138. The slug guard below already pins identity;
[Rank] only removed the ability to notice a rank regression.
WIDENED, task 8 fix round 1 (coordinator review, Important 3(a)/(b)):
[First_f; Gospel_f] joined the accepted set. Important 3(a)'s own fix
(tools/bootstrap_lectionary.ml's [nativity_octave_entries]) gives
these dates a real, Missal-verified citation for the first time --
and it does NOT converge with lectio's own (differently incomplete)
Sunday-walkback answer, so ALL 138 of this entry's rows now carry a
citation diff too, not merely the slug one. See data/ef/expected-
divergences.sexp's own C6 entry for why this entry's [verdict] is
now [open], not [colitur] -- Important 3(b)'s own RG 69 finding. *)
&& subset diffs [ Slug_f; First_f; Gospel_f ]
&& List.mem c.slug nativity_octave_day_slugs
then Some "C6"
else if
(String.equal c.slug "ef-rogation-monday" || String.equal c.slug "ef-rogation-tuesday")
&& subset diffs [ Season; Slug_f; Colour_f ]
then Some "C8"
(* [Colour_f] is admitted only on the Saturday of this week, and only
alongside the citation difference: that day is ALSO an unoccupied
IV-class Saturday, so RG 78's votive Office of the BVM makes colitur
white where lectio (which builds no such office) is green -- C17's own
shape, landing on the same day as this one. [First_f] is REQUIRED, so a
colour-only Saturday still falls to C17 and cannot be absorbed here. *)
else if is_bvm_saturday_row c diffs then Some "C17"
else if diffs = [ Colour_f ] && List.mem c.slug audit_colour_corrected_slugs then Some "C18"
(* C25 -- CLOSED, BVM Saturday Mass (2026-08-17), predicate removed: 0 rows.
Its days are unoccupied IV-class Saturdays, so they carry RG 78's office
and now RG 309(a)'s Mass for it, which answers before step 3's walkback
is reached. They still differ from lectio, but for C17's cause, and are
filed there. See data/ef/expected-divergences.sexp's own closure note. *)
else None
(* ---------------------------------------------------------------------- *)
(* data/ef/expected-divergences.sexp loading -- a plain sequence of *)
(* top-level records (not one wrapping list: see the file's own header). *)
(* ---------------------------------------------------------------------- *)
open Sexplib0.Sexp_conv
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
(* ---------------------------------------------------------------------- *)
(* The comparison itself, run once and shared by every test case below *)
(* (Alcotest test cases are independent processes-in-a-list, not free to *)
(* share mutable state across a suite, so this recomputes per call -- *)
(* acceptable: colitur's own 1583-9999 property sweep runs orders of *)
(* magnitude more days in tens of seconds, and this is 16801 civil days, *)
(* once per test case, twice total). *)
(* ---------------------------------------------------------------------- *)
type outcome = Matched | Explained of string | Unexplained of field list
let compare_streams () =
let lectio = lectio_rows () in
let colitur = colitur_rows_2005_2050 () in
(lectio, colitur)
let classify lectio colitur =
List.map2
(fun (l : row) (c : row) ->
if not (String.equal l.date c.date) then
Alcotest.failf "streams misaligned: lectio %s vs colitur %s" l.date c.date;
let diffs = diff_fields l c in
if diffs = [] then (l, c, Matched)
else
match layer_c_reason l c diffs with
| Some id -> (l, c, Explained id)
| None -> (l, c, Unexplained diffs))
lectio colitur
(* Task 8: [first]/[gospel] joined the row dump on both sides -- naming the
date, the slug and the PART (via [diffs]' own field name, "first" or
"gospel") and each side's reference is exactly what the task brief asks
a citation mismatch to report, never a bare count. *)
let describe_unexplained (l : row) (c : row) diffs =
Printf.sprintf
"%s: %s differ -- lectio=(%s %s %s %s %s first=%s gospel=%s) colitur=(%s %s %s %s %s first=%s gospel=%s)"
l.date
(String.concat "," (List.map field_name diffs))
l.weekday l.season l.slug l.rank l.colour l.first l.gospel c.weekday c.season c.slug c.rank c.colour
c.first c.gospel
(* Fix round 1, finding 4: the fixture's own byte content must still match
the SHA-256 its provenance note claims (lectio commit 2386a45, recorded
both here and in test/fixtures/lectio-ef-2005-2050.provenance). Checked
before anything else reads the fixture -- a drifted fixture makes every
other assertion in this suite a statement about an unlabelled snapshot,
not about the pinned lectio commit it claims to be. *)
let test_fixture_checksum () =
Alcotest.(check string) "fixture SHA-256 matches its provenance note" fixture_sha256
(sha256_of_file fixture_path)
(* Dates align 1:1 in the same order on both streams (both are one line per
civil day, 2005-01-01..2050-12-31 -- see the fixture's own provenance
note and [colitur_rows_2005_2050]'s construction). A silent misalignment
would make every subsequent comparison meaningless -- checked first, on
its own, rather than trusted. *)
let test_dates_align () =
let lectio, colitur = compare_streams () in
Alcotest.(check int) "both streams have 16801 rows (46*365 + 11 leap days)" 16801 (List.length lectio);
Alcotest.(check int) "colitur recomputed the same number of rows" (List.length lectio) (List.length colitur);
let mismatched =
List.filter_map
(fun (l, c) -> if String.equal l.date c.date then None else Some (l.date, c.date))
(List.combine lectio colitur)
in
Alcotest.(check (list (pair string string))) "no misaligned dates" [] mismatched
(* The core assertion: every one of the 4975 raw differences is either
normalised away (Layers A/B) or named in the cited allow-list (Layer C).
Nothing else is permitted to pass silently. *)
let test_no_unexplained_differences () =
let lectio, colitur = compare_streams () in
let classified = classify lectio colitur in
let unexplained =
List.filter_map
(fun (l, c, outcome) ->
match outcome with Unexplained diffs -> Some (describe_unexplained l c diffs) | _ -> None)
classified
in
Alcotest.(check (list string)) "no differences outside Layers A/B/C" [] unexplained
(* Teeth, not just green: EVERY Layer C entry's actual row count over this
fixture must equal what data/ef/expected-divergences.sexp declares, in
BOTH directions -- an id used by [layer_c_reason] that is missing from
the sexp file, an id declared but never matched, or a count that has
drifted either up or down, all fail loudly. A silent drift here is
exactly the "allow-list absorbs a new bug" failure mode this task was
warned about. *)
let test_layer_c_counts_match_citations () =
let lectio, colitur = compare_streams () in
let classified = classify lectio colitur in
let actual_counts = Hashtbl.create 16 in
List.iter
(fun (_, _, outcome) ->
match outcome with
| Explained id ->
Hashtbl.replace actual_counts id (1 + Option.value ~default:0 (Hashtbl.find_opt actual_counts id))
| _ -> ())
classified;
let declared = load_allow_list () in
let expected =
List.sort compare (List.map (fun e -> (e.id, e.expected_rows)) declared)
in
let actual =
List.sort compare
(Hashtbl.fold (fun id n acc -> (id, n) :: acc) actual_counts [])
in
Alcotest.(check (list (pair string int)))
"every allow-list id's actual row count matches its citation's expected_rows, and no id is unused or \
undeclared"
expected actual
let suite =
( "differential (lectio, EF, 2005-2050)",
[ Alcotest.test_case "fixture SHA-256 matches its provenance note" `Quick test_fixture_checksum;
Alcotest.test_case "streams are 16801 rows each, dates aligned 1:1" `Quick test_dates_align;
Alcotest.test_case "every difference is normalised (A/B) or cited (C) -- none unexplained" `Quick
test_no_unexplained_differences;
Alcotest.test_case "Layer C counts match data/ef/expected-divergences.sexp exactly" `Quick
test_layer_c_counts_match_citations
] )
|