(* Bootstraps data/of/lectionary.sexp from lectio's of-lectionary.ini (Task 4, 2026-08-25-colitur-of-phases-3-5). Lives in tools/, OCaml, not Python -- deliberately: this task's own brief named tools/extract_of_lectionary.py, but tools/bootstrap_lectionary.ml already IS the established shape for exactly this job (EF's own lectionary bootstrap), including a real, load-bearing safety net -- [assert_reachable] below sweeps Rite_of.Temporal_of.temporal AND the real merged sanctoral layer directly, so a mapping-table typo cannot ship a dead key silently. Reimplementing that check in Python would mean duplicating colitur's own date arithmetic in a second language, exactly the drift risk this generator exists to avoid. Named to match the OF-specific sibling convention (temporal_of.ml, precedence_of.ml), not bootstrap_lectionary.ml's own bare name, since a second rite needs a second, distinguishable generator. THE LINEAGE, stated once, here, because every downstream comment and the emitted file's own header both lean on it: lectio's of-lectionary.ini says of itself (its own top-of-file comment) that it is "Generated from niedziela.pl by scripts/genlect-of.go (harvest 2020-2025)". niedziela.pl is a POLISH VERNACULAR pastoral lectionary aid, not the Latin OLM (Ordo Lectionum Missae) 1981 itself, and its own citations are, per the design spec (2026-08-24-colitur-of-rite-module-design.md sec4.4), ENGLISH-CANONICAL (English book names/verse numbering), not the OLM's Vulgate-numbered Latin. THIS IS A SEPARATE LINEAGE from lectio's OF CALENDAR (data/of/calendar-2002.sexp, roman-calendar.ini, upstream calapi.inadiutorium.cz) -- lectio is not one OF witness but two, unrelated upstreams glued together by one downstream project, and neither is the typical edition. What this data CANNOT show: any divergence between niedziela.pl's own pastoral choices and the Latin OLM's own text (verse-range short/long forms, alternative readings, Vulgate-vs-Nova-Vulgata numbering -- the design spec's own sec4.4 records three real, confirmed such divergences against the actual OLM page images: Holy Family Year A's second-reading short form, Trinity Sunday's Dan 3:56, and the Baptism of the Lord's Mc 9:6 vs "Mark 9:7"); nor can it show anything about the OLM's SHORT-FORM/LONG-FORM alternatives, which niedziela.pl does not distinguish. This generator does not attempt to correct any of that -- it transcribes the vernacular data faithfully, states the lineage loudly, and lets a future task with the actual OLM page images (docs/research/of/olm-1981.pdf) do primary-source verification the way tools/bootstrap_lectionary.ml's own Holy Week entries did for EF. *) open Colitur_kernel let default_source = "../lectio/internal/caldata/of-lectionary.ini" let default_dest = "data/of/lectionary.sexp" let die fmt = Printf.ksprintf (fun s -> prerr_endline ("bootstrap_lectionary_of: " ^ s); exit 1) fmt (* ---- INI parsing --------------------------------------------------- *) type section = { name : string; fields : (string * string) list } (* Fails loudly, not silently, when the source file is absent -- Task 1's own review found exactly this bug in the calendar extractor (parse_lectio_ini swallowing FileNotFoundError, producing a DIFFERENT file with a different SHA-256 and no warning): checked BEFORE any read is attempted, and the [open_in] itself is also guarded as defence in depth, mirroring tools/bootstrap_lectionary.ml's own [parse_ini]. *) let parse_ini path = if not (Sys.file_exists path) then die "%s: no such file (a missing lectio snapshot must fail loudly, not \ silently regenerate with a different SHA-256 and no warning -- Task 1's \ own review round found exactly this bug)" path; let ic = try open_in path with Sys_error e -> die "%s" e in let sections = ref [] and cur = ref None and malformed = ref [] and lineno = ref 0 in let flush () = match !cur with | Some (n, fs) -> sections := { name = n; fields = List.rev fs } :: !sections | None -> () in (try while true do incr lineno; let raw = input_line ic in let line = String.trim raw in if line = "" || line.[0] = ';' || line.[0] = '#' then () else if line.[0] = '[' then begin flush (); cur := Some (String.sub line 1 (String.length line - 2), []) end else let known_key k = List.mem k [ "first"; "psalm"; "second"; "gospel" ] in match String.index_opt line '=' with | Some i when known_key (String.trim (String.sub line 0 i)) -> let k = String.trim (String.sub line 0 i) in let v = String.trim (String.sub line (i + 1) (String.length line - i - 1)) in cur := (match !cur with | Some (n, fs) -> Some (n, (k, v) :: fs) | None -> die "%s:%d: field %S before any section" path !lineno k) | Some _ | None -> (* Known, single, understood anomaly (see the assertion below): line 1276 of the shipped snapshot, inside [holy-family-A], reads bare "=Sir3:2-6,12-14-Gk" -- an EMPTY key, not "no '=' sign" (an earlier version of this parser tested for the latter and missed this line entirely, since [String. index_opt] DOES find the '=' at position 0; a key must now be one of the four known field names, checked explicitly). This is the Polish lectionary's own Septuagint/Greek- numbering variant for Sirach 3 (design spec sec5's own "Polish is independent on one point": Syr 3,2-6.12-14 against the OLM/CEI/USA table's shared Nova Vulgata 3,3-7.14-17a), evidently emitted by niedziela.pl's own scraper as a stray annotation rather than a proper "first_alt = ..." field. Recorded, not silently dropped: every such line is collected and the count is asserted below, so a NEW malformed line in a future lectio snapshot still fails loudly rather than being quietly absorbed by this same tolerance. *) malformed := (!lineno, line) :: !malformed done with End_of_file -> ()); flush (); close_in ic; (match List.rev !malformed with | [ (1276, "=Sir3:2-6,12-14-Gk") ] -> () | [] -> die "%s: expected exactly one known malformed line (1276, the Sirach \ Septuagint-numbering annotation) but found none -- either the \ snapshot changed or this tolerance is now dead code; re-check \ by hand before touching this assertion" path | other -> List.iter (fun (n, l) -> Printf.eprintf " line %d: %S\n" n l) other; die "%s: %d unparseable line(s) found, not the one previously-known \ anomaly (see above) -- investigate before shipping; a new \ malformed line must be understood, not silently swallowed" path (List.length other)); List.rev !sections (* ---- Citation extraction -- First + Gospel only --------------------- *) (* Psalm and Second are deliberately dropped, even though lectio's ini carries [psalm] (every entry) and [second] (Sundays/solemnities): Colitur_kernel.Validate's own ["citations"] check (kernel, off limits to this task) hard-asserts every day's citation-part list is EXACTLY [[First; Gospel]] -- the identical scope boundary tools/bootstrap_lectionary.ml's own [convert] already holds for EF ("Nothing here encodes... the chants (Psalm, Second, Tract, Alleluia, Sequence) are deliberately unbuilt"). Carrying Psalm/Second through here would not add capability today (nothing reads them) and would set up a guaranteed collision with that kernel check the moment a future task wires Validate over OF data. *) let cite sec = let get k = match List.assoc_opt k sec.fields with None | Some "" -> None | Some v -> Some v in match (get "first", get "gospel") with | Some first, Some gospel -> Some [ { Citation.part = Citation.First; reference = first }; { Citation.part = Citation.Gospel; reference = gospel } ] | _ -> None (* ---- Cycle-family grouping ------------------------------------------ *) (* Every one of the 988 sections carries a cycle suffix: -A/-B/-C (Sunday cycle, 102 bases x 3 = 306) or -I/-II (weekday cycle, 341 bases x up to 2 = up to 682; two bases are short one cycle -- see [collapse_weekday] below). Measured exhaustively before writing this generator: no section carries any OTHER suffix, and no base name appears in both groups. *) let strip_suffix ~suffix s = let ls = String.length s and lsuf = String.length suffix in if ls > lsuf && String.sub s (ls - lsuf) lsuf = suffix then Some (String.sub s 0 (ls - lsuf)) else None let base_and_letter name = match strip_suffix ~suffix:"-A" name with | Some b -> Some (b, `A) | None -> ( match strip_suffix ~suffix:"-B" name with | Some b -> Some (b, `B) | None -> ( match strip_suffix ~suffix:"-C" name with | Some b -> Some (b, `C) | None -> ( match strip_suffix ~suffix:"-II" name with | Some b -> Some (b, `II) | None -> ( match strip_suffix ~suffix:"-I" name with | Some b -> Some (b, `I) | None -> None)))) type resolved = | Flat of Citation.t list (* cycle-independent: one citation set for every year *) | Sunday_cycle of (Citation.t list * Citation.t list * Citation.t list) (* A, B, C *) | Weekday_cycle of (Citation.t list option * Citation.t list option) (* I, II -- either may be absent *) (* Sunday-cycle collapse: mechanical, not curated per-saint. Measured across all 102 bases before this generator existed: 43 are byte- identical across A/B/C (routine -- a fixed-date saint's own Mass simply does not vary by year, e.g. andrew-the-apostle); 57 are genuinely distinct on all three letters (real OLM n.66 three-year-cycle content -- every numbered Sunday, plus the handful of movable/fixed solemnities of the Lord whose OWN Gospel varies by year: Holy Family, Baptism of the Lord, Ascension, Corpus Christi, Christ the King, Trinity, Sacred Heart, Transfiguration); and EXACTLY 2 have a clean 2-of-3 majority with the third an unrelated passage from a DIFFERENT day entirely -- annunciation-of-the-lord (B is Monday of Holy Week's own Mass, Isa 42:1-7/John 12:1-11 -- a scraping-year artifact: the Annunciation was genuinely impeded and transferred that harvest year, so niedziela.pl's site had Holy Week's Mass on 25 March instead) and immaculate-conception-of-the-blessed-virgin-mary (A is Isa 35:1-10/ Luke 5:17-26, an ordinary Lenten-feria Gospel, the same shape). Both solemnities are, in real practice, NOT cycle-dependent at all (single Mass every year) -- corroborated externally by the "2 of 3 agree, none of the 57 genuine cases exhibit that shape" measurement itself, not merely asserted -- so majority-collapse is applied, and the discarded outlier is logged. No other base in the whole 102-base population has this 2-of-3 shape; the rule is general, not hand-targeted at these two names. *) let collapse_sunday base va vb vc = if va = vb && vb = vc then Flat va else if va = vb then begin Printf.eprintf "bootstrap_lectionary_of: %s: cycle C disagreed with A/B (scraping-year \ artifact, majority kept): %s\n" base (match vc with [ f; _ ] -> f.Citation.reference | _ -> "?"); Flat va end else if va = vc then begin Printf.eprintf "bootstrap_lectionary_of: %s: cycle B disagreed with A/C (scraping-year \ artifact, majority kept): %s\n" base (match vb with [ f; _ ] -> f.Citation.reference | _ -> "?"); Flat va end else if vb = vc then begin Printf.eprintf "bootstrap_lectionary_of: %s: cycle A disagreed with B/C (scraping-year \ artifact, majority kept): %s\n" base (match va with [ f; _ ] -> f.Citation.reference | _ -> "?"); Flat vb end else Sunday_cycle (va, vb, vc) (* Weekday-cycle collapse: same mechanical byte-identity test. OLM 1981 Praenotanda n.69 points 2-3 (page-image verified, docs/research/of/olm-1981.pdf p.33/"XXXIII") state that Lent has its own fixed seasonal cycle and Advent/Christmastide/Paschaltide ferias "do not change" year to year -- exactly the byte-identical shape measured for those families (advent-1/2/3, advent-dec-17..24, lent-after-ashes-*, holy-week-*, easter-octave-*: 119 of 341 bases). Point 4 confines the REAL two-year alternation to Ordinary Time's 34 weeks, and even there only the FIRST reading alternates -- the Gospel is one single, year-independent cycle (n.69 point 4: "lectiones evangelicae unico disponuntur cyclo... Prior vero lectio, in duplici cyclo ordinatur") -- which is exactly why 198 of the 221 non-identical bases still share a common Gospel with only the First Reading differing: real content, kept as two entries because the FULL pair genuinely differs, not a scraping artifact (unlike the two Sunday-cycle cases above, no base in this group showed a "majority of one, unrelated outlier" shape). *) let collapse_weekday base v1 v2 = match (v1, v2) with | Some a, Some b when a = b -> Flat a | Some _, Some _ -> Weekday_cycle (v1, v2) | Some _, None | None, Some _ -> Printf.eprintf "bootstrap_lectionary_of: %s: only one weekday-cycle year present in \ lectio's own snapshot (a real gap in niedziela.pl's 2020-2025 harvest, \ not a parsing defect) -- transcribed as-is\n" base; Weekday_cycle (v1, v2) | None, None -> die "%s: cycle collapse called with no data" base let resolve_sections secs = let tbl = Hashtbl.create 1024 in List.iter (fun sec -> match base_and_letter sec.name with | None -> die "%s: no recognised cycle suffix (-A/-B/-C/-I/-II)" sec.name | Some (base, letter) -> ( match cite sec with | None -> die "%s: no first/gospel field" sec.name | Some cs -> let cur = try Hashtbl.find tbl base with Not_found -> (None, None, None, None, None) in let a, b, c, i, ii = cur in let updated = match letter with | `A -> (Some cs, b, c, i, ii) | `B -> (a, Some cs, c, i, ii) | `C -> (a, b, Some cs, i, ii) | `I -> (a, b, c, Some cs, ii) | `II -> (a, b, c, i, Some cs) in Hashtbl.replace tbl base updated)) secs; Hashtbl.fold (fun base (a, b, c, i, ii) acc -> match (a, b, c, i, ii) with | Some va, Some vb, Some vc, None, None -> (base, collapse_sunday base va vb vc) :: acc | None, None, None, i, ii when i <> None || ii <> None -> (base, collapse_weekday base i ii) :: acc | Some va, Some vb, Some vc, i, ii when i <> None || ii <> None -> (* A real, measured duplicate in lectio's own source, confined (checked below by this generator's own exhaustive Hashtbl.fold) to the six Easter Octave weekdays: each carries BOTH an -A/-B/-C section AND an -I/-II one, evidently written by two different passes of niedziela.pl's own generation pipeline. Content-verified identical in substance (Acts 2:14,22-32/33, Ps 16, Matt 28:8-15 -- "Acts"/"The Acts" and "Mat"/"Matthew" spelling differ, the citation does not), and each of the two representations is ALSO internally self-consistent (A=B=C, I=II) on its own -- so this is redundancy, not a real conflict. The -A/-B/-C reading is kept (it is what this generator's own [named_overrides] table for these six bases already targets); the -I/-II reading is discarded, logged here rather than silently dropped. *) Printf.eprintf "bootstrap_lectionary_of: %s: BOTH Sunday- and weekday-cycle data present \ (a genuine lectio duplicate, not a defect -- see the comment on this branch); \ keeping the Sunday-cycle (A/B/C) reading, discarding the weekday-cycle (I/II) one\n" base; (base, collapse_sunday base va vb vc) :: acc | _ -> die "%s: mixed or incomplete cycle data (a=%b b=%b c=%b i=%b ii=%b)" base (a <> None) (b <> None) (c <> None) (i <> None) (ii <> None)) tbl [] (* ---- colitur slug mapping -------------------------------------------- *) let weekday_pairs = (* (colitur full word, lectio abbreviation) *) [ ("monday", "mon"); ("tuesday", "tue"); ("wednesday", "wed"); ("thursday", "thu"); ("friday", "fri"); ("saturday", "sat") ] let split_dash s = String.split_on_char '-' s (* (colitur season word, lectio season word, max week number) -- ferial families. Min week is always 1. Week 6 of Lent (Holy Week) and week 1 of Easter (the Octave) are deliberately excluded from this generic table -- both have their own named families, handled in [special_overrides]. *) let ferial_families = [ ("advent", "advent", 4); ("lent", "lent", 5); ("easter", "easter", 7); ("ordinary-time", "ordinary", 34) ] (* Easter's own numbered Sunday family runs 3..7 in lectio (2 is the separately-named "easter-octave-sun", see [named_overrides]) but the upper bound here is deliberately still a loose "any n >= 1" check (see [pattern_match]'s own guard) -- lectio simply never emits an "easter-sunday-1"/"-2" base for this family to collide with. *) let sunday_families = [ ("advent", "advent", 4); ("lent", "lent", 5); ("easter", "easter", 7); ("ordinary-time", "ordinary", 33) ] (* A base that pattern-matches a numbered ferial or Sunday family. Returns the colitur slug directly. *) let pattern_match base = match split_dash base with | [ w; "sunday"; n ] -> ( match (List.find_opt (fun (_, lw, _) -> lw = w) sunday_families, int_of_string_opt n) with | Some (cw, _, max_n), Some ni when ni >= 1 && ni <= max_n -> Some (Printf.sprintf "of-%s-sunday-%d" cw ni) | _ -> None) | [ w; n; wd ] -> ( match ( List.find_opt (fun (_, lw, _) -> lw = w) ferial_families, int_of_string_opt n, List.find_opt (fun (_, la) -> la = wd) weekday_pairs ) with | Some (cw, _, max_n), Some ni, Some (full_wd, _) when ni >= 1 && ni <= max_n -> Some (Printf.sprintf "of-%s-%d-%s" cw ni full_wd) | _ -> None) | _ -> None (* Named-day and irregular-family renames -- everything [pattern_match] above cannot reach because colitur's own slug uses a DIFFERENT word, a DIFFERENT numbering scheme, or (the Christmas-season families) the SAME weekday-keyed scheme lectio happens to expose under a differently-named family. Each entry cites the content verification that justified it, not merely the name resemblance -- three of these (christmas, christmas-sunday-sun, easter-octave-sun) would have been WRONG if matched by name alone; see the comment on each. *) let named_overrides = [ (* Content-verified against the real reading (Isa 62:1-5/Matt 1:18-25, the genealogy/annunciation-to-Joseph narrative): this is the VIGIL Mass, not "Christmas Day" despite the bare name -- the real Christmas DAY Mass (Isa 52:7-10/John 1:1-14) is ABSENT from lectio's own 988 keys entirely, a genuine, notable gap (see the generator's own coverage report). *) ("christmas", [ "of-nativity-vigil" ]); (* Content-verified (Sirach 24 Wisdom-personified / Eph 1 / John 1:1-18): this is the SECOND SUNDAY AFTER THE NATIVITY (Normae n.36), not a generic "the Sunday of Christmas" despite the bare name -- Holy Family (a DIFFERENT Sunday, Normae n.35(a)) has its own "holy-family" base, mapped just below. *) ("christmas-sunday-sun", [ "of-christmas-sunday-2" ]); ("mary-mother-of-god-octave-of-christmas", [ "of-mary-mother-of-god" ]); (* Content-verified (Joel 2:12-18/Matt 6:1-6,16-18, THE Ash Wednesday Mass): lectio groups Ash Wednesday itself into this weekday-cycle family rather than giving it a bare "ash-wednesday" key. Its own three siblings (Thursday/Friday/Saturday right after Ash Wednesday) are plain ferias, matched by the SAME family, needing only the word rename ("lent-after-ashes" -> "of-lent-after-ashes") [pattern_match] cannot supply on its own (it is not a numbered week). *) ("lent-after-ashes-wed", [ "of-ash-wednesday" ]); ("lent-after-ashes-thu", [ "of-lent-after-ashes-thursday" ]); ("lent-after-ashes-fri", [ "of-lent-after-ashes-friday" ]); ("lent-after-ashes-sat", [ "of-lent-after-ashes-saturday" ]); ("trinity-sunday", [ "of-trinity" ]); (* Content-verified (John 20:19-31, the Divine Mercy Gospel): the Sunday CLOSING the Easter Octave, colitur's own generic week-2-of-Easter Sunday slug, not a second distinct office. *) ("easter-octave-sun", [ "of-easter-sunday-2" ]); (* The remaining single- or compound-word NAMED temporal days (Temporal_of.named/[temporal]'s own Sunday branches): colitur's own slug is simply "of-" prepended to lectio's own bare name, verified against temporal_of.ml directly rather than assumed -- these are NOT reachable by pass-through (pass-through checks the SANCTORAL layer only, and none of these is sanctoral data). *) ("epiphany", [ "of-epiphany" ]); ("palm-sunday", [ "of-palm-sunday" ]); ("easter-sunday", [ "of-easter-sunday" ]); ("ascension", [ "of-ascension" ]); ("pentecost", [ "of-pentecost" ]); ("baptism-of-the-lord", [ "of-baptism-of-the-lord" ]); ("christ-the-king", [ "of-christ-the-king" ]); ("corpus-christi", [ "of-corpus-christi" ]); ("holy-family", [ "of-holy-family" ]); (* Sacred Heart is SANCTORAL data (calendar-2002.sexp, Easter_offset 68 -- F-HEARTS finding, this plan's Task 1), not a Temporal_of slug, and its colitur slug carries "-of-jesus" that lectio's bare "sacred-heart" does not. *) ("sacred-heart", [ "sacred-heart-of-jesus" ]); (* Holy Week Monday-Wednesday and the Triduum: colitur's own Temporal_of deliberately does NOT name these days (Lent's own generic week-6 ferial slug covers them, see temporal_of.ml's own [season] function -- Lent runs "through Holy Saturday inclusive"). lectio names them by a WEEKDAY family lectio itself calls "holy-week"/"triduum" rather than "lent-6". *) ("holy-week-mon", [ "of-lent-6-monday" ]); ("holy-week-tue", [ "of-lent-6-tuesday" ]); ("holy-week-wed", [ "of-lent-6-wednesday" ]); ("triduum-thu", [ "of-lent-6-thursday" ]); ("triduum-fri", [ "of-lent-6-friday" ]); ("triduum-sat", [ "of-lent-6-saturday" ]); (* Easter Octave weekdays: colitur names these directly (of-easter-octave-day-N, Monday=2..Saturday=7 -- temporal_of.ml's own [named], "Easter Sunday (above) plus these six"). *) ("easter-octave-mon", [ "of-easter-octave-day-2" ]); ("easter-octave-tue", [ "of-easter-octave-day-3" ]); ("easter-octave-wed", [ "of-easter-octave-day-4" ]); ("easter-octave-thu", [ "of-easter-octave-day-5" ]); ("easter-octave-fri", [ "of-easter-octave-day-6" ]); ("easter-octave-sat", [ "of-easter-octave-day-7" ]); (* The three Christmas-season ferial stretches (Temporal_of.christmas_feria_slug's own three "of-christmas-{0,1,2}- " 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-" (1 John/John 1, the Octave itself, 26-31 Dec) -> stretch 0. - "christmas-" 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-" (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 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 -- this is what I2's own review finding asked for as the minimum acceptable fix if full conversion could not be reached, and it was not: see test/test_citation_coverage_of.ml for the pinned, exact residual and book.ml's own citation for why it remains (chapter-crossing hyphen ranges -- "2:29-3:6" -- a {!Colitur_citation.Parse.t} shape this parser does not represent, deliberately not built this task; every OTHER shape this file's data used to expose, including 345 previously- unregistered book names, is now fixed at the source). *) 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 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 let oc = open_out dst in Printf.fprintf oc "; data/of/lectionary.sexp -- OF (2002) temporal + sanctoral lectionary\n\ ; (Epistle + Gospel citations, never scripture text), bootstrapped from\n\ ; lectio.\n\ ;\n\ ; LINEAGE, stated loudly because it constrains what this file can show\n\ ; (design spec 2026-08-24-colitur-of-rite-module-design.md sec4.4/sec5):\n\ ; this is a POLISH VERNACULAR pastoral lectionary (niedziela.pl,\n\ ; harvested 2020-2025 by lectio's own scripts/genlect-of.go), NOT the\n\ ; Latin OLM (Ordo Lectionum Missae) 1981 itself, and its citations are\n\ ; ENGLISH-CANONICAL, not OLM's Vulgate numbering. It CANNOT show any\n\ ; divergence between niedziela.pl's own pastoral choices and OLM's own\n\ ; text -- three such divergences are already confirmed against the real\n\ ; OLM page images (design spec sec4.4): Holy Family Year A's second-\n\ ; reading short form, Trinity Sunday's Dan 3:56, and the Baptism of the\n\ ; Lord's Mc 9:6 vs \"Mark 9:7\". Nor can it show OLM's short/long-form\n\ ; reading alternatives, which niedziela.pl does not distinguish. This is\n\ ; ALSO a SEPARATE lineage from lectio's own OF CALENDAR data\n\ ; (data/of/calendar-2002.sexp, roman-calendar.ini, upstream\n\ ; calapi.inadiutorium.cz) -- lectio is two unrelated upstreams glued\n\ ; together by one downstream project, not one witness, and neither is\n\ ; the typical edition.\n\ ;\n\ ; Generator: tools/bootstrap_lectionary_of.ml -- do not hand-edit;\n\ ; re-run against the same lectio snapshot (its SHA-256 is pinned below;\n\ ; a MISSING source file is fatal, checked before any read is attempted --\n\ ; see this generator's own [parse_ini]) and commit the diff instead.\n\ ; Every emitted key is asserted, at generation time, to be a slug\n\ ; Rite_of.Temporal_of actually computes, OR a slug the real merged\n\ ; sanctoral layer (data/of/calendar-2002.sexp + all 13 decree overlays)\n\ ; actually carries, 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\ ; 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): of\n\ ; %d emitted (First, Gospel) citation fields, %d distinct\n\ ; references (out of the full %d) do not parse -- every one a\n\ ; hyphen range that crosses a chapter boundary (\"2:29-3:6\"), a\n\ ; {!Colitur_citation.Parse.t} shape this parser's [part]/\n\ ; [verse_range] types do not represent, deliberately not built\n\ ; this task (see book.ml's own [is_single_chapter] neighbourhood\n\ ; for what WAS fixed: 345 previously-unregistered book names and\n\ ; verse sub-letter markers, both closed at the parser/book-table\n\ ; level, not here). Such a reference still prints, verbatim,\n\ ; never a crash or a dropped citation -- {!Colitur_citation.Sigla\n\ ; .format}'s own documented contract on a parse miss. Pinned\n\ ; exactly, both directions, by test/test_citation_coverage_of.ml.\n\ ; Named below.\n\ ; Regenerate with:\n\ ; eval $(opam env) && dune exec tools/bootstrap_lectionary_of.exe -- %s %s\n" src (sha256 src) (List.length secs) (List.length resolved) report.mapped (List.length excluded_bases) (List.length report.unmapped) (List.length entries) gap_total gap_count (List.length report.unmapped) (List.length resolved) (Hashtbl.length sanctoral_slugs) report.sanctoral_passthrough (Hashtbl.length sanctoral_slugs - report.sanctoral_passthrough) cit_total (List.length cit_bad) 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, all chapter-crossing ranges):\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