From 155c2e55948dc179a45458d856808f7105878d1f Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 26 Aug 2026 09:15:08 +0200 Subject: 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. --- lib/rites/rite_of/lectionary_of.ml | 107 ++++++++++++++++++++++++++++ lib/rites/rite_of/lectionary_of.mli | 136 ++++++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 lib/rites/rite_of/lectionary_of.ml create mode 100644 lib/rites/rite_of/lectionary_of.mli (limited to 'lib') diff --git a/lib/rites/rite_of/lectionary_of.ml b/lib/rites/rite_of/lectionary_of.ml new file mode 100644 index 0000000..1664fad --- /dev/null +++ b/lib/rites/rite_of/lectionary_of.ml @@ -0,0 +1,107 @@ +(* See lectionary_of.mli for the full citations and design argument. *) +open Colitur_kernel + +type sunday_cycle = Year_a | Year_b | Year_c +type weekday_cycle = Year_i | Year_ii + +let sunday_cycle_letter = function Year_a -> "a" | Year_b -> "b" | Year_c -> "c" +let weekday_cycle_letter = function Year_i -> "i" | Year_ii -> "ii" + +(* The civil year in which the liturgical year CONTAINING [date] opened -- + i.e. the year y such that [year_start y <= date < year_start (y + 1)]. + This is the one piece of arithmetic both cycle rules share, and it is + exactly the "get the boundary right" the task brief names: a date in, + say, early December belongs to the liturgical year that opened THAT + same Advent only once Advent has actually started that year (compared + by real date, not by month) -- a month-based shortcut ("December always + means the new liturgical year") is wrong in the (real, if rare) case of + a December date still ahead of that year's own Advent I. *) +let liturgical_year_open_year ~year_start date = + let y = Date.year date in + if Date.compare date (year_start y) >= 0 then y else y - 1 + +(* OLM n.66 + footnote 102 (mli's own citation): the LABEL year is the one + most of the liturgical year falls in, i.e. one more than the year + Advent opened in ("a prima hebdomada Adventus, quae cadit in anno + civili praecedente" -- the first week of Advent falls in the PRECEDING + civil year relative to the label). Divisible by 3 -> C, then A, then B, + worked example 1980=C/1981=A/1982=B/1983=C verified against the real + page image. *) +let sunday_cycle ~year_start date = + let label = liturgical_year_open_year ~year_start date + 1 in + match label mod 3 with 0 -> Year_c | 1 -> Year_a | _ -> Year_b + +(* OLM n.69 point 4 (mli's own citation): "Annus primus adhibetur annis + imparibus, annus secundus vero annis paribus" -- Year I in odd label + years, Year II in even. Same label-year computation as [sunday_cycle] + above, for the identical reason: this is a two-year cycle, not a + three-year one, but the label-year definition itself does not depend on + which modulus governs it. *) +let weekday_cycle ~year_start date = + let label = liturgical_year_open_year ~year_start date + 1 in + if label mod 2 = 1 then Year_i else Year_ii + +(* Tries [base] flat, then [base]-, then [base]-, in that order. Safe unconditionally: tools/ + bootstrap_lectionary_of.ml emits a given base under AT MOST ONE of + these three forms (a flat entry when the two/three cycle years were + byte-identical, a cycle-lettered family otherwise -- never both), so at + most one of the three lookups below can ever hit, and trying the two + irrelevant forms for a family of the other kind or a flat family is + simply two wasted, harmless misses. This is what lets [readings] below + stay ignorant of which of the two cycle systems (or neither) a given + slug's own family actually uses. *) +let lookup_any lectionary ~year_start base date = + match Lectionary.find lectionary base with + | Some cs -> Some cs + | None -> ( + let with_suffix suffix = Slug.of_string_exn (Slug.to_string base ^ "-" ^ suffix) in + match Lectionary.find lectionary (with_suffix (sunday_cycle_letter (sunday_cycle ~year_start date))) with + | Some cs -> Some cs + | None -> Lectionary.find lectionary (with_suffix (weekday_cycle_letter (weekday_cycle ~year_start date)))) + +let readings ~lectionary ~year_start ~(observed : Vocab_of.rank Celebration.t) + ~(temporal : (Vocab_of.season, Vocab_of.rank) Temporal.t) ~date ~temporal_at:_ = + match observed.Celebration.citations with + | _ :: _ as cs -> + (Some { Mass_formulary.said = Some observed.Celebration.slug; via = Mass_formulary.Proper }, cs) + | [] -> ( + (* Step 2: a sanctoral entity actually won the day -- i.e. the + observed celebration is not itself the day's own temporal office. + Same guard shape as Rite_ef.Lectionary_ef.readings' own step 4 + (see that module's implementation comment for the full argument + for why this comparison, not a [subject]/[status] test, is the + right one): comparing SLUGS is exact whenever the temporal and + sanctoral streams cannot collide, which holds here for the + identical reason it holds for EF -- every Temporal_of slug this + module ever constructs carries the "of-" prefix (temporal_of.ml's + own [temporal], every branch), and 0 of the 222 shipped + data/of/calendar-2002.sexp + amendments sanctoral slugs do. *) + let sanctoral_office = + not (Slug.equal observed.Celebration.slug temporal.Temporal.office.Celebration.slug) + in + match + if sanctoral_office then lookup_any lectionary ~year_start observed.Celebration.slug date else None + with + | Some cs -> + (* Mass_formulary.Proper, not .Common -- see lectionary_of.mli's + own doc comment on this branch for the full argument. In + short: EF's [Common] constructor names a SPECIFIC mechanism + (several saints sharing one formulary via an explicit + assignment table, data/ef/commons.sexp) that OF's own data has + no counterpart for -- every sanctoral slug this step resolves + carries its OWN dedicated lectio entry, not a shared one, so + relabelling it [Common] would claim a mechanism that is not + actually present. [Proper] is the honest fit: "the observed + celebration's own proper Mass", true regardless of whether the + citations are embedded on Celebration.t (step 1) or held + externally in [lectionary] (this step) -- a storage detail + Mass_formulary.source was never built to carry. *) + (Some { Mass_formulary.said = Some observed.Celebration.slug; via = Mass_formulary.Proper }, cs) + | None -> ( + match lookup_any lectionary ~year_start temporal.Temporal.office.Celebration.slug date with + | Some cs -> + ( Some { Mass_formulary.said = Some temporal.Temporal.office.Celebration.slug; + via = Mass_formulary.Own_slug }, + cs ) + | None -> (None, []))) diff --git a/lib/rites/rite_of/lectionary_of.mli b/lib/rites/rite_of/lectionary_of.mli new file mode 100644 index 0000000..0c4d1d6 --- /dev/null +++ b/lib/rites/rite_of/lectionary_of.mli @@ -0,0 +1,136 @@ +(** The OF reading-cycle arithmetic and the temporal + sanctoral lectionary + resolution chain (Task 4, 2026-08-25-colitur-of-phases-3-5). + + Two halves with a different character, per the task's own brief: + + - {b The cycle rules are CODE}, computed from the civil year, cited to + the OLM (Ordo Lectionum Missae) 1981 Praenotanda -- see {!sunday_cycle} + and {!weekday_cycle} below for the citations, each verified against the + real page image (docs/research/of/olm-1981.pdf pp.32-33/"XXXII-XXXIII"), + not the unreliable OCR text layer (design spec 2026-08-24-colitur-of- + rite-module-design.md sec4.4's own caveat). + - {b The data is bootstrapped from lectio} (binding decision 3), a + POLISH VERNACULAR pastoral lectionary (niedziela.pl), not the Latin + OLM itself -- see tools/bootstrap_lectionary_of.ml's own top-of-file + comment and data/of/lectionary.sexp's own provenance header for the + full lineage statement and what it cannot show. *) + +open Colitur_kernel + +(** OLM 1981 Praenotanda n.66 (page-image verified, p.32/"XXXII"): "cyclus + trium annorum proponitur" -- the three-year Sunday/solemnity cycle. + Footnote 102, same page, gives the determining rule with a worked + example: "Per litteram C designatur annus cuius numerus in tres partes + aequales dividi potest... Sic annus 1980 est annus C, annus vero + sequens, scilicet annus 1981, est annus A, annus vero 1982 est annus B, + et annus 1983 est iterum annus C" -- the year divisible by three is + Year C, the next Year A, the next Year B. The SAME footnote also gives + the Gospel-per-year identity quoted on {!sunday_cycle}'s own doc + comment below (A = Matthew, B = Mark, C = Luke), and states explicitly + that each cycle's own "year" runs "iuxta dispositionem anni liturgici, + nempe a prima hebdomada Adventus, quae cadit in anno civili + praecedente" -- according to the liturgical year, namely from the first + week of Advent, WHICH FALLS IN THE PRECEDING CIVIL YEAR. This is the + task brief's own "the cycle turns at Advent I, not 1 January": Advent + 1980 (civil year 1980) already opens the 1981-labelled liturgical year + (Year A), even though most of civil year 1980 was still Year C. *) +type sunday_cycle = Year_a | Year_b | Year_c + +(** OLM 1981 Praenotanda n.69 (page-image verified, p.33/"XXXIII"), the + "c) De ordinatione lectionum pro feriis" subsection's own point 4: + "Pro feriis vero triginta quattuor hebdomadarum <> lectiones + evangelicae unico disponuntur cyclo, qui singulis annis resumitur. + Prior vero lectio, in duplici cyclo ordinatur, alternis annis legenda. + Annus primus adhibetur annis imparibus, annus secundus vero annis + paribus" -- for the ferias of the thirty-four weeks of Ordinary Time, + the GOSPEL is arranged in a single cycle repeated every year; the FIRST + reading alone alternates over two years. Year I is used in odd years, + Year II in even years. The SAME point 4, its own preceding points 2-3, + state the two-year alternation is confined to Ordinary Time: Lent has + its own single seasonal cycle (point 2) and "Pro feriis Adventus, + temporis Nativitatis et paschalis cyclus eodem modo annualis est: + lectiones ideo non mutantur" -- for the ferias of Advent, Christmastide + and Paschaltide the cycle is likewise annual: the readings DO NOT + CHANGE (point 3). {!Lectionary.t} does not need to encode that + distinction itself: a non-alternating family's own two cycle-letter + keys were collapsed to one flat entry at bootstrap time (see + tools/bootstrap_lectionary_of.ml's own [collapse_weekday]), so trying + both letters here is always safe -- the flat entry is found first + either way. *) +type weekday_cycle = Year_i | Year_ii + +val sunday_cycle_letter : sunday_cycle -> string +val weekday_cycle_letter : weekday_cycle -> string + +(** [sunday_cycle ~year_start date]: which of the three-year cycle's own + labelled years [date] falls in, per {!sunday_cycle}'s own citation. + A = Matthew, B = Mark, C = Luke -- OLM n.66's own footnote 102, same + passage: "primus cycli annus dicitur et est annus legendi Matthaeum, + ceteri vero, secundus nempe et tertius, respective annus legendi + Marcum et annus legendi Lucam". [year_start] is the rite's own + Advent-anchor function ({!Temporal_of.year_start}), supplied by the + caller rather than hard-coded, the same seam {!Rite_ef.Lectionary_ef + .readings}'s own [~lectionary]/[~commons] already use for rite data -- + this module reads no filesystem and knows no OF-specific season + vocabulary of its own beyond what [year_start] and {!Colitur_kernel + .Temporal.t} already carry. *) +val sunday_cycle : year_start:(int -> Date.t) -> Date.t -> sunday_cycle + +(** [weekday_cycle ~year_start date]: which of the two-year cycle's own + labelled years [date] falls in, per {!weekday_cycle}'s own citation. *) +val weekday_cycle : year_start:(int -> Date.t) -> Date.t -> weekday_cycle + +(** The Mass's Epistle and Gospel citations for this day, and (when the + shipped data can name one) which formulary they came from -- the OF + counterpart of {!Rite_ef.Lectionary_ef.readings}, deliberately simpler: + OF has no Commons indirection table (spec's own scope for this task), + so the chain is three steps, not four: + + - {b Step 1} -- the observed celebration's own embedded citations, if + any. {!Colitur_kernel.Mass_formulary.Proper}. Defensive: every + shipped data/of/calendar-2002.sexp entry carries [citations ()] + today (Task 1), so this step is currently dead on shipped data, kept + for the same forward-compatibility reason EF's own step 1 stays + first even where most callers reach it rarely. + - {b Step 2} -- when the observed celebration is NOT itself the day's + own temporal office (a real sanctoral entity won occurrence), its + own slug looked up directly in [lectionary] (tried flat, then both + cycle-letter suffixes via {!sunday_cycle}/{!weekday_cycle} -- a + sanctoral entry may be tagged either way in the shipped data, see + tools/bootstrap_lectionary_of.ml's own header, so both are tried + rather than assumed). Tagged {!Colitur_kernel.Mass_formulary.Proper} + too, not {!Colitur_kernel.Mass_formulary.Common}: OF has no shared- + formulary indirection the way EF's Commons do (this task's own + scope), so a looked-up sanctoral citation is still, substantively, + "the observed celebration's own proper" -- only ITS STORAGE differs + from step 1's (external file vs an embedded field), which + {!Colitur_kernel.Mass_formulary.source} was never designed to + distinguish (its own doc names what DECIDED the Mass, not where the + bytes happen to live). {!Colitur_kernel.Mass_formulary.source} is a + closed kernel type outside this task's own scope to extend; see + lectionary_of.ml's own implementation comment on this branch for the + full argument. [said] the observed slug. + - {b Step 3} -- the day's own temporal slug, same three-way lookup. + {!Colitur_kernel.Mass_formulary.Own_slug}, [said] that slug. + - A day matching neither gets [(None, [])] -- see data/of/lectionary + .sexp's own provenance header for exactly which days that is, both + directions, measured, not guessed. + + NO preceding-Sunday fallback (EF's own step 3): unlike EF's RG 299, + the OF's own two-year FERIAL cycle assigns every Ordinary Time weekday + its own reading by design (OLM n.69 point 4, {!weekday_cycle}'s own + citation) -- there is no OF norm this task found instructing a ferial + with no proper of its own to repeat the preceding Sunday's Mass, so no + such step is built. [temporal_at] is therefore accepted, unused, to + satisfy {!Colitur_kernel.Rite.t}'s own [readings] field shape (the same + contract {!Rite_ef.Lectionary_ef.readings} satisfies) -- kept rather + than dropped so a future task that DOES find such a rule has the + callback already in place. *) +val readings : + lectionary:Lectionary.t -> + year_start:(int -> Date.t) -> + observed:Vocab_of.rank Celebration.t -> + temporal:(Vocab_of.season, Vocab_of.rank) Temporal.t -> + date:Date.t -> + temporal_at:(Date.t -> (Vocab_of.season, Vocab_of.rank) Temporal.t) -> + Mass_formulary.t option * Citation.t list -- cgit v1.3