aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lib/rites/rite_of/precedence_of.ml97
-rw-r--r--lib/rites/rite_of/precedence_of.mli40
-rw-r--r--test/test_colitur.ml2
-rw-r--r--test/test_precedence_of.ml72
4 files changed, 210 insertions, 1 deletions
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 ])