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