aboutsummaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/bootstrap_lectionary_of.ml827
-rw-r--r--tools/dune15
2 files changed, 842 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
diff --git a/tools/dune b/tools/dune
index 9a4dea5..40e9836 100644
--- a/tools/dune
+++ b/tools/dune
@@ -23,6 +23,21 @@
(name bootstrap_lectionary)
(libraries colitur_kernel rite_ef unix sexplib))
+; Task 4 (2026-08-25-colitur-of-phases-3-5): converts lectio's
+; of-lectionary.ini into data/of/lectionary.sexp. Run via `dune exec
+; tools/bootstrap_lectionary_of.exe -- <source.ini> <dest.sexp>`. Same
+; `unix`-for-`sha256sum` usage as bootstrap_lectionary.ml above; `rite_of`
+; for the identical "code, not data" reason bootstrap_lectionary.ml links
+; `rite_ef` -- [assert_reachable] sweeps Rite_of.Temporal_of.temporal AND
+; the real merged sanctoral layer directly, so a mapping-table typo cannot
+; ship a dead key silently. Runs from the repo root (its own [reachable_
+; sanctoral_slugs] reads data/of/calendar-2002.sexp and data/of/amendments/
+; by a repo-root-relative path, unlike bootstrap_lectionary.ml's own
+; source/dest arguments, which are caller-supplied).
+(executable
+ (name bootstrap_lectionary_of)
+ (libraries colitur_kernel rite_of unix sexplib))
+
; Task 6 (2026-08-21-colitur-celebrant-rubrics-phase1): turns pdftotext's
; -layout dump of the Latin Mass Society Ordo into test/fixtures/
; lms-ordo-2024-2025.sexp. Run via `dune exec tools/extract_lms_ordo.exe --