summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-13 11:19:19 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-13 11:19:19 +0200
commitee5c81e70adcd2ce6cecc57da4c416d482c187ca (patch)
tree45b5d7951bae89b0720d876a2abfddc82627e78c /lib
parentb5dcdd8d4048295cfbdf1606f4cafd39b3c433e6 (diff)
downloadcolitur-ee5c81e70adcd2ce6cecc57da4c416d482c187ca.tar.gz
colitur-ee5c81e70adcd2ce6cecc57da4c416d482c187ca.zip
precedence(ef): RG 110, the inseparable Peter/Paul commemoration
RG 110 (Caput XIV): "In Officio et Missa S. Petri semper fit commemoratio S. Pauli, et vicissim... pro unica habeantur" -- in the Office and Mass of either Peter or Paul, a commemoration of the other is always made, uncapped by RG 111's own admission count. Both photographic scans, full text including sub-clause (c) (a separate trigger this register previously had not transcribed: the same rule also fires when one Apostle's oration is admitted merely as an ordinary/privileged commemoration, not only when it is the day's own office). Three real pairs in the 1962 calendar, all confirmed on both scans: 25 January (conversion-of-st-paul + peter), 22 February (chair-of-st-peter + paul), 30 June (in-commemoratione-sancti-pauli-apostoli + a new commemoration-of-st-peter). The first two already existed as ordinary sanctoral candidates but were wired into RG 111's ordinary, capped admission contest instead of this rule's own uncapped one -- the defect this closes. 30 June had no companion at all: a genuine gap in lectio's own source data (confirmed directly against tridentine-calendar.ini) and in missalemeum's own oracle output for both fixture years, not only a colitur bootstrap miss, so colitur is now uniquely correct on this date among the three engines compared. Added via data/ef/adjustments.sexp's own Add directive, the architecture's existing extension point for exactly this shape of gap, since data/ef/sanctoral.sexp is generated and lectio must not be touched. Precedence_ef.admit gained rg110_additions/rg110_companion_slug, layered on after admit's own four ordinarily-capped branches decide their normal result: checks both the observed candidate and every already-admitted candidate against the three-pair lookup and prepends any companion found, uncapped and additional, never competing for RG 111's own slot. Measured full blast radius (1583-9999, pre-change binary from git archive at 59fbda8 vs the fixed binary, 3,074,246 days each side, 14,627 differing days, all four predicted shapes, zero unclassified): 3,619 Holy Name fallback days (previous commit), 3,533 days Paul was wrongly excluded from Chair of St Peter's own day by a competing privileged feria, 593 days Chair of St Peter itself was admitted only as an ordinary commemoration with Paul entirely absent, and 6,882 days the new 30 June companion is added. The 3,533+593 figure supersedes an earlier, narrower 852-day estimate that measured only the delta between two older code commits, not the rule's full scope. The lectio differential needed no change: it does not compare commemorations at all, and this fix never touches season, slug, rank, or colour. The missalemeum oracle allow-list needed three changes: M12 removed (22 February 2027 no longer diverges in commemoration count), M15's date list gained 2027-02-22 (now reaching identity comparison for the first time, where it lands on that entry's own pre-existing, unrelated limit -- one of the two commemorations is temporal-origin and unnamed), and a new M19 for the 30 June gap. Mutation-tested, both halves separately: disabling the admit-level addition reddens the two new unit tests, the oracle's own M12-shaped checks, and an existing golden pin (updated to expect the Paul companion); removing only the 30 June data entry reddens solely the oracle's new M19 count, confirming the code and data halves are independently load-bearing.
Diffstat (limited to 'lib')
-rw-r--r--lib/rites/rite_ef/precedence_ef.ml161
-rw-r--r--lib/rites/rite_ef/precedence_ef.mli20
2 files changed, 175 insertions, 6 deletions
diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml
index 374e9a4..c488596 100644
--- a/lib/rites/rite_ef/precedence_ef.ml
+++ b/lib/rites/rite_ef/precedence_ef.ml
@@ -1039,6 +1039,145 @@ let rec take n = function
still the pair, not the triple; only the INPUT carries [band]. *)
let drop_band (c, p, (_ : int)) = (c, p)
+(* RG 110 (docs/research/rules-register.md §4, Caput XIV, "De dierum
+ liturgicorum occurrentia perpetua"), full text, both photographic scans,
+ word for word: "110. In Officio et Missa S. Petri semper fit
+ commemoratio S. Pauli, et vicissim. Haec commemoratio dicitur
+ inseparabilis; et duae orationes adeo in unam coalescere censentur ut, in
+ numero orationum computando, pro unica habeantur. Proinde: a) in Officio
+ S. Petri aut S. Pauli, oratio alterius Apostoli additur, ad Laudes et ad
+ Vesperas, sub unica conclusione, orationi diei, absque antiphona et
+ versu; b) in Missa S. Petri aut S. Pauli, oratio alterius Apostoli
+ additur, sub unica conclusione, orationi diei; c) quoties vero oratio
+ unius Apostoli addenda est ad modum commemorationis, huic orationi
+ additur altera immediate, ante omnes alias commemorationes." -- in the
+ Office and Mass of St Peter, a commemoration of St Paul is ALWAYS made,
+ and vice versa. This commemoration is called INSEPARABLE, and the two
+ orations are held to coalesce into one so much that, in counting the
+ number of orations [RG 111's own admission cap], they are counted AS
+ ONE. Accordingly: (a)/(b) in the Office/Mass OF Peter or Paul [i.e. when
+ one of them is the day's own observed office], the other Apostle's
+ oration is added, under a single conclusion, to the oration of the day;
+ (c) whenever the oration of ONE Apostle must be added AS A
+ COMMEMORATION [i.e. when one of them is not the day's own office but is
+ itself only being admitted as an ordinary/privileged commemoration
+ elsewhere], the OTHER is added to it immediately, BEFORE ALL OTHER
+ COMMEMORATIONS.
+
+ Three real pairs in this codebase's data, all three confirmed on both
+ photographic scans (register §4): 25 January's [conversion-of-st-paul] +
+ [peter]; 22 February's [chair-of-st-peter] + [paul]; 30 June's
+ [in-commemoratione-sancti-pauli-apostoli] + [commemoration-of-st-peter]
+ (data/ef/adjustments.sexp's own `Add` directive -- lectio has no
+ equivalent entry at all, a genuine upstream data gap, not merely a
+ colitur bootstrap miss). 29 June ([sts-peter-paul]) needs none: it is
+ already the JOINT feast, not one Apostle's office alone. [peter]/[paul]
+ already existed as ordinary [Commemoration_only] sanctoral candidates
+ before this task (Task 10's bootstrap) -- part of RG 110's own machinery,
+ just wired into the ORDINARY, CAPPED RG 111 admission contest instead of
+ this rule's own UNCAPPED one, which is the defect this closes: measured
+ (1583-9999, `tools/`'s own throwaway scan, reproduced and discarded per
+ this task's own report) at 3,533 years where [chair-of-st-peter] is
+ observed but [paul] loses the day's single non-Sunday-II-class slot to a
+ competing privileged Advent/Lent/Ember feria (RG 109(e)/(d)), and a
+ FURTHER 593 years of shape (c) below, where [chair-of-st-peter] itself
+ loses outright to an ordinary (ordinary, i.e. non-I-class) II-class
+ Sunday and is admitted only AS a commemoration -- [paul] was entirely
+ absent in every one of those 593 years too, before this fix. 25
+ January/30 June never show shape (c) at all: both trigger candidates are
+ [Class3], below RG 111(b)'s "de festo II classis" floor, so neither can
+ ever be admitted as a mere ordinary commemoration on a II-class Sunday
+ in the first place -- confirmed by the same scan, 0 instances either
+ way, not assumed absent.
+
+ [rg110_companion_slug] reads ONLY the candidate's own slug -- the same
+ convention every other not-an-RG-citation lookup in this file uses
+ ({!universal_layer}, {!vigil_suffix}, {!nativity_octave_prefix}) -- not
+ [subject]/[rank]/anything else, because "which slug pairs with which" is
+ RG 110's own closed, three-pair list, not a general property [band]/
+ [disposition] could derive structurally the way (say) [is_sunday_slug]
+ derives Sunday-ness from a naming convention. A rename of any of the six
+ slugs involved (either side of a pair) has nowhere else to be caught but
+ here -- flagged the same way {!annunciation_slug} already is. *)
+let rg110_companion_slug (slug : string) : string option =
+ if slug = "chair-of-st-peter" then Some "paul"
+ else if slug = "conversion-of-st-paul" then Some "peter"
+ else if slug = "in-commemoratione-sancti-pauli-apostoli" then Some "commemoration-of-st-peter"
+ else None
+
+(* Looks [companion_slug] up directly in [comms] -- the SAME full candidate
+ pool {!admit} itself sorts from, before any cap is applied, so this finds
+ the companion regardless of whether the NORMAL admission process would
+ have kept it (it never would: {!band} always ranks a [Commemoration_only]
+ candidate at {!unclassified}, worse than literally any real table entry,
+ so a companion competing on its own merits never survives a contested
+ slot -- RG 110's entire point is that it should not have to). Returns the
+ ORIGINAL triple's candidate/privilege pair, unrebuilt, for the same
+ physical-equality reason {!drop_band} and this function's own top comment
+ already document -- reusing the [privilege] {!disposition}/{!privilege_of}
+ already computed for it rather than inventing a new tag here. *)
+let rg110_find_companion comms companion_slug =
+ List.find_map
+ (fun (c, p, (_ : int)) ->
+ if Slug.to_string c.Precedence.cel.Celebration.slug = companion_slug then Some (c, p) else None)
+ comms
+
+(* RG 110's own uncapped addition, applied to [normal] -- the day's ALREADY-
+ COMPUTED, ORDINARILY-CAPPED admission result (every branch of {!admit}
+ below, unchanged otherwise). Two triggers, covering RG 110(a)/(b) and
+ RG 110(c) respectively, in this function's own comment above:
+ - [observed] itself is one of the three trigger slugs (shape (a)/(b):
+ Peter's or Paul's own Office/Mass IS today's day) -- the companion is
+ looked up directly and appended, uncapped.
+ - one of [normal]'s OWN members is a trigger slug (shape (c): Peter's or
+ Paul's own office lost the day outright but is itself being admitted
+ as an ordinary/privileged commemoration of whatever else won) -- same
+ lookup, same append.
+ Both can never fire together on one call ({!Precedence.resolve} only
+ ever calls {!Precedence.rules.admit} once per date, and all three
+ trigger slugs are distinct FIXED calendar dates -- 25 January, 22
+ February, 30 June -- so at most one of the two conditions is ever true
+ for a given [observed]/[normal] pair; [List.filter_map] over both
+ sources together is simply the shortest way to express "check every
+ place a trigger slug could legitimately appear", not evidence they are
+ expected to co-occur).
+
+ PREPENDED, not appended, per RG 110(c)'s own explicit ordering directive
+ ("ante omnes alias commemorationes") -- the one clause in this rule that
+ actually specifies WHERE, restated here for shape (a)/(b) too rather than
+ drawing an unwarranted distinction between the two: no live data in this
+ codebase ever has a THIRD, unrelated commemoration alongside a RG 110
+ pair to make the ordering choice visible in real output (the companion is
+ always the only OTHER item, or the only item, in [normal] already), so
+ this is closer to a defensive convention than a tested outcome -- flagged
+ honestly rather than claimed as independently verified.
+
+ A companion already present in [normal] on its own merits (structurally
+ unreachable today -- {!unclassified}'s own comment above -- but not
+ provably so for every future rite/data shape) is not added a second
+ time: [List.exists] guards each append. *)
+let rg110_additions (comms : (Vocab_ef.rank Precedence.candidate * Precedence.privilege * int) list)
+ ~(observed : Vocab_ef.rank Precedence.candidate)
+ (normal : (Vocab_ef.rank Precedence.candidate * Precedence.privilege) list) :
+ (Vocab_ef.rank Precedence.candidate * Precedence.privilege) list =
+ let already_has slug =
+ List.exists (fun (c, _) -> Slug.to_string c.Precedence.cel.Celebration.slug = slug) normal
+ in
+ let trigger_slugs =
+ Slug.to_string observed.Precedence.cel.Celebration.slug
+ :: List.map (fun (c, _) -> Slug.to_string c.Precedence.cel.Celebration.slug) normal
+ in
+ let companions =
+ List.filter_map
+ (fun trigger_slug ->
+ match rg110_companion_slug trigger_slug with
+ | None -> None
+ | Some companion_slug ->
+ if already_has companion_slug then None else rg110_find_companion comms companion_slug)
+ trigger_slugs
+ in
+ companions @ normal
+
let admit ~(observed : Vocab_ef.rank Precedence.candidate)
~(temporal : Vocab_ef.rank Precedence.candidate)
(comms : (Vocab_ef.rank Precedence.candidate * Precedence.privilege * int) list) :
@@ -1073,8 +1212,16 @@ let admit ~(observed : Vocab_ef.rank Precedence.candidate)
is_sunday_slug (Slug.to_string temporal.Precedence.cel.Celebration.slug)
in
let open Vocab_ef in
- match (observed_rank, day_is_sunday) with
- | Class1, _ ->
+ (* RG 110's own uncapped additions are layered on AFTER this match --
+ {!rg110_additions}'s own top comment -- never inside any one branch:
+ the three trigger slugs can each reach [observed] via any rank/Sunday
+ combination the table admits (a Class2 fixed feast, here, but nothing
+ in {!rg110_additions} itself assumes that), so computing it once,
+ uniformly, over whatever this match already decided is both simpler
+ and safer than duplicating the same lookup into every branch. *)
+ let normal =
+ match (observed_rank, day_is_sunday) with
+ | Class1, _ ->
(* RG 111: "I class: none save one privileged." Ordinary commemorations
never get a slot at all on a I-class day, no matter how many are
due; at most one privileged one does, the highest-precedence one
@@ -1127,10 +1274,12 @@ let admit ~(observed : Vocab_ef.rank Precedence.candidate)
goes to whichever candidate outranks the rest by RG 113's own
table-of-precedence order ({!band}), privileged or not. *)
(match sorted with [] -> [] | best :: _ -> [ drop_band best ])
- | (Class3 | Class4), _ ->
- (* RG 111: "III-IV class: at most two" -- by RG 113's table order, same
- as the non-Sunday II-class case, just with room for two. *)
- List.map drop_band (take 2 sorted)
+ | (Class3 | Class4), _ ->
+ (* RG 111: "III-IV class: at most two" -- by RG 113's table order, same
+ as the non-Sunday II-class case, just with room for two. *)
+ List.map drop_band (take 2 sorted)
+ in
+ rg110_additions comms ~observed normal
(* Task 11: RG 96 -- where an impeded I-class feast lands (docs/research/
rules-register.md §4, "Transfer/translation"). [band] decides who is
diff --git a/lib/rites/rite_ef/precedence_ef.mli b/lib/rites/rite_ef/precedence_ef.mli
index 6a96c4e..765b573 100644
--- a/lib/rites/rite_ef/precedence_ef.mli
+++ b/lib/rites/rite_ef/precedence_ef.mli
@@ -243,6 +243,26 @@ val september_ember_prefix : string
- [observed] a [Class3] or [Class4] day: at most two, by {!band}'s table
order alone.
+ ADDED, ef-holyname-rg110 task: RG 110 (docs/research/rules-register.md
+ §4, Caput XIV) -- "In Officio et Missa S. Petri semper fit
+ commemoratio S. Pauli, et vicissim... pro unica habeantur" -- whenever
+ [observed], or one of the candidates the FOUR cases above already admit,
+ is one of RG 110's own three trigger slugs (25 January's
+ [conversion-of-st-paul], 22 February's [chair-of-st-peter], 30 June's
+ [in-commemoratione-sancti-pauli-apostoli]), its companion Apostle
+ ([peter]/[paul]/[commemoration-of-st-peter] respectively) is looked up
+ directly in [comms] and PREPENDED to whatever the four cases above
+ already produced -- UNCAPPED, i.e. added on top of, not competing for,
+ any of those cases' own admission limits ("pro unica habeantur": the
+ pair is held as ONE for RG 111's own counting purposes, so it never
+ consumes a second ordinary/privileged slot). This is layered on AFTER
+ the four cases above decide the day's ordinarily-capped result, uniformly
+ regardless of which of the four produced it -- see the .ml's own
+ [rg110_additions] for the full citation, the measured blast radius, and
+ why the two lookups (observed itself, and each already-admitted
+ candidate) together cover both of RG 110's own textual triggers,
+ (a)/(b) and (c).
+
CORRECTED, Task B (branch ef-rg16a): this used to sort by
[Vocab_ef.rank] (RG 8's coarse four-class "dignity") and break same-rank
ties alphabetically by slug -- an engineering convention with no