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
|
(* Bootstraps data/of/lectionary.sexp from lectio's of-lectionary.ini
(Task 4, 2026-08-25-colitur-of-phases-3-5). Lives in tools/, OCaml, not
Python -- deliberately: this task's own brief named
tools/extract_of_lectionary.py, but tools/bootstrap_lectionary.ml
already IS the established shape for exactly this job (EF's own
lectionary bootstrap), including a real, load-bearing safety net --
[assert_reachable] below sweeps Rite_of.Temporal_of.temporal AND the
real merged sanctoral layer directly, so a mapping-table typo cannot
ship a dead key silently. Reimplementing that check in Python would mean
duplicating colitur's own date arithmetic in a second language, exactly
the drift risk this generator exists to avoid. Named to match the
OF-specific sibling convention (temporal_of.ml, precedence_of.ml), not
bootstrap_lectionary.ml's own bare name, since a second rite needs a
second, distinguishable generator.
THE LINEAGE, stated once, here, because every downstream comment and the
emitted file's own header both lean on it: lectio's of-lectionary.ini
says of itself (its own top-of-file comment) that it is "Generated from
niedziela.pl by scripts/genlect-of.go (harvest 2020-2025)". niedziela.pl
is a POLISH VERNACULAR pastoral lectionary aid, not the Latin OLM
(Ordo Lectionum Missae) 1981 itself, and its own citations are, per the
design spec (2026-08-24-colitur-of-rite-module-design.md sec4.4),
ENGLISH-CANONICAL (English book names/verse numbering), not the OLM's
Vulgate-numbered Latin. THIS IS A SEPARATE LINEAGE from lectio's OF
CALENDAR (data/of/calendar-2002.sexp, roman-calendar.ini, upstream
calapi.inadiutorium.cz) -- lectio is not one OF witness but two,
unrelated upstreams glued together by one downstream project, and
neither is the typical edition. What this data CANNOT show: any
divergence between niedziela.pl's own pastoral choices and the Latin
OLM's own text (verse-range short/long forms, alternative readings,
Vulgate-vs-Nova-Vulgata numbering -- the design spec's own sec4.4 records
three real, confirmed such divergences against the actual OLM page
images: Holy Family Year A's second-reading short form, Trinity Sunday's
Dan 3:56, and the Baptism of the Lord's Mc 9:6 vs "Mark 9:7"); nor can it
show anything about the OLM's SHORT-FORM/LONG-FORM alternatives, which
niedziela.pl does not distinguish. This generator does not attempt to
correct any of that -- it transcribes the vernacular data faithfully,
states the lineage loudly, and lets a future task with the actual OLM
page images (docs/research/of/olm-1981.pdf) do primary-source
verification the way tools/bootstrap_lectionary.ml's own Holy Week
entries did for EF. *)
open Colitur_kernel
let default_source = "../lectio/internal/caldata/of-lectionary.ini"
let default_dest = "data/of/lectionary.sexp"
let die fmt =
Printf.ksprintf
(fun s -> prerr_endline ("bootstrap_lectionary_of: " ^ s); exit 1)
fmt
(* ---- INI parsing --------------------------------------------------- *)
type section = { name : string; fields : (string * string) list }
(* Fails loudly, not silently, when the source file is absent -- Task 1's
own review found exactly this bug in the calendar extractor
(parse_lectio_ini swallowing FileNotFoundError, producing a DIFFERENT
file with a different SHA-256 and no warning): checked BEFORE any read
is attempted, and the [open_in] itself is also guarded as defence in
depth, mirroring tools/bootstrap_lectionary.ml's own [parse_ini]. *)
let parse_ini path =
if not (Sys.file_exists path) then
die "%s: no such file (a missing lectio snapshot must fail loudly, not \
silently regenerate with a different SHA-256 and no warning -- Task 1's \
own review round found exactly this bug)"
path;
let ic = try open_in path with Sys_error e -> die "%s" e in
let sections = ref [] and cur = ref None and malformed = ref [] and lineno = ref 0 in
let flush () =
match !cur with
| Some (n, fs) -> sections := { name = n; fields = List.rev fs } :: !sections
| None -> ()
in
(try
while true do
incr lineno;
let raw = input_line ic in
let line = String.trim raw in
if line = "" || line.[0] = ';' || line.[0] = '#' then ()
else if line.[0] = '[' then begin
flush ();
cur := Some (String.sub line 1 (String.length line - 2), [])
end
else
let known_key k = List.mem k [ "first"; "psalm"; "second"; "gospel" ] in
match String.index_opt line '=' with
| Some i when known_key (String.trim (String.sub line 0 i)) ->
let k = String.trim (String.sub line 0 i) in
let v = String.trim (String.sub line (i + 1) (String.length line - i - 1)) in
cur :=
(match !cur with
| Some (n, fs) -> Some (n, (k, v) :: fs)
| None -> die "%s:%d: field %S before any section" path !lineno k)
| Some _ | None ->
(* Known, single, understood anomaly (see the assertion below):
line 1276 of the shipped snapshot, inside [holy-family-A],
reads bare "=Sir3:2-6,12-14-Gk" -- an EMPTY key, not "no
'=' sign" (an earlier version of this parser tested for the
latter and missed this line entirely, since [String.
index_opt] DOES find the '=' at position 0; a key must now
be one of the four known field names, checked explicitly).
This is the Polish lectionary's own Septuagint/Greek-
numbering variant for Sirach 3 (design spec sec5's own
"Polish is independent on one point": Syr 3,2-6.12-14
against the OLM/CEI/USA table's shared Nova Vulgata
3,3-7.14-17a), evidently emitted by niedziela.pl's own
scraper as a stray annotation rather than a proper
"first_alt = ..." field. Recorded, not silently dropped:
every such line is collected and the count is asserted
below, so a NEW malformed line in a future lectio snapshot
still fails loudly rather than being quietly absorbed by
this same tolerance. *)
malformed := (!lineno, line) :: !malformed
done
with End_of_file -> ());
flush ();
close_in ic;
(match List.rev !malformed with
| [ (1276, "=Sir3:2-6,12-14-Gk") ] -> ()
| [] -> die "%s: expected exactly one known malformed line (1276, the Sirach \
Septuagint-numbering annotation) but found none -- either the \
snapshot changed or this tolerance is now dead code; re-check \
by hand before touching this assertion" path
| other ->
List.iter (fun (n, l) -> Printf.eprintf " line %d: %S\n" n l) other;
die "%s: %d unparseable line(s) found, not the one previously-known \
anomaly (see above) -- investigate before shipping; a new \
malformed line must be understood, not silently swallowed"
path (List.length other));
List.rev !sections
(* ---- Citation extraction -- First + Gospel only --------------------- *)
(* Psalm and Second are deliberately dropped, even though lectio's ini
carries [psalm] (every entry) and [second] (Sundays/solemnities):
Colitur_kernel.Validate's own ["citations"] check (kernel, off limits to
this task) hard-asserts every day's citation-part list is EXACTLY
[[First; Gospel]] -- the identical scope boundary
tools/bootstrap_lectionary.ml's own [convert] already holds for EF
("Nothing here encodes... the chants (Psalm, Second, Tract, Alleluia,
Sequence) are deliberately unbuilt"). Carrying Psalm/Second through here
would not add capability today (nothing reads them) and would set up a
guaranteed collision with that kernel check the moment a future task
wires Validate over OF data. *)
let cite sec =
let get k = match List.assoc_opt k sec.fields with None | Some "" -> None | Some v -> Some v in
match (get "first", get "gospel") with
| Some first, Some gospel ->
Some [ { Citation.part = Citation.First; reference = first };
{ Citation.part = Citation.Gospel; reference = gospel } ]
| _ -> None
(* ---- Cycle-family grouping ------------------------------------------ *)
(* Every one of the 988 sections carries a cycle suffix: -A/-B/-C (Sunday
cycle, 102 bases x 3 = 306) or -I/-II (weekday cycle, 341 bases x up to
2 = up to 682; two bases are short one cycle -- see
[collapse_weekday] below). Measured exhaustively before writing this
generator: no section carries any OTHER suffix, and no base name
appears in both groups. *)
let strip_suffix ~suffix s =
let ls = String.length s and lsuf = String.length suffix in
if ls > lsuf && String.sub s (ls - lsuf) lsuf = suffix then
Some (String.sub s 0 (ls - lsuf))
else None
let base_and_letter name =
match strip_suffix ~suffix:"-A" name with
| Some b -> Some (b, `A)
| None -> (
match strip_suffix ~suffix:"-B" name with
| Some b -> Some (b, `B)
| None -> (
match strip_suffix ~suffix:"-C" name with
| Some b -> Some (b, `C)
| None -> (
match strip_suffix ~suffix:"-II" name with
| Some b -> Some (b, `II)
| None -> (
match strip_suffix ~suffix:"-I" name with
| Some b -> Some (b, `I)
| None -> None))))
type resolved =
| Flat of Citation.t list (* cycle-independent: one citation set for every year *)
| Sunday_cycle of (Citation.t list * Citation.t list * Citation.t list) (* A, B, C *)
| Weekday_cycle of (Citation.t list option * Citation.t list option) (* I, II -- either may be absent *)
(* Sunday-cycle collapse: mechanical, not curated per-saint. Measured
across all 102 bases before this generator existed: 43 are byte-
identical across A/B/C (routine -- a fixed-date saint's own Mass simply
does not vary by year, e.g. andrew-the-apostle); 57 are genuinely
distinct on all three letters (real OLM n.66 three-year-cycle content --
every numbered Sunday, plus the handful of movable/fixed solemnities of
the Lord whose OWN Gospel varies by year: Holy Family, Baptism of the
Lord, Ascension, Corpus Christi, Christ the King, Trinity, Sacred Heart,
Transfiguration); and EXACTLY 2 have a clean 2-of-3 majority with the
third an unrelated passage from a DIFFERENT day entirely --
annunciation-of-the-lord (B is Monday of Holy Week's own Mass,
Isa 42:1-7/John 12:1-11 -- a scraping-year artifact: the Annunciation
was genuinely impeded and transferred that harvest year, so
niedziela.pl's site had Holy Week's Mass on 25 March instead) and
immaculate-conception-of-the-blessed-virgin-mary (A is Isa 35:1-10/
Luke 5:17-26, an ordinary Lenten-feria Gospel, the same shape). Both
solemnities are, in real practice, NOT cycle-dependent at all (single
Mass every year) -- corroborated externally by the "2 of 3 agree, none
of the 57 genuine cases exhibit that shape" measurement itself, not
merely asserted -- so majority-collapse is applied, and the discarded
outlier is logged. No other base in the whole 102-base population has
this 2-of-3 shape; the rule is general, not hand-targeted at these two
names. *)
let collapse_sunday base va vb vc =
if va = vb && vb = vc then Flat va
else if va = vb then begin
Printf.eprintf
"bootstrap_lectionary_of: %s: cycle C disagreed with A/B (scraping-year \
artifact, majority kept): %s\n"
base (match vc with [ f; _ ] -> f.Citation.reference | _ -> "?");
Flat va
end
else if va = vc then begin
Printf.eprintf
"bootstrap_lectionary_of: %s: cycle B disagreed with A/C (scraping-year \
artifact, majority kept): %s\n"
base (match vb with [ f; _ ] -> f.Citation.reference | _ -> "?");
Flat va
end
else if vb = vc then begin
Printf.eprintf
"bootstrap_lectionary_of: %s: cycle A disagreed with B/C (scraping-year \
artifact, majority kept): %s\n"
base (match va with [ f; _ ] -> f.Citation.reference | _ -> "?");
Flat vb
end
else Sunday_cycle (va, vb, vc)
(* Weekday-cycle collapse: same mechanical byte-identity test. OLM 1981
Praenotanda n.69 points 2-3 (page-image verified,
docs/research/of/olm-1981.pdf p.33/"XXXIII") state that Lent has its own
fixed seasonal cycle and Advent/Christmastide/Paschaltide ferias "do not
change" year to year -- exactly the byte-identical shape measured for
those families (advent-1/2/3, advent-dec-17..24, lent-after-ashes-*,
holy-week-*, easter-octave-*: 119 of 341 bases). Point 4 confines the
REAL two-year alternation to Ordinary Time's 34 weeks, and even there
only the FIRST reading alternates -- the Gospel is one single,
year-independent cycle (n.69 point 4: "lectiones evangelicae unico
disponuntur cyclo... Prior vero lectio, in duplici cyclo ordinatur") --
which is exactly why 198 of the 221 non-identical bases still share a
common Gospel with only the First Reading differing: real content, kept
as two entries because the FULL pair genuinely differs, not a scraping
artifact (unlike the two Sunday-cycle cases above, no base in this group
showed a "majority of one, unrelated outlier" shape). *)
let collapse_weekday base v1 v2 =
match (v1, v2) with
| Some a, Some b when a = b -> Flat a
| Some _, Some _ -> Weekday_cycle (v1, v2)
| Some _, None | None, Some _ ->
Printf.eprintf
"bootstrap_lectionary_of: %s: only one weekday-cycle year present in \
lectio's own snapshot (a real gap in niedziela.pl's 2020-2025 harvest, \
not a parsing defect) -- transcribed as-is\n"
base;
Weekday_cycle (v1, v2)
| None, None -> die "%s: cycle collapse called with no data" base
let resolve_sections secs =
let tbl = Hashtbl.create 1024 in
List.iter
(fun sec ->
match base_and_letter sec.name with
| None -> die "%s: no recognised cycle suffix (-A/-B/-C/-I/-II)" sec.name
| Some (base, letter) -> (
match cite sec with
| None -> die "%s: no first/gospel field" sec.name
| Some cs ->
let cur = try Hashtbl.find tbl base with Not_found -> (None, None, None, None, None) in
let a, b, c, i, ii = cur in
let updated =
match letter with
| `A -> (Some cs, b, c, i, ii)
| `B -> (a, Some cs, c, i, ii)
| `C -> (a, b, Some cs, i, ii)
| `I -> (a, b, c, Some cs, ii)
| `II -> (a, b, c, i, Some cs)
in
Hashtbl.replace tbl base updated))
secs;
Hashtbl.fold
(fun base (a, b, c, i, ii) acc ->
match (a, b, c, i, ii) with
| Some va, Some vb, Some vc, None, None -> (base, collapse_sunday base va vb vc) :: acc
| None, None, None, i, ii when i <> None || ii <> None ->
(base, collapse_weekday base i ii) :: acc
| Some va, Some vb, Some vc, i, ii when i <> None || ii <> None ->
(* A real, measured duplicate in lectio's own source, confined
(checked below by this generator's own exhaustive Hashtbl.fold)
to the six Easter Octave weekdays: each carries BOTH an
-A/-B/-C section AND an -I/-II one, evidently written by two
different passes of niedziela.pl's own generation pipeline.
Content-verified identical in substance (Acts 2:14,22-32/33,
Ps 16, Matt 28:8-15 -- "Acts"/"The Acts" and "Mat"/"Matthew"
spelling differ, the citation does not), and each of the two
representations is ALSO internally self-consistent
(A=B=C, I=II) on its own -- so this is redundancy, not a real
conflict. The -A/-B/-C reading is kept (it is what this
generator's own [named_overrides] table for these six bases
already targets); the -I/-II reading is discarded, logged
here rather than silently dropped. *)
Printf.eprintf
"bootstrap_lectionary_of: %s: BOTH Sunday- and weekday-cycle data present \
(a genuine lectio duplicate, not a defect -- see the comment on this branch); \
keeping the Sunday-cycle (A/B/C) reading, discarding the weekday-cycle (I/II) one\n"
base;
(base, collapse_sunday base va vb vc) :: acc
| _ ->
die "%s: mixed or incomplete cycle data (a=%b b=%b c=%b i=%b ii=%b)" base (a <> None)
(b <> None) (c <> None) (i <> None) (ii <> None))
tbl []
(* ---- colitur slug mapping -------------------------------------------- *)
let weekday_pairs =
(* (colitur full word, lectio abbreviation) *)
[ ("monday", "mon"); ("tuesday", "tue"); ("wednesday", "wed"); ("thursday", "thu");
("friday", "fri"); ("saturday", "sat") ]
let split_dash s = String.split_on_char '-' s
(* (colitur season word, lectio season word, max week number) -- ferial
families. Min week is always 1. Week 6 of Lent (Holy Week) and week 1 of
Easter (the Octave) are deliberately excluded from this generic table --
both have their own named families, handled in [special_overrides]. *)
let ferial_families =
[ ("advent", "advent", 4); ("lent", "lent", 5); ("easter", "easter", 7);
("ordinary-time", "ordinary", 34) ]
(* Easter's own numbered Sunday family runs 3..7 in lectio (2 is the
separately-named "easter-octave-sun", see [named_overrides]) but the
upper bound here is deliberately still a loose "any n >= 1" check (see
[pattern_match]'s own guard) -- lectio simply never emits an
"easter-sunday-1"/"-2" base for this family to collide with. *)
let sunday_families =
[ ("advent", "advent", 4); ("lent", "lent", 5); ("easter", "easter", 7);
("ordinary-time", "ordinary", 33) ]
(* A base that pattern-matches a numbered ferial or Sunday family. Returns
the colitur slug directly. *)
let pattern_match base =
match split_dash base with
| [ w; "sunday"; n ] -> (
match (List.find_opt (fun (_, lw, _) -> lw = w) sunday_families, int_of_string_opt n) with
| Some (cw, _, max_n), Some ni when ni >= 1 && ni <= max_n ->
Some (Printf.sprintf "of-%s-sunday-%d" cw ni)
| _ -> None)
| [ w; n; wd ] -> (
match
( List.find_opt (fun (_, lw, _) -> lw = w) ferial_families,
int_of_string_opt n,
List.find_opt (fun (_, la) -> la = wd) weekday_pairs )
with
| Some (cw, _, max_n), Some ni, Some (full_wd, _) when ni >= 1 && ni <= max_n ->
Some (Printf.sprintf "of-%s-%d-%s" cw ni full_wd)
| _ -> None)
| _ -> None
(* Named-day and irregular-family renames -- everything [pattern_match]
above cannot reach because colitur's own slug uses a DIFFERENT word, a
DIFFERENT numbering scheme, or (the Christmas-season families) the SAME
weekday-keyed scheme lectio happens to expose under a differently-named
family. Each entry cites the content verification that justified it,
not merely the name resemblance -- three of these (christmas,
christmas-sunday-sun, easter-octave-sun) would have been WRONG if
matched by name alone; see the comment on each. *)
let named_overrides =
[ (* Content-verified against the real reading (Isa 62:1-5/Matt 1:18-25,
the genealogy/annunciation-to-Joseph narrative): this is the VIGIL
Mass, not "Christmas Day" despite the bare name -- the real Christmas
DAY Mass (Isa 52:7-10/John 1:1-14) is ABSENT from lectio's own 988
keys entirely, a genuine, notable gap (see the generator's own
coverage report). *)
("christmas", [ "of-nativity-vigil" ]);
(* Content-verified (Sirach 24 Wisdom-personified / Eph 1 / John 1:1-18):
this is the SECOND SUNDAY AFTER THE NATIVITY (Normae n.36), not a
generic "the Sunday of Christmas" despite the bare name -- Holy
Family (a DIFFERENT Sunday, Normae n.35(a)) has its own "holy-family"
base, mapped just below. *)
("christmas-sunday-sun", [ "of-christmas-sunday-2" ]);
("mary-mother-of-god-octave-of-christmas", [ "of-mary-mother-of-god" ]);
(* Content-verified (Joel 2:12-18/Matt 6:1-6,16-18, THE Ash Wednesday
Mass): lectio groups Ash Wednesday itself into this weekday-cycle
family rather than giving it a bare "ash-wednesday" key. Its own
three siblings (Thursday/Friday/Saturday right after Ash
Wednesday) are plain ferias, matched by the SAME family, needing
only the word rename ("lent-after-ashes" -> "of-lent-after-ashes")
[pattern_match] cannot supply on its own (it is not a numbered
week). *)
("lent-after-ashes-wed", [ "of-ash-wednesday" ]);
("lent-after-ashes-thu", [ "of-lent-after-ashes-thursday" ]);
("lent-after-ashes-fri", [ "of-lent-after-ashes-friday" ]);
("lent-after-ashes-sat", [ "of-lent-after-ashes-saturday" ]);
("trinity-sunday", [ "of-trinity" ]);
(* Content-verified (John 20:19-31, the Divine Mercy Gospel): the
Sunday CLOSING the Easter Octave, colitur's own generic
week-2-of-Easter Sunday slug, not a second distinct office. *)
("easter-octave-sun", [ "of-easter-sunday-2" ]);
(* The remaining single- or compound-word NAMED temporal days
(Temporal_of.named/[temporal]'s own Sunday branches): colitur's own
slug is simply "of-" prepended to lectio's own bare name, verified
against temporal_of.ml directly rather than assumed -- these are
NOT reachable by pass-through (pass-through checks the SANCTORAL
layer only, and none of these is sanctoral data). *)
("epiphany", [ "of-epiphany" ]);
("palm-sunday", [ "of-palm-sunday" ]);
("easter-sunday", [ "of-easter-sunday" ]);
("ascension", [ "of-ascension" ]);
("pentecost", [ "of-pentecost" ]);
("baptism-of-the-lord", [ "of-baptism-of-the-lord" ]);
("christ-the-king", [ "of-christ-the-king" ]);
("corpus-christi", [ "of-corpus-christi" ]);
("holy-family", [ "of-holy-family" ]);
(* Sacred Heart is SANCTORAL data (calendar-2002.sexp, Easter_offset 68
-- F-HEARTS finding, this plan's Task 1), not a Temporal_of slug, and
its colitur slug carries "-of-jesus" that lectio's bare "sacred-heart"
does not. *)
("sacred-heart", [ "sacred-heart-of-jesus" ]);
(* Holy Week Monday-Wednesday and the Triduum: colitur's own
Temporal_of deliberately does NOT name these days (Lent's own
generic week-6 ferial slug covers them, see temporal_of.ml's own
[season] function -- Lent runs "through Holy Saturday inclusive").
lectio names them by a WEEKDAY family lectio itself calls
"holy-week"/"triduum" rather than "lent-6". *)
("holy-week-mon", [ "of-lent-6-monday" ]);
("holy-week-tue", [ "of-lent-6-tuesday" ]);
("holy-week-wed", [ "of-lent-6-wednesday" ]);
("triduum-thu", [ "of-lent-6-thursday" ]);
("triduum-fri", [ "of-lent-6-friday" ]);
("triduum-sat", [ "of-lent-6-saturday" ]);
(* Easter Octave weekdays: colitur names these directly
(of-easter-octave-day-N, Monday=2..Saturday=7 -- temporal_of.ml's
own [named], "Easter Sunday (above) plus these six"). *)
("easter-octave-mon", [ "of-easter-octave-day-2" ]);
("easter-octave-tue", [ "of-easter-octave-day-3" ]);
("easter-octave-wed", [ "of-easter-octave-day-4" ]);
("easter-octave-thu", [ "of-easter-octave-day-5" ]);
("easter-octave-fri", [ "of-easter-octave-day-6" ]);
("easter-octave-sat", [ "of-easter-octave-day-7" ]);
(* The three Christmas-season ferial stretches
(Temporal_of.christmas_feria_slug's own three "of-christmas-{0,1,2}-
<weekday>" stretches, 26-31 Dec / 2-5 Jan / 7 Jan..pre-Baptism) are
WEEKDAY-keyed on colitur's side. lectio exposes THREE separate
weekday-keyed families that line up with them one-for-one, under
names that do not textually resemble colitur's own -- matched here
by CONTENT/POSITION, not by name:
- "christmas-octave-<wd>" (1 John/John 1, the Octave itself, 26-31
Dec) -> stretch 0.
- "christmas-<wd>" bare, no "-octave-"/"-dec-"/"-jan-" (1 John
2-3/John 1:29-51, the semi-continuous reading BETWEEN the Octave
and Epiphany, 2-5 Jan) -> stretch 1.
- "christmas-after-epiphany-<wd>" (7 Jan..Baptism) -> stretch 2.
lectio ALSO carries "christmas-dec-29/30/31" and "christmas-jan-
2..7", a DATE-keyed duplicate of the same stretches -- deliberately
UNUSED (see [excluded_bases] below): colitur's own slugs are
weekday-keyed, not date-keyed, so a fixed civil date such as
"29 December" is not any single colitur slug -- it is a DIFFERENT
weekday (hence a different of-christmas-0-<weekday>) in different
years, and Lectionary.t has no per-date lookup at all. *)
("christmas-octave-mon", [ "of-christmas-0-monday" ]);
("christmas-octave-tue", [ "of-christmas-0-tuesday" ]);
("christmas-octave-wed", [ "of-christmas-0-wednesday" ]);
("christmas-octave-thu", [ "of-christmas-0-thursday" ]);
("christmas-octave-fri", [ "of-christmas-0-friday" ]);
("christmas-octave-sat", [ "of-christmas-0-saturday" ]);
("christmas-mon", [ "of-christmas-1-monday" ]);
("christmas-tue", [ "of-christmas-1-tuesday" ]);
("christmas-wed", [ "of-christmas-1-wednesday" ]);
("christmas-thu", [ "of-christmas-1-thursday" ]);
("christmas-fri", [ "of-christmas-1-friday" ]);
("christmas-sat", [ "of-christmas-1-saturday" ]);
("christmas-after-epiphany-mon", [ "of-christmas-2-monday" ]);
("christmas-after-epiphany-tue", [ "of-christmas-2-tuesday" ]);
("christmas-after-epiphany-wed", [ "of-christmas-2-wednesday" ]);
("christmas-after-epiphany-thu", [ "of-christmas-2-thursday" ]);
("christmas-after-epiphany-fri", [ "of-christmas-2-friday" ]);
("christmas-after-epiphany-sat", [ "of-christmas-2-saturday" ])
]
(* Deliberately unmapped, with the reason named -- see [named_overrides]'s
own comment on the Christmas-season families for the date-vs-weekday
mismatch these two groups share:
- "advent-dec-17".."advent-dec-24": the O-Antiphon days. colitur's own
Temporal_of has no per-date slug for late Advent at all (Temporal_ef's
EF counterpart doesn't either); these ferias fall through to the
ordinary week-numbered "of-advent-{3,4}-<weekday>" slug, and WHICH
weekday-string a given December date is varies year to year, so no
static base-name mapping is possible. A future task wanting these
eight dates covered needs a genuinely date-keyed lookup path in
Lectionary_of.readings, not a slug rename here.
- "christmas-dec-29/30/31", "christmas-jan-2/3/4/5/6/7": the date-keyed
duplicates of the weekday-keyed families already mapped above. Same
reasoning, same remedy if ever closed.
- "easter-6-thu": Thursday of Easter week 6 is STRUCTURALLY, always,
the Ascension (Easter+39 is always a Thursday) -- Temporal_of's own
[named] intercepts it before the generic ferial branch ever runs, so
"of-easter-6-thursday" is not a slug Temporal_of can ever produce.
- "advent-4-sat": the Saturday of Advent week 4 is STRUCTURALLY, always,
either 24 December (intercepted by [named]'s own Nativity Vigil
branch on every non-Sunday year) or does not exist as a ferial at all
(when 24 December is itself the Fourth Sunday of Advent, week 4 has
no ferial days whatsoever) -- "of-advent-4-saturday" is not a slug
Temporal_of can ever produce either.
Both confirmed, not assumed: [assert_reachable]'s own reachability
sweep flagged each as a genuine dead key this generator's own pattern
rule produced (after fixing the suffix-stripping bug that function's
own comment describes, which had masked them under 580 false
positives on this generator's first run). *)
let excluded_bases =
[ "advent-dec-17"; "advent-dec-18"; "advent-dec-19"; "advent-dec-20"; "advent-dec-21";
"advent-dec-22"; "advent-dec-23"; "advent-dec-24";
"christmas-dec-29"; "christmas-dec-30"; "christmas-dec-31";
"christmas-jan-2"; "christmas-jan-3"; "christmas-jan-4"; "christmas-jan-5"; "christmas-jan-6";
"christmas-jan-7"; "easter-6-thu"; "advent-4-sat" ]
type mapping_report = { mapped : int; excluded : int; unmapped : string list }
(* Resolves every (base, resolved-citations) pair into zero or more
(colitur-slug, resolved-citations) pairs, and separately tracks what
could not be resolved -- the second half of Step 1's own "measure the
gap in both directions" requirement. A sanctoral PASS-THROUGH (lectio's
own base name used verbatim as colitur's slug) is verified against the
real merged sanctoral layer at generation time, not assumed: an
unverified guess would be exactly the silent-drop failure mode this
whole task exists to avoid. *)
let map_bases resolved ~sanctoral_slugs =
let unmapped = ref [] in
let excluded = ref 0 in
let out =
List.filter_map
(fun (base, r) ->
if List.mem base excluded_bases then begin
incr excluded;
None
end
else
match pattern_match base with
| Some slug -> Some (slug, r)
| None -> (
match List.assoc_opt base named_overrides with
| Some [ slug ] -> Some (slug, r)
| Some _ -> die "%s: named_overrides entry must name exactly one slug" base
| None ->
if Hashtbl.mem sanctoral_slugs base then Some (base, r)
else begin
unmapped := base :: !unmapped;
None
end))
resolved
in
(out, { mapped = List.length out; excluded = !excluded; unmapped = List.sort compare !unmapped })
(* ---- Emit one Lectionary.t entry per (slug, resolved) pair ---------- *)
let sunday_letter = function `A -> "a" | `B -> "b" | `C -> "c"
let weekday_letter = function `I -> "i" | `II -> "ii"
let slug_or_die name = match Slug.of_string name with Ok s -> s | Error e -> die "%s" e
let entries_of (slug, r) =
match r with
| Flat cs -> [ (slug_or_die slug, cs) ]
| Sunday_cycle (a, b, c) ->
[ (slug_or_die (slug ^ "-" ^ sunday_letter `A), a);
(slug_or_die (slug ^ "-" ^ sunday_letter `B), b);
(slug_or_die (slug ^ "-" ^ sunday_letter `C), c) ]
| Weekday_cycle (i, ii) ->
List.filter_map
(fun (letter, v) -> Option.map (fun cs -> (slug_or_die (slug ^ "-" ^ weekday_letter letter), cs)) v)
[ (`I, i); (`II, ii) ]
(* ---- Real-code reachability, both temporal and sanctoral ------------ *)
(* Same discipline as tools/bootstrap_lectionary.ml's own
[reachable_temporal_slugs]/[assert_reachable]: this sweeps
Rite_of.Temporal_of.temporal directly over a real civil-day range, so an
emitted key naming no real office is a DEAD KEY, caught here rather than
shipped silently. 2004-2051 matches the EF generator's own range and
for the same reason -- wide enough to enumerate every distinct slug
FAMILY (season/week/weekday recur every year; only which year exhibits a
given alignment changes), not a claim about the kernel's full
1583-9999 domain, which is this bootstrap tool's business only insofar
as [Temporal_of.temporal] itself is already total over it. *)
let reachable_temporal_slugs () =
let tbl = Hashtbl.create 512 in
let mk y m d = match Date.make ~year:y ~month:m ~day:d with Ok t -> t | Error e -> die "%s" e in
for y = 2004 to 2051 do
let d = ref (mk y 1 1) in
let stop = mk y 12 31 in
while Date.compare !d stop <= 0 do
let t = Rite_of.Temporal_of.temporal !d in
Hashtbl.replace tbl (Slug.to_string t.Temporal.office.Celebration.slug) true;
d := Date.add_days !d 1
done
done;
tbl
(* The merged sanctoral layer -- calendar-2002.sexp plus all 13 decree
overlays, the SAME base + amendment set Tasks 1/2 shipped, loaded here
read-only, purely to know which slugs are real. A missing/malformed
calendar or amendment file is fatal (die), not silently treated as "zero
sanctoral slugs", which would make [map_bases]' own pass-through
validation vacuously reject every saint. *)
let amendment_files =
[ "001-padre-pio.sexp"; "002-juan-diego-cuauhtlatoatzin.sexp"; "003-our-lady-of-guadalupe.sexp";
"004-john-xxiii-john-paul-ii.sexp"; "005-mary-magdalene-rank.sexp";
"006-mary-mother-of-the-church.sexp"; "007-paul-vi.sexp"; "008-our-lady-of-loreto.sexp";
"009-faustina-kowalska.sexp"; "010-narek-avila-hildegard.sexp"; "011-martha-mary-lazarus.sexp";
"012-teresa-of-calcutta.sexp"; "013-john-henry-newman.sexp" ]
let reachable_sanctoral_slugs () =
let base_path = "data/of/calendar-2002.sexp" in
let amendments_dir = "data/of/amendments/" in
let base =
match Layer.load Rite_of.Vocab_of.rank_of_sexp base_path with
| Ok l -> l
| Error e -> die "%s: %s" base_path e
in
let overlays =
List.map
(fun name ->
let path = amendments_dir ^ name in
match Overlay.load Rite_of.Vocab_of.rank_of_sexp path with
| Ok o -> o
| Error e -> die "%s: %s" path e)
amendment_files
in
let merged, diagnostics = Overlay.merge base overlays in
(match diagnostics with
| [] -> ()
| ds ->
die "data/of/amendments: %d diagnostic(s) applying to the base calendar -- \
fix the amendment files before bootstrapping the lectionary from them: %s"
(List.length ds)
(String.concat "; " (List.map Overlay.diagnostic_to_string ds)));
let tbl = Hashtbl.create 256 in
List.iter (fun (e : _ Layer.entry) -> Hashtbl.replace tbl (Slug.to_string e.Layer.cel.Celebration.slug) true)
merged.Layer.entries;
tbl
(* [base_and_letter] strips the RAW ini suffixes (-A/-B/-C/-I/-II,
uppercase, as lectio itself spells them). The final EMITTED slugs use
{!entries_of}'s own lowercase letters (-a/-b/-c/-i/-ii, valid
{!Slug.t} characters -- Slug.ml's own [valid_char] rejects uppercase
outright), so a distinct stripper is needed here; reusing
[base_and_letter] silently matched nothing (a real bug this generator's
own first run caught: every emitted key showed up as "dead", 580 of
them, because "-a" never matches an uppercase "-A" test). *)
let strip_emitted_suffix s =
List.fold_left
(fun acc suf -> match acc with Some _ -> acc | None -> strip_suffix ~suffix:suf s)
None [ "-a"; "-b"; "-c"; "-ii"; "-i" ]
|> Option.value ~default:s
let assert_reachable entries ~temporal_slugs ~sanctoral_slugs =
let base_of s = strip_emitted_suffix (Slug.to_string s) in
let dead =
List.filter
(fun (s, _) ->
let b = base_of s in
not (Hashtbl.mem temporal_slugs b || Hashtbl.mem sanctoral_slugs b))
entries
in
(match dead with
| [] -> ()
| _ ->
List.iter
(fun (s, _) ->
Printf.eprintf
"bootstrap_lectionary_of: DEAD KEY -- %S is not a real Temporal_of slug (2004-2051) \
nor a real sanctoral slug\n"
(Slug.to_string s))
dead;
die "%d emitted key(s) are unreachable (see above)" (List.length dead));
let emitted_temporal = Hashtbl.create 512 in
List.iter
(fun (s, _) ->
let b = base_of s in
if Hashtbl.mem temporal_slugs b then Hashtbl.replace emitted_temporal b true)
entries;
let uncovered =
Hashtbl.fold (fun s _ acc -> if Hashtbl.mem emitted_temporal s then acc else s :: acc) temporal_slugs []
|> List.sort compare
in
uncovered
(* ---- Coverage measurement, both directions --------------------------- *)
(* Number 1 (temporal-day coverage): over every day of civil year 2026, how
many days does Rite_of.Temporal_of.temporal produce a slug this
generator never emits an entry for (tried flat, then both cycle-letter
suffixes -- exactly the lookup order Lectionary_of.readings itself
uses). Those are days with no readings via the temporal-slug path
(a sanctoral proper on the same day, if any, is a separate, second
chance -- see the generator's own summary printout for that count too). *)
let temporal_day_gap ~lectionary ~year_start =
let mk m d = match Date.make ~year:2026 ~month:m ~day:d with Ok t -> t | Error e -> die "%s" e in
let count = ref 0 and total = ref 0 and misses = ref [] in
for m = 1 to 12 do
let days_in_month = match m with
| 1 | 3 | 5 | 7 | 8 | 10 | 12 -> 31 | 4 | 6 | 9 | 11 -> 30 | 2 -> 28 | _ -> assert false
in
for d = 1 to days_in_month do
incr total;
let date = mk m d in
let t = Rite_of.Temporal_of.temporal date in
let base = Slug.to_string t.Temporal.office.Celebration.slug in
let found =
Lectionary.mem lectionary (slug_or_die base)
|| Lectionary.mem lectionary
(slug_or_die
(base ^ "-"
^ Rite_of.Lectionary_of.sunday_cycle_letter (Rite_of.Lectionary_of.sunday_cycle ~year_start date)))
|| Lectionary.mem lectionary
(slug_or_die
(base ^ "-"
^ Rite_of.Lectionary_of.weekday_cycle_letter
(Rite_of.Lectionary_of.weekday_cycle ~year_start date)))
in
if not found then begin
incr count;
misses := (Date.to_iso8601 date, base) :: !misses
end
done
done;
(!count, !total, List.rev !misses)
let sha256 path =
let ic = Unix.open_process_in (Printf.sprintf "sha256sum %s" (Filename.quote path)) in
let line = try input_line ic with End_of_file -> die "sha256sum failed" in
ignore (Unix.close_process_in ic);
List.hd (String.split_on_char ' ' line)
let () =
let src = if Array.length Sys.argv > 1 then Sys.argv.(1) else default_source in
let dst = if Array.length Sys.argv > 2 then Sys.argv.(2) else default_dest in
let secs = parse_ini src in
let resolved = resolve_sections secs in
let sanctoral_slugs = reachable_sanctoral_slugs () in
let mapped, report = map_bases resolved ~sanctoral_slugs in
let entries = List.concat_map entries_of mapped in
let temporal_slugs = reachable_temporal_slugs () in
let uncovered_temporal = assert_reachable entries ~temporal_slugs ~sanctoral_slugs in
let lect = match Lectionary.of_entries entries with Ok l -> l | Error e -> die "%s" e in
let gap_count, gap_total, gap_misses =
temporal_day_gap ~lectionary:lect ~year_start:Rite_of.Temporal_of.year_start
in
let oc = open_out dst in
Printf.fprintf oc
"; data/of/lectionary.sexp -- OF (2002) temporal + sanctoral lectionary\n\
; (Epistle + Gospel citations, never scripture text), bootstrapped from\n\
; lectio.\n\
;\n\
; LINEAGE, stated loudly because it constrains what this file can show\n\
; (design spec 2026-08-24-colitur-of-rite-module-design.md sec4.4/sec5):\n\
; this is a POLISH VERNACULAR pastoral lectionary (niedziela.pl,\n\
; harvested 2020-2025 by lectio's own scripts/genlect-of.go), NOT the\n\
; Latin OLM (Ordo Lectionum Missae) 1981 itself, and its citations are\n\
; ENGLISH-CANONICAL, not OLM's Vulgate numbering. It CANNOT show any\n\
; divergence between niedziela.pl's own pastoral choices and OLM's own\n\
; text -- three such divergences are already confirmed against the real\n\
; OLM page images (design spec sec4.4): Holy Family Year A's second-\n\
; reading short form, Trinity Sunday's Dan 3:56, and the Baptism of the\n\
; Lord's Mc 9:6 vs \"Mark 9:7\". Nor can it show OLM's short/long-form\n\
; reading alternatives, which niedziela.pl does not distinguish. This is\n\
; ALSO a SEPARATE lineage from lectio's own OF CALENDAR data\n\
; (data/of/calendar-2002.sexp, roman-calendar.ini, upstream\n\
; calapi.inadiutorium.cz) -- lectio is two unrelated upstreams glued\n\
; together by one downstream project, not one witness, and neither is\n\
; the typical edition.\n\
;\n\
; Generator: tools/bootstrap_lectionary_of.ml -- do not hand-edit;\n\
; re-run against the same lectio snapshot (its SHA-256 is pinned below;\n\
; a MISSING source file is fatal, checked before any read is attempted --\n\
; see this generator's own [parse_ini]) and commit the diff instead.\n\
; Every emitted key is asserted, at generation time, to be a slug\n\
; Rite_of.Temporal_of actually computes OR a slug the real merged\n\
; sanctoral layer (data/of/calendar-2002.sexp + all 13 decree overlays)\n\
; actually carries -- see [assert_reachable].\n\
;\n\
; Source: %s\n\
; SHA-256: %s\n\
; %d ini sections (988 expected) -> %d resolved (base, cycle-shape) \
pairs -> %d colitur slugs mapped, %d ini bases deliberately excluded (date-\n\
; keyed duplicates/late-Advent, no colitur date-keyed lookup exists --\n\
; see [excluded_bases]), %d ini bases genuinely unmapped (no pattern, no\n\
; override, no matching sanctoral slug) -> %d emitted lectionary entries.\n\
;\n\
; COVERAGE, BOTH DIRECTIONS (Step 1 of this task's own brief):\n\
; (1) Temporal-day gap: of the %d days of civil year 2026, %d produce a\n\
; Rite_of.Temporal_of slug with NO entry in this file (tried flat,\n\
; then both the Sunday- and weekday-cycle letter suffixes -- the\n\
; same order Lectionary_of.readings itself tries). Those are days\n\
; with no readings via the temporal-slug path (a sanctoral proper\n\
; on the same civil day, where one exists, is Lectionary_of's own\n\
; separate first chance). Named set below.\n\
; (2) Unmapped lectio keys: %d ini base names (of 988 sections, %d\n\
; distinct bases) map to no colitur slug at all -- data being\n\
; silently dropped if unreported. Named below.\n\
; Regenerate with:\n\
; eval $(opam env) && dune exec tools/bootstrap_lectionary_of.exe -- %s %s\n"
src (sha256 src) (List.length secs) (List.length resolved) report.mapped
(List.length excluded_bases) (List.length report.unmapped) (List.length entries)
gap_total gap_count (List.length report.unmapped) (List.length resolved) src dst;
Printf.fprintf oc "; Unmapped lectio bases (%d):\n" (List.length report.unmapped);
List.iter (fun b -> Printf.fprintf oc "; %s\n" b) report.unmapped;
Printf.fprintf oc "; Temporal_of slugs (2004-2051) with no lectionary entry (%d, informational --\n\
; most are the Christmas-season/late-Advent date-vs-weekday gap named above):\n"
(List.length uncovered_temporal);
List.iter (fun s -> Printf.fprintf oc "; %s\n" s) uncovered_temporal;
Sexplib.Sexp.output_hum oc (Lectionary.sexp_of_t lect);
output_char oc '\n';
close_out oc;
Printf.printf "bootstrap_lectionary_of: %d entries -> %s\n" (List.length entries) dst;
Printf.printf "bootstrap_lectionary_of: coverage gap 1 (temporal days, 2026): %d/%d\n" gap_count gap_total;
Printf.printf "bootstrap_lectionary_of: coverage gap 2 (unmapped lectio bases): %d\n"
(List.length report.unmapped);
ignore gap_misses
|