aboutsummaryrefslogtreecommitdiff
path: root/tools/bootstrap_lectionary.ml
blob: a4c82aec887fdebd9a2983fde6979b195d8545ba (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
(* Bootstraps data/ef/lectionary.sexp from lectio's tridentine-lectionary.ini.
   Lives in tools/ and not the kernel for the same reason
   bootstrap_sanctoral.ml does: reading someone else's INI needs a reader,
   which does not belong in a pure kernel.

   Only `first` and `gospel` are mapped -- the plan's Global Constraints fix
   the scope at Epistle + Gospel, and lectio's data carries nothing else. *)

open Colitur_kernel

let default_source = "../lectio/internal/caldata/tridentine-lectionary.ini"
let default_dest = "data/ef/lectionary.sexp"

let die fmt =
  Printf.ksprintf
    (fun s -> prerr_endline ("bootstrap_lectionary: " ^ s); exit 1)
    fmt

type section = { name : string; fields : (string * string) list }

let parse_ini path =
  let ic = try open_in path with Sys_error e -> die "%s" e in
  let sections = ref [] and cur = ref None in
  let flush () =
    match !cur with
    | Some (n, fs) -> sections := { name = n; fields = List.rev fs } :: !sections
    | None -> ()
  in
  (try
     while true do
       let line = String.trim (input_line ic) 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
         match String.index_opt line '=' with
         | None -> die "%s: cannot parse line %S" path line
         | Some 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: field %S before any section" path k)
     done
   with End_of_file -> ());
  flush ();
  close_in ic;
  List.rev !sections

(* Task 8 (branch ef-lectionary, differential fix round): lectio's own ini
   section names are NOT always the slug {!Rite_ef.Temporal_ef} computes
   for the IDENTICAL office. Confirmed the hard way: the first version of
   this generator carried section names through {!Slug.of_string}
   verbatim, so the data these renamed families need was sitting in this
   very file all along -- under lectio's spelling -- while
   [Lectionary_ef.readings]' step 2 looked it up under colitur's own,
   found nothing, and silently fell through to step 3's ferial-resumption
   answer instead. Invisible until test/test_differential.ml first
   compared citation CONTENT (this task): a day's SLUG/rank/colour already
   matched, so nothing before this task ever noticed the READING was
   wrong. [colitur_keys] is the closed translation table -- the SAME
   families test/test_differential.ml's own Layer A ([norm_slug]) already
   established as pure vocabulary, carrying no liturgical substance, not
   re-derived here. Kept in the generator, not in [Lectionary_ef.readings]
   itself, because a bootstrap step exists exactly to translate an
   external vocabulary into colitur's own ONCE, at data-generation time --
   baking an alias table into the rite's own runtime lookup would just
   move the vocabulary problem, not solve it.

   A translation can widen ONE ini section into several colitur keys
   (Passiontide: lectio never distinguishes Passion week from Holy week at
   the slug level, "ef-passiontide-0-<weekday>" for both -- confirmed
   empirically, its OWN computed reading is byte-identical between the two
   weeks' same weekday throughout 2005-2050 -- so both of colitur's own
   week-specific keys legitimately point at the one source entry); it
   never narrows or drops one, and every key not named here passes through
   unchanged (e.g. "ef-easter-8-{monday,tuesday,thursday}", the three
   non-Ember Pentecost-octave ferias colitur does NOT rename). *)
let weekdays = [ "monday"; "tuesday"; "wednesday"; "thursday"; "friday"; "saturday" ]

let colitur_keys name =
  if String.equal name "ef-christmas-sunday-0" then
    (* WIDEN, not rename -- fix round (found by test_lectionary_ef.ml's own
       pre-existing test_step3_christmas_feria_resumes_sunday going from
       PASS to FAIL): "ef-christmas-sunday-0" is not only lectio's own
       calendar-level slug for RG 17(a)'s Holy Name Sunday
       (test_differential.ml's own [norm_slug] comment) -- it is ALSO
       colitur's own GENUINE, DIRECTLY-COMPUTED temporal slug (Rite_ef.
       Temporal_ef.temporal, "if m = 12 && dd >= 26 then Some
       \"ef-christmas-sunday-0\"") for an ordinary Sunday landing on 26-28
       December, the historical "Sunday within the Octave of the Nativity"
       -- a DIFFERENT civil window than Holy Name Sunday (2-5 January), and
       Missal-confirmed (docs/research/scan1.txt, "Dominica infra octavam
       Nativitatis Domini, II classis") to be the IDENTICAL Mass (Gal.
       4,1-7 / Luc. 2,33-40), not a coincidence. The first version of this
       translation REPLACED the key outright and broke that pre-existing,
       working case -- caught by the existing test suite, not by this
       generator's own reasoning; keeping the original key alongside the
       Holy-Name-Sunday alias fixes it, following the same "widen, never
       narrow" rule Passiontide below already established. *)
    [ "ef-christmas-sunday-0"; "ef-holy-name-sunday" ]
  else if String.equal name "ef-easter-8-wednesday" then [ "ef-pentecost-ember-wed" ]
  else if String.equal name "ef-easter-8-friday" then [ "ef-pentecost-ember-fri" ]
  else if String.equal name "ef-easter-8-saturday" then [ "ef-pentecost-ember-sat" ]
  else (
    match List.find_opt (fun wd -> String.equal name ("ef-passiontide-0-" ^ wd)) weekdays with
    | Some wd -> [ "ef-passiontide-1-" ^ wd; "ef-passiontide-2-" ^ wd ]
    | None -> [ name ])

let convert sec =
  let cite part key =
    match List.assoc_opt key sec.fields with
    | None | Some "" -> None
    | Some reference -> Some { Citation.part; reference }
  in
  let cs = List.filter_map Fun.id [ cite Citation.First "first"; cite Citation.Gospel "gospel" ] in
  if cs = [] then die "%s: no first/gospel field" sec.name;
  if List.length cs = 1 then
    die "%s: has one reading, not two -- an Epistle without a Gospel (or the \
         reverse) is malformed and must be investigated, not silently shipped"
      sec.name;
  List.map
    (fun key ->
      match Slug.of_string key with
      | Ok slug -> (slug, cs)
      | Error e -> die "bad slug %S (translated from %S): %s" key sec.name e)
    (colitur_keys sec.name)

(* [ef-nativity-vigil] (RG 91 entry 5, {!Rite_ef.Temporal_ef}'s own [named])
   is a TEMPORAL office with no section of its own in THIS ini at all --
   lectio computes ITS citations from a different source file entirely,
   its SANCTORAL calendar (tridentine-calendar.ini's own
   "[vigil-of-christmas]" section: "reading.first = Rom 1:1-6",
   "reading.gospel = Matt 1:18-21"). colitur's own sanctoral bootstrap
   (Task 3) already carried those identical citations onto
   data/ef/sanctoral.sexp's own `vigil-of-christmas` entry -- inert there
   because data/ef/adjustments.sexp suppresses it (that overlay's own
   comment: the SAME celebration as this temporal office, not a second
   one) -- which corroborates this value independently rather than merely
   asserting it. Hand-authored here, the same discipline
   data/ef/adjustments.sexp's own RG 110 companion and Major Litanies
   `Add` directives already use for a genuine upstream-source gap this
   generator's own single-ini design cannot reach on its own. *)
let vigil_entries =
  [ ( (match Slug.of_string "ef-nativity-vigil" with Ok s -> s | Error e -> die "%s" e),
      [ { Citation.part = Citation.First; reference = "Rom 1:1-6" };
        { Citation.part = Citation.Gospel; reference = "Matt 1:18-21" } ] )
  ]

(* One more colitur-only slug, DERIVED from [ef-holy-name-sunday]'s own
   citations (just translated above) rather than a second hand-typed copy
   of the same text: [ef-holy-name] is RG 17(a)'s own fallback ("secus die
   2 ianuarii", 2 January in a year with no Sunday 2-5 January). The
   calendarium's own table (data/ef/expected-divergences.sexp's own C16
   note quotes it) gives ONE heading for both the Sunday and the fallback
   shape -- the identical Mass, not two -- so this is the same citation
   pair, not an independent lookup. Safe to hard-wire (unlike a step-3
   fallback, which would walk back into a DIFFERENT, unrelated Sunday of
   the OLD liturgical year): RG 17(a)'s whole point is that this Mass is
   said in place of, not alongside, whatever ferial reading a bare
   fallback would otherwise find.

   REJECTED, and recorded so it is not re-attempted: giving
   [ef-nativity-octave-day-{5,6,7}] (29-31 December, RG 91 entry 17) this
   SAME derived value too. Tried, then measured against the real
   differential and REVERTED -- it is wrong. lectio's own citation for
   those three dates is NOT uniform across years (confirmed against the
   fixture: 2005-12-29 reads "Heb 1:1-12"/"John 1:1-14", 2006-12-29 reads
   "1 Cor. 4:1-5"/"Luke 3:1-6" -- two different Masses, not one). Read
   against the Missal directly (docs/research/scan1.txt, "Dominica infra
   octavam Nativitatis Domini, II classis": Epistle "Gal. 4, 1-7", Gospel
   "Luc. 2, 33-40" -- the SAME pair as Holy Name Sunday, corroborating the
   guess would have been right ONLY for that specific case): the two
   observed fixture values are each the PREVIOUS true civil Sunday's OWN
   Mass -- Christmas Day itself (25 Dec 2005 was a Sunday that year:
   "In Nativitate Domini... Missa in die", Heb 1:1-12/John 1:1-14) or, in
   a year with no Sunday between Christmas and New Year at all, all the
   way back to Advent IV (24 Dec 2006 was a Sunday; 1 Cor. 4:1-5/Luke
   3:1-6 is that Mass's own citation, already in [ef-advent-sunday-4]
   above). colitur's OWN existing step 3 ([Lectionary_ef.readings], NOT
   this generator) already walks back to the true PRECEDING CIVIL Sunday
   regardless of season and would find EXACTLY these same answers on its
   own -- a hard-wired entry here would have SHORT-CIRCUITED that correct,
   already-general mechanism with one wrong, uniform guess. Left absent
   deliberately: these three dates stay whatever step 3 resolves through
   [ef-advent-sunday-4]/[ef-holy-name-sunday] et al, or [] on a date whose
   own preceding Sunday has no entry either (data/ef/expected-
   divergences.sexp's own C21 entry records what remains, if anything,
   after that). *)
let derived_entries entries =
  let holy_name_sunday_citations =
    match
      List.find_opt (fun (s, _) -> String.equal (Slug.to_string s) "ef-holy-name-sunday") entries
    with
    | Some (_, cs) -> cs
    | None ->
        die "internal: ef-holy-name-sunday missing after translation -- cannot derive its dependants"
  in
  match Slug.of_string "ef-holy-name" with
  | Ok s -> [ (s, holy_name_sunday_citations) ]
  | Error e -> die "%s" e

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 translated = List.concat_map convert secs in
  let entries = translated @ vigil_entries @ derived_entries translated in
  let lect =
    match Lectionary.of_entries entries with
    | Ok l -> l
    | Error e -> die "%s" e
  in
  let oc = open_out dst in
  Printf.fprintf oc
    "; data/ef/lectionary.sexp -- EF (1962) temporal lectionary (Epistle +\n\
     ; Gospel citations, never scripture text), bootstrapped from lectio.\n\
     ; Generator: tools/bootstrap_lectionary.ml -- do not hand-edit; re-run the\n\
     ; generator against a newer lectio and commit the diff instead.\n\
     ;\n\
     ; Source: %s\n\
     ; SHA-256: %s\n\
     ; %d entries (%d ini sections translated/widened into colitur's own\n\
     ; Temporal_ef vocabulary via [colitur_keys]; %d hand-authored from a\n\
     ; second source file [vigil_entries]; %d derived from an already-\n\
     ; translated entry above rather than re-typed [derived_entries] --\n\
     ; see this generator's own comments on all three). Regenerate with:\n\
     ;   eval $(opam env) && dune exec tools/bootstrap_lectionary.exe -- %s %s\n"
    src (sha256 src) (List.length entries) (List.length secs) (List.length vigil_entries)
    (List.length entries - List.length translated - List.length vigil_entries)
    src dst;
  Sexplib.Sexp.output_hum oc (Lectionary.sexp_of_t lect);
  output_char oc '\n';
  close_out oc;
  Printf.printf "bootstrap_lectionary: %d entries -> %s\n" (List.length entries) dst