aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-11 22:46:30 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-11 22:46:30 +0200
commit07c87370d6a0de687b42a41d967135578f014bdc (patch)
tree841b29952515e9fd662bc46c0b8457303fb2300d
parent553dc44d2ba0e131e7f2ac79dc755641afcd6a1c (diff)
downloadcolitur-07c87370d6a0de687b42a41d967135578f014bdc.tar.gz
colitur-07c87370d6a0de687b42a41d967135578f014bdc.zip
rite(ef): occurrence dispositions (RG 92-95, 33, 94)
Precedence_ef.disposition decides the loser's fate in an occurrence: a Commemoration_only celebration is always commemorated (it can never win or transfer); a I- or II-class vigil impeded by any Sunday or a I-class feast is entirely omitted (RG 33), checked before the generic rule below or the Nativity/Pentecost Vigil could wrongly transfer; any other I-class loser transfers (RG 95 -- only I class has the right of translation); everything else is commemorated, with the admit-or-omit decision left to RG 108-111's admission count (Task 9). RG 94 needs no code: resolve always compares a loser against the day's actual winner, never against a departed sibling, so no commemoration can ride along with a transferred feast in this design. This is the branch that completes Task 7's carried All Souls fix: once it loses to an occurring Sunday, its untouched Class1 rank routes it to Transfer via the generic rule, not a special case. Landing on 3 November is Rite.transfer_target's job, not wired up yet. Commemorate carries an interim Precedence.Ordinary privilege pending Task 9's RG 109 implementation, exposed as interim_privilege for that task to replace. Table-driven tests cover each rule, including RG 33's boundary from both sides and a Commemoration_only loser that is also Class1 and vigil-shaped to pin the branch ordering. Mutation-tested: disabling RG 33, either direction of RG 95's rank condition, or the Commemoration_only priority check each fail exactly the rows built to catch them.
-rw-r--r--lib/rites/rite_ef/precedence_ef.ml95
-rw-r--r--lib/rites/rite_ef/precedence_ef.mli46
-rw-r--r--test/test_precedence_ef.ml148
3 files changed, 285 insertions, 4 deletions
diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml
index 0716177..d8d64a8 100644
--- a/lib/rites/rite_ef/precedence_ef.ml
+++ b/lib/rites/rite_ef/precedence_ef.ml
@@ -214,3 +214,98 @@ let band (ctx : Vocab_ef.season Precedence.context) (c : Vocab_ef.rank Precedenc
"feria" that is also a vigil is not a feria RG 91 describes. *)
else if (not is_vigil) && rank = Class4 then 28
else unclassified
+
+(* Task 8: what happens to the day's LOSING candidate (docs/research/
+ rules-register.md §4, "Occurrence" RG 92-95 and "Vigils" RG 33, plus RG
+ 94). [band] above decides who wins; this decides the loser's fate, which
+ turns on the LOSER's own rank and status (RG 95), except RG 33's vigil
+ omission, which also has to read the winner. Nothing here ever returns
+ [Precedence.Repose]: that disposition denotes RG 100-102's *repositio*
+ (perpetual impediment from a proper/diocesan calendar), out of this
+ plan's scope -- see calendar.mli's own note that nothing in the EF
+ ruleset currently emits it. *)
+
+(* RG 33 (register line 383-384): a I- or II-class vigil falling on any
+ Sunday or a I-class feast is entirely omitted. Every Sunday slug this
+ rite's temporal cycle produces -- named (temporal_ef.ml's [named], e.g.
+ "ef-easter-sunday") or the generic "ef-<season>-sunday-<n>" fallback
+ ([sunday_slug]) -- contains this marker; nothing else [band] classifies
+ does. Not an RG citation itself -- see [universal_layer]'s note on this
+ file's own naming conventions -- exposed for the same reason as
+ {!vigil_suffix}: a future rename of temporal_ef's Sunday-slug format has
+ somewhere to be caught other than a silently-wrong RG 33 disposition. *)
+let sunday_marker = "-sunday"
+
+let contains_substring s ~needle =
+ let ls = String.length s and ln = String.length needle in
+ let rec at i = i + ln <= ls && (String.sub s i ln = needle || at (i + 1)) in
+ ln = 0 || at 0
+
+let is_sunday_slug slug = contains_substring slug ~needle:sunday_marker
+
+(* RG 33's "any Sunday or a I-class feast": every RG 91 entry that can ever
+ outrank a II-class vigil (entry 21) without itself being a Sunday is I
+ class by the table's own structure (entries 1-13), so [rank = Class1]
+ alone already covers every way a I-class vigil (entries 5, 9 -- Nativity,
+ Pentecost) can be impeded at all; the slug check is what a II-class vigil
+ impeded by an ordinary Sunday (entry 15, rank Class2) needs, since that
+ winner's own rank is not Class1. *)
+let impedes_vigil (winner : Vocab_ef.rank Precedence.candidate) =
+ let cel = winner.Precedence.cel in
+ cel.Celebration.rank = Vocab_ef.Class1
+ || is_sunday_slug (Slug.to_string cel.Celebration.slug)
+
+(* Not yet RG 109 (Task 9's job: the closed list of privileged commemorations
+ and RG 108-111's admission counts). Every [Commemorate] this function
+ returns carries this one placeholder rather than a silent default, so the
+ choice is visible and grep-able. [Ordinary] chosen over [Privileged]
+ deliberately: it grants no admission entitlement RG 111 has not earned,
+ so code that trusts this value before Task 9 replaces it under-privileges
+ a commemoration rather than over-privileges one -- the safer direction to
+ be wrong in. *)
+let interim_privilege = Precedence.Ordinary
+
+let disposition ~(winner : Vocab_ef.rank Precedence.candidate)
+ ~(loser : Vocab_ef.rank Precedence.candidate) : Precedence.disposition =
+ let open Vocab_ef in
+ let cel = loser.Precedence.cel in
+ if cel.Celebration.status = Celebration.Commemoration_only then
+ (* Always -- checked before RG 33's omission and RG 95's transfer so
+ neither can override it: a Commemoration_only entry can never win
+ (Precedence.resolve holds it out of the band contest entirely, see
+ that module's [resolve]) and, per the brief, can never transfer
+ either. *)
+ Precedence.Commemorate interim_privilege
+ else if
+ (cel.Celebration.rank = Class1 || cel.Celebration.rank = Class2)
+ && is_vigil (Slug.to_string cel.Celebration.slug)
+ && impedes_vigil winner
+ then
+ (* RG 33. Checked before the generic Class1 -> Transfer rule below, or a
+ I-class vigil (Nativity, Pentecost) impeded on its own Sunday/
+ I-class-feast terms would wrongly transfer instead of vanishing. *)
+ Precedence.Omit
+ else if cel.Celebration.rank = Class1 then
+ (* RG 95: only I-class feasts have the right of translation. This is the
+ branch that completes Task 7's All Souls fix (register line 334, RG
+ 91 entry 8): All Souls is I class and not a vigil, so once it loses
+ to an occurring Sunday it reaches here and transfers -- to 3
+ November per the register, but WHERE it lands is
+ Rite.transfer_target's job (RG 96), not this function's; disposition
+ only says THAT it moves. *)
+ Precedence.Transfer
+ else
+ (* RG 95's other branch, for everything below I class: "aut
+ commemorantur aut penitus omittuntur" -- commemorated or wholly
+ omitted. Which of the two survives is RG 108-111's admission count
+ (Task 9's [admit]), not this function's decision; this only opens the
+ commemoration.
+
+ RG 94 (a fixed-day commemoration is not carried along with a
+ transferred feast) needs no code here: [Precedence.resolve] calls
+ this function once per loser, always against the day's actual
+ [observed] winner -- never against a fellow loser that itself
+ transferred away -- so no mechanism exists by which a commemoration
+ could ride along with a departing feast in the first place; there is
+ nothing to suppress. *)
+ Precedence.Commemorate interim_privilege
diff --git a/lib/rites/rite_ef/precedence_ef.mli b/lib/rites/rite_ef/precedence_ef.mli
index 63fb0a1..9fe8789 100644
--- a/lib/rites/rite_ef/precedence_ef.mli
+++ b/lib/rites/rite_ef/precedence_ef.mli
@@ -56,3 +56,49 @@ val unclassified : int
including shapes the 1962 table itself does not describe (see
{!unclassified}). *)
val band : Vocab_ef.season Precedence.context -> Vocab_ef.rank Precedence.candidate -> int
+
+(** RG 33's marker: every Sunday slug this rite's temporal cycle produces
+ (temporal_ef.ml's [named] and [sunday_slug]) contains this substring;
+ nothing else {!band} classifies does. Also colitur's own convention, not
+ an RG citation -- see {!universal_layer} -- exposed for the same reason
+ as {!vigil_suffix}: a rename of temporal_ef's Sunday-slug format has
+ somewhere to be caught other than a silently-wrong RG 33 disposition. *)
+val sunday_marker : string
+
+(** The placeholder {!Precedence.privilege} every [Commemorate] disposition
+ below carries until Task 9 implements RG 109's closed list of privileged
+ commemorations and RG 108-111's admission counts. Exposed so Task 9 (and
+ any test wanting to assert on it explicitly) does not have to duplicate
+ the literal [Precedence.Ordinary]. *)
+val interim_privilege : Precedence.privilege
+
+(** [disposition ~winner ~loser]: RG 92-95, 33, 94 (docs/research/
+ rules-register.md §4, "Occurrence" and "Vigils"). What becomes of a
+ losing candidate, decided by the LOSER's own rank and status (RG 95),
+ except RG 33's vigil omission, which also reads the winner:
+ - a {!Celebration.status} of [Commemoration_only] is always
+ [Commemorate] (checked first: it can never win -- see
+ {!Precedence.resolve} -- and, by that same status's own definition,
+ already denotes an office with nothing left to translate, so it never
+ transfers either; not itself a further RG citation beyond RG 93's
+ general four-mechanism statement above);
+ - a [Class1] or [Class2] loser whose slug marks it a vigil ({!vigil_suffix})
+ is [Omit] when the winner is any Sunday ({!sunday_marker}) or itself
+ [Class1] (RG 33 -- entirely omitted, not merely commemorated);
+ - any other [Class1] loser is [Transfer] (RG 95 -- only I class has the
+ right of translation; this is also what moves All Souls, register
+ line 334, once it loses to an occurring Sunday -- WHERE it lands is
+ {!Rite.t.transfer_target}'s job, not this function's);
+ - everything else is [Commemorate], carrying {!interim_privilege} until
+ Task 9 replaces it with RG 109's real per-day computation.
+
+ Total over every winner/loser pair {!Precedence.resolve} or {!Calendar}
+ can construct: [Vocab_ef.rank] (RG 8) and {!Celebration.status} are both
+ closed variants, so the four cases above exhaust every representable
+ shape -- there is no fifth, "unclassified" case the way {!band} needs
+ one, because this function's own return type has no such slot to fall
+ into by accident. *)
+val disposition :
+ winner:Vocab_ef.rank Precedence.candidate ->
+ loser:Vocab_ef.rank Precedence.candidate ->
+ Precedence.disposition
diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml
index 7033978..3b2d5f6 100644
--- a/test/test_precedence_ef.ml
+++ b/test/test_precedence_ef.ml
@@ -25,9 +25,9 @@ let mk y m dd = match D.make ~year:y ~month:m ~day:dd with Ok t -> t | Error e -
actually occur together with it. *)
let ctx date = { P.date; season = T.season date; weekday = D.weekday date }
-let cand ?(origin = P.Temporal) ?(rank = V.Class1) ?(subject = Sub.Temporal) ?(layer = "temporal")
- slug =
- { P.cel = Cel.make ~slug:(S.of_string_exn slug) ~rank ~colour:Col.White ~subject ~layer ();
+let cand ?(origin = P.Temporal) ?(rank = V.Class1) ?(status = Cel.Feast) ?(subject = Sub.Temporal)
+ ?(layer = "temporal") slug =
+ { P.cel = Cel.make ~slug:(S.of_string_exn slug) ~rank ~status ~colour:Col.White ~subject ~layer ();
origin }
(* A candidate built from [Temporal_ef.temporal]'s own real output, not a
@@ -281,6 +281,136 @@ let test_all_souls_yields_to_sunday () =
"ef-time-after-pentecost-sunday-x"
(S.to_string resolution.P.observed.P.cel.Cel.slug)
+(* Task 8: [disposition] -- what happens to the day's LOSING candidate (RG
+ 92-95, 33, 94; register lines 316-325, 381-384). Table-driven like [band]'s
+ own [cases] above, one row per rule, each checked against a description of
+ which register clause it pins. [disposition] takes no context (see
+ precedence.mli's [rules.disposition]), so "is the winner a Sunday" is read
+ off the winner's own slug the same way [band] itself reads "is this a
+ vigil" off the loser's -- see precedence_ef.ml's [sunday_marker]. *)
+
+let string_of_disposition = function
+ | P.Omit -> "Omit"
+ | P.Commemorate P.Privileged -> "Commemorate(Privileged)"
+ | P.Commemorate P.Ordinary -> "Commemorate(Ordinary)"
+ | P.Transfer -> "Transfer"
+ | P.Repose -> "Repose"
+
+(* A II-class ordinary Sunday, built the same way [test_all_souls_yields_to_sunday]
+ builds its Sunday -- a hand-typed slug matching temporal_ef.ml's own
+ "ef-<season>-sunday-<n>" convention, since [disposition] only ever reads
+ this string, never the real computed date. *)
+let an_ordinary_sunday =
+ cand ~rank:V.Class2 "ef-time-after-pentecost-sunday-11"
+
+let disposition_cases =
+ [ (* RG 95 -- register line 323-325: only I-class feasts transfer; a
+ II-class feast loses to a I-class day and is COMMEMORATED, not
+ transferred. Paired with the next row (a I-class loser, same shape of
+ winner) so the discriminating factor is provably the LOSER's own
+ rank, not the winner's -- the brief's explicit "one without the other
+ proves nothing" pairing. *)
+ ( "RG95 II-class feast loses to I-class day -> Commemorate",
+ cand "ef-nativity",
+ cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "ef-some-saint",
+ "Commemorate(Ordinary)" );
+ ( "RG95 I-class feast loses to a higher I-class day -> Transfer",
+ cand "ef-nativity",
+ cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-local-i-class-feast",
+ "Transfer" );
+ (* RG 33 -- register line 383-384: a I/II-class vigil impeded by any
+ Sunday or a I-class feast is entirely OMITTED, not commemorated. The
+ vigil is sourced from [Temporal_ef.temporal]'s own real output (as
+ [of_temporal]'s existing callers above do), not a hand-typed
+ "ef-ascension-vigil", so a drift in temporal_ef's vigil-slug
+ convention cannot silently defeat this row the way a duplicated
+ literal could. This is the row the brief singles out as most likely
+ to pass vacuously if the fallback below happened to already be
+ [Omit] -- it is not: the fallback is [Commemorate] (see the next two
+ rows), so this genuinely exercises RG 33's own branch. *)
+ ( "RG33 II-class vigil loses to an ordinary Sunday -> Omit",
+ an_ordinary_sunday,
+ of_temporal (off 38) (* Ascension Vigil *),
+ "Omit" );
+ ( "RG33 II-class vigil loses to a I-class feast (non-Sunday) -> Omit",
+ cand "ef-immaculate-conception",
+ of_temporal (off 38),
+ "Omit" );
+ (* RG 33's own boundary, proved from both sides so the rule is shown to
+ gate on the WINNER too, not "any vigil is always omitted": *)
+ ( "RG33 boundary: vigil loses to an ordinary (non-Sunday, non-I-class) \
+ II-class day -> Commemorate, NOT Omit",
+ cand ~origin:P.Sanctoral ~rank:V.Class2 ~layer:PE.universal_layer "ef-some-other-feast",
+ of_temporal (off 38),
+ "Commemorate(Ordinary)" );
+ ( "RG33 boundary: a III-class vigil (outside RG33's I/II-class scope) \
+ loses to a Sunday -> Commemorate, NOT Omit",
+ an_ordinary_sunday,
+ cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-lawrence-vigil",
+ "Commemorate(Ordinary)" );
+ (* Brief: a Commemoration_only loser is ALWAYS Commemorate -- checked
+ here with a loser that ALSO carries a Class1 rank and a vigil-suffixed
+ slug losing to a Sunday, so this row only passes if the
+ Commemoration_only check is checked BEFORE both RG 33's omission and
+ RG 95's transfer, not after. *)
+ ( "Commemoration_only loser is always Commemorate, even if I-class and \
+ vigil-shaped, even losing to a Sunday",
+ an_ordinary_sunday,
+ cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~layer:PE.universal_layer
+ "ef-suppressed-vigil",
+ "Commemorate(Ordinary)" );
+ (* Totality: the lower ranks the RG 33/RG 95 branches never touch still
+ reach the RG 95 "commemorated or omitted" branch, not an
+ unhandled/exceptional case. *)
+ ( "III-class feast loses to a I-class day -> Commemorate",
+ cand "ef-nativity",
+ cand ~origin:P.Sanctoral ~rank:V.Class3 ~layer:PE.universal_layer "ef-some-saint-3",
+ "Commemorate(Ordinary)" );
+ ( "IV-class feria loses to a II-class Sunday -> Commemorate",
+ an_ordinary_sunday,
+ cand ~rank:V.Class4 "ef-time-after-pentecost-1-sat",
+ "Commemorate(Ordinary)" )
+ ]
+
+(* Completes Task 7's carried fix (register line 334): on a real Sunday
+ landing on 2 November, All Souls does not merely lose (that was Task 7's
+ [band] fix, proved by [test_all_souls_yields_to_sunday] above) -- it must
+ be TRANSFERRED, not commemorated and not omitted. All Souls is I class
+ (RG 91 entry 8's own [rank] field, untouched by the Sunday-exception band
+ bump -- see precedence_ef.ml's comment on entry 8), so RG 95's rank
+ condition alone should route it to [Transfer]. *)
+let test_all_souls_disposition_is_transfer () =
+ let sunday = an_ordinary_sunday in
+ let all_souls = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls" in
+ Alcotest.(check string) "All Souls loses to a Sunday and transfers"
+ "Transfer"
+ (string_of_disposition (PE.disposition ~winner:sunday ~loser:all_souls))
+
+(* The same fact, proved end-to-end through [Precedence.resolve] with the
+ REAL [PE.band] and REAL [PE.disposition] wired together (Task 7's own
+ integration test above still stubs [disposition] to a constant [Omit],
+ which is exactly what this task must not leave true) -- All Souls must
+ land in [deferred], not [commemorations] or [omitted]. WHERE it is placed
+ (3 November, RG 96) is [Rite.transfer_target]'s job, out of this task's
+ scope; this only proves [resolve] hands it to the transfer path at all. *)
+let test_all_souls_transfers_end_to_end () =
+ let date = mk 2025 11 2 in
+ let day_ctx = ctx date in
+ let sunday =
+ { P.cel =
+ Cel.make ~slug:(S.of_string_exn "ef-time-after-pentecost-sunday-x") ~rank:V.Class2
+ ~colour:Col.Green ~subject:Sub.Temporal ~layer:"temporal" ();
+ origin = P.Temporal }
+ in
+ let all_souls = cand ~origin:P.Sanctoral ~layer:PE.universal_layer "ef-all-souls" in
+ let rules = { P.band = PE.band; disposition = PE.disposition; admit = (fun ~observed:_ cs -> cs) } in
+ let resolution = P.resolve rules day_ctx ~temporal:sunday ~sanctoral:[ all_souls ] in
+ Alcotest.(check (list string)) "All Souls is deferred (transferred), not omitted or commemorated"
+ [ "ef-all-souls" ]
+ (List.map (fun c -> S.to_string c.P.cel.Cel.slug) resolution.P.deferred);
+ Alcotest.(check int) "nothing commemorated" 0 (List.length resolution.P.commemorations);
+ Alcotest.(check int) "nothing omitted" 0 (List.length resolution.P.omitted)
+
let suite =
( "Precedence_ef",
List.map
@@ -289,4 +419,14 @@ let suite =
Alcotest.(check int) desc expect (PE.band (ctx date) c)))
cases
@ [ Alcotest.test_case "8 All Souls yields to a Sunday (resolve-level)" `Quick
- test_all_souls_yields_to_sunday ] )
+ test_all_souls_yields_to_sunday ]
+ @ List.map
+ (fun (desc, winner, loser, expect) ->
+ Alcotest.test_case desc `Quick (fun () ->
+ Alcotest.(check string) desc expect
+ (string_of_disposition (PE.disposition ~winner ~loser))))
+ disposition_cases
+ @ [ Alcotest.test_case "All Souls disposition is Transfer" `Quick
+ test_all_souls_disposition_is_transfer;
+ Alcotest.test_case "All Souls transfers end-to-end (resolve, real rules)" `Quick
+ test_all_souls_transfers_end_to_end ] )