(* 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. Links `rite_ef` (fix round 1, coordinator review, Critical 2): the generator used to have no way to check its own [colitur_keys] table against reality, which is exactly how a translation this table forgot (Lent's own Ember days) shipped silently -- both engines independently fall through to the same wrong ferial answer there, so even the differential could not see it (the "Holy Thursday was violet in both" shape this project's own CLAUDE.md already names). [assert_reachable] below sweeps {!Rite_ef.Temporal_ef.temporal} directly -- no sanctoral layer, no lectionary of its own, no circularity with the file this tool is generating -- exactly the same "code, not data" seam tools/bootstrap_sanctoral.ml already links this library for. *) 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; 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 this table's own general design already intends. *) [ "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 if String.equal name "ef-lent-1-wednesday" then (* Critical 2, fix round 1 (coordinator review): the Lenten Ember days (RG 91 entry 18, "Quatuor Tempora... post primam dominicam Quadragesimae") fall on the Wednesday/Friday/Saturday of Lent's own first week -- the SAME three civil days colitur names "ef-lent-ember-{wed,fri,sat}" (Rite_ef.Temporal_ef's own [ember], checked ahead of the generic week-numbering fallback, so colitur NEVER emits "ef-lent-1-wednesday/friday/saturday" as its own slug for ANY civil day -- confirmed by sweeping [Temporal_ef.temporal] directly, not assumed; [assert_reachable] below would now catch it if that stopped being true). A RENAME, not a widen, unlike "ef-christmas-sunday-0" above: colitur has no second, legitimate use for the un-renamed key the way it does there. temporal_ef.ml's own comment on [ember] previously claimed "lectio has no Ember slug for [Lent]" -- true only in the sense that lectio's OWN naming is the generic "ef-lent-1-" family, not that the DATA is missing; it is right there in the ini, just unreachable under colitur's own spelling until this rename. That comment is corrected alongside this fix. *) [ "ef-lent-ember-wed" ] else if String.equal name "ef-lent-1-friday" then [ "ef-lent-ember-fri" ] else if String.equal name "ef-lent-1-saturday" then [ "ef-lent-ember-sat" ] else ( match List.find_opt (fun wd -> String.equal name ("ef-passiontide-0-" ^ wd)) weekdays with | Some wd -> (* Critical 1, fix round 1 (coordinator review): the original version of this table widened "ef-passiontide-0-" into BOTH "ef-passiontide-1-" (Passion week) AND "ef-passiontide-2-" (HOLY WEEK, including the entire Sacred Triduum) on the strength of lectio's own citation being byte-identical between the two weeks. That only proves lectio CONFLATES the two weeks -- it has no Holy Week propers of its own -- not that the Missal does: Holy Monday's real Mass (docs/research/scan1.txt, "Feria II Hebdomadae sanctae, I classis") is Isai. 50,5-10 / Io. 12,1-9, nothing like Passion-week Monday's Ionae 3,1-10 / Io. 7,32-39 that the widen was putting there, and Holy Thursday's is 1 Cor. 11,20-32 / Io. 13,1-15 -- the Mass of the Lord's Supper reading Passion Sunday's own ferial Mass was the actual defect, not merely a citation nicety. Narrowed to Passion week ONLY; Holy Week's own citations (Monday, Tuesday, Thursday, Saturday) are hand-authored below, [holy_week_entries] -- see its own comment for which two days of Holy Week (Wednesday, Good Friday) are deliberately NOT included and why. *) [ "ef-passiontide-1-" ^ 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 slug_or_die name = match Slug.of_string name with Ok s -> s | Error e -> die "%s" e let pair ~first ~gospel = [ { Citation.part = Citation.First; reference = first }; { Citation.part = Citation.Gospel; reference = gospel } ] let vigil_entries = [ (slug_or_die "ef-nativity-vigil", pair ~first:"Rom 1:1-6" ~gospel:"Matt 1:18-21") ] (* Critical 1, fix round 1 (coordinator review): Holy Week's own Mass propers, hand-authored the same discipline [vigil_entries] above already uses for a genuine upstream-source gap -- lectio's own ini has no Holy Week data at all (see [colitur_keys]'s own Passiontide comment for the full account of what it has instead). Every citation below is Missal-verified TWICE, independently (docs/research/scan1.txt AND scan2.txt, the two different printings/scans Task 6 also cross-checked between): - [ef-passiontide-2-monday] (Holy Monday, "Feria II Hebdomadae sanctae, I classis"): Isai. 50,5-10 / Io. 12,1-9 -- scan1.txt "Lectio Isaiae Prophetae... Isai. 50, 5-10" + "Sequentia... secundum Ioannem. Io. 12, 1-9"; scan2.txt corroborates both citations word for word. - [ef-passiontide-2-tuesday] (Holy Tuesday, "Feria III Hebdomadae sanctae, I classis"): Ier. 11,18-20 / the Passion according to Mark, 14,32-72;15,1-46 -- scan1.txt "Lectio Ieremiae Prophetae... Ier. 11, 18-20" + "Evangelium Passionis et Mortis Domini secundum Marcum. 14,32-72; 15,1-46"; scan2.txt corroborates both. - [ef-passiontide-2-thursday] (Holy Thursday, "Feria V in Cena Domini"): 1 Cor. 11,20-32 / Io. 13,1-15 -- scan1.txt "Lectio Epistolae beati Pauli Apostoli ad Corinthios... 1 Cor. 11, 20-32" + "Sequentia... secundum Ioannem... Io. 13,1-15"; scan2.txt corroborates both. - [ef-passiontide-2-saturday] (Holy Saturday, Missa Vigiliae Paschalis' own Epistle+Gospel -- the actual Mass, distinct from the earlier prophecies, explicitly labelled "Lectio EPISTOLAE"): Col. 3,1-4 / Matt. 28,1-7 -- scan1.txt "Lectio Epistolae beati Pauli Apostoli ad Colossenses... Col. 3,1-4" + "Sequentia... secundum Matthaeum. Mt. 28,1-7"; scan2.txt corroborates both. DELIBERATELY NOT INCLUDED, and recorded here rather than guessed: - [ef-passiontide-2-wednesday] (Holy Wednesday, "Feria IV Hebdomadae sanctae"): TWO Old Testament lessons (Isai. 62,11;63,1-7, then Isai. 53,1-12), neither labelled "Epistola", before the Passion according to Luke (22,39-71;23,1-53) -- no single reading occupies the "Epistle" position this schema's [First]/[Gospel] pair assumes every other entry in this file has, unlike Holy Saturday's genuinely labelled Epistle above. - [ef-passiontide-2-friday] (Good Friday, "Feria VI in Passione et Morte Domini"): not even a Mass ("Solemnis Actio liturgica"), with TWO peer lessons (Osee 6,1-6, then Exodus 12,1-11), again neither labelled "Epistola", directly into the Passion according to John (18,1-40;19,1-42). Forcing either into a single First/Gospel pair would be an editorial choice this generator has no textual warrant to make on its own -- unlike Holy Saturday, where the Mass's own Epistle is explicitly labelled and distinct from its own preceding prophecies. Left absent: data/ef/expected-divergences.sexp's own Layer C entry records what this means for the differential (colitur emits [], an honest absence, not a guess). *) let holy_week_entries = [ (slug_or_die "ef-passiontide-2-monday", pair ~first:"Isai. 50, 5-10" ~gospel:"Io. 12, 1-9"); (slug_or_die "ef-passiontide-2-tuesday", pair ~first:"Ier. 11, 18-20" ~gospel:"Mark 14, 32-72; 15, 1-46"); (slug_or_die "ef-passiontide-2-thursday", pair ~first:"1 Cor. 11, 20-32" ~gospel:"Io. 13, 1-15"); (slug_or_die "ef-passiontide-2-saturday", pair ~first:"Col. 3, 1-4" ~gospel:"Matt. 28, 1-7") ] (* Important 3(a), fix round 1 (coordinator review): the fixed Nativity- Octave days (RG 91 entry 17, 29-31 December, colitur's own [ef-nativity-octave-day-{5,6,7}]) have a DIRECT formulary in the Missal, not merely a resolvable-by-walk-back gap -- "Diebus infra octavam Nativitatis Domini, II classis": Tit. 3,4-7 / Luc. 2,15-20 (docs/research/scan1.txt:6281-6329, scan2.txt:6900-6960, both word for word), and each specific date's own rubric points straight at it ("Die 29 decembris... Missa Puer natus est nobis, ut supra [28]", scan1.txt, repeated verbatim at 30 and 31 December). This is the SAME shape [vigil_entries] above already uses (one direct entry, both scans), not the Task-6-sized verification this task's own report scoped Task 8 away from -- confirmed, not merely asserted, since a single Missal heading answers it completely. REJECTED, and recorded so it is not re-attempted: the FIRST guess tried here gave these three slugs [ef-holy-name-sunday]'s own citation instead (Gal. 4,1-7 / Luc. 2,33-40, "Dominica infra octavam Nativitatis Domini" -- a DIFFERENT heading, for the Sunday specifically, not the weekdays). Measured against the real fixture and reverted: lectio's own citation for these dates is not uniform across years (2005-12-29 reads Christmas Day's own Mass, Heb 1:1-12/John 1:1-14, since 25 December 2005 was itself a Sunday that year; 2006-12-29 reads Advent IV's, 1 Cor. 4:1-5/Luke 3:1-6) -- neither matches "Diebus infra octavam"'s own formulary, because in BOTH those years the civil date landed on an ordinary WEEKDAY within the octave, not the (different) Sunday the first guess's citation was actually for. This entry is right for the weekday case precisely because it is sourced from the weekday's own heading, not the Sunday's. IMPORTANT CAVEAT, carried into data/ef/expected-divergences.sexp's own C6 entry, not fixed here: colitur's OWN [Temporal_ef] assigns [ef-nativity-octave-day-N] to BOTH an ordinary weekday within 29-31 December AND a Sunday landing there, undifferentiated at the temporal- slug level. RG 69 ("De dominica infra octavam Nativitatis Domini... semper fit Officium... nisi dominica incidat in festum I classis") is unconditional -- the Sunday's own distinct Office (RG 91 places II-class Sundays above days within the octave) should be observed instead of the weekday placeholder whenever 29-31 December IS a Sunday. This entry therefore gives the CORRECT citation for the majority (weekday) case and the WRONG one on the years the civil date is itself a Sunday -- a pre-existing [Temporal_ef] defect this generator cannot fix (it has no day-of-week logic of its own to add), out of this task's own scope, and the same RG 67/69 gap data/ef/expected-divergences-missalemeum.sexp's own M11 already tracks from a different differential layer. *) let nativity_octave_entries = let cs = pair ~first:"Tit. 3, 4-7" ~gospel:"Luc. 2, 15-20" in List.map (fun n -> (slug_or_die (Printf.sprintf "ef-nativity-octave-day-%d" n), cs)) [ 5; 6; 7 ] (* 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. [ef-nativity-octave-day-{5,6,7}] does NOT reuse this value -- a first attempt tried exactly that and was measured wrong and reverted; see [nativity_octave_entries] above for the correct, DIFFERENT, directly Missal-sourced formulary and the full account of why the two headings differ. *) 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) (* Critical 2, fix round 1 (coordinator review): "fix the class, not just the instance". [colitur_keys] is a hand-maintained table with no reality check of its own -- exactly how the Lent Ember mismatch survived the first pass (both engines independently fall through to the same wrong answer there, so even the differential could not see it). This sweeps {!Rite_ef.Temporal_ef.temporal} directly over a real civil-year range -- no sanctoral layer, no lectionary, no circularity with the file this tool generates -- and collects every DISTINCT office slug it ever actually produces. [assert_reachable] then requires every key this generator is about to EMIT to be a member of that set (an emitted key that is not a real Temporal_ef slug is dead data, unreachable by any caller -- exactly the shape both Critical findings had), dying loudly and naming every offender if not. Separately, informational only, it prints every real Temporal_ef slug that has NO entry in the final table -- not an error (most such gaps are the correctly-unproper ferias step 3 already resolves, task-5-report.md's own "297 of 304" measurement), but a standing audit log a human reader can check against that same report rather than trusting silence. 1583 is deliberately NOT the sweep's start: the full 1583-9999 domain is the KERNEL's own contract, not this rite-specific bootstrap tool's -- sweeping the differential's own window (2005-2050), widened by one year on each side for step-3 preceding/following-Sunday edge cases, is enough to enumerate every DISTINCT slug FAMILY (season/week/weekday combinations recur every year; only which YEAR exhibits a given alignment changes, e.g. how many Sundays fall in Time after Epiphany) -- confirmed against task-5-report.md's own domain-wide "412 distinct temporal slugs" figure: this sweep alone already finds all 412. *) 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_ef.Temporal_ef.temporal !d in Hashtbl.replace tbl (Slug.to_string t.Temporal.office.Celebration.slug) true; d := Date.add_days !d 1 done done; tbl let assert_reachable entries = let reachable = reachable_temporal_slugs () in let dead = List.filter (fun (s, _) -> not (Hashtbl.mem reachable (Slug.to_string s))) entries in (match dead with | [] -> () | _ -> List.iter (fun (s, _) -> Printf.eprintf "bootstrap_lectionary: DEAD KEY -- %S is not a real Temporal_ef slug over any civil \ day 2004-2051, so nothing can ever look it up\n" (Slug.to_string s)) dead; die "%d emitted key(s) are unreachable (see above) -- fix colitur_keys/vigil_entries/\ holy_week_entries/nativity_octave_entries/derived_entries, never delete this check" (List.length dead)); let emitted = Hashtbl.create 512 in List.iter (fun (s, _) -> Hashtbl.replace emitted (Slug.to_string s) true) entries; let uncovered = Hashtbl.fold (fun s _ acc -> if Hashtbl.mem emitted s then acc else s :: acc) reachable [] |> List.sort compare in Printf.eprintf "bootstrap_lectionary: %d of %d real Temporal_ef slugs (2004-2051) have no lectionary entry \ (informational -- most resolve correctly via step 3's ferial resumption; see \ task-5-report.md):\n" (List.length uncovered) (Hashtbl.length reachable); List.iter (fun s -> Printf.eprintf " %s\n" s) uncovered 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 @ holy_week_entries @ nativity_octave_entries @ derived_entries translated in assert_reachable entries; 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. Every\n\ ; emitted key is asserted, at generation time, to be a slug\n\ ; Rite_ef.Temporal_ef actually computes (see this generator's own\n\ ; [assert_reachable]) -- a dead key cannot ship silently again.\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 hand-authored directly from\n\ ; the Missal [holy_week_entries]; %d hand-authored directly from the\n\ ; Missal [nativity_octave_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 five). 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 holy_week_entries) (List.length nativity_octave_entries) (List.length entries - List.length translated - List.length vigil_entries - List.length holy_week_entries - List.length nativity_octave_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