aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-22 22:40:07 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-22 22:40:07 +0200
commit761ae859d73660bcfaa59581312989cb942e704d (patch)
treeadcf7aa53f104fd753769445ced043946d6f0927
parent9c96e0afc19925b651e0dfee3bc9182dee72acf0 (diff)
downloadcolitur-761ae859d73660bcfaa59581312989cb942e704d.tar.gz
colitur-761ae859d73660bcfaa59581312989cb942e704d.zip
feat(ef): RG 111(a), the sung-Mass commemoration cap
Item 1 of Phase 3 (celebrant-rubrics-phase1): the "at Low Mass" commemoration-placement rule the design spec recorded as unread. It is not a new rule -- it is the sung/low axis of RG 111, which colitur already implements. RG 111(a) (LT.txt, "Ratio admittendi commemorationes"): a liturgical day of the first class, AND any non-conventual sung Mass regardless of the day's own class, admits at most one commemoration, and only if it is privileged. (b)/(c)/(d), the same rubric's remaining clauses, give the wider caps colitur's admit already computes -- which is exactly the LOW MASS answer. Exposed as Precedence.sung_mass_commemorations, a pure derivation over the existing Low-Mass admitted list (filter to Privileged, keep the first), not a new stored field on Liturgical_day.t: the input list is already validated and privilege-tagged, so a second field would only create a second place for the same fact to drift out of sync with the first, with no new information gained. Liturgical_day.t.commemorations is now documented as the Low Mass set explicitly, removing the ambiguity its .mli previously left unstated. Tested against synthetic Low-Mass sets (none/one/two privileged, already-first, empty) and two real calendar days resolved through the normal Cal.day pipeline: 2026-08-14 (Vigil of the Assumption, an ordinary-only commemoration, dropped at Sung Mass) and 2026-04-25 (the Major Litanies, RG 80/109(f), privileged, kept at both Masses).
-rw-r--r--lib/kernel/liturgical_day.mli10
-rw-r--r--lib/kernel/precedence.ml14
-rw-r--r--lib/kernel/precedence.mli44
-rw-r--r--test/test_precedence.ml55
-rw-r--r--test/test_rubrics_ef.ml38
5 files changed, 159 insertions, 2 deletions
diff --git a/lib/kernel/liturgical_day.mli b/lib/kernel/liturgical_day.mli
index 939f8f5..be17d73 100644
--- a/lib/kernel/liturgical_day.mli
+++ b/lib/kernel/liturgical_day.mli
@@ -8,6 +8,16 @@ type ('s, 'r) t = {
places for them to disagree *)
observed : 'r Celebration.t;
commemorations : ('r Celebration.t * Precedence.privilege) list;
+ (** The LOW MASS admitted set (RG 111(b)/(c)/(d) -- {!Precedence.rules.admit}'s
+ own wider caps). Not named [low_mass_commemorations]: this field
+ predates the sung/low distinction and every existing reader
+ ([emit], the differential/oracle layers, {!Record}) already reads
+ it under this name, so renaming it would be a needless breaking
+ change for a doc clarification alone. The SUNG-Mass equivalent
+ (RG 111(a): at most one commemoration, and it must be privileged)
+ is {!Precedence.sung_mass_commemorations}, a pure derivation over
+ this same list -- see its own citation for why it is a function,
+ not a second stored field. *)
transferred_in : 'r Celebration.t option;
(** arrived here from an impeded day *)
transferred_out : ('r Celebration.t * Date.t) list;
diff --git a/lib/kernel/precedence.ml b/lib/kernel/precedence.ml
index c207117..7138b6a 100644
--- a/lib/kernel/precedence.ml
+++ b/lib/kernel/precedence.ml
@@ -91,3 +91,17 @@ let resolve rules ctx ~temporal ~sanctoral =
List.rev omitted
@ List.map (fun (c, _) -> (c, "omitted: admission limit reached")) dropped;
}
+
+(* RG 111(a): at most one commemoration, and it must be privileged -- see
+ the .mli for the full citation and why this is a derivation over the
+ admitted Low-Mass list rather than a second admission rule. [List.find_opt]
+ keeps input order, which is already precedence order (RG 113), so "the
+ first privileged entry, if any" is "the highest-precedence privileged
+ entry, if any" -- the correct single survivor if more than one privileged
+ commemoration were ever admitted together (not known to occur on any
+ shipped rite's data, but not assumed impossible either: this reads the
+ list rather than asserting its length). *)
+let sung_mass_commemorations low_mass_set =
+ match List.find_opt (fun (_, p) -> p = Privileged) low_mass_set with
+ | Some c -> [ c ]
+ | None -> []
diff --git a/lib/kernel/precedence.mli b/lib/kernel/precedence.mli
index a0b4b1a..05022c5 100644
--- a/lib/kernel/precedence.mli
+++ b/lib/kernel/precedence.mli
@@ -145,3 +145,47 @@ type 'r resolution = {
val resolve :
('s, 'r) rules -> 's context -> temporal:'r candidate ->
sanctoral:'r candidate list -> 'r resolution
+
+(** RG 111(a) (EF; docs/research/rules-register.md; LT.txt, grep "Ratio
+ admittendi commemorationes"): {i "in diebus liturgicis I classis et in
+ Missis in cantu non conventualibus, nulla admittitur commemoratio,
+ praeter unam privilegiatam"} -- on a liturgical day of the first class,
+ AND at any non-conventual SUNG Mass regardless of the day's own class,
+ at most ONE commemoration is admitted, and only if it is privileged.
+ (b)/(c)/(d), immediately following in the same rubric, give the wider
+ caps -- two ordinary/privileged commemorations depending on class --
+ that apply everywhere else; {!rules.admit} already computes exactly
+ that wider set, which is why this function's own INPUT is the admitted
+ LOW-MASS list, not a fresh resolution: (a) is not a distinct admission
+ RULE with its own candidate-ranking logic, it is a narrower CAP applied
+ afterwards to the identical admitted, precedence-ordered list -- "at
+ most one, and it must be privileged" is exactly "keep the first
+ admitted entry, if any, that is privileged", nothing else in the list
+ can ever outrank it (RG 113: admission order already follows the
+ rite's own table of precedence, {!rules.admit}'s own citation).
+
+ Deliberately a pure post-hoc DERIVATION over
+ {!Liturgical_day.t.commemorations}, not a second stored field: the
+ input list is already validated (privilege-tagged, admission-capped);
+ this function adds no new information and can regress in no way the
+ input list itself could not already regress, so a second field would
+ only create a second place for the same fact to drift out of sync with
+ the first -- the identical reasoning {!Liturgical_day.t.temporal}'s own
+ "embedded, not flattened" comment gives for a different field. Exposed
+ here, at the kernel level, rather than left for an output layer to
+ reimplement: the filter is small but the RULE it encodes (RG 111(a))
+ is not obvious from the type alone, and a caller (the [rubrics] CLI
+ column, a future template, a differential test) should name the
+ rubric, not re-derive "privileged commemorations, capped at one" for
+ itself.
+
+ SCOPE: "non conventualibus" is read literally -- this models the
+ ordinary (non-conventual) sung Mass only. RG 111(a)'s own text implies
+ a CONVENTUAL sung Mass keeps the wider (b)/(c)/(d) caps even when sung
+ (a choir-obligation distinction), but this engine has no concept of
+ "conventual" at all (no community/choir dimension anywhere in
+ {!Celebration.t} or {!Liturgical_day.t}), so this function's result
+ should be read as "what a normal parish/private Low OR sung Mass
+ admits", never "what every sung Mass, everywhere, admits" -- a
+ documented scope limit, not an oversight. *)
+val sung_mass_commemorations : ('a * privilege) list -> ('a * privilege) list
diff --git a/test/test_precedence.ml b/test/test_precedence.ml
index acc89fc..54ab3f9 100644
--- a/test/test_precedence.ml
+++ b/test/test_precedence.ml
@@ -98,6 +98,49 @@ let test_temporal_only_day () =
Alcotest.(check int) "nothing deferred" 0 (List.length r.P.deferred);
Alcotest.(check int) "nothing omitted" 0 (List.length r.P.omitted)
+(* RG 111(a): sung_mass_commemorations -- see precedence.mli's own citation.
+ Pure function, no {!resolve} needed: exercised directly against
+ hand-built Low-Mass admitted lists, the same shape {!P.resolve} would
+ produce, rather than through a full resolution. *)
+let sung_test_cand rank slug = cand ~rank slug
+
+let test_sung_mass_no_privileged () =
+ let low_mass = [ (sung_test_cand Lo "a", P.Ordinary); (sung_test_cand Lo "b", P.Ordinary) ] in
+ Alcotest.(check (list string)) "no privileged commemoration -> sung Mass keeps none" []
+ (List.map (fun (c, _) -> slug_of c) (P.sung_mass_commemorations low_mass))
+
+let test_sung_mass_one_privileged () =
+ let low_mass =
+ [ (sung_test_cand Lo "ordinary-one", P.Ordinary); (sung_test_cand Hi "privileged-one", P.Privileged) ]
+ in
+ Alcotest.(check (list string)) "the ordinary commemoration is dropped, the privileged one kept"
+ [ "privileged-one" ]
+ (List.map (fun (c, _) -> slug_of c) (P.sung_mass_commemorations low_mass))
+
+let test_sung_mass_privileged_first_already () =
+ let low_mass =
+ [ (sung_test_cand Hi "privileged-one", P.Privileged); (sung_test_cand Lo "ordinary-one", P.Ordinary) ]
+ in
+ Alcotest.(check (list string)) "a privileged commemoration already first is kept alone" [ "privileged-one" ]
+ (List.map (fun (c, _) -> slug_of c) (P.sung_mass_commemorations low_mass))
+
+let test_sung_mass_empty_low_mass_set () =
+ Alcotest.(check (list string)) "an empty Low-Mass set stays empty at Sung Mass" []
+ (List.map (fun (c, _) -> slug_of c) (P.sung_mass_commemorations []))
+
+(* If two privileged commemorations were ever admitted together (not known
+ to occur on any shipped rite's data -- precedence.mli's own citation),
+ [sung_mass_commemorations] keeps only the FIRST -- input order is
+ already RG 113 precedence order, so this is "the highest-precedence
+ privileged entry survives", not an arbitrary truncation. *)
+let test_sung_mass_two_privileged_keeps_first () =
+ let low_mass =
+ [ (sung_test_cand Hi "first-privileged", P.Privileged); (sung_test_cand Hi "second-privileged", P.Privileged) ]
+ in
+ Alcotest.(check (list string)) "only the first (higher-precedence) privileged commemoration survives"
+ [ "first-privileged" ]
+ (List.map (fun (c, _) -> slug_of c) (P.sung_mass_commemorations low_mass))
+
let suite =
( "Precedence",
[ Alcotest.test_case "highest band wins" `Quick test_highest_band_wins;
@@ -107,4 +150,14 @@ let suite =
test_commemoration_only_never_observed;
Alcotest.test_case "nothing silently lost" `Quick test_nothing_silently_lost;
Alcotest.test_case "order independent" `Quick test_order_independent;
- Alcotest.test_case "temporal-only day" `Quick test_temporal_only_day ] )
+ Alcotest.test_case "temporal-only day" `Quick test_temporal_only_day;
+ Alcotest.test_case "RG 111(a): sung Mass, no privileged commemoration" `Quick
+ test_sung_mass_no_privileged;
+ Alcotest.test_case "RG 111(a): sung Mass keeps the one privileged commemoration" `Quick
+ test_sung_mass_one_privileged;
+ Alcotest.test_case "RG 111(a): sung Mass, privileged already first" `Quick
+ test_sung_mass_privileged_first_already;
+ Alcotest.test_case "RG 111(a): sung Mass, empty Low-Mass set" `Quick
+ test_sung_mass_empty_low_mass_set;
+ Alcotest.test_case "RG 111(a): sung Mass keeps only the first of two privileged" `Quick
+ test_sung_mass_two_privileged_keeps_first ] )
diff --git a/test/test_rubrics_ef.ml b/test/test_rubrics_ef.ml
index 6679702..d533670 100644
--- a/test/test_rubrics_ef.ml
+++ b/test/test_rubrics_ef.ml
@@ -746,6 +746,38 @@ let test_exhaustive_gloria_domain_sweep () =
Alcotest.(check bool) "the full domain reached a real number of Requiem days" true (black > 1_000)
end
+(* ---- ITEM 1: RG 111(a), the sung-Mass commemoration cap
+ ({!Colitur_kernel.Precedence.sung_mass_commemorations}) -- two real
+ calendar days, one of each shape, resolved through the identical
+ [Cal.day] pipeline every other test in this file uses. See
+ precedence.mli's own citation for the rubric and the reasoning for why
+ this is a derivation over [LD.commemorations] (the Low-Mass admitted
+ set), not a second stored field. ---- *)
+
+module Prec = Colitur_kernel.Precedence
+
+let sung_slugs y m d =
+ List.map
+ (fun (c, _) -> Slug.to_string c.Cel.slug)
+ (Prec.sung_mass_commemorations (day_on y m d).LD.commemorations)
+
+let test_rg111a_ordinary_only_dropped () =
+ (* 2026-08-14: Vigil of the Assumption, commemorating [eusebius-confessor]
+ ORDINARILY (no privileged category applies) -- confirmed via `colitur
+ day 2026`. At Low Mass this is admitted (RG 111(c)); at a
+ non-conventual Sung Mass RG 111(a) admits it not at all. *)
+ Alcotest.(check (list string)) "RG 111(a): an ordinary-only Low-Mass set is empty at Sung Mass" []
+ (sung_slugs 2026 8 14)
+
+let test_rg111a_privileged_kept () =
+ (* 2026-04-25: St Mark, commemorating the Major Litanies -- RG 80/109(f),
+ a PRIVILEGED commemoration (data/ef/adjustments.sexp's own [Add
+ major-litanies]) -- confirmed via `colitur day 2026`. Kept at BOTH Low
+ Mass (RG 111(c), one privileged) and Sung Mass (RG 111(a), the same
+ single privileged commemoration). *)
+ Alcotest.(check (list string)) "RG 111(a): the one privileged commemoration survives at Sung Mass"
+ [ "major-litanies" ] (sung_slugs 2026 4 25)
+
let suite =
( "Rubrics_ef",
[ Alcotest.test_case "475(a): ordinary Sunday" `Quick test_475a_ordinary_sunday;
@@ -819,4 +851,8 @@ let suite =
Alcotest.test_case "domain sanity: every Requiem day has [gloria]=false (sample)" `Quick
test_domain_requiem_implies_no_gloria_sample;
Alcotest.test_case "domain sweep 1583..9999: violet/Requiem invariants, committed not sampled"
- `Slow test_exhaustive_gloria_domain_sweep ] )
+ `Slow test_exhaustive_gloria_domain_sweep;
+ Alcotest.test_case "RG 111(a): an ordinary-only Low-Mass set is empty at Sung Mass" `Quick
+ test_rg111a_ordinary_only_dropped;
+ Alcotest.test_case "RG 111(a): a privileged commemoration survives at Sung Mass" `Quick
+ test_rg111a_privileged_kept ] )