From a2be1f88ff9d2e4dd99689213692f2a03bbf999d Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 25 Aug 2026 19:01:39 +0200 Subject: feat(of): transcribe Tabula part I into precedence_of The Tabula dierum liturgicorum's entries 1-4, one band branch each, scaled x10 as Precedence_ef's own table is. The Triduum is identified structurally by Easter offset because Temporal_of emits Good Friday and Holy Saturday as ordinary Lent week-6 ferias, leaving no slug to key on. Holy Thursday sits at entry 2, not entry 1: the Tabula's own entry 2 runs 'a feria II ad feriam V inclusive' and the Triduum opens with an evening Mass inside that civil day. Same granularity decision the litcal layer's L1 records on the season axis. --- lib/rites/rite_of/precedence_of.ml | 97 +++++++++++++++++++++++++++++++++++++ lib/rites/rite_of/precedence_of.mli | 40 +++++++++++++++ test/test_colitur.ml | 2 +- test/test_precedence_of.ml | 72 +++++++++++++++++++++++++++ 4 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 lib/rites/rite_of/precedence_of.ml create mode 100644 lib/rites/rite_of/precedence_of.mli create mode 100644 test/test_precedence_of.ml diff --git a/lib/rites/rite_of/precedence_of.ml b/lib/rites/rite_of/precedence_of.ml new file mode 100644 index 0000000..bca6ca9 --- /dev/null +++ b/lib/rites/rite_of/precedence_of.ml @@ -0,0 +1,97 @@ +(* The Tabula dierum liturgicorum secundum ordinem praecedentiae disposita + (Missale Romanum, editio typica tertia 2002; "Normae universales de anno + liturgico et de calendario", printed immediately before it) -- 13 numbered + entries in three parts. This module transcribes it, one [band] branch per + entry, the same discipline Rite_ef.Precedence_ef applies to RG 91's 28. + + CITATION DISCIPLINE: the 2002 Missal PDF carries BOTH the Normae universales + AND the Institutio Generalis Missalis Romani, each independently numbered + from 1. Every citation below therefore names its document: "Normae n. N" or + "IGMR n. N". A bare "n. N" is ambiguous and must not appear. + + ENTRY VALUES ARE THE TABLE'S OWN NUMBER TIMES TEN (entry 1 -> 10, entry 13 + -> 130). Identical to Precedence_ef's own convention and adopted for the + identical reason: a future sub-rank (a movable/fixed split of one row, the + shape RG 91 entry 14 forced on the EF side) becomes an ordinary position + BETWEEN neighbours, with no rescale of every other branch. Do NOT express a + sub-rank as a negative sentinel: Precedence_ef's own history records that as + a defect, because a negative value avoids collision but inverts ORDERING. *) + +open Colitur_kernel + +let unclassified = max_int + +(* Not a Normae citation. The Tabula distinguishes universal-calendar entries + (3, 7, 10) from proper-calendar ones (4, 8, 11), and nothing in + Celebration.t marks that distinction except [layer]. Same mechanism, and + same naming, as Precedence_ef.{universal_layer,indult_prefix}. *) +let universal_layer = "of-universal" +let proper_prefix = "proper:" +let is_universal layer = String.equal layer universal_layer +let is_proper layer = String.starts_with ~prefix:proper_prefix layer + +(* Days measured from Easter, so the Triduum and the Easter Octave are + identified STRUCTURALLY rather than by slug. Temporal_of emits Good Friday + and Holy Saturday as ordinary Lent week-6 ferias + ("of-lent-6-friday"/"of-lent-6-saturday"), so there is no slug to key on -- + the same situation Precedence_ef.band faces for RG 91 entry 2, resolved the + same way. *) +let easter_offset (ctx : Vocab_of.season Precedence.context) = + let y = Date.year ctx.date in + Date.to_rata ctx.date - Date.to_rata (Computus.gregorian_easter y) + +let slug_of (c : Vocab_of.rank Precedence.candidate) = Slug.to_string c.cel.slug + +(* Tabula I.1: "Triduum paschale Passionis et Resurrectionis Domini." + + HOLY THURSDAY IS DELIBERATELY NOT HERE. The Tabula's own entry 2 reads + "Feriae Hebdomadae sanctae, a feria II ad feriam V inclusive" -- Monday + through THURSDAY inclusive -- and the Paschal Triduum begins with the + evening Mass of the Lord's Supper, an event INSIDE Holy Thursday's civil + day. At colitur's civil-day granularity (one office per date, never a + sub-day boundary) the day itself therefore stays with the Holy Week ferias + at entry 2. This is the SAME granularity decision, on the precedence axis, + that data/of/expected-divergences-litcal.sexp's own L1 records on the season + axis (Normae nn. 28/30) -- one decision with two visible consequences, not + two independent judgements. *) +let is_triduum ctx = List.mem (easter_offset ctx) [ -2; -1; 0 ] + +(* Tabula I.2, five clauses in one entry. *) +let entry_2_named = + [ "of-nativity"; "of-epiphany"; "of-ascension"; "of-pentecost"; "of-ash-wednesday" ] + +let is_privileged_sunday (ctx : Vocab_of.season Precedence.context) c = + ctx.weekday = Date.Sun + && (match ctx.season with + | Vocab_of.Advent | Vocab_of.Lent | Vocab_of.Easter -> true + | Vocab_of.Christmas | Vocab_of.Ordinary_time -> false) + && c.Precedence.origin = Precedence.Temporal + +(* "Feriae Hebdomadae sanctae, a feria II ad feriam V inclusive": Easter-6 + (Monday) through Easter-3 (Thursday). Palm Sunday (Easter-7) is a Sunday of + Lent, covered by [is_privileged_sunday] in the same entry. *) +let is_holy_week_feria ctx = List.mem (easter_offset ctx) [ -6; -5; -4; -3 ] + +(* "Dies infra octavam Paschae": Easter+1 .. Easter+7. Easter Sunday itself is + entry 1. *) +let is_easter_octave_day ctx = + let o = easter_offset ctx in + o >= 1 && o <= 7 + +let band (ctx : Vocab_of.season Precedence.context) + (c : Vocab_of.rank Precedence.candidate) : int = + let temporal = c.Precedence.origin = Precedence.Temporal in + let layer = c.cel.layer in + if temporal && is_triduum ctx then 10 (* Tabula I.1 *) + else if + temporal + && (List.mem (slug_of c) entry_2_named + || is_privileged_sunday ctx c + || is_holy_week_feria ctx + || is_easter_octave_day ctx) + then 20 (* Tabula I.2 *) + else if c.cel.rank = Vocab_of.Sollemnitas && is_universal layer then + 30 (* Tabula I.3 *) + else if c.cel.rank = Vocab_of.Sollemnitas && is_proper layer then + 40 (* Tabula I.4 *) + else unclassified diff --git a/lib/rites/rite_of/precedence_of.mli b/lib/rites/rite_of/precedence_of.mli new file mode 100644 index 0000000..dfee504 --- /dev/null +++ b/lib/rites/rite_of/precedence_of.mli @@ -0,0 +1,40 @@ +(** The Tabula dierum liturgicorum of the 2002 Missale Romanum -- 13 numbered + entries in three parts -- plus Normae n. 60's occurrence and transfer + rules. The OF counterpart of {!Rite_ef.Precedence_ef}, and deliberately + the same shape. + + Every citation names its document ("Normae n. N" or "IGMR n. N"): the + 2002 Missal carries both, independently numbered from 1. *) + +open Colitur_kernel + +(** Returned for a candidate shape the Tabula has no row for. [max_int], so an + unclassified candidate always loses. Task 4's property asserts this is + never reached by anything {!Rite_of.Temporal_of} constructs. *) +val unclassified : int + +(** The universal (General Roman Calendar) layer id. Not a Normae citation: + the Tabula distinguishes universal entries (3, 7, 10) from proper ones + (4, 8, 11), and [Celebration.t.layer] is the only field carrying that + distinction. *) +val universal_layer : string + +(** Prefix marking a candidate as belonging to a PROPER (diocesan, national or + religious) calendar -- Tabula entries 4, 8 and 11. + + NO SHIPPED DATA PRODUCES THIS. Phase 3 transcribes the General Roman + Calendar only (the spec's own universal-calendar-only discipline), so + entries 4, 8 and 11 are unreachable on shipped data and are exercised in + tests by synthetic candidates alone. They are transcribed anyway for two + reasons: the transcription of a 13-entry table should be complete rather + than silently 10 entries long, and a user [--overlay] carrying a local + patron becomes correct without a later edit here. *) +val proper_prefix : string + +(** [band ctx c]: the Tabula's own entry number for [c], TIMES TEN (entry 1 -> + 10, entry 13 -> 130); lower wins. {!unclassified} for a shape with no row. + + The ×10 scaling matches {!Rite_ef.Precedence_ef.band} and exists for the + same reason: a future sub-rank fits between neighbours without rescaling + the table. *) +val band : Vocab_of.season Precedence.context -> Vocab_of.rank Precedence.candidate -> int diff --git a/test/test_colitur.ml b/test/test_colitur.ml index d1febea..b6675fd 100644 --- a/test/test_colitur.ml +++ b/test/test_colitur.ml @@ -11,7 +11,7 @@ let () = Test_citation.render_suite; ("sigla", Test_citation.sigla_suite); Test_config.suite; - Test_overlay.suite; Test_overlay_ini.suite; Test_temporal_ef.suite; Test_temporal_of.suite; Test_litcal_of.suite; + Test_overlay.suite; Test_overlay_ini.suite; Test_temporal_ef.suite; Test_temporal_of.suite; Test_litcal_of.suite; Test_precedence_of.suite; Test_validate.suite; Test_precedence.suite; Test_calendar.suite; Test_precedence_ef.suite; Test_sanctoral_ef.suite; Test_rite_ef.suite; diff --git a/test/test_precedence_of.ml b/test/test_precedence_of.ml new file mode 100644 index 0000000..bf514ff --- /dev/null +++ b/test/test_precedence_of.ml @@ -0,0 +1,72 @@ +(* Table-driven, one Alcotest case per Tabula entry, so a transcription error + names the entry it broke. Mirrors test/test_precedence_ef.ml. *) +open Colitur_kernel +module Vocab_of = Rite_of.Vocab_of + +let mk_cel ?(layer = Rite_of.Precedence_of.universal_layer) + ?(status = Celebration.Feast) ?(colour = Colour.White) + ?(subject = Subject.Saint) ~slug ~rank () = + { Celebration.slug = Slug.of_string_exn slug; + names = Names.empty; rank; status; colour; subject; + citations = []; layer } + +let mk ?layer ?status ?colour ?subject ~slug ~rank ~origin () = + { Precedence.cel = mk_cel ?layer ?status ?colour ?subject ~slug ~rank (); + origin } + +let ctx ~iso ~season = + let date = Date.of_iso8601 iso |> Result.get_ok in + { Precedence.date; season; weekday = Date.weekday date } + +(* Each row: label, context, candidate, expected band value. *) +let part1_cases = + [ ( "entry 1: Good Friday", + ctx ~iso:"2026-04-03" ~season:Vocab_of.Lent, + mk ~slug:"of-lent-6-friday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (), + 10 ); + ( "entry 1: Holy Saturday", + ctx ~iso:"2026-04-04" ~season:Vocab_of.Lent, + mk ~slug:"of-lent-6-saturday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (), + 10 ); + ( "entry 1: Easter Sunday", + ctx ~iso:"2026-04-05" ~season:Vocab_of.Easter, + mk ~slug:"of-easter-sunday" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (), + 10 ); + ( "entry 2: Holy Thursday is a Holy Week feria, not the Triduum", + ctx ~iso:"2026-04-02" ~season:Vocab_of.Lent, + mk ~slug:"of-lent-6-thursday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (), + 20 ); + ( "entry 2: the Nativity", + ctx ~iso:"2026-12-25" ~season:Vocab_of.Christmas, + mk ~slug:"of-nativity" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (), + 20 ); + ( "entry 2: a Sunday of Advent", + ctx ~iso:"2026-11-29" ~season:Vocab_of.Advent, + mk ~slug:"of-advent-sunday-1" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (), + 20 ); + ( "entry 2: Ash Wednesday", + ctx ~iso:"2026-02-18" ~season:Vocab_of.Lent, + mk ~slug:"of-ash-wednesday" ~rank:Vocab_of.Feria ~origin:Precedence.Temporal (), + 20 ); + ( "entry 2: a day within the Octave of Easter", + ctx ~iso:"2026-04-07" ~season:Vocab_of.Easter, + mk ~slug:"of-easter-octave-day-3" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Temporal (), + 20 ); + ( "entry 3: a universal solemnity of a saint", + ctx ~iso:"2026-06-29" ~season:Vocab_of.Ordinary_time, + mk ~slug:"of-peter-and-paul" ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Sanctoral (), + 30 ); + ( "entry 4: a proper solemnity", + ctx ~iso:"2026-06-29" ~season:Vocab_of.Ordinary_time, + mk ~layer:"proper:diocese-of-poznan" ~slug:"of-local-patron" + ~rank:Vocab_of.Sollemnitas ~origin:Precedence.Sanctoral (), + 40 ) ] + +let test_part1 () = + List.iter + (fun (label, c, cand, expected) -> + Alcotest.(check int) label expected (Rite_of.Precedence_of.band c cand)) + part1_cases + +let suite = + ("precedence-of", [ Alcotest.test_case "Tabula part I (entries 1-4)" `Quick test_part1 ]) -- cgit v1.3