summaryrefslogtreecommitdiff
path: root/tools/bootstrap_lectionary_of.ml
blob: 3ba54df9f43cd505f541519ee6dad68812a44a99 (plain) (blame)
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
(* 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 (+ Second on Sundays/solemnities) + Gospel --- *)

(* Fix wave I7 (final-review.md, 2026-08-25-colitur-of-phases-3-5): Psalm is
   still dropped -- it is a CHANT (sung, with a congregational refrain, OLM
   1981 Praenotanda n. 71's own "psalmi et versus inter lectiones
   occurrentes", listed apart from the readings proper), not a reading, and
   {!Colitur_kernel.Rite.t.citation_shapes} (see that field's own doc
   comment) still declares no shape carrying it for either rite -- nothing
   here would read a Psalm citation even if extracted. See the fix wave's
   own report for the fuller argument and what was rejected.

   Second is now extracted, but ONLY from a SUNDAY-CYCLE section (its own
   [~is_sunday_cycle] argument, [`A]/[`B]/[`C] at the call site below) --
   never from a WEEKDAY-CYCLE one ([`I]/[`II]), even on the one occasion
   lectio's own snapshot carries a stray [second] field there
   (advent-3-sat-II, "Zephaniah 3:14-17" -- content-verified as a
   scraping-pipeline artifact, not a real OLM assignment: that citation is
   the THIRD Sunday of Advent's own Year C first reading, evidently bled
   into an unrelated Saturday ferial section by niedziela.pl's own
   generator). This is not merely a guard against that one artifact; it is
   the general rule OLM 1981 Praenotanda n. 66.1 vs n. 69.1 states (see
   {!Rite_of.Rite_of.context}'s own citation): a ferial Mass has two
   readings, never three, so a weekday-cycle section can never legitimately
   carry a Second reading regardless of what stray data a snapshot exposes.
   {!Colitur_kernel.Validate}'s own rite-supplied ["citations"] check (no
   longer off limits to this task -- the kernel change this fix wave itself
   makes) would reject an emitted weekday-cycle entry carrying [Second]
   outright, since neither of the OF's two declared shapes admits it
   alongside a non-Sunday/solemnity office; extracting it here regardless
   would just move the failure from "silently wrong" to "loudly rejected at
   test time" rather than avoiding it. *)
let cite ~is_sunday_cycle 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 ->
      let second =
        if is_sunday_cycle then
          Option.map (fun v -> { Citation.part = Citation.Second; reference = v }) (get "second")
        else None
      in
      Some
        (List.filter_map Fun.id
           [ Some { Citation.part = Citation.First; reference = first };
             second;
             Some { 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"
      (* [f :: _], not a fixed-arity [[f; _]]: [cite] now emits either a
         2-element ([First; Gospel]) or 3-element ([First; Second; Gospel])
         list, and [First] is its head either way -- a fixed-arity pattern
         would silently fall through to "?" on the 3-element shape. *)
      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) -> (
          let is_sunday_cycle = match letter with `A | `B | `C -> true | `I | `II -> false in
          match cite ~is_sunday_cycle 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 is ABSENT from lectio's own 988 keys entirely (a genuine
       gap, not a mapping bug here). CLOSED, fix wave I8: [hand_authored]
       below now injects it directly (Isa 52:7-10/John 1:1-18, OLM 1981's
       own "16 Ad Missam in die") -- see that table's own citation for the
       full argument, including why it is not routed through THIS table
       (there is no ini section to override). *)
    ("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" -- content-verified (2026-08-26 review) NOT duplicates of the
       weekday-keyed families above (17 December's own O-Antiphon sibling
       proved the same shape first: Gen 49:2,8-10/Matt 1:1-17, found
       nowhere else in the 754 emitted entries). These are OLM n. 69.3's
       genuinely date-fixed readings; mapped separately below via
       {!Rite_of.Lectionary_of.date_keyed_slug}, not through this table --
       see [excluded_bases]'s own comment for exactly which of the two
       ranges are and are not still excluded, and why. *)
    ("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" ]);
    (* OLM n. 69.3's date-fixed windows (see the block comment above and
       Rite_of.Lectionary_of.date_keyed_slug's own doc comment for the
       full citation and argument -- this task's fix, 2026-08-26 review).
       These slugs are NOT Temporal_of office slugs (no colitur day's
       [Celebration.slug] is ever literally "of-advent-dec-17") -- they
       exist purely as [Lectionary.t] lookup keys that
       [Lectionary_of.readings]'s own step 3 constructs directly from the
       civil date via [date_keyed_slug], bypassing this table's usual
       "base name -> real colitur slug" contract. [assert_reachable]
       below is widened with its own matching date-keyed reachability
       sweep (calling the SAME function, not a re-implementation) so a
       typo here still dies loudly rather than shipping a dead key.
       6 January is deliberately absent -- see [excluded_bases]. *)
    ("advent-dec-17", [ "of-advent-dec-17" ]);
    ("advent-dec-18", [ "of-advent-dec-18" ]);
    ("advent-dec-19", [ "of-advent-dec-19" ]);
    ("advent-dec-20", [ "of-advent-dec-20" ]);
    ("advent-dec-21", [ "of-advent-dec-21" ]);
    ("advent-dec-22", [ "of-advent-dec-22" ]);
    ("advent-dec-23", [ "of-advent-dec-23" ]);
    ("advent-dec-24", [ "of-advent-dec-24" ]);
    ("christmas-dec-29", [ "of-christmas-dec-29" ]);
    ("christmas-dec-30", [ "of-christmas-dec-30" ]);
    ("christmas-dec-31", [ "of-christmas-dec-31" ]);
    ("christmas-jan-2", [ "of-christmas-jan-2" ]);
    ("christmas-jan-3", [ "of-christmas-jan-3" ]);
    ("christmas-jan-4", [ "of-christmas-jan-4" ]);
    ("christmas-jan-5", [ "of-christmas-jan-5" ]);
    ("christmas-jan-7", [ "of-christmas-jan-7" ])
  ]

(* Deliberately unmapped, with the reason named. CORRECTED 2026-08-26
   review: this list previously ALSO carried the 16 O-Antiphon/Christmas-
   season date-keyed bases, under the claim that they were "date-keyed
   duplicates" of the weekday-keyed families [named_overrides] already
   maps. That claim was false -- diffed against lectio's own ini directly,
   each carries unique per-date content (17 December: Gen 49:2,8-10/
   Matt 1:1-17, found nowhere else among the 754 emitted entries), which
   OLM n. 69.3 explains: those ferias are date-fixed, not merely
   non-alternating within a weekday slot. They are mapped now, via
   [named_overrides]'s own date-keyed block, not here. Genuinely still
   unreachable, for three DIFFERENT structural reasons, none of them
   "duplicate":

   - "christmas-jan-6": 6 January is always Epiphany in colitur's model
     (Temporal_of's own [named] fixes it unconditionally, "m = 1 && dd =
     6", before the ferial dispatch ever runs) -- "of-christmas-jan-6"
     would be a real lectio entry with no colitur day that could ever look
     it up. See Rite_of.Lectionary_of.date_keyed_slug's own doc comment,
     which deliberately excludes 6 January from its date range for this
     exact reason.
   - "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.
     The latter two 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 = [ "christmas-jan-6"; "easter-6-thu"; "advent-4-sat" ]

type mapping_report = {
  mapped : int;
  excluded : int;
  unmapped : string list;
  sanctoral_passthrough : int;
      (* Count of the [Hashtbl.mem sanctoral_slugs base] branch below --
         how many of the real merged sanctoral layer's own slugs got a
         DEDICATED lectio entry this way, out of its full total (passed in
         separately at the print site, [Hashtbl.length sanctoral_slugs]).
         Named explicitly, 2026-08-26 review's own Minor finding: the
         REMAINDER (222 shipped sanctoral slugs total, per that review --
         189 without a dedicated entry) is not a gap, it is OLM norms
         working as designed (a saint with no proper of its own falls
         through to the day's own ferial, {!Lectionary_of.readings}'s own
         step 2 -> step 3), but the provenance header never said so before
         this fix, which reads as an unstated 85% gap rather than the
         correctly-small dedicated-entry set it actually is. *)
}

(* 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 sanctoral_passthrough = 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 begin
                    incr sanctoral_passthrough;
                    Some (base, r)
                  end
                  else begin
                    unmapped := base :: !unmapped;
                    None
                  end))
      resolved
  in
  ( out,
    { mapped = List.length out; excluded = !excluded; unmapped = List.sort compare !unmapped;
      sanctoral_passthrough = !sanctoral_passthrough } )

(* ---- 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

(* Fix wave I8 (final-review.md, 2026-08-25-colitur-of-phases-3-5): the
   Christmas DAY Mass ("of-nativity") is genuinely absent from lectio's own
   988 ini keys -- niedziela.pl's own harvest never covered it at all, not
   a mapping bug in this generator (already noted, correctly, in
   [named_overrides]'s own comment on the "christmas" base above, which is
   the VIGIL, not the Day). Confirmed sourceable, not merely "cannot
   convert": docs/research/of/olm-1981-ocr.txt:4513-4520 ("16 Ad Missam in
   die"), the same primary authority this whole module already cites for
   the cycle rules (lectionary_of.mli).

   UPDATED, fix wave I7 (final-review.md, 2026-08-25-colitur-of-phases-3-5,
   2026-08-26): Christmas Day is a solemnity, so its own Second Reading is
   now in scope and included -- Hebr 1, 1-6 ("Locutus est nobis Deus in
   Filio"), same source line (docs/research/of/olm-1981-ocr.txt:4517),
   confirming the value this comment already named before this fix wave
   built the capability to emit it. Psalm (Ps 97) remains out of scope,
   deliberately, not merely by omission -- see [cite]'s own doc comment
   above for the argument (a chant, not a reading; no declared
   {!Colitur_kernel.Rite.t.citation_shapes} shape carries it for either
   rite).

   The Gospel (Io 1, 1-18) carries OLM's own "longior/brevior" choice
   ("1-18 (longior) vel 1-5.9-14 (brevior)") -- the LONGER form is used,
   the same already-disclosed limitation this file's own LINEAGE section
   states for every other short/long-form pair in this data ("Nor can it
   show OLM's short/long-form reading alternatives, which niedziela.pl does
   not distinguish"), not a new one introduced here.

   Injected directly as a Lectionary entry, not through the ini-derived
   [mapped]/[entries_of] pipeline above -- there is no ini section to
   derive it FROM. Merged into [entries] before {!assert_reachable} (so it
   is checked exactly like every other emitted key: "of-nativity" is a
   real Rite_of.Temporal_of slug) and before {!Lectionary.of_entries}. *)
let hand_authored =
  [ ( slug_or_die "of-nativity",
      [ { Citation.part = Citation.First; reference = "Isaiah 52:7-10" };
        { Citation.part = Citation.Second; reference = "Hebrews 1:1-6" };
        { Citation.part = Citation.Gospel; reference = "John 1:1-18" } ] ) ]

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

(* THIS TASK'S fix, 2026-08-26 review: the 16 new date-keyed slugs
   ([named_overrides]'s own date-keyed block) are not Temporal_of office
   slugs and not sanctoral slugs, so without this they would all be
   flagged DEAD by [assert_reachable] below. Calls
   Rite_of.Lectionary_of.date_keyed_slug directly -- the SAME function
   {!Rite_of.Lectionary_of.readings} calls at runtime -- rather than
   re-deriving the date ranges here a second time, so a typo in either
   [named_overrides]'s literal strings or in [date_keyed_slug]'s own
   ranges still dies loudly instead of silently drifting apart. Swept over
   the same 2004-2051 range as [reachable_temporal_slugs] and for the same
   reason: every distinct date recurs every year, only its weekday
   alignment (irrelevant here, [date_keyed_slug] itself is weekday-
   independent except for its Sunday guard) and which years hit a Sunday
   change. *)
let reachable_date_keyed_slugs () =
  let tbl = Hashtbl.create 32 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
      (match Rite_of.Lectionary_of.date_keyed_slug !d with
      | Some s -> Hashtbl.replace tbl (Slug.to_string s) true
      | None -> ());
      d := Date.add_days !d 1
    done
  done;
  tbl

let assert_reachable entries ~temporal_slugs ~sanctoral_slugs ~date_keyed_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 || Hashtbl.mem date_keyed_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, nor a real date_keyed_slug (2004-2051)\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 =
        (* THIS TASK'S fix, 2026-08-26 review: tried first, matching
           Lectionary_of.readings' own step-3 order, so the O-Antiphon/
           Christmas-season date-keyed days this fix closed no longer
           misreport as a gap in the header below. *)
        (match Rite_of.Lectionary_of.date_keyed_slug date with
        | Some s -> Lectionary.mem lectionary s
        | None -> false)
        || 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)

(* Fix wave I2 (final-review.md, 2026-08-25-colitur-of-phases-3-5): every
   reference this file emits, run through the SAME parser [colitur readings]
   itself uses to convert to Latin sigla ({!Colitur_citation.Sigla.format}
   -> {!Colitur_citation.Parse.parse} on a miss). Disclosed here, at
   generation time, rather than left for a reader to discover as a silent
   "- | -"-shaped surprise or an unconverted English fragment sitting next
   to Latin ones.

   W4 (this task): the residual I2/I7 found here -- a hyphenated verse
   range whose two endpoints lie in different chapters ("2:29-3:6") -- is
   now CLOSED. {!Colitur_citation.Parse.verse_end} lets a range's [last]
   name its own, later chapter, and {!Colitur_citation.Render} renders it
   back through the part's own [chapter_verse] template; the compound
   shape (a crossing range followed by further same-chapter verses in the
   same comma list, "Matthew 9:35-10:1,5a,6-8") is handled too. See
   test/test_citation_coverage_of.ml for the (now empty) pinned residual,
   still asserted exactly rather than merely "at least these fail". *)
let citation_conversion_census lect =
  let total = ref 0 and bad = ref [] in
  List.iter
    (fun (_slug, cits) ->
      List.iter
        (fun (c : Citation.t) ->
          incr total;
          match Colitur_citation.Parse.parse c.Citation.reference with
          | Ok _ -> ()
          | Error _ -> if not (List.mem c.Citation.reference !bad) then bad := c.Citation.reference :: !bad)
        cits)
    (Lectionary.entries lect);
  (!total, List.sort compare !bad)

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 @ hand_authored in
  let temporal_slugs = reachable_temporal_slugs () in
  let date_keyed_slugs = reachable_date_keyed_slugs () in
  let uncovered_temporal = assert_reachable entries ~temporal_slugs ~sanctoral_slugs ~date_keyed_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 cit_total, cit_bad = citation_conversion_census lect in
  (* Fix wave I7 (final-review.md, 2026-08-25-colitur-of-phases-3-5): how
     many emitted citation FIELDS are a Second reading -- i.e. how many of
     [entries]'s own Sunday-cycle entries actually carried one in lectio's
     snapshot (Sunday-cycle sections without a NAMED Second in the ini, and
     every weekday-cycle entry by [cite]'s own [~is_sunday_cycle] guard,
     never get one). Printed in the header below alongside [cit_total] so a
     reader can see the Second-reading count is a real, measured subset of
     the total, not a guess. *)
  let second_count =
    List.concat_map snd entries
    |> List.filter (fun (c : Citation.t) -> c.Citation.part = Citation.Second)
    |> List.length
  in
  let oc = open_out dst in
  Printf.fprintf oc
    "; data/of/lectionary.sexp -- OF (2002) temporal + sanctoral lectionary\n\
     ; (First, and on Sundays/solemnities also Second, and Gospel citations,\n\
     ; never scripture text -- \"Epistle\" is EF vocabulary, not used here),\n\
     ; bootstrapped from 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, OR a date Rite_of.Lectionary_of.date_keyed_slug\n\
     ; actually reaches -- see [assert_reachable].\n\
     ;\n\
     ; CORRECTED 2026-08-26 (review): this file used to exclude 16 lectio\n\
     ; bases -- the 8 O-Antiphon days (17-24 December) and 8 further\n\
     ; Christmas-season dates (29-31 December, 2-5 and 7 January) -- as\n\
     ; \"date-keyed duplicates\" of the weekday-keyed ferial families mapped\n\
     ; above. That was false: each carries content found nowhere else among\n\
     ; the emitted entries (17 December: Gen 49:2,8-10/Matt 1:1-17), which\n\
     ; OLM n. 69.3 explains -- these ferias are fixed by CIVIL DATE, not\n\
     ; merely non-alternating within a weekday slot like every other Advent/\n\
     ; Christmastide feria. Every day in 2005-2050 previously served a\n\
     ; DRIFTING citation there (whichever weekday-keyed family that year's\n\
     ; own alignment happened to land on) instead of the Missal's fixed one.\n\
     ; Fixed via a new date-keyed lookup route, tried BEFORE the weekday-\n\
     ; keyed one (Rite_of.Lectionary_of.date_keyed_slug, readings' own step\n\
     ; 3) -- Temporal_of's slugs are UNCHANGED, only which lectionary key\n\
     ; resolves the day's citations. 3 lectio bases remain excluded, for\n\
     ; three genuinely different structural-unreachability reasons, none of\n\
     ; them \"duplicate\" -- see [excluded_bases]'s own comment.\n\
     ;\n\
     ; CORRECTED, fix wave I7 (final-review.md, 2026-08-25-colitur-of-\n\
     ; phases-3-5, 2026-08-26): this file used to carry First and Gospel\n\
     ; only, and called the First reading an \"Epistle\" above -- EF\n\
     ; vocabulary, wrong for the OF (its First reading is often Old\n\
     ; Testament or Acts, not an epistle at all; OLM 1981 Praenotanda\n\
     ; n. 66.1). Both fixed at the source: {!Colitur_kernel.Rite\n\
     ; .t.citation_shapes} (kernel change, {!Rite_of.Rite_of.context}'s own\n\
     ; citation) makes the well-formed citation-part shape RITE-supplied\n\
     ; rather than a kernel-hardcoded [[First; Gospel]], and [cite] above\n\
     ; now extracts a Second reading from every SUNDAY-CYCLE (Sunday/\n\
     ; solemnity) entry lectio's own snapshot carries one for. See\n\
     ; COVERAGE (5) below for the measured count.\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 genuinely excluded (structurally\n\
     ; unreachable -- see [excluded_bases]), %d ini bases genuinely unmapped\n\
     ; (no pattern, no override, no matching sanctoral slug) -> %d emitted\n\
     ; 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 the\n\
     ;       date-keyed route first, then flat, then both the Sunday- and\n\
     ;       weekday-cycle letter suffixes -- the same order\n\
     ;       Lectionary_of.readings itself tries). Those are days with no\n\
     ;       readings via the temporal-slug path (a sanctoral proper on the\n\
     ;       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\
     ;   (3) Sanctoral coverage (Minor, 2026-08-26 review): of the %d shipped\n\
     ;       sanctoral slugs (data/of/calendar-2002.sexp + all 13 decree\n\
     ;       overlays), %d have a DEDICATED entry in this file (a lectio base\n\
     ;       name this generator recognised verbatim as one of them). The\n\
     ;       other %d have none and correctly fall through to the day's own\n\
     ;       ferial (Lectionary_of.readings' own step 2 -> step 3) -- this is\n\
     ;       OLM norms working as designed for a saint with no proper of\n\
     ;       their own, not a gap in this data.\n\
     ;   (4) Citation-siglum conversion (fix wave I2, 2026-08-26 review;\n\
     ;       CLOSED by W4): of %d emitted (First, Second, Gospel) citation\n\
     ;       fields, %d distinct references (out of the full %d) do not\n\
     ;       parse. I2/I7 left 49 unconverted here, every one a hyphen\n\
     ;       range crossing a chapter boundary (\"2:29-3:6\"); W4 gave\n\
     ;       {!Colitur_citation.Parse.t} a [verse_end] so a range's [last]\n\
     ;       can name its own, later chapter (see book.ml's own\n\
     ;       [is_single_chapter] neighbourhood for the other, earlier fix:\n\
     ;       345 previously-unregistered book names and verse sub-letter\n\
     ;       markers, closed at the parser/book-table level). A reference\n\
     ;       that still fails to parse would still print verbatim, never a\n\
     ;       crash or a dropped citation -- {!Colitur_citation.Sigla\n\
     ;       .format}'s own documented contract on a parse miss -- but none\n\
     ;       does today. Pinned exactly, both directions, by\n\
     ;       test/test_citation_coverage_of.ml. Named below (if any).\n\
     ;   (5) Second-reading coverage (fix wave I7, 2026-08-26): %d of the\n\
     ;       %d emitted citation fields above are a Second reading -- every\n\
     ;       one from a SUNDAY-CYCLE (-A/-B/-C, i.e. Sunday/solemnity)\n\
     ;       entry, never a weekday-cycle one ([cite]'s own\n\
     ;       [~is_sunday_cycle] guard), matching OLM 1981 Praenotanda\n\
     ;       n. 66.1/n. 69.1 and n. 84(b)/(c) (docs/research/of/olm-1981\n\
     ;       .pdf pp.32-33,37-38/\"XXXII-XXXIII\",\"XXXVII-XXXVIII\"): a\n\
     ;       Sunday or solemnity Mass has three readings, a feria/feast/\n\
     ;       memorial two. The Responsorial Psalm (lectio's own [psalm]\n\
     ;       field, present on nearly every section) is deliberately NOT\n\
     ;       extracted -- see [cite]'s own doc comment for the argument\n\
     ;       (a chant, not a reading; declared in neither rite's\n\
     ;       {!Colitur_kernel.Rite.t.citation_shapes}).\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)
    (Hashtbl.length sanctoral_slugs) report.sanctoral_passthrough
    (Hashtbl.length sanctoral_slugs - report.sanctoral_passthrough)
    cit_total (List.length cit_bad) cit_total second_count cit_total 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 "; Citations that do not parse (%d distinct):\n" (List.length cit_bad);
  List.iter (fun r -> Printf.fprintf oc ";   %s\n" r) cit_bad;
  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