summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md184
-rw-r--r--data/ef/adjustments.sexp88
-rw-r--r--data/ef/expected-divergences-missalemeum.sexp21
-rw-r--r--lib/kernel/calendar.ml112
-rw-r--r--lib/rites/rite_ef/precedence_ef.ml239
-rw-r--r--lib/rites/rite_ef/precedence_ef.mli60
-rw-r--r--test/test_calendar.ml90
-rw-r--r--test/test_golden.ml138
-rw-r--r--test/test_oracle.ml125
-rw-r--r--test/test_precedence_ef.ml124
-rw-r--r--test/test_rite_ef.ml53
11 files changed, 1072 insertions, 162 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 7875fbe..6e8289a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -169,6 +169,20 @@ against missalemeum; layer 5 pins ~30 dates.
`band`'s own `Commemoration_only` guard (below) also turns it red, on the same
date this whole gap was originally found through — both reverted after
confirming.
+- **Major Litanies (RG 80/109(f), `ef-major-litanies` task): layer 3 is
+ entirely BLIND to a commemoration-only entity, confirmed not merely
+ argued** — lectio computes no Major Litanies at all, and its own `row`
+ type carries no commemorations field in the first place (limit 1, same
+ file). `data/ef/expected-divergences.sexp` needed no change; a whole new
+ privileged commemoration, present or absent, present-but-displacing-a-
+ saint, or transferring to a different date entirely, is genuinely
+ invisible to that layer. Layer 4 (missalemeum) sees the entity and the
+ RG 111(b) question (both years in its 2026-2027 window), but is ALSO
+ blind to the transfer specifically, because neither year in that window
+ is a trigger year — confirmed by mutation (disabling the transfer
+ branch produced zero oracle failures), not merely by the calendar
+ coincidence. Only golden pins see the transfer at all. Full account:
+ `.superpowers/sdd/2026-08-12-colitur-rg16a/major-litanies-report.md`.
- **The `admit` same-rank tie-break is RG 113, not an uncited convention** (same
task, fix round 1): RG 113's own second sentence ("in admittendis et ordinandis
aliis commemorationibus, servetur ordo tabellae praecedentiae"), previously
@@ -196,8 +210,13 @@ against missalemeum; layer 5 pins ~30 dates.
## Current state (Plans 1–3 DONE — verify with `git log`)
-**Plans 1 + 2 are on `main` (35 commits). Plan 3 is branch `ef-plan3`, 42 commits,
-259 tests green** (260 with the exhaustive sweep). The kernel, the **complete EF
+**Plans 1 + 2 are on `main` (35 commits). Plan 3 and its follow-on fix/feature
+tasks (RG 16(a), Holy Family/RG 112(a), Holy Name/RG 110, the Sacred Triduum,
+the BVM Saturday Office, the Major Litanies) have landed on a chain of feature
+branches since — test count keeps climbing task by task (325 tests green, 326
+with the exhaustive sweep, as of `ef-major-litanies`; this line is not kept in
+lockstep with every task, `git log`/`dune test` are the actual source of
+truth).** The kernel, the **complete EF
temporal cycle**, the **resolution engine**, the **sanctoral data**, and **all five
validation layers** are built. `colitur day <year>` emits a full resolved year.
@@ -231,12 +250,16 @@ commemorations RG 108–112, transfers RG 96–98) · `rite_ef` (the bundle).
in its provenance header) · `data/ef/adjustments.sexp` (overlay — `Add` as well
as `Suppress`/`Edit`: RG 110's own 30 June companion, `commemoration-of-st-peter`,
is genuinely missing from lectio's own source, not merely from colitur's
-bootstrap, so it is hand-authored here rather than upstream) · two cited
+bootstrap, so it is hand-authored here rather than upstream; `Add major-litanies`,
+`ef-major-litanies` task, RG 80/81, same reasoning) · two cited
allow-lists, `expected-divergences.sexp` (7 active entries, vs lectio — C1, C6,
C8, C14, C15, C16, C17; several more closed and recorded in the register, not
-deleted) and `expected-divergences-missalemeum.sexp` (11 active, vs the oracle —
+deleted — untouched by the Major Litanies, layer 3 is blind to that entity, see
+above) and `expected-divergences-missalemeum.sexp` (12 active, vs the oracle —
M2 closed/M18 widened by the `ef-bvm-saturday` task; M12 closed/M19 opened by an
-earlier one). Fixtures live in `test/fixtures/` with asserted SHA-256s.
+earlier one; M5 corrected (a prior note had 2027's own outcome backwards) and
+M20 added by the `ef-major-litanies` task, M18 394 not 395 accordingly).
+Fixtures live in `test/fixtures/` with asserted SHA-256s.
**CLI**: `colitur easter <year>`, `temporal <year>`, `day <year>`.
@@ -474,24 +497,27 @@ Boniface 14 May, Evaristus, Theodore); and `romanus`, which should not exist on
9 August. lectio's ini is **generated from missalemeum**, so the two are one
lineage, not two independent sources.
-**Unbuilt, recorded**: RG 112(b)/(c)/(d) (only (a) has a live witness this
-codebase's data can construct). Allow-list entries M11 and M13 are `verdict
-open` by design. Holy Name of Jesus (RG 17(a)) and RG 110 (inseparable
-Peter/Paul commemorations) are **RESOLVED — see item 4 above.** The Sacred
-Triduum's own identity is **RESOLVED — see item 5 below.** RG 91 entry 27's
-BVM Saturday Office is **RESOLVED — see item 9 below.**
-Major Litanies (25 April) and the Rogation-Wednesday commemoration are
-**NARROWED, still unbuilt — see item 5 below**: both rules are now
-scan-verified and register-cited, but neither is built, because both need the
-SAME missing machinery (a rite-computed, Easter-relative, non-competing
-commemoration candidate — no channel in `Precedence.resolve`/`calendar.ml`
-constructs one; sanctoral candidates are Fixed-date only, the day's temporal
-candidate is exactly one and already spoken for).
+**Unbuilt, recorded**: RG 112(b)/(c)/(d, non-BVM half) (only (a) and (d)'s
+BVM half have a live witness this codebase's data can construct). Allow-list
+entries M11 and M13 are `verdict open` by design. Holy Name of Jesus (RG
+17(a)) and RG 110 (inseparable Peter/Paul commemorations) are **RESOLVED —
+see item 4 above.** The Sacred Triduum's own identity is **RESOLVED — see
+item 5 below.** RG 91 entry 27's BVM Saturday Office is **RESOLVED — see
+item 9 below.** The Major Litanies (25 April, RG 80/109(f)) are **RESOLVED
+— see item 5 below.**
+Rogation Wednesday's own commemoration (RG 87-89) **remains genuinely
+unbuilt** — see item 5 below: the Major Litanies' own "no third channel"
+blocker turned out to be dissolved by REUSING the existing RG 96 transfer
+machinery rather than by adding the missing channel, but that reuse is not
+available to Rogation Wednesday, whose trigger is not a fixed civil date at
+all (it is the day's own temporal identity, Easter+38, which coincides
+structurally with the Ascension Vigil) — confirmed still blocked for the
+original, distinct architectural reason.
5. **The Sacred Triduum (RG 91 entry 2) — RESOLVED (2026-08-13,
- `ef-triduum-litanies` task); Major Litanies (RG 80) and Rogation
- Wednesday's own commemoration (RG 87-89) — established from a
- photographic scan, deliberately NOT built.** Holy Thursday/Good
+ `ef-triduum-litanies` task); Major Litanies (RG 80/81/109(f)) —
+ RESOLVED (2026-08-13, `ef-major-litanies` task); Rogation Wednesday's
+ own commemoration (RG 87-89) remains genuinely unbuilt.** Holy Thursday/Good
Friday/Holy Saturday kept their existing slugs
(`ef-passiontide-2-{thursday,friday,saturday}` — RG 91 entry 2 is
identified structurally by `Precedence_ef.band`, off rank and Easter
@@ -512,67 +538,63 @@ candidate is exactly one and already spoken for).
unit test see it at all. Mutation-tested: reverting `temporal_ef.ml`
alone reddens 6 tests across those two files.
- Major Litanies (25 April, RG 80) and Rogation Wednesday's own
- commemoration (RG 87-89, Minor Litanies) were both investigated to the
- same depth and **both scan-verified and fully register-cited, but
- deliberately not built**: RG 80's own transfer clause (25 April moving to
- the following Tuesday whenever it coincides with Easter Sunday or Easter
- Monday — **194 of 8,417 domain years, ≈2.3%, measured**, not assumed
- rare) and Rogation Wednesday's own commemoration (RG 88/89, competing
- only when the Ascension Vigil already occupies the day) both need a
- commemoration candidate keyed to a MOVABLE, Easter-relative date that
- is not the day's own temporal office — a kind of thing
- `Precedence.resolve`'s architecture has no channel for: sanctoral
- candidates come exclusively from `Layer.on_date`'s Fixed-`Date_spec`
- lookup (`date_spec.ml`'s own header: Easter-relative forms "arrive with
- the OF sanctoral" — not yet), and the day's temporal candidate is
- exactly one, already spoken for. RG 110's own `rg110_additions`
- mechanism (item 4 above) looks like a precedent but is not one: it only
- ever RE-INCLUDES a candidate that already exists elsewhere in that
- date's own candidate pool (both Peter and Paul are ordinary Fixed-date
- sanctoral entries); neither the Major Litanies' Easter+2 target nor
- Rogation Wednesday's own Mass commemoration has any such pre-existing
- candidate to find.
+ **Major Litanies (25 April, RG 80/81/109(f)) — RESOLVED (2026-08-13,
+ `ef-major-litanies` task).** The "no channel for a movable,
+ Easter-relative commemoration candidate" blocker this entry previously
+ recorded (and the item-5 header used to describe as blocking BOTH the
+ Litanies and Rogation Wednesday) turned out to be dissolved by a
+ DIFFERENT design, not by adding the missing channel: RG 80's own
+ transfer is structurally the SAME operation RG 96 already performs for
+ an impeded I-class feast (a losing candidate relocated to a named later
+ date), so it is built by REUSING `Precedence.disposition`'s existing
+ `Transfer` constructor and `Calendar`'s existing placement machinery,
+ with a fixed target (Easter+2) instead of a searched one — no new
+ `Date_spec` variant, no third candidate stream. Entity:
+ `Commemoration_only`, `Fixed(4,25)`, `data/ef/adjustments.sexp`'s `Add
+ major-litanies`. **The genuine reason to defer, correctly identified by
+ an earlier fix-round review** (25 April is St Mark, II class, so RG
+ 111(b) — not (c), a citation this task corrected — makes a privileged
+ Litanies commemoration DISPLACE Mark's own ordinary one whenever both
+ compete) **is now measured and adjudicated**: full domain blast radius
+ (1583-9999, zero unclassified findings) is 7 394 years the Litanies
+ simply appear, 829 years they displace Mark (4 of them, 2010/2021/
+ 2027/2032, in the 2005-2050 window), 194 origin departures + 194
+ target arrivals for the transfer (the same 194 figure this entry
+ already had, now independently re-derived through the real
+ `Calendar`/`Precedence` pipeline). The Sunday-displacement question
+ itself (RG 111(b): does a privileged commemoration categorically
+ override an ordinary II-class one, or does missalemeum's own
+ divergent data mean otherwise?) is ADJUDICATED colitur, honestly
+ flagged as the first real (non-synthetic) test of that specific admit
+ clause — see the "know what each layer cannot see" section above and
+ the task's own full report for the reasoning and the correction this
+ task made to a PRE-EXISTING allow-list note (M5) that had 2027's own
+ oracle outcome backwards. A genuine kernel bug was found and fixed
+ along the way, kept rite-agnostic: `calendar.ml`'s `build_day` used to
+ decide "did a transfer settle" by checking ONLY whether the candidate
+ became `observed` at its target — impossible by construction for a
+ `Commemoration_only` candidate (RG 81), caught by `prop_invariants`'
+ SAMPLED 200-year property (the default `dune test` run), NOT the
+ committed exhaustive sweep (which walks in order and would have found
+ it deterministically at year 1638, not the later, seed-dependent year
+ the sample happened to draw) — attribution corrected in fix round 1
+ (F4). Fix round 1 also found and closed a THIRD settlement channel
+ `settled_at` still missed (a transferred candidate capped out by
+ admission limits AT its own target, F1) — unreachable on shipped data,
+ caught by the same sampled property while mutation-testing the RG 109(f)
+ privilege. Full account: `.superpowers/sdd/2026-08-12-colitur-rg16a/major-
+ litanies-report.md`.
- **The real reason to defer the Major Litanies, corrected by the
- fix-round review** — the version above was wrong in two load-bearing
- ways and is retracted:
-
- - It said the suppression half needs "a kernel signature extension
- threading Easter-offset into `disposition`/`admit`". It does not.
- `admit` **already** takes `~temporal` (added for exactly this class of
- question during the RG 16(a) task) and `disposition` already takes
- `~winner`; on 25 April in a transfer year both carry the Easter Sunday
- or Easter Monday office. A rite-local slug test suppresses it with
- **zero** kernel surface.
- - It implied Easter Monday is unmarkable. Measured over the whole
- domain: `ef-easter-1-monday` occurs **exactly 8 417 times in 8 417
- years** — once every year, never displaced, since it is an I-class
- octave day — making it precisely as reliable a marker as
- `ef-easter-sunday`. The stated asymmetry between the two trigger
- conditions does not exist.
-
- So the **guarded** build (a `Commemoration_only` `Fixed(4,25)` entry, RG
- 109(f) wired, plus slug-based suppression on the two Easter slugs) is a
- strict improvement, not a regression: 8 223 years newly correct, 194
- unchanged, zero new wrong answers. "Worse than the recorded gap" was
- true only of the *unguarded* build, which is the only option that was
- scored.
-
- Deferring is still right, for a reason nobody had identified: **25 April
- is St Mark, II class**, so under **RG 111(c)** a *privileged* Litanies
- commemoration (RG 109(f)) would **displace** whatever ordinary
- commemoration the day currently carries, in ≈97.7% of years — a live,
- unmeasured blast radius straight through layers 3 and 4. That
- measurement is the prerequisite, and it makes this its own task rather
- than a rider on another. The 194 figure is exact and reproduced twice
- (Easter = 25 April in 67 years, Easter Monday = 25 April in 127).
-
- Full reasoning: the `ef-triduum-litanies` task report and register
- (Rogations/§4, and the two narrowed open items in §6). **Rogation
- Wednesday remains genuinely blocked** — Easter+38 coincides with the
- Ascension Vigil by construction, so there is no `(month, day)` pair a
- `Fixed` spec could anchor to and no partial build exists at all.
+ **Rogation Wednesday remains genuinely blocked**, and for the ORIGINAL
+ architectural reason, now confirmed distinct from the Litanies' own
+ (dissolved) one: its trigger is not a fixed civil date at all, but the
+ day's OWN temporal identity (Easter+38), which coincides structurally
+ with the Ascension Vigil — there is no `(month, day)` pair a `Fixed`
+ spec could ever anchor to, so the Litanies' own "reuse the RG 96
+ transfer machinery" trick does not apply here (nothing is being
+ transferred FROM a civil date; the commemoration would have to be
+ synthesised from the day's own Easter offset, which still has no
+ channel). No partial build exists.
9. **RG 91 entry 27, the votive Office of the BVM on Saturday — RESOLVED
(2026-08-13, `ef-bvm-saturday` task).** `Precedence_ef.band` already
diff --git a/data/ef/adjustments.sexp b/data/ef/adjustments.sexp
index 740180c..a5e7c6f 100644
--- a/data/ef/adjustments.sexp
+++ b/data/ef/adjustments.sexp
@@ -165,6 +165,87 @@
; (Commemoration_only entries are never the OBSERVED celebration, so this
; entry's OWN colour is never printed by the current pipeline, the same
; note `Edit eusebius-confessor` above makes for its own colour field).
+;
+; `Add major-litanies` -- NEW (ef-major-litanies task). RG 80 (Caput X, "De
+; Litaniis maioribus et minoribus", §A "De Litaniis maioribus"; all three
+; primary documents -- both photographic scans AND the electronic
+; transcription -- word for word, no divergence to adjudicate): "80.
+; Litaniae maiores assignatae sunt diei 25 aprilis; si vero eo die
+; occurrit dominica Paschatis vel feria II post Pascha, transferuntur in
+; sequentem feriam III." RG 81, same division: "81. De Litaniis maioribus
+; nihil fit in Officio, sed tantum in Missa. Earum autem commemoratio non
+; est habenda commemoratio 'de Tempore'." RG 109(f) (Caput XVI, "De
+; Commemorationibus"): "f) de Litaniis maioribus, in Missa" -- a
+; privileged commemoration. The calendarium's own April table (all three
+; documents, e.g. the transcription: "iv c vii 25 Litania Maior. - S.
+; MARCI EVANGELISTAE, II classis.") prints "Litania Maior" as part of 25
+; April's own descriptive line, alongside St Mark, not as a second,
+; separately class-numbered row -- corroborating RG 81's "nihil fit in
+; Officio" the same way Maurice's own "Commemoratio ... " line (no class
+; number) corroborates a demoted feast elsewhere in this project (see
+; precedence_ef.ml's [band], top-of-function guard).
+;
+; MODELLING DECISION: `Commemoration_only`, `Fixed(4, 25)`, the same shape
+; as `commemoration-of-st-peter` above -- because RG 81 denies the
+; Litanies any Office standing at all ("nihil fit in Officio"), so they
+; can never be the OBSERVED day (RG 91's table enumerates "dies
+; liturgici", Office days, and this is explicitly none), only ever a Mass
+; commemoration. `Precedence_ef.disposition`/`.privilege_of` wire RG
+; 109(f)'s privilege and RG 80's own transfer condition (both cited in
+; full at `Precedence_ef.major_litanies_slug` and its two call sites); see
+; that module for the reasoning and precedence_ef.ml's own comments for
+; why the transfer reuses the existing RG 96 placement machinery
+; ({!Colitur_kernel.Precedence.Transfer}) rather than new machinery.
+;
+; `rank Class4`: RG 91's Table of Precedence has NO row for the Major
+; Litanies at all (RG 81 denies them Office standing; the calendarium's
+; own printed line above confirms it), so there is no class this data
+; field could faithfully report -- the same situation `band`'s own
+; top-of-function guard already documents for every `Commemoration_only`
+; entry ("never had a row in the table for that occasion to begin with").
+; `Class4` is chosen as the lowest/inertest value available, deliberately
+; NOT `Class1` (which would make `Precedence_ef.privilege_of`'s branch (b),
+; "of a I-class day", grant this entry Privileged status for the WRONG
+; cited reason, silently making the (f) branch this task wires dead code
+; again). `band` ignores rank entirely for any `Commemoration_only`
+; candidate (checked first, unconditionally); `admit`'s only rank-keyed
+; test for an ordinary (non-privileged) II-class-Sunday commemoration
+; never applies here either, since this entry is always Privileged. No
+; live behavioural consequence either way -- recorded as a deliberate,
+; honestly-flagged placeholder, not a citation.
+;
+; `colour Violet`: NOT independently scan-verified for this specific
+; entry (out of this task's scope -- Mass propers are Plan 4, `citations`
+; stays `()`). Matches, by internal consistency, the colour this same
+; codebase already assigns the sibling Minor Litanies/Rogation days
+; (`rite_ef/temporal_ef.ml`'s own Rogation branch: `~colour:Colour.Violet`
+; for RG 87's Monday/Tuesday before Ascension) -- both are "Litaniae",
+; major and minor, sharing the same penitential/processional character;
+; RG 80/81/109(f) themselves say nothing about colour. Inert today
+; regardless (`Commemoration_only` entries are never OBSERVED, so this
+; field is never printed by the current pipeline -- the same note `Edit
+; eusebius-confessor` above makes for its own colour field).
+;
+; `subject Saint`: no clean fit among {Lord, Bvm, Saint, Temporal} -- the
+; Litanies are tied to no particular Divine Person or to the BVM (RG 80/81
+; name no one), and are far from an ordinary saint's feast either, but
+; `Subject.Temporal` is reserved for the temporal cycle's OWN office
+; (`subject.mli`), which this Fixed, sanctoral-origin entry structurally
+; is not. `Saint` is the same "no special exclusion applies" default this
+; codebase's own other `Commemoration_only` sanctoral entries already use
+; (RG 110's own Peter/Paul companion entries, `commemoration-of-st-peter`
+; immediately below) -- its only live behavioural consequence anywhere in
+; this codebase, RG 112(a)/(d)'s Lord/BVM mutual-exclusion rules, is one
+; this entry should NOT trigger either way, so `Saint` is safe as well as
+; consistent.
+;
+; `names`: English ("The Major Litanies") is a plain translation of RG
+; 80's own "Litaniae maiores", not independently primary-sourced (no
+; English title exists in any of the three documents, all Latin). Polish
+; ("Litanie Większe") likewise a plain vernacular rendering, not verified
+; against any external Polish liturgical reference -- flagged here rather
+; than presented as sourced, the same honesty this file's own
+; `commemoration-of-st-peter` entry gives its own colour field above.
((id ef-adjustments)
(directives
((Suppress vigil-of-christmas)
@@ -177,4 +258,11 @@
(names
((en "St. Peter") (pl "\197\155w. Piotra, Aposto\197\130a")))
(rank Class3) (status Commemoration_only) (colour Red)
+ (subject Saint) (citations ()) (layer ef-universal)))))
+ (Add
+ ((date (Fixed (month 4) (day 25)))
+ (cel
+ ((slug major-litanies)
+ (names ((en "The Major Litanies") (pl "Litanie Większe")))
+ (rank Class4) (status Commemoration_only) (colour Violet)
(subject Saint) (citations ()) (layer ef-universal))))))))
diff --git a/data/ef/expected-divergences-missalemeum.sexp b/data/ef/expected-divergences-missalemeum.sexp
index 1fce257..96d6405 100644
--- a/data/ef/expected-divergences-missalemeum.sexp
+++ b/data/ef/expected-divergences-missalemeum.sexp
@@ -168,9 +168,9 @@
(note "missalemeum, like lectio, computes no Rogation day at all and shows the plain paschaltide-week feria instead. Only 3 May 2027 (Rogation Monday) shows it in this window -- every other Rogation day here is won outright by a saint on both sides, so only the underlying temporal identity differs, which this comparator's axes do not expose.")
(expected_rows 1))
((id M5)
- (citation "RG 80 (Major Litanies, 25 April) -- register §6's own pre-existing open item, \"Major Litanies (25 April, RG 80) are not computed\"")
- (verdict missalemeum)
- (note "Independently confirmed by the oracle, not a new finding: missalemeum's \"Pro rogationibus\" commemoration on St Mark's day (25 April, the Major Litanies' fixed date) is exactly the missing office register §6 already tracks. Only 2026 shows as a comparator MISMATCH: 25 April 2026 is a Saturday with Mark winning outright, so colitur's commemoration list is empty where the oracle's carries \"Pro rogationibus\" -- a real presence gap. 25 April 2027 is a Sunday: the Sunday wins on BOTH sides, and each side's own single commemoration slot goes to a DIFFERENT candidate (colitur: St Mark himself, an ordinary II-class sanctoral loser admitted under RG111(b)'s \"de festo II classis\" slot; missalemeum: the Major Litanies) -- both non-empty, same count, so this comparator's count-only axis (deliberately not slug-identity, see test_oracle.ml's own header) cannot see that mismatch; the underlying gap is the same, just invisible to this year's row.")
+ (citation "RG 80/81 (Major Litanies, Caput X \"De Litaniis maioribus et minoribus\") + RG 109(f) (Caput XVI \"De Commemorationibus\") -- now BUILT (ef-major-litanies task): Rite_ef.Precedence_ef's own [major_litanies_slug]/[disposition]/[privilege_of]/[transfer_target], data/ef/adjustments.sexp's matching `Add` entry")
+ (verdict colitur)
+ (note "CORRECTED (ef-major-litanies task): this entry's own PRIOR text claimed missalemeum's single admitted commemoration on 25 April 2027 was \"the Major Litanies\" -- checked directly against the raw fixture row while adjudicating a new divergence this task found (test/fixtures/missalemeum-ef-2026-2027.txt, the 2027-04-25 line: \"...|IV Sunday after Easter|-|St. Mark|Pro rogationibus|1|sancti:04-25:2:r\") and found BACKWARDS: missalemeum's own [commemorations] field there is \"St. Mark\", and \"Pro rogationibus\" (the Litanies) is the one listed in [displaced]. A genuine source-fidelity slip in this entry's own prior authorship, corrected here; see [M20] below for the divergence this correction actually motivates (2027's own now-real colitur-vs-missalemeum disagreement, adjudicated separately with its own RG citation). This entry (M5) now covers only 25 April 2026 (the ordinary, non-Sunday shape -- Mark wins the day outright on both sides, uncontested): both engines now admit a commemoration there (colitur: major-litanies; missalemeum: \"Pro rogationibus\"), so PRESENCE agrees (the register §6 gap this entry used to track is closed), but the two engines name the same real-world observance in different registers -- colitur's own English descriptive name (data/ef/adjustments.sexp's own honestly-flagged, not-primary-sourced \"The Major Litanies\") against missalemeum's Latin-ish \"Pro rogationibus\" -- never going to match as literal strings, and not a rubric dispute: RG 80/81 prescribe no English title for this observance at all. The SAME declared comparator limit M15/M18 already document for a temporal-origin candidate's identity, hit here for a different structural reason (a real candidate on BOTH sides now, just two different vocabularies, not an unresolvable name). Identity-gated (test_oracle.ml's own [m5_commemoration_matches]): pins that colitur's own sole admitted commemoration really is [major-litanies], not merely that some [Comm_identity_mismatch] diff exists on this date, the same discipline M19's own identity guard already established.")
(expected_rows 1))
((id M8)
(citation "RG 109(a) (\"of a Sunday\" is always privileged) + RG 111(a) (\"I class: none save one privileged\")")
@@ -205,10 +205,21 @@
((id M18)
(citation "test_oracle.ml's own header, \"COMMEMORATION IDENTITY\" section, restated for the OBSERVED axis (ef-rg112-rg110 task) -- not an RG citation, a comparator LIMIT: Rite_ef.Temporal_ef never sets an English [names] field on any candidate it builds, the SAME limit M15 already names for a commemoration, now visible for the OBSERVED day itself now that the observed-identity axis exists")
(verdict unresolvable)
- (note "REVISED, ef-bvm-saturday task: 395 of the 730 days in this window now carry this shape alone (M1/M3/M16 absorb a further 4 where it fires ALONGSIDE their own pre-existing citation -- see each entry's own widened note; 395+4=399 is the axis's own full unresolved population, UNCHANGED from before this task -- only the SPLIT moved, since M2 (closed above, this task) used to separately absorb 22 of the 26 that used to be \"elsewhere\"). Before this revision the split was 373 alone / 26 elsewhere (373+26=399) -- CORRECTED, fix round 1 (coordinator finding 8) -- the full breakdown, precisely: of 730 days, 331 are RESOLVED (colitur's own observed celebration carries a name) -- 330 resolved-and-MATCHING, 1 resolved-and-MISMATCHED (M13, Joseph vs the Seven Sorrows, already its own entry above) -- and 399 are UNRESOLVED (colitur's own name is [None]), split between this entry's own 395 and the 4 absorbed elsewhere. The BLIND SPOT this axis exists to close, stated precisely: a TEMPORAL-origin observed day silently replaced by a DIFFERENT temporal-origin observed day of the SAME RANK AND COLOUR -- exactly the shape that hid Holy Family from every layer before this task (rank 2/white on both sides, purely coincidental). This count PIN is what stands guard against that population growing silently in either direction: an implementation change that made MORE days temporal-origin-and-unresolvable, or fewer, changes 373 and fails the pin, even though the axis itself cannot say WHICH specific day moved or why. The overwhelming majority of days whose observed celebration is TEMPORAL-origin (an ordinary Sunday, a feria, a movable named feast including Holy Family itself) never carry an English name on colitur's side -- not a rubric dispute and not a data gap either engine is wrong about, a LIMIT of this comparator, honestly counted rather than silently passed, per the brief's own explicit instruction (\"a day whose observed identity cannot be resolved must be a counted, allow-listed outcome, never a silent skip\"). Gated on the diff SHAPE alone (exactly, and only, observed-identity-unresolved), not a literal date list the way every other entry in this file is -- at this population size a date list would itself be the \"pattern that could silently widen\" this file's own header warns against, for the opposite reason a date RANGE is normally risky: the predicate (colitur's own resolved name is [None]) is the precise, falsifiable evidence, the same shape M2's own title-substring predicate already uses instead of enumerating dates, just keyed on presence-of-a-name rather than a title string. Building an English name onto every temporal-cycle candidate (a data/lectionary-bootstrap task, Plan 4, out of this task's scope -- register §6, the same open item M15's own note already tracks) would resolve this entry to Matched for every one of these days it does not instead expose as a REAL divergence (Holy Family's own two dates in this window, 2026-01-11 and 2027-01-10, are counted here, not separately -- the name comparison genuinely cannot distinguish \"Holy Family\" from any other unnamed Sunday, so proving Holy Family's own correctness rests on the golden pins and the differential's own C15, not this axis). Derived directly from the comparator's own failure output (test_layer_m_counts_match_citations), not hand-counted first and cross-checked after: 373.")
- (expected_rows 395))
+ (note "REVISED AGAIN, ef-major-litanies task: 394, down by exactly 1 from the ef-bvm-saturday task's own 395 (breakdown below unchanged in kind, only this one row moved). 25 April 2027 used to carry [Observed_identity_unresolved] ALONE (the Sunday itself is temporal-origin, unresolved; commemoration identity agreed by coincidence -- Mark on both sides, pre-Litanies) and so fell into this entry's own bucket; now that the Major Litanies are built, that same date ALSO carries [Comm_identity_mismatch] (colitur admits the Litanies, missalemeum still admits Mark -- see [M20]'s own new entry), so it moves OUT of this bucket and into M20's own single-row citation instead. Verified directly from the comparator's own failure output, not merely arithmetic (395-1=394 is necessary but not sufficient: the mechanism above is why it left, not merely that some count decreased).
+
+REVISED, ef-bvm-saturday task: 395 of the 730 days in this window used to carry this shape alone (M1/M3/M16 absorb a further 4 where it fires ALONGSIDE their own pre-existing citation -- see each entry's own widened note; 395+4=399 is the axis's own full unresolved population, UNCHANGED from before that task -- only the SPLIT moved, since M2 (closed above) used to separately absorb 22 of the 26 that used to be \"elsewhere\"). Before that revision the split was 373 alone / 26 elsewhere (373+26=399) -- CORRECTED, fix round 1 (coordinator finding 8) -- the full breakdown, precisely: of 730 days, 331 are RESOLVED (colitur's own observed celebration carries a name) -- 330 resolved-and-MATCHING, 1 resolved-and-MISMATCHED (M13, Joseph vs the Seven Sorrows, already its own entry above) -- and 399 are UNRESOLVED (colitur's own name is [None]), split between this entry's own (then 395, now 394) and the 4 absorbed elsewhere. The BLIND SPOT this axis exists to close, stated precisely: a TEMPORAL-origin observed day silently replaced by a DIFFERENT temporal-origin observed day of the SAME RANK AND COLOUR -- exactly the shape that hid Holy Family from every layer before the ef-rg112-rg110 task (rank 2/white on both sides, purely coincidental). This count PIN is what stands guard against that population growing silently in either direction: an implementation change that made MORE days temporal-origin-and-unresolvable, or fewer, changes this number and fails the pin, even though the axis itself cannot say WHICH specific day moved or why -- exactly the guard that caught this task's own -1 shift, which HAD to be traced to a specific date and mechanism (above) rather than merely re-baselined. The overwhelming majority of days whose observed celebration is TEMPORAL-origin (an ordinary Sunday, a feria, a movable named feast including Holy Family itself) never carry an English name on colitur's side -- not a rubric dispute and not a data gap either engine is wrong about, a LIMIT of this comparator, honestly counted rather than silently passed, per the brief's own explicit instruction (\"a day whose observed identity cannot be resolved must be a counted, allow-listed outcome, never a silent skip\"). Gated on the diff SHAPE alone (exactly, and only, observed-identity-unresolved), not a literal date list the way every other entry in this file is -- at this population size a date list would itself be the \"pattern that could silently widen\" this file's own header warns against, for the opposite reason a date RANGE is normally risky: the predicate (colitur's own resolved name is [None]) is the precise, falsifiable evidence, the same shape M2's own title-substring predicate already uses instead of enumerating dates, just keyed on presence-of-a-name rather than a title string. Building an English name onto every temporal-cycle candidate (a data/lectionary-bootstrap task, Plan 4, out of this task's scope -- register §6, the same open item M15's own note already tracks) would resolve this entry to Matched for every one of these days it does not instead expose as a REAL divergence (Holy Family's own two dates in this window, 2026-01-11 and 2027-01-10, are counted here, not separately -- the name comparison genuinely cannot distinguish \"Holy Family\" from any other unnamed Sunday, so proving Holy Family's own correctness rests on the golden pins and the differential's own C15, not this axis).")
+ (expected_rows 394))
((id M19)
(citation "RG 110's OTHER direction: \"In Officio et Missa S. Petri semper fit commemoratio S. Pauli, ET VICISSIM\" -- the SAME rule M12 used to cite, applied here to Paul's own office commemorating Peter, not Peter's own office commemorating Paul; confirmed a third time by the calendarium's own June table, both photographic scans, word for word: \"In Commemoratione S. Pauli Ap., III classis. / Commemoratio S. Petri Ap.\"")
(verdict colitur)
(note "30 June, both years in this window (2026-06-30, 2027-06-30): colitur now shows +commemoration-of-st-peter (data/ef/adjustments.sexp's own `Add` directive) where missalemeum shows nothing at all -- not even Peter listed under \"displaced\", meaning missalemeum's own engine never constructs a candidate for this commemoration in the first place, the SAME shape this task found in lectio's own source data (`~/git/projects/lectio/internal/caldata/tridentine-calendar.ini` has no 06-30 companion entry either -- an upstream data gap both reference engines share, not a colitur bootstrap miss). Verdict colitur, the same shape as M1/M3/M8/M10 above (each already \"missalemeum does not implement X\"): the calendarium's own text is unconditional and confirmed on both photographic scans, and colitur's own machinery for the other two RG 110 pairs (25 January, 22 February) already produces the identical shape correctly. Diff shape [Comm_presence] alone: rank/colour/observed-identity all already agreed before this fix (the OBSERVED celebration on both dates is unchanged, `in-commemoratione-sancti-pauli-apostoli`, sanctoral-origin with a resolvable name matching missalemeum's own title exactly on both rows) -- only the new commemoration's PRESENCE is new, so identity comparison is never even reached (this file's own header: identity is only attempted once presence and count already agree). 2, one per year, independently re-derived against the fixture's own two 06-30 rows before being set here, not assumed from the citation's own two-pair symmetry.")
(expected_rows 2))
+ ((id M20)
+ (citation "RG 109(f) + RG 111(b), Caput XVI \"De Commemorationibus\", CORROBORATED by RG 434(b) (\"VIII - De diversis Missae partibus\", \"D) De orationibus\", \"I - De orationibus in genere\"), added fix round 1 (F5) -- all primary-source-verified word for word, all three documents (docs/research/rules-register.md §4). RG 109(f): \"de Litaniis maioribus, in Missa\" -- in the SAME closed privileged list as (a)-(e). RG 111(b): \"in dominicis II classis, una tantum admittitur commemoratio, scilicet de festo II classis, QUAE TAMEN OMITTITUR SI COMMEMORATIO PRIVILEGIATA FACIENDA SIT.\" RG 434(b), WORD-IDENTICAL, but explicitly scoped to the MASS (\"post orationem Missae\"), which is exactly where RG 109(f)'s own privilege lives (\"in Missa\"): \"in dominicis II classis, nulla alia admittitur oratio, praeter commemorationem festi II classis, quae tamen omittitur si commemoratio privilegiata facienda sit.\" The second citation answers the objection that RG 111 sits among Office-and-Mass rubrics and might be read as Office-shaped: RG 434 cannot be so read, and says the identical thing.")
+ (verdict colitur)
+ (note "NEW (ef-major-litanies task). 25 April 2027: RG 80's own transfer condition (25 April = Easter Sunday or Monday) does NOT fire this year, so the Litanies stay on 25 April itself -- which this year happens to be an ordinary II-class Sunday (RG 91 entry 15). St Mark (II class, entry 16) also loses to the Sunday. Two losing candidates, ONE slot (RG 111(b)/RG 434(b)): colitur admits the Litanies, not Mark; missalemeum's raw fixture row shows the reverse (verified directly against test/fixtures/missalemeum-ef-2026-2027.txt's own 2027-04-25 line -- see [M5]'s own corrected note above for the exact text, which previously mis-transcribed this same row backwards). ADJUDICATED, not merely asserted: {!Precedence_ef.admit}'s [Class2, true] branch already implements RG 111(b)'s privilege-overrides-ordinary clause exactly as quoted above, and this project's own prior work (precedence_ef.ml's \"Fix, Task 16\" comment) already primary-source-verified that same clause independently of this task -- the Litanies winning here is the SAME mechanism, not a special case written for it; St Mark meets the fate any ordinary Class2 commemoration meets when a privileged one is also due (RG 16(a)'s Transfiguration/Sixtus shape is the nearest existing witness, though there the privileged side wins the whole DAY, not merely the commemoration slot).
+
+ SCOPE, made explicit (fix round 1, F5): RG 108 (\"Commemorationes privilegiatae fiunt in Laudibus et in Vesperis necnon in omnibus Missis; commemorationes vero ordinariae fiunt tantum in Laudibus, in Missis conventualibus et in omnibus Missis lectis\") + RG 81 (\"nihil fit in Officio, sed tantum in Missa\") together mean the Litanies' own privileged slot is due IN THE MASS specifically; nothing here computes or asserts a separate OFFICE-level answer for the same day. colitur emits ONE resolved day, not one per liturgical hour -- this adjudication, and every one of the 829 domain-wide Sunday-displacement days it governs, is the MASS answer. Defensible for a Mass-facing engine (this codebase's own stated scope, citations/readings, is Mass-oriented throughout), but stated here rather than left implicit, since it is what the whole adjudication rests on.
+
+ HONESTLY FLAGGED, now closer to certain than first written: this is the first real (non-synthetic) data point this codebase has for \"an ordinary Class2 feast and a privileged non-feast commemoration both losing to the identical Sunday\" -- every other witness for this admit branch in test_precedence_ef.ml is hand-built. First written \"moderate-high, not certain\"; RG 434(b)'s own independent, Mass-specific repetition of RG 111(b)'s exact rule (fix round 1 finding, above) closes the main remaining doubt (that RG 111 might be Office-shaped) and moves this near-certain -- but NOT to certain, and the revisit trigger stands: no primary text anywhere names the Major Litanies in a Mass-orations-count worked example, only the general II-class-Sunday rule, twice over. RG 434(b) closes the Office-shaped doubt and nothing further. If a future primary-source pass finds textual grounds narrowing RG 109(f)'s privilege specifically, THIS is the entry to revisit first. (That sentence was dropped in the same edit that raised the label to near-certain, and is restored here: upgrading a confidence while deleting its revisit trigger is the one move this record must not make.) missalemeum's divergence remains consistent with this project's already-documented pattern of RG 108-111 gaps in that oracle (M1, M8, M10 above each already \"missalemeum does not implement X\") -- plausibly one more instance of the same generator not modelling RG 109(f)'s privilege for this rare, single-date observance. Identity-gated (test_oracle.ml's own [m20_commemoration_matches]): pins that colitur's own sole admitted commemoration really is [major-litanies].")
+ (expected_rows 1))
diff --git a/lib/kernel/calendar.ml b/lib/kernel/calendar.ml
index 5d3572c..cffdf69 100644
--- a/lib/kernel/calendar.ml
+++ b/lib/kernel/calendar.ml
@@ -304,14 +304,116 @@ let build_day (rite : ('s, 'r) Rite.t) (idx : 'r Layer.by_date)
day it landed and [transferred_out] here, not via [omitted] too --
double-booking it in both would fail Task 12's "appears exactly once"
reading of this day alone. *)
+ (* Whether a transferred/injected candidate is genuinely accounted for at
+ its assigned target -- true under any of THREE conditions, not two
+ (fix round 1, ef-major-litanies task -- the first version of this
+ function, and this comment, claimed two, missing exactly the same
+ shape one channel further out than the bug it had just fixed; see
+ below for how that was found).
+
+ (1) It won the day outright there (every [Transfer]-disposed candidate
+ this kernel produced before a rite could transfer a
+ [Celebration.status = Commemoration_only] one: a losing FEAST, which
+ RG 96's own "next day not I or II class" guarantees an unblocked day
+ to win once it arrives -- [occupant_of] alone used to answer this, and
+ still would).
+
+ (2) It survives at the target as one of the day's own admitted
+ COMMEMORATIONS instead (a shape [place_transfers] itself never used to
+ produce, because nothing before could dispose a [Commemoration_only]
+ candidate as [Transfer] -- {!Precedence.resolve} holds such a
+ candidate out of the band contest entirely, so it can never win a day
+ outright, only ever be commemorated on one; a rite is nonetheless free
+ to [Transfer] one to a named date, e.g. RG 80's Major Litanies, RG
+ 81's own "nihil fit in Officio" making [observed] structurally
+ impossible for it anywhere). The FIRST version of this function
+ stopped here, at (1) and (2) -- {!Liturgical_day.t}'s own doc comment
+ promises [observed] and [commemorations] are never silently lost, and
+ this read as "the same two channels", which is where the "two, not
+ three" miscount came from: that promise is about {!Liturgical_day.t}'s
+ OWN five fields, not an exhaustive account of every way
+ {!Precedence.resolve} can dispose of a candidate at one date.
+
+ (3) It reaches the target and is disposed there as [Omit] by
+ {!Precedence.rules.disposition} itself (RG 33's vigil omission, RG
+ 16(a)'s Sunday suppression, RG 26's IV-class-feria omission, the
+ Lord-vs-Lord exclusion, ...) OR is offered to
+ {!Precedence.rules.admit} there but capped out by an admission-count
+ limit RG 111 itself imposes (e.g. a Class1 day admits only ONE
+ privileged commemoration; a second one due the same day, or the SAME
+ Litanies candidate no longer privileged, loses that slot) -- both
+ land in the target's own [omitted], with their own accurate,
+ already-diagnostic reason ("omitted: yielded to a higher day" /
+ "omitted: admission limit reached"), and both are just as genuinely
+ "delivered and considered" as (1)/(2), not stuck anywhere. Missing
+ this third channel reproduces the EXACT ORIGINAL BUG this function was
+ written to fix, one level further out: a candidate settled (via (3))
+ at its target was still reported [unresolved] at its ORIGIN, under
+ the same wrong, hardcoded [unconverged_reason] label, and
+ double-counted by {!Validate}'s own "duplicated" check the same way.
+
+ Found, not merely reasoned to: fix round 1's review reproduced it two
+ ways. Constructively, a second privileged [Commemoration_only] entry
+ placed on the Litanies' own transfer target (Easter+2) that sorts
+ ahead of it forces the Litanies to lose {!Precedence.rules.admit}'s
+ own Class1 "one privileged commemoration only" cap there. And,
+ already present in this branch's own mutation-testing record without
+ being run to ground at the time: mutation 3 (this task's own report,
+ `privilege_of`'s RG 109(f) branch forced to [Ordinary]) makes the
+ transferred Litanies itself lose that SAME Class1 cap at its OWN
+ target -- no second candidate needed, since an [Ordinary] commemoration
+ has no standing at all against a [Class1] day's privileged-only
+ admission rule ({!Precedence_ef.admit}'s own Class1 case). That
+ mutation's 9th failure, the exhaustive `Validate` property sweep on a
+ random year, was this bug; the report noted the failure and declined
+ to diagnose it before reverting the mutation, which is precisely how
+ it survived one fix round.
+
+ Unreachable on shipped EF data today (every `Commemoration_only`
+ candidate this rite's own real data carries is at most [Class3]
+ except the Litanies themselves, and no second privileged
+ commemoration can ever fall on Easter+2 -- {!Precedence_ef
+ .privilege_of}'s own (a)-(e) categories are all Sunday/Ember/Advent-
+ Lent-Passiontide/Nativity-octave shaped, none of which Easter+2 is or
+ can be), which is exactly why it survived this far: nothing in the
+ shipped calendar has ever exercised it. Fails LOUDLY (a wrong,
+ misleading label) rather than silently, and is fixed here rather than
+ left as a documented residual, since the fix is a one-line
+ generalisation of the same check, not new machinery. Kept
+ rite-agnostic: nothing here reads anything EF-specific, only
+ {!Precedence.resolution}'s own [observed]/[commemorations]/[omitted]
+ fields -- THREE of {!Precedence.resolution}'s four fields (the fourth,
+ [deferred], denotes a candidate that has NOT yet settled at this date,
+ by definition, so it is correctly never consulted here).
+ A SIGNAL TRADED AWAY, named because it is real (fix-round re-review):
+ channel (3) accepts both shapes of [omitted] -- the admission cap, and
+ a rite whose own [disposition] omits the candidate AT the target its
+ own [transfer_target] named. For the cap this is unambiguously
+ "settled". For the second it is a judgement: before this change that
+ shape produced a loud, if mislabelled, [Validate] "unconverged"
+ failure; now it is silent at the origin and honestly reported at the
+ target. The kernel cannot tell "the rite deliberately omitted it
+ there" from "the rite chose a bad target" without rite knowledge it
+ must not have, so accepting it is the right call -- but the diagnostic
+ it used to give up is gone. Unreachable in [Rite_ef] today: only the
+ Major Litanies transfer as [Commemoration_only], and RG 96's search
+ guarantees a transferred FEAST an unblocked target. *)
+ let settled_at target slug =
+ let _, _, target_resolution = resolve_with_injected rite idx injected target in
+ let matches (c : 'r Precedence.candidate) =
+ Slug.equal c.Precedence.cel.Celebration.slug slug
+ in
+ matches target_resolution.Precedence.observed
+ || List.exists (fun (c, _) -> matches c) target_resolution.Precedence.commemorations
+ || List.exists (fun (c, _) -> matches c) target_resolution.Precedence.omitted
+ in
let unresolved c =
- let slug = Slug.to_string c.Precedence.cel.Celebration.slug in
- if Hashtbl.mem out_of_range slug then true
+ let slug = c.Precedence.cel.Celebration.slug in
+ if Hashtbl.mem out_of_range (Slug.to_string slug) then true
else
- match Hashtbl.find_opt assignment slug with
+ match Hashtbl.find_opt assignment (Slug.to_string slug) with
| None -> true
- | Some (_, target) ->
- not (Slug.equal (occupant_of rite idx injected target).Celebration.slug c.Precedence.cel.Celebration.slug)
+ | Some (_, target) -> not (settled_at target slug)
in
let reason_for c =
if Hashtbl.mem out_of_range (Slug.to_string c.Precedence.cel.Celebration.slug) then
diff --git a/lib/rites/rite_ef/precedence_ef.ml b/lib/rites/rite_ef/precedence_ef.ml
index 7b64b83..c9e6a55 100644
--- a/lib/rites/rite_ef/precedence_ef.ml
+++ b/lib/rites/rite_ef/precedence_ef.ml
@@ -651,6 +651,48 @@ let nativity_octave_prefix = "ef-nativity-octave-day-"
[universal_layer] -- private: nothing outside [privilege_of] needs it. *)
let alp_feria_prefixes = [ "ef-advent-"; "ef-lent-"; "ef-passiontide-" ]
+(* RG 80 (Caput X, "De Litaniis maioribus et minoribus", §A "De Litaniis
+ maioribus"; both photographic scans and the electronic transcription,
+ word for word, no scan-vs-transcription conflict to adjudicate --
+ docs/research/rules-register.md's own citation, this task): "80.
+ Litaniae maiores assignatae sunt diei 25 aprilis; si vero eo die
+ occurrit dominica Paschatis vel feria II post Pascha, transferuntur in
+ sequentem feriam III." -- the Major Litanies are assigned to 25 April;
+ but if Easter Sunday or Easter Monday falls on that day, they are
+ transferred to the FOLLOWING TUESDAY. Both trigger shapes land on the
+ SAME target, Easter+2 (worked out from the text, not independently
+ stated by it): 25 April = Easter Sunday means the "following Tuesday"
+ is Easter+2; 25 April = Easter Monday means Easter itself is 24 April,
+ and the following Tuesday is again Easter+2. {!transfer_target}'s own
+ Litanies branch, below, computes that directly.
+
+ RG 81, same division, immediately following: "81. De Litaniis
+ maioribus nihil fit in Officio, sed tantum in Missa. Earum autem
+ commemoratio non est habenda commemoratio 'de Tempore'." -- nothing is
+ done in the Office, only in the Mass, and its commemoration is not to
+ be reckoned a "de Tempore" one. This is why [major_litanies_slug]'s own
+ data/ef/adjustments.sexp entry is [Commemoration_only]: RG 91's table
+ enumerates "dies liturgici" (Office days), and this is explicitly
+ denied any Office standing at all -- the same reasoning {!band}'s own
+ top-of-function guard already gives every [Commemoration_only]
+ candidate (never a table row, never [observed]), which is exactly the
+ behaviour RG 81 requires here independently.
+
+ [major_litanies_slug] is this codebase's OWN invented English slug: RG
+ 80/81/109(f) name the observance, never a computer key for it, and
+ lectio does not compute the Major Litanies at all (this task's own
+ measurement) -- nothing to adopt verbatim, unlike every other slug this
+ file cross-checks against temporal_ef.ml's own conventions.
+ data/ef/adjustments.sexp's own [Add] directive is the ONE other place
+ this exact string is written; a rename here with no matching rename
+ there silently stops every branch below from ever seeing a live
+ candidate again -- flagged the same way [rg110_companion_slug] already
+ flags its own three hand-maintained pairs. *)
+let major_litanies_slug = "major-litanies"
+
+let is_major_litanies (c : Vocab_ef.rank Precedence.candidate) =
+ String.equal (Slug.to_string c.Precedence.cel.Celebration.slug) major_litanies_slug
+
(* RG 109 (docs/research/rules-register.md §4, "Commemorations"): the
closed list of privileged commemorations, checked in the register's own
lettered order. A candidate matching none of (a)-(f) is ordinary, per the
@@ -741,23 +783,30 @@ let privilege_of (c : Vocab_ef.rank Precedence.candidate) : Precedence.privilege
it under its own name. *)
else if is_temporal && List.exists (fun p -> String.starts_with ~prefix:p slug) alp_feria_prefixes then
Precedence.Privileged
- (* (f) RG 109(f) (§4): "of the Major Rogations, in Mass" -- the
- Major Litanies (25 April, RG 80) are STILL not computed anywhere in
- this codebase (temporal_ef.ml's own comment on [temporal]'s Rogation
- branch, CORRECTED final fix wave item 7: they did not, in fact,
- "arrive with Plan 3's sanctoral" -- Plan 3 shipped without them,
- register §6 tracks this as an open item with no plan yet committed to
- build it), so no candidate this engine can currently construct
- represents one. There is no existing slug
- convention to anchor a check to, and guessing one risks silently
- misclassifying whatever a future task does name it -- a wrong citation
- is worse than a missing one, so this is left unimplemented and flagged
- in the task report rather than guessed. Deliberately NOT matched by
- anything above: the Minor Litanies/Rogations ("ef-rogation-monday"/
- "-tuesday", RG 87) temporal_ef.ml DOES compute are a different
- observance RG 109(f) does not name (RG 88: the Minor Rogations change
- nothing in the Office at all), so they correctly fall through to
- "ordinary" below, not this category. *)
+ (* (f) RG 109(f) (Caput XVI, "De Commemorationibus", §4): "de Litaniis
+ maioribus, in Missa" -- of the Major Litanies, IN THE MASS (RG 81's
+ own restriction: never in the Office). NOW LIVE (this task,
+ ef-major-litanies): this branch used to be dead code -- "no candidate
+ this engine can currently construct represents one" -- because
+ nothing built the Major Litanies as a candidate at all.
+ data/ef/adjustments.sexp's own [major_litanies_slug] entry
+ ({!major_litanies_slug}'s own citation above has the full RG 80/81
+ text) is exactly that candidate now. Read by slug alone, the same
+ convention every other one-off entry in this file uses
+ ([nativity_octave_prefix], [rg110_companion_slug], [annunciation_slug]
+ below) -- RG 109(f) names one specific, closed-list observance, not a
+ structural property [is_temporal]/[rank] could derive the way (c)/(d)/
+ (e) above do for whole classes of temporal ferias. Deliberately NOT
+ matched by anything above it: the Minor Litanies/Rogations
+ ("ef-rogation-monday"/"-tuesday", RG 87) temporal_ef.ml DOES compute
+ are a different observance RG 109(f) does not name (RG 88: the Minor
+ Rogations change nothing in the Office at all, and [is_sunday_slug]/
+ [rank = Class1]/[nativity_octave_prefix]/the Ember prefixes/
+ [alp_feria_prefixes] never match their slugs either), so they
+ correctly fall through to "ordinary" below, not this category --
+ {!privilege_cases}'s own negative row in test_precedence_ef.ml pins
+ this boundary. *)
+ else if is_major_litanies c then Precedence.Privileged
else Precedence.Ordinary
let disposition ~(winner : Vocab_ef.rank Precedence.candidate)
@@ -793,6 +842,63 @@ let disposition ~(winner : Vocab_ef.rank Precedence.candidate)
exception for two commemorations invoking the identical BVM, the
exact gap this fix closes. *)
Precedence.Omit
+ else if
+ is_major_litanies loser
+ && (let wslug = Slug.to_string winner.Precedence.cel.Celebration.slug in
+ String.equal wslug "ef-easter-sunday" || String.equal wslug "ef-easter-1-monday")
+ then
+ (* RG 80 -- {!major_litanies_slug}'s own citation above has the full
+ text and the "both trigger shapes land on Easter+2" derivation.
+ data/ef/adjustments.sexp's own [major_litanies_slug] entry is
+ UNCONDITIONALLY Fixed at 25 April, so it is offered as a candidate
+ every year regardless of what 25 April turns out to be -- this is
+ where the two RETRACTED blockers this task's own brief names
+ (docs/research/rules-register.md's "the recorded blocker was
+ wrong") actually close: [disposition] already receives [~winner],
+ so a rite-local test on the WINNER's own slug alone (no kernel
+ signature change, no [context]/date needed here at all) is enough
+ to tell the 194 trigger years apart from the other 8,223 -- exactly
+ the "zero kernel surface" the retraction predicted. "ef-easter-
+ sunday" and "ef-easter-1-monday" are [Temporal_ef.named]'s/[temporal]'s
+ own slugs for Easter Sunday and Easter Monday respectively (this
+ file's own [entry_15_band]-adjacent branches above already trust
+ the same convention); Easter Monday's own reliability as a marker
+ (measured, register: 8,417 occurrences in 8,417 years, once per
+ year, never displaced, being a I-class octave day) is what makes
+ reading it off a bare slug string safe here, the same argument that
+ retracted the second blocker.
+
+ Checked ahead of the [Commemoration_only] branch immediately below:
+ that branch's own "always Commemorate, nothing overrides it" would
+ otherwise fire first, and this candidate would wrongly commemorate
+ 25 April itself in exactly the 194 years RG 80 forbids it from
+ doing so. No earlier branch in this function can pre-empt it
+ either -- [is_bvm_office] above is keyed on Marian/BVM subjects
+ this candidate never carries ({!major_litanies_slug}'s own entry is
+ [subject = Saint], and its slug is not on {!marian_slugs}).
+
+ [Transfer], not [Omit]: RG 80 does not say the Litanies simply
+ vanish in a trigger year, it says WHERE they move to -- and
+ {!Precedence.disposition}'s own [Transfer] constructor, together
+ with the placement machinery {!Calendar} already has for RG 96
+ (calendar.ml's [place_transfers]/[resolve_with_injected]), is
+ exactly "move a losing candidate to another day and inject it
+ there" -- reused here rather than invented a second time, because
+ RG 80's own transfer is structurally the same operation, just with
+ its OWN named target instead of RG 96's generic forward search
+ ({!transfer_target}'s own Litanies branch, below, computes that
+ target directly rather than searching for it). A
+ [Commemoration_only] candidate transferring is a new shape for this
+ codebase, checked rather than assumed safe: {!Precedence.resolve}'s
+ own fold matches [Transfer | Repose] uniformly, with no [status]
+ test anywhere in it, and once re-offered at the target date this
+ candidate is still [Commemoration_only], so it is still held out of
+ the band contest there too (the same [forced_comm] partition,
+ {!Precedence.resolve}'s own top comment) -- it can never
+ accidentally become [observed] at its transfer target either,
+ matching RG 81's "nihil fit in Officio" for the same reason it
+ cannot become [observed] on 25 April itself. *)
+ Precedence.Transfer
else 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
@@ -1509,7 +1615,46 @@ let admit ~(observed : Vocab_ef.rank Precedence.candidate)
2026 (Holy Family, a II-class Sunday) has St Hyginus (Class3,
commemoration-only) as its only competing candidate -- missalemeum
shows him "displaced" (omitted), never commemorated; the
- pre-fix code admitted him regardless. *)
+ pre-fix code admitted him regardless.
+
+ CORROBORATED (ef-major-litanies task, fix round 1, F5): RG 111
+ sits in Caput XVI, "De Commemorationibus", governing "tam pro
+ Missa quam pro Officio" per RG 106 -- read alone it could be
+ mistaken for Office-shaped, incidentally reused for the Mass.
+ Rubricae Generales Missalis Romani n. 434(b) (Part "VIII - De
+ diversis Missae partibus", "D) De orationibus", "I - De
+ orationibus in genere"), verified word for word, all three
+ documents: "in dominicis II classis, nulla alia admittitur
+ oratio, praeter commemorationem festi II classis, quae tamen
+ omittitur si commemoratio privilegiata facienda sit" --
+ IDENTICAL IN ITS OPERATIVE CLAUSE to the one above -- not word
+ for word throughout: RG 111(b) opens "una tantum admittitur
+ commemoratio, SCILICET DE FESTO II CLASSIS", n. 434(b) recasts
+ that into the orations register as "NULLA ALIA ADMITTITUR ORATIO,
+ PRAETER COMMEMORATIONEM festi II classis", and only the trailing
+ "quae tamen omittitur si commemoratio privilegiata facienda sit"
+ is verbatim in both. That trailing clause is the one this
+ adjudication turns on. And n. 434 is not merely a different part
+ of the same document: the running heads show RG 111 under
+ "Rubricae generales" and n. 434 under "Rubricae generales Missalis
+ Romani" -- two distinct rubrical corpora bound in one volume,
+ which is the whole force of the corroboration. Explicitly scoped "post
+ orationem Missae" -- an independent, Mass-structure-rubric
+ confirmation of the exact same privilege-overrides-ordinary rule
+ this branch already implements, from a different part of the
+ same document. First load-bearing live witness for this branch's
+ "privileged wins even against a WORSE table position" half: the
+ Major Litanies (RG 109(f), major_litanies_slug above), whose own
+ [band] value is {!unclassified} (worse than every real table
+ entry, per that constant's own comment) yet still displaces St
+ Mark's real entry-16 table position on the four Sunday-conflict
+ years the Litanies' own data/ef/expected-divergences-missalemeum
+ .sexp M20 entry adjudicates -- every earlier witness for this
+ branch (Holy Family/Hyginus above; the RG16(a) admit_cases table)
+ happened to also be the day's own privileged Sunday/feast winning
+ OUTRIGHT (band already decided it before admit ran), not two
+ independently-offered LOSING candidates settled by this clause
+ alone. *)
(match List.filter is_privileged sorted with
| best :: _ -> [ drop_band best ]
| [] -> (
@@ -1656,14 +1801,52 @@ let rec search_from (occupant : Date.t -> Vocab_ef.rank Celebration.t) (steps :
directly tests the rubric's own words. *)
let transfer_target (c : Vocab_ef.rank Precedence.candidate) (origin : Date.t)
(occupant : Date.t -> Vocab_ef.rank Celebration.t) : Date.t =
- let general_target = search_from occupant 0 (Date.add_days origin 1) in
- let is_annunciation = Slug.to_string c.Precedence.cel.Celebration.slug = annunciation_slug in
let easter = Computus.gregorian_easter (Date.year origin) in
- if is_annunciation && Date.compare general_target easter > 0 then
- (* Low Sunday = Easter + 7 (register §0, temporal_ef.ml's [off 7]); the
- Monday after it = Easter + 8. Searched onward from there exactly
- like the general case searches from [origin + 1] -- "only if that
- day is itself blocked" (rite.mli) is [search_from]'s ordinary
- behaviour, not a second mechanism. *)
- search_from occupant 0 (Date.add_days easter 8)
- else general_target
+ if is_major_litanies c then
+ (* RG 80 -- {!major_litanies_slug}'s own citation (precedence_ef.ml,
+ above [privilege_of]) has the full text and the "both trigger
+ shapes land on Easter+2" derivation. Checked BEFORE computing
+ [general_target] at all (unlike the Annunciation branch below,
+ which computes the general RG 96 walk first and only overrides it
+ conditionally) -- this candidate's target is never the general
+ walk's own result, so running {!search_from} for it would be
+ wasted work at best.
+
+ "The following Tuesday", unconditionally: [search_from]'s ordinary
+ forward walk exists because a DISPLACED FEAST needs an unoccupied
+ (non-I/II-class) day to be fully celebrated as itself (RG 96's own
+ "next day that is not I or II class"). The Major Litanies are never
+ a feast -- RG 81: "nihil fit in Officio, sed tantum in Missa" --
+ only ever a commemoration once they arrive, and a commemoration
+ needs no unoccupied day at all (RG 108-111 admit commemorations on
+ I-class days routinely, see [privilege_of]'s own (b) branch and
+ [admit]'s [Class1] case above). Running {!search_from} here would
+ therefore be actively WRONG, not merely unnecessary: Easter+2 is
+ itself I-class (within the Easter octave, {!band}'s own entry-10
+ branch, {!is_blocking}'s own [Class1] test), so a search starting
+ there would walk PAST the exact date RG 80 names, looking for a
+ day the rubric never asked for.
+
+ Total and terminating trivially (no search performed at all).
+ Strictly after [origin], {!Rite.t.transfer_target}'s own contract
+ (rite.mli), on both trigger shapes (this file's header, worked
+ examples): 25 April = Easter Sunday (origin = Easter itself =
+ Easter+0, target = Easter+2 = origin+2) or 25 April = Easter Monday
+ (Easter itself = 24 April = origin-1, target = Easter+2 =
+ origin+1) -- either way strictly forward. Not conditioned on
+ [occupant] at all, unlike every other branch in this function --
+ deliberately: nothing about RG 80's own text makes the target
+ depend on what else is observed that year, only on Easter's own
+ date. *)
+ Date.add_days easter 2
+ else
+ let general_target = search_from occupant 0 (Date.add_days origin 1) in
+ let is_annunciation = Slug.to_string c.Precedence.cel.Celebration.slug = annunciation_slug in
+ if is_annunciation && Date.compare general_target easter > 0 then
+ (* Low Sunday = Easter + 7 (register §0, temporal_ef.ml's [off 7]); the
+ Monday after it = Easter + 8. Searched onward from there exactly
+ like the general case searches from [origin + 1] -- "only if that
+ day is itself blocked" (rite.mli) is [search_from]'s ordinary
+ behaviour, not a second mechanism. *)
+ search_from occupant 0 (Date.add_days easter 8)
+ else general_target
diff --git a/lib/rites/rite_ef/precedence_ef.mli b/lib/rites/rite_ef/precedence_ef.mli
index 765b573..419613a 100644
--- a/lib/rites/rite_ef/precedence_ef.mli
+++ b/lib/rites/rite_ef/precedence_ef.mli
@@ -120,17 +120,40 @@ val band : Vocab_ef.season Precedence.context -> Vocab_ef.rank Precedence.candid
somewhere to be caught other than a silently-wrong RG 33 disposition. *)
val sunday_marker : string
+(** The Major Litanies' own invented slug (RG 80, Caput X "De Litaniis
+ maioribus et minoribus" §A; RG 81; RG 109(f) -- see the .ml's own
+ citation, above [privilege_of], for the full text). Not adopted from
+ lectio, which does not compute the Major Litanies at all (this task's
+ own measurement) -- colitur's own key, data/ef/adjustments.sexp's
+ matching [Add] entry. Exposed for the same reason as
+ {!annunciation_slug} below: a future rename in the data with no
+ matching rename here silently stops {!disposition}, {!admit}'s RG
+ 109(f) privilege, and {!transfer_target}'s own RG 80 branch from ever
+ recognising a live candidate again. *)
+val major_litanies_slug : string
+
(** [disposition ~winner ~loser]: RG 92-95, 33, 21-27, 94 (docs/research/
rules-register.md §4, "Occurrence", "Vigils" and "Caput IV, 'De
feriis'"). 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 loser whose slug is {!major_litanies_slug} (ef-major-litanies task)
+ is [Transfer], NOT [Commemorate], when the WINNER's own slug is
+ Easter Sunday or Easter Monday (RG 80: 25 April's Major Litanies are
+ transferred to the following Tuesday, always Easter+2, when 25 April
+ is itself one of those two days) -- checked first, ahead of the
+ [Commemoration_only] bullet immediately below (of which this would
+ otherwise be a case), because RG 80's own condition overrides RG 81's
+ default "always Commemorate" for these two specific winners only. See
+ the .ml's own citation, and {!transfer_target}'s own RG 80 branch for
+ where the transfer lands;
- 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);
+ [Commemorate] otherwise (checked first among the remaining cases: it
+ can never win -- see {!Precedence.resolve} -- and, by that same
+ status's own definition, already denotes an office with nothing left
+ to translate under RG 95's ordinary rule, so it never transfers
+ either there; not itself a further RG citation beyond RG 93's general
+ four-mechanism statement above);
- a [Class2] or [Class3] loser whose slug marks it a vigil
({!vigil_suffix} OR {!vigil_prefix} -- both conventions this
codebase's data uses, see {!vigil_prefix}'s own comment) is [Omit]
@@ -243,6 +266,14 @@ val september_ember_prefix : string
- [observed] a [Class3] or [Class4] day: at most two, by {!band}'s table
order alone.
+ RG 109(f) (ef-major-litanies task): every candidate in [comms] arrives
+ already tagged with its real privilege by {!disposition}, which now
+ includes {!major_litanies_slug} tagged [Privileged] -- this function
+ itself needed no change for it, the same "no new machinery" shape RG
+ 109(a)/(c)/(d)/(e) already had: it competes for whichever of the four
+ cases above its host day falls into, exactly like any other privileged
+ candidate.
+
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
@@ -339,11 +370,20 @@ val annunciation_slug : string
*is* {!Colitur_kernel.Rite.t}.transfer_target; see that field's own
fuller rationale for why the search has to be rite-supplied at all.
- RG 96's own rule: the next following day whose currently-resolved
- occupant is not I or II class (read off [Vocab_ef.rank], RG 8's dignity
- -- not {!band}'s finer occurrence-table entry, the same distinction
- {!admit} draws for RG 111). This general target is computed for EVERY
- candidate, always, first.
+ RG 80 (ef-major-litanies task): checked FIRST, ahead of everything
+ below -- when [c] is {!major_litanies_slug}, the target is Easter+2
+ directly, with no RG 96 search at all (this candidate is only ever a
+ commemoration at its target, RG 81's own "nihil fit in Officio", which
+ needs no unoccupied day the way a displaced FEAST does; the general
+ RG 96 search below would in fact walk PAST Easter+2, since it is itself
+ I-class). See the .ml's own citation for the full text and the "why not
+ the general search" argument.
+
+ RG 96's own rule, for every OTHER candidate: the next following day
+ whose currently-resolved occupant is not I or II class (read off
+ [Vocab_ef.rank], RG 8's dignity -- not {!band}'s finer occurrence-table
+ entry, the same distinction {!admit} draws for RG 111). This general
+ target is computed for every such candidate, always, first.
RG 96's own named exception (Attamen (a), primary-source-verified --
see {!annunciation_slug}'s comment for the Latin and the register's own
diff --git a/test/test_calendar.ml b/test/test_calendar.ml
index 93cd0bb..22f5f04 100644
--- a/test/test_calendar.ml
+++ b/test/test_calendar.ml
@@ -426,6 +426,90 @@ let test_transfer_guard_records_failure_instead_of_looping () =
in
Alcotest.(check bool) "non-convergence is recorded rather than silently dropped or hung" true stuck
+(* ef-major-litanies task, fix round 1 (F1) -- a regression test for a
+ THIRD settlement channel `build_day`'s own `settled_at` had to learn to
+ recognise: a transferred candidate that reaches its target and is then
+ CAPPED OUT there by the target day's own RG 111 admission-count limit
+ (not `observed`, not surviving as a `commemoration` -- the two channels
+ the first version of this fix checked), rather than settling cleanly.
+ Missing it reproduces the exact original bug ONE LEVEL FURTHER OUT: the
+ origin wrongly reports the transferred candidate as
+ `unconverged_reason`, even though placement genuinely converged.
+
+ Unreachable on real EF data ALONE (the Major Litanies, RG 80, are the
+ only privileged `Commemoration_only` candidate real data carries, and
+ no second one can ever coincide with Easter+2) -- reproduced here the
+ same way the fix-round review did: one synthetic privileged
+ `Commemoration_only` candidate, added directly to the REAL EF layer
+ (not a hand-built synthetic rite -- this bug is about the real Litanies
+ candidate's own real transfer, so the real rite is the honest fixture),
+ on the real Litanies' own real 2011 transfer target (26 April -- Easter
+ 2011 = 24 April, so 25 April is Easter Monday, RG 80's second trigger,
+ landing on Easter+2 = 26 April), with a slug ("aaa-probe") sorting
+ ahead of "major-litanies" so it wins {!Rite_ef.Precedence_ef.admit}'s
+ Class1 "one privileged commemoration only" selection there, capping the
+ Litanies out. *)
+let real_ef_layer_for_transfer_probes =
+ match Colitur_kernel.Layer.load Rite_ef.Vocab_ef.rank_of_sexp "../data/ef/sanctoral.sexp" with
+ | Error e -> failwith ("../data/ef/sanctoral.sexp: " ^ e)
+ | Ok layer -> (
+ match Colitur_kernel.Overlay.load Rite_ef.Vocab_ef.rank_of_sexp "../data/ef/adjustments.sexp" with
+ | Error e -> failwith ("../data/ef/adjustments.sexp: " ^ e)
+ | Ok overlay ->
+ let layer, diagnostics = Colitur_kernel.Overlay.apply layer overlay in
+ if diagnostics <> [] then failwith "unexpected overlay diagnostics loading the real EF layer";
+ layer)
+
+let test_transferred_commemoration_only_capped_out_at_target_settles_cleanly () =
+ let probe_date =
+ match Colitur_kernel.Date_spec.fixed ~month:4 ~day:26 with Ok d -> d | Error e -> failwith e
+ in
+ let probe =
+ { Colitur_kernel.Layer.date = probe_date;
+ cel =
+ Cel.make ~slug:(Sl.of_string_exn "aaa-probe") ~rank:Rite_ef.Vocab_ef.Class1
+ ~status:Cel.Commemoration_only ~colour:Colitur_kernel.Colour.White
+ ~subject:Colitur_kernel.Subject.Saint ~layer:"synthetic-probe" ()
+ }
+ in
+ let augmented_layer = Colitur_kernel.Layer.set real_ef_layer_for_transfer_probes probe in
+ (* Liturgical year "2010" (Advent 2010 -- eve of Advent 2011) covers both
+ 25 and 26 April 2011. *)
+ let year = C.year Rite_ef.context augmented_layer 2010 in
+ let find_date target =
+ match Array.to_list year |> List.find_opt (fun d -> D.compare d.LD.date target = 0) with
+ | Some d -> d
+ | None -> failwith "date not found in resolved year"
+ in
+ let origin = find_date (mk 2011 4 25) and target = find_date (mk 2011 4 26) in
+ let slug_s (c : Rite_ef.Vocab_ef.rank Cel.t) = Sl.to_string c.Cel.slug in
+ Alcotest.(check string) "2011-04-25 is Easter Monday, RG80's second trigger" "ef-easter-1-monday"
+ (slug_s origin.LD.observed);
+ let omitted_s d = List.map (fun (c, r) -> (slug_s c, r)) d.LD.omitted in
+ Alcotest.(check (list (pair string string))) "origin: major-litanies is NOT in [omitted] at all -- it \
+ genuinely, cleanly transferred away, no false 'did not converge'" []
+ (List.filter (fun (s, _) -> s = "major-litanies") (omitted_s origin));
+ 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
+ in
+ Alcotest.(check (list (pair string string))) "origin: no [omitted] entry anywhere claims non-convergence \
+ (the exact regression this test guards against, stated directly rather than only via the slug check \
+ above)" []
+ (List.filter (fun (_, r) -> contains_substring r ~needle:"did not converge") (omitted_s origin));
+ Alcotest.(check (list string)) "origin: [transferred_out] still correctly names major-litanies -> target"
+ [ "major-litanies->2011-04-26" ]
+ (List.map
+ (fun (c, d) -> Printf.sprintf "%s->%s" (slug_s c) (D.to_iso8601 d))
+ origin.LD.transferred_out);
+ Alcotest.(check (list string)) "target: aaa-probe wins the Class1 privileged slot (sorts ahead of \
+ major-litanies at the tied [unclassified] band)" [ "aaa-probe" ]
+ (List.map (fun (c, _) -> slug_s c) target.LD.commemorations);
+ Alcotest.(check bool) "target: major-litanies is capped out into [omitted] there, with the REAL \
+ admission-limit reason, not lost and not mislabelled" true
+ (List.mem ("major-litanies", "omitted: admission limit reached") (List.map (fun (c,r) -> (slug_s c, r)) target.LD.omitted))
+
let suite =
( "Calendar",
[ Alcotest.test_case "year covers every day" `Quick test_year_covers_every_day;
@@ -447,4 +531,8 @@ let suite =
Alcotest.test_case "transfer target outside year is recorded not lost" `Quick
test_transfer_target_outside_year_is_recorded_not_lost;
Alcotest.test_case "transfer guard records failure instead of looping" `Quick
- test_transfer_guard_records_failure_instead_of_looping ] )
+ test_transfer_guard_records_failure_instead_of_looping;
+ Alcotest.test_case
+ "ef-major-litanies fix round 1 (F1): a transferred candidate capped out at its own target settles \
+ cleanly, no false 'did not converge'"
+ `Quick test_transferred_commemoration_only_capped_out_at_target_settles_cleanly ] )
diff --git a/test/test_golden.ml b/test/test_golden.ml
index fdfc01d..7b461bf 100644
--- a/test/test_golden.ml
+++ b/test/test_golden.ml
@@ -173,7 +173,18 @@ let test_easter_extreme_1598 () =
(* 1666: latest Gregorian Easter possible, 25 April -- hand-verified via
Gauss's algorithm (a = 13, b = 16, c = 66, ..., h = 29, l = 5, m = 0 ->
- 25 April). Same citations as 1598 above. *)
+ 25 April). Same citations as 1598 above.
+
+ ef-major-litanies task: 25 April = Easter Sunday is RG 80's own FIRST
+ trigger condition ("si vero eo die occurrit dominica Paschatis...
+ transferuntur in sequentem feriam III") -- this pin, already the
+ project's own hand-verified latest-Easter witness, now ALSO carries the
+ transfer this rule requires: [major-litanies] departs 25 April for
+ 27 April (Easter+2, "the following Tuesday" -- Precedence_ef.transfer_
+ target's own Litanies branch has the full citation). Nothing else about
+ this day changes: [observed]/[comms]/[in] are exactly as before,
+ proving the Litanies departure is additive, not disruptive, to the
+ day Easter itself owns outright. *)
let test_easter_extreme_1666 () =
check ~msg:"1666-04-24 Holy Saturday: still Passiontide, violet (RG 128)" 1666 4 24
"1666-04-24 saturday season=passiontide week=2 slug=ef-passiontide-2-saturday rank=class-1 colour=violet subject=lord \
@@ -181,7 +192,21 @@ let test_easter_extreme_1666 () =
check ~msg:"1666-04-25 latest possible Easter (Gauss-verified): Paschaltide begins, white, class-1" 1666 4
25
"1666-04-25 sunday season=paschaltide week=1 slug=ef-easter-sunday rank=class-1 colour=white subject=temporal name_la=- comms=[] in=- \
- out=[]"
+ out=[major-litanies->1666-04-27]";
+ (* The transfer's own target, positively pinned (not merely inferred from
+ [out] above): an ordinary Easter-octave Tuesday, I class, white (RG
+ 119 -- white "a Missa Vigiliae paschalis usque ad Missam vigiliae
+ Pentecostis"), with the Litanies as its own sole, privileged
+ commemoration (RG 109(f); RG 111(a), "I class: none save one
+ privileged" -- {!PE.admit}'s own [Class1] case). No [transferred_in]
+ printed: [Liturgical_day.transferred_in] only ever names a candidate
+ that went on to WIN the day (calendar.ml's own [build_day]), and the
+ Litanies structurally never can (RG 81, Commemoration_only) -- this is
+ the SAME asymmetry {!Colitur_kernel.Calendar}'s own [settled_at] fix
+ (this task) exists to get right on the OTHER side of the ledger. *)
+ check ~msg:"1666-04-27 Easter+2: the Litanies' own transfer target, commemorated and privileged" 1666 4 27
+ "1666-04-27 tuesday season=paschaltide week=1 slug=ef-easter-1-tuesday rank=class-1 colour=white subject=temporal \
+ name_la=- comms=[major-litanies:privileged] in=- out=[]"
(* 2038: the brief's "late modern case" -- Easter also 25 April (Gauss-
verified: a = 5, b = 20, c = 38, ..., h = 29, l = 5, m = 0 -> 25 April,
@@ -203,13 +228,24 @@ let test_easter_extreme_1666 () =
temporal-cycle-only test regardless (the two concerns -- Easter-extreme
arithmetic and a sanctoral occurrence rule -- stay separate pins even
though nothing forces that any more). *)
+(* ef-major-litanies task: 2038 is this file's own worked example for the
+ OTHER RG 80 trigger shape checked elsewhere in this task (Easter Monday
+ = 25 April, e.g. 2011) landing on the identical target (Easter+2) --
+ here Easter SUNDAY itself is 25 April, so the "following Tuesday" is
+ two days later, 27 April, not one; both shapes converge on Easter+2,
+ never on Easter+1 or Easter+3 (precedence_ef.ml's own [transfer_target]
+ Litanies branch has the full RG 80 citation and the arithmetic for
+ both shapes). *)
let test_easter_extreme_2038_late_modern () =
check ~msg:"2038-04-24 Holy Saturday: still Passiontide, violet (RG 128)" 2038 4 24
"2038-04-24 saturday season=passiontide week=2 slug=ef-passiontide-2-saturday rank=class-1 colour=violet subject=lord \
name_la=Sabbato sancto comms=[] in=- out=[]";
check ~msg:"2038-04-25 late-modern instance of the latest possible Easter (Gauss-verified)" 2038 4 25
"2038-04-25 sunday season=paschaltide week=1 slug=ef-easter-sunday rank=class-1 colour=white subject=temporal name_la=- comms=[] in=- \
- out=[]"
+ out=[major-litanies->2038-04-27]";
+ check ~msg:"2038-04-27 Easter+2: the Litanies' own transfer target, commemorated and privileged" 2038 4 27
+ "2038-04-27 tuesday season=paschaltide week=1 slug=ef-easter-1-tuesday rank=class-1 colour=white subject=temporal \
+ name_la=- comms=[major-litanies:privileged] in=- out=[]"
(* ------------------------------------------------------------------ *)
(* Annunciation transfer, 25 March inside Holy Week (register §4, RG 96
@@ -958,6 +994,91 @@ let test_bvm_saturday_excludes_mt_carmel_2033 () =
rank=class-4 colour=white subject=bvm name_la=Officium sanctae Mariae in sabbato comms=[] in=- out=[]"
(describe d)
+(* ------------------------------------------------------------------ *)
+(* RG 80/81/109(f), the Major Litanies (ef-major-litanies task). Every
+ date below independently derived from the primary text, not read off a
+ `colitur day` run first and rationalised (this file's own header, rule
+ 3): Easter's own date for each civil year comes from Computus (already
+ independently Gauss-cross-checked by this file's own extreme-year
+ pins), 25 April's Easter-offset and weekday follow arithmetically from
+ that, and the RULE applied at each offset (RG 80's transfer condition;
+ RG 91 entries 15/16 for the Sunday-vs-Mark band comparison; RG 111(b)'s
+ privilege-overrides-ordinary clause) is quoted at
+ Rite_ef.Precedence_ef's own [disposition]/[admit]/[transfer_target] and
+ at data/ef/expected-divergences-missalemeum.sexp's own M5/M20 entries,
+ which independently adjudicate the one substantive rubric question here
+ (WHO wins the Sunday's single slot) against the missalemeum oracle. *)
+(* ------------------------------------------------------------------ *)
+
+(* The ORDINARY case (~97.7% of years, register's own measurement): 25
+ April 2026 is a Saturday (independently checked via `date -d
+ 2026-04-25 +%A`, this file's own header rule 2), Easter+20 (Easter 2026
+ = 5 April, itself independently checked the same way against a
+ published 2026 Easter date). St Mark (II class, RG 91 entry 16) wins the day outright
+ over the ordinary Class4 Paschaltide feria (entry 28); the Litanies,
+ Commemoration_only, is the day's ONLY other candidate and is admitted
+ uncontested ({!Rite_ef.Precedence_ef.admit}'s [Class2, false] case: one
+ slot, table order alone, no rival). This is the SAME date and shape
+ data/ef/expected-divergences-missalemeum.sexp's own [M5] entry
+ adjudicates against the real missalemeum oracle (verdict colitur, a
+ naming-convention difference only -- "The Major Litanies" vs
+ missalemeum's own "Pro rogationibus"). *)
+let test_major_litanies_ordinary_2026 () =
+ check ~msg:"2026-04-25: St Mark observed outright (RG91 e16 > e28); the Major Litanies (RG80/109(f)) ride \
+ along uncontested" 2026 4 25
+ "2026-04-25 saturday season=paschaltide week=3 slug=mark rank=class-2 colour=red subject=saint name_la=- \
+ comms=[major-litanies:privileged] in=- out=[]"
+
+(* The FOUR Sunday-displacement years in 2005-2050 (this task's own
+ measurement, register): 25 April is a Sunday, so St Mark (entry 16, II
+ class) loses to it exactly as any other II-class feast would (entry 15
+ < entry 16), becoming an ordinary commemoration CANDIDATE rather than
+ the observed day -- and then loses the day's own single slot to the
+ Litanies (RG 111(b): "quæ tamen omittitur si commemoratio privilegiata
+ facienda sit" -- the ordinary de-festo-II-classis commemoration is
+ DROPPED once a privileged one is due). [omitted_has] on each date below
+ confirms Mark is genuinely reported dropped, not silently absent --
+ "omitted: admission limit reached", {!Precedence.resolve}'s own generic
+ reason for a candidate [disposition] admitted to the contest but
+ [admit] then cut. Two different generic Sunday slugs appear (week 4 in
+ 2010/2021, week 5 in 2027/2032) because Easter falls on a different
+ date in each pair -- neither is hand-picked, both are what
+ [Temporal_ef.sunday_slug] actually computes. *)
+let test_major_litanies_displaces_mark_on_ii_class_sunday () =
+ List.iter
+ (fun (y, week) ->
+ let d = fetch y 4 25 in
+ Alcotest.(check bool)
+ (Printf.sprintf "%d-04-25: St Mark is in [omitted] (RG111(b)'s privileged-commemoration override), not \
+ silently dropped" y)
+ true (omitted_has d "mark");
+ Alcotest.(check string)
+ (Printf.sprintf "%d-04-25: the Sunday observed (II class); ONLY the Litanies commemorated, Mark \
+ displaced (RG111(b))" y)
+ (Printf.sprintf
+ "%d-04-25 sunday season=paschaltide week=%d slug=ef-easter-sunday-%d rank=class-2 colour=white \
+ subject=temporal name_la=- comms=[major-litanies:privileged] in=- out=[]"
+ y week week)
+ (describe d))
+ [ (2010, 4); (2021, 4); (2027, 5); (2032, 5) ]
+
+(* The OTHER RG 80 trigger shape, complementing 1666/2038 above (Easter
+ Sunday itself = 25 April): here Easter MONDAY = 25 April (Easter = 24
+ April 2011, independently Gauss-derivable and already the domain's own
+ register-cited "Easter Monday" marker, [ef-easter-1-monday]). RG 80's
+ "following Tuesday" is one day later this time (26 April, not 27) --
+ both shapes converge on the SAME offset, Easter+2, never on a fixed
+ civil-date difference. *)
+let test_major_litanies_transfer_2011_easter_monday () =
+ check ~msg:"2011-04-25 Easter Monday (RG80's SECOND trigger): Litanies depart for the following Tuesday, \
+ 26 April" 2011 4 25
+ "2011-04-25 monday season=paschaltide week=1 slug=ef-easter-1-monday rank=class-1 colour=white \
+ subject=temporal name_la=- comms=[] in=- out=[major-litanies->2011-04-26]";
+ check ~msg:"2011-04-26 Easter+2: the Litanies land here, commemorated and privileged, exactly one day after \
+ origin (not two, unlike the Easter-Sunday shape)" 2011 4 26
+ "2011-04-26 tuesday season=paschaltide week=1 slug=ef-easter-1-tuesday rank=class-1 colour=white \
+ subject=temporal name_la=- comms=[major-litanies:privileged] in=- out=[]"
+
let suite =
( "golden pins (known-tricky years)",
[ Alcotest.test_case "Easter extreme: 1598 earliest (22 Mar, Gauss-verified)" `Quick
@@ -1026,5 +1147,14 @@ let suite =
Alcotest.test_case
"RG112(d), fix round 1 (F1): the BVM Saturday office excludes Mt Carmel's own commemoration \
(2033-07-16)"
- `Quick test_bvm_saturday_excludes_mt_carmel_2033
+ `Quick test_bvm_saturday_excludes_mt_carmel_2033;
+ Alcotest.test_case "RG80/109(f): the Major Litanies, ordinary year, St Mark wins outright (2026-04-25)"
+ `Quick test_major_litanies_ordinary_2026;
+ Alcotest.test_case
+ "RG111(b): the Major Litanies displace St Mark's own commemoration on a II-class Sunday (2010, 2021, \
+ 2027, 2032)"
+ `Quick test_major_litanies_displaces_mark_on_ii_class_sunday;
+ Alcotest.test_case
+ "RG80's second trigger: Easter Monday = 25 April, the Litanies transfer to Easter+2 (2011)" `Quick
+ test_major_litanies_transfer_2011_easter_monday
] )
diff --git a/test/test_oracle.ml b/test/test_oracle.ml
index c0badfe..3a9e499 100644
--- a/test/test_oracle.ml
+++ b/test/test_oracle.ml
@@ -517,25 +517,108 @@ let m3_dates = [ "2027-05-03" ]
sourced from lectio's independently-fixed generator; the divergence no
longer occurs on either 2026-01-28 or 2027-01-28). *)
-(* M5 -- register §6's OWN already-open item, independently confirmed by
- the oracle: "Major Litanies (25 April, RG 80) are not yet computed" --
- missalemeum's commemoration "Pro rogationibus" ("for the Rogations") on
- St Mark's day (25 April, the Major Litanies' fixed date) is exactly the
- missing office. Not a new finding; recorded here so the automated
- comparator does not report it as an unexplained mismatch, and cross-
- referenced from the register so a reader sees both. Verdict missalemeum
- (colitur is missing a real, cited office; register §6 already tracks
- it). Only ONE date below, not two: 25 April 2026 is a Saturday (Mark
- wins outright, colitur's commemoration list is empty where the oracle's
- is not -- a visible presence gap); 25 April 2027 is a Sunday, where the
- Sunday wins on BOTH sides and each side's own single slot goes to a
- DIFFERENT candidate (colitur: Mark himself, admitted under RG 111(b)'s
- "de festo II classis"; missalemeum: the Major Litanies) -- both
- non-empty, same count, so this comparator's deliberately-not-slug-
- identity axes cannot see that year's instance at all (see the task
- report). *)
+(* M5 -- CORRECTED (ef-major-litanies task): the Major Litanies (RG 80,
+ Caput X "De Litaniis maioribus et minoribus") are now built
+ (Rite_ef.Precedence_ef's own [major_litanies_slug]/[disposition]/
+ [privilege_of] RG 109(f) branch/[transfer_target] RG 80 branch,
+ data/ef/adjustments.sexp's matching [Add] entry). This note's own
+ PRIOR text (struck below, kept for the record of what was corrected)
+ claimed missalemeum's single admitted commemoration on 25 April 2027
+ (the Sunday-conflict shape) was "the Major Litanies" -- CHECKED AGAINST
+ THE RAW FIXTURE ROW DIRECTLY while adjudicating this task's own new
+ divergence (test/fixtures/missalemeum-ef-2026-2027.txt, the 2027-04-25
+ line: "...|IV Sunday after Easter|-|St. Mark|Pro rogationibus|1|
+ sancti:04-25:2:r") and found BACKWARDS: missalemeum's own
+ [commemorations] field for that row is "St. Mark", and "Pro
+ rogationibus" (the Litanies) is the one listed in [displaced]. The
+ note had the two swapped -- a genuine source-fidelity slip in this
+ file's own prior authorship, not a re-reading of a primary document,
+ but corrected on the same "read positively, quote it" discipline this
+ project applies to the primary scans. See [M20] below for the fix
+ this correction actually motivates (2027's own now-real divergence,
+ colitur=Litanies vs missalemeum=Mark, adjudicated separately).
+
+ What THIS entry (M5) still covers, unchanged in kind: 25 April 2026 (an
+ ordinary, non-Sunday year -- Mark wins the day outright on both sides,
+ uncontested). Both engines now admit a commemoration there (colitur:
+ [major-litanies]; missalemeum: "Pro rogationibus") -- PRESENCE now
+ agrees (closing the register §6 gap this entry used to track), but the
+ two engines name the SAME real-world observance in different registers:
+ colitur's own English descriptive name (data/ef/adjustments.sexp's own
+ honestly-flagged, not-primary-sourced "The Major Litanies") against
+ missalemeum's Latin-ish "Pro rogationibus" ("for the Rogations") --
+ never going to match as literal strings, and not a rubric dispute at
+ all (RG 80/81 do not prescribe an English title for this observance;
+ this comparator's own [identity_diff] can only ever compare exact
+ strings, the same declared limit [M15]/[M18] already document for
+ temporal-origin candidates, now hit here for a different structural
+ reason -- a real candidate on both sides, just two different
+ vocabularies). Verdict colitur (the identity axis's own declared limit,
+ not an error): {!m5_commemoration_matches} pins WHICH candidate colitur
+ actually admits here, not merely that some [Comm_identity_mismatch]
+ diff exists, the same discipline [M19]'s own identity guard already
+ established. *)
let m5_dates = [ "2026-04-25" ]
+let m5_commemoration_matches (c : colitur_row) =
+ match c.c_commemorations with [ (slug, _, _, _) ] -> String.equal slug "major-litanies" | _ -> false
+
+(* M20 -- ef-major-litanies task, NEW. 25 April 2027: the Sunday-conflict
+ shape (RG 80's own condition -- 25 April is Easter Sunday or Monday --
+ does NOT fire this year, so the Litanies stay put on 25 April itself,
+ which this year happens to be an ordinary II-class Sunday, RG 91 entry
+ 15). St Mark (II class, RG 91 entry 16) also loses to the Sunday. Two
+ losing candidates, ONE slot (RG 111(b)): colitur admits the Litanies,
+ NOT Mark; missalemeum's raw fixture row shows the reverse (verified
+ directly, not inferred -- see [M5]'s own corrected note above for the
+ exact line).
+
+ ADJUDICATED: verdict colitur, RG 109(f) + RG 111(b) (docs/research/
+ rules-register.md §4 "Commemorations", both primary-source-verified
+ word for word, all three documents): RG 109(f), Caput XVI "De
+ Commemorationibus", places "de Litaniis maioribus, in Missa" in the
+ SAME closed list as (a)-(e), with equal grammatical standing -- no
+ textual qualifier narrows its privilege relative to theirs. RG 111(b),
+ same chapter: "in dominicis II classis, una tantum admittitur
+ commemoratio, scilicet de festo II classis, QUÆ TAMEN OMITTITUR SI
+ COMMEMORATIO PRIVILEGIATA FACIENDA SIT" -- the II-class-feast
+ commemoration is DROPPED if a privileged commemoration is due, full
+ stop; the clause names no table-order qualifier ("whichever ranks
+ higher"), only the categorical fact of a privileged commemoration being
+ due. This is the SAME mechanism {!Precedence_ef.admit}'s [Class2, true]
+ branch already implements and this project's OWN prior work already
+ primary-source-verified for this exact clause (precedence_ef.ml's own
+ "Fix, Task 16" comment) -- not new code, not a special case written for
+ the Litanies; St Mark simply meets the same fate any other ordinary
+ Class2 commemoration meets when a privileged one is also due that
+ Sunday (RG 16(a)'s Transfiguration/Sixtus shape is the nearest existing
+ witness, though there the privileged side wins the DAY, not merely the
+ commemoration slot).
+
+ HONESTLY FLAGGED, not overclaimed: this is the FIRST real, independent
+ (non-synthetic) data point this codebase has for "an ordinary Class2
+ feast candidate and a privileged non-feast commemoration candidate,
+ both losing to the identical Sunday" -- every other witness for
+ [admit]'s Class2-Sunday privilege-override branch in this suite's own
+ test_precedence_ef.ml is hand-built (real slugs/ranks, but a
+ constructed collision, not one the calendar itself produces). Read
+ plainly, RG 111(b)'s text supports colitur's outcome; missalemeum's own
+ divergence here is consistent with this project's ALREADY-DOCUMENTED
+ pattern of RG 108-111 gaps in that oracle (M1: RG 33's Sunday omission
+ not implemented; M8: RG 109(a)+RG 111(a) not implemented for an impeded
+ I-class Sunday; M10: RG 109(e) inconsistently applied) -- plausibly one
+ more instance of the same generator not modelling RG 109(f)'s privilege
+ for this one rare, single-date observance, not evidence the general
+ RG 111(b) mechanism itself is mis-read. Recorded as an adjudicated
+ verdict, not a certainty: if a future primary-source pass finds
+ textual grounds narrowing RG 109(f)'s privilege specifically (e.g. a
+ clause this task's own scan reading did not surface), this entry is
+ the one to revisit first. *)
+let m20_dates = [ "2027-04-25" ]
+
+let m20_commemoration_matches (c : colitur_row) =
+ match c.c_commemorations with [ (slug, _, _, _) ] -> String.equal slug "major-litanies" | _ -> false
+
(* M6 -- REMOVED, ef-rebootstrap (2026-08-12): see data/ef/expected-
divergences-missalemeum.sexp's own "FIVE MORE REMOVED" note. Eusebius
Confessor (14 Aug) is now present in data/ef/sanctoral.sexp
@@ -852,7 +935,8 @@ let layer_m_reason (c : colitur_row) (_o : oracle_row) diffs =
observes is temporal-origin, same root cause as M1/M2 above. *)
else if List.mem c.c_date m3_dates && subset diffs [ Colour_f; Observed_identity_unresolved ] then
Some "M3"
- else if List.mem c.c_date m5_dates && diffs = [ Comm_presence ] then Some "M5"
+ else if List.mem c.c_date m5_dates && diffs = [ Comm_identity_mismatch ] && m5_commemoration_matches c then
+ Some "M5"
else if List.mem c.c_date m8_dates && diffs = [ Comm_presence ] then Some "M8"
else if List.mem c.c_date m10_dates && diffs = [ Comm_presence ] then Some "M10"
else if List.mem c.c_date m11_dates && diffs = [ Comm_presence ] then Some "M11"
@@ -880,6 +964,11 @@ let layer_m_reason (c : colitur_row) (_o : oracle_row) diffs =
else if diffs = [ Observed_identity_unresolved ] then Some "M18"
else if List.mem c.c_date m19_dates && diffs = [ Comm_presence ] && m19_commemoration_matches c then
Some "M19"
+ else if
+ List.mem c.c_date m20_dates
+ && subset diffs [ Comm_identity_mismatch; Observed_identity_unresolved ]
+ && m20_commemoration_matches c
+ then Some "M20"
else None
(* ---------------------------------------------------------------------- *)
diff --git a/test/test_precedence_ef.ml b/test/test_precedence_ef.ml
index 6f8b5b5..790dab6 100644
--- a/test/test_precedence_ef.ml
+++ b/test/test_precedence_ef.ml
@@ -555,6 +555,45 @@ let disposition_cases =
cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~layer:PE.universal_layer
"ef-suppressed-vigil",
"Commemorate(Privileged)" );
+ (* RG 80 (ef-major-litanies task, NEW) -- {!PE.major_litanies_slug}'s
+ own citation (precedence_ef.ml, above [privilege_of]) has the full
+ text. The row immediately above proves "Commemoration_only is
+ ALWAYS Commemorate" as a GENERAL rule; these three rows prove
+ [PE.major_litanies_slug] is the ONE deliberate, cited EXCEPTION to
+ it, and prove the exception is gated on the WINNER's own slug, not
+ merely on the loser being this particular Commemoration_only
+ candidate. *)
+ ( "RG80 first trigger: the Litanies lose to Easter Sunday itself -> \
+ Transfer, not Commemorate (25 April IS Easter Sunday this year)",
+ cand "ef-easter-sunday",
+ cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~rank:V.Class4 ~layer:PE.universal_layer
+ PE.major_litanies_slug,
+ "Transfer" );
+ ( "RG80 second trigger: the Litanies lose to Easter Monday -> \
+ Transfer (25 April IS Easter Monday this year, i.e. Easter itself \
+ is 24 April)",
+ cand "ef-easter-1-monday",
+ cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~rank:V.Class4 ~layer:PE.universal_layer
+ PE.major_litanies_slug,
+ "Transfer" );
+ (* MUTATION-PROOFING the WINNER-slug guard specifically (not merely
+ "the Litanies can transfer at all"): losing to a THIRD I-class
+ temporal winner that is neither of RG 80's two named trigger days
+ must NOT transfer -- RG 80's own condition is exactly "Easter
+ Sunday or Easter Monday", not "any I-class day", and 25 April can
+ structurally never actually coincide with, say, the Nativity in
+ real data (this row is a deliberately synthetic collision, the
+ same "proves the guard, not merely the absence of its own bug"
+ shape {!band}'s own Holy-Family-window row uses) -- if
+ {!PE.disposition}'s own winner-slug test were ever loosened to
+ "any Class1 winner", this row would wrongly turn [Transfer] too. *)
+ ( "SYNTHETIC: the Litanies losing to an UNRELATED I-class day (not \
+ Easter Sunday or Monday) is an ordinary Commemoration_only loser \
+ -- Commemorate(Privileged) via RG109(f), NOT Transfer",
+ cand "ef-nativity",
+ cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~rank:V.Class4 ~layer:PE.universal_layer
+ PE.major_litanies_slug,
+ "Commemorate(Privileged)" );
(* Totality (SANCTORAL side): the lower ranks the RG 33/RG 95/Task-16
branches never touch still reach the RG 95 "commemorated or omitted"
branch's [Commemorate] side, not an unhandled/exceptional case -- RG
@@ -865,12 +904,13 @@ let disposition_cases =
a plain [Feast]-status [Class1] loser can never reach [Commemorate] at
all in this ruleset (RG 95 routes it to [Transfer] instead), so no
further row for (b) is added here -- see the task report. Category (f),
- "of the Major Rogations, in Mass", has no row at all: no candidate this
- codebase can currently construct represents one (see [privilege_of]'s own
- comment on (f)) -- the negative row below proves the one slug this engine
- DOES compute that could be mistaken for it (the Minor Rogations) is
- correctly NOT conflated with it, which is the strongest claim available
- without inventing an unfounded slug convention. *)
+ "of the Major Litanies, in Mass" -- NOW LIVE (ef-major-litanies task):
+ [PE.major_litanies_slug]'s own data/ef/adjustments.sexp entry is the
+ real candidate; the positive row below is added alongside the existing
+ negative one, which still proves the one slug this engine ALSO computes
+ that could be mistaken for it (the Minor Rogations, RG 87 -- a
+ different observance RG 109(f) does not name) is correctly NOT
+ conflated with it. *)
let privilege_cases =
[ (* (a) RG 109(a) (§4): "of a Sunday". [an_ordinary_sunday] is Class2,
not Class1, not within the Nativity octave, not an Ember day, not a
@@ -960,6 +1000,26 @@ let privilege_cases =
cand "ef-nativity",
of_temporal (off (-39)),
"Commemorate(Privileged)" );
+ (* (f) RG 109(f) (§4; Caput XVI "De Commemorationibus"): "de Litaniis
+ maioribus, in Missa" -- ef-major-litanies task, NEW. [rank] is
+ DELIBERATELY [Class4], not [cand]'s own [Class1] default: category
+ (b) above ("of a I-class day") fires on [rank = Class1] alone and
+ is checked BEFORE (f) in [privilege_of]'s own branch order, so a
+ [Class1] row here would reach [Privileged] via (b) regardless of
+ whether (f) itself is even wired -- proving nothing about (f)
+ specifically (the exact "test day that is both [X] and [Y] proves
+ nothing about either" hazard this table's own header warns about,
+ worked for the FIRST time on this axis rather than Sunday-vs-
+ I-class). [Class4] (data/ef/adjustments.sexp's own honestly-flagged
+ placeholder rank, chosen for exactly this reason) rules out (b),
+ and the slug is neither a Sunday, within the Nativity octave, an
+ Ember day, nor an Advent/Lent/Passiontide feria -- (f) is this
+ row's only route to [Privileged], the real proof. *)
+ ( "(f) the Major Litanies commemoration is privileged (RG109(f))",
+ cand "ef-nativity",
+ cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~rank:V.Class4 ~layer:PE.universal_layer
+ PE.major_litanies_slug,
+ "Commemorate(Privileged)" );
(* Negative, RG 109(f)'s own boundary: the Minor Litanies/Rogations
(Monday/Tuesday before Ascension, RG 87 -- [Temporal_ef.temporal]
DOES compute these, unlike the Major Litanies RG 109(f) actually
@@ -2006,6 +2066,49 @@ let test_transfer_target_does_not_raise_at_domain_ceiling () =
true
(D.compare target (mk 9999 12 31) > 0)
+(* RG 80 (ef-major-litanies task, NEW) -- {!PE.major_litanies_slug}'s own
+ citation has the full text. [occupant_always_blocking] is deliberately
+ PATHOLOGICAL (every day reports I-class, forever) -- for every OTHER
+ candidate this would send [search_from] to its own 400-day structural
+ ceiling ({!test_transfer_target_terminates_under_pathological_occupant}
+ above proves exactly that shape). Using it here is the mutation proof
+ this branch's own comment promises ("running search_from here would be
+ ACTIVELY WRONG"): if the Litanies' own early-return branch were ever
+ deleted or bypassed, this test would immediately land somewhere near
+ [origin + 1000], not Easter+2, and fail loudly -- a MUCH stronger
+ witness than a cooperative occupant could give, which would pass either
+ way by accident (Easter+2 is itself never blocking under a realistic
+ occupant, so a real [Temporal_ef.temporal] could not distinguish "no
+ search happened" from "a search happened and immediately succeeded"). *)
+let test_transfer_target_major_litanies_easter_sunday_shape () =
+ let easter_2038 = Comp.gregorian_easter 2038 in
+ Alcotest.(check string) "2038: Easter Sunday IS 25 April (this file's own worked example)" "2038-04-25"
+ (D.to_iso8601 easter_2038);
+ let origin = easter_2038 in
+ let c =
+ cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~rank:V.Class4 ~layer:PE.universal_layer
+ PE.major_litanies_slug
+ in
+ let target = PE.transfer_target c origin occupant_always_blocking in
+ Alcotest.(check string) "lands on Easter+2 regardless of the pathological occupant (no RG96 search at all)"
+ (D.to_iso8601 (D.add_days easter_2038 2)) (D.to_iso8601 target);
+ Alcotest.(check bool) "strictly after origin (rite.mli's own obligation)" true (D.compare target origin > 0)
+
+let test_transfer_target_major_litanies_easter_monday_shape () =
+ let easter_2011 = Comp.gregorian_easter 2011 in
+ Alcotest.(check string) "2011: Easter Sunday is 24 April, so 25 April is Easter Monday" "2011-04-24"
+ (D.to_iso8601 easter_2011);
+ let origin = D.add_days easter_2011 1 (* 25 April 2011, Easter Monday *) in
+ let c =
+ cand ~origin:P.Sanctoral ~status:Cel.Commemoration_only ~rank:V.Class4 ~layer:PE.universal_layer
+ PE.major_litanies_slug
+ in
+ let target = PE.transfer_target c origin occupant_always_blocking in
+ Alcotest.(check string) "ALSO lands on Easter+2 (not Easter+3 -- both RG80 trigger shapes converge on the \
+ SAME offset), regardless of the pathological occupant"
+ (D.to_iso8601 (D.add_days easter_2011 2)) (D.to_iso8601 target);
+ Alcotest.(check bool) "strictly after origin (rite.mli's own obligation)" true (D.compare target origin > 0)
+
let suite =
( "Precedence_ef",
@@ -2098,4 +2201,11 @@ let suite =
Alcotest.test_case "transfer_target: terminates and stays forward under a pathological occupant"
`Quick test_transfer_target_terminates_under_pathological_occupant;
Alcotest.test_case "transfer_target: does not raise probing past the domain ceiling" `Quick
- test_transfer_target_does_not_raise_at_domain_ceiling ] )
+ test_transfer_target_does_not_raise_at_domain_ceiling;
+ Alcotest.test_case
+ "transfer_target: RG80, Easter-Sunday shape -- lands on Easter+2 with NO search, even under a \
+ pathological occupant"
+ `Quick test_transfer_target_major_litanies_easter_sunday_shape;
+ Alcotest.test_case
+ "transfer_target: RG80, Easter-Monday shape -- ALSO lands on Easter+2, not Easter+3" `Quick
+ test_transfer_target_major_litanies_easter_monday_shape ] )
diff --git a/test/test_rite_ef.ml b/test/test_rite_ef.ml
index 9a1e6d5..6cd8709 100644
--- a/test/test_rite_ef.ml
+++ b/test/test_rite_ef.ml
@@ -233,6 +233,21 @@ let sample_years =
let rec range a b = if a > b then [] else a :: range (a + 1) b in
range 2005 2050
+(* ef-major-litanies task: [major-litanies] (RG 80, Precedence_ef's own
+ [major_litanies_slug]) is a DELIBERATE, cited exception to this
+ property, not a bug to catch -- RG 80's own text sends the Major
+ Litanies to "the following Tuesday" unconditionally in a transfer year,
+ which is always Easter+2, squarely inside [Easter, Easter+7]
+ (precedence_ef.ml's own [transfer_target] Litanies branch has the full
+ citation and the "why not the general RG96 search" argument). Every
+ OTHER slug this rite ever transfers is still held to the original
+ property below: RG 96's general walk (and its own named Annunciation
+ exception) is what this property actually protects, and neither of
+ those two mechanisms is exempted here -- only this one rite-cited,
+ hand-verified exception, matched by NAME so a future regression in some
+ OTHER slug cannot silently hide behind this exemption. *)
+let is_major_litanies_slug slug = String.equal slug PE.major_litanies_slug
+
(* Property 1: no day in the resolved output, across the whole sample, is
EVER a transfer's landing point inside [Easter, Easter+7] -- checked two
ways. [transferred_out]'s own recorded target is what [transfer_target]
@@ -248,7 +263,7 @@ let test_no_transfer_lands_in_easter_octave () =
Array.iter
(fun (d : (V.season, V.rank) LD.t) ->
(match d.LD.transferred_in with
- | Some c when in_easter_octave d.LD.date ->
+ | Some c when in_easter_octave d.LD.date && not (is_major_litanies_slug (slug_of c)) ->
violations :=
(Printf.sprintf "%s transferred_in on %s (Easter+%d)" (slug_of c)
(Date.to_iso8601 d.LD.date) (easter_offset d.LD.date))
@@ -256,7 +271,7 @@ let test_no_transfer_lands_in_easter_octave () =
| _ -> ());
List.iter
(fun (c, target) ->
- if in_easter_octave target then
+ if in_easter_octave target && not (is_major_litanies_slug (slug_of c)) then
violations :=
Printf.sprintf "%s transferred_out from %s to %s (Easter+%d)" (slug_of c)
(Date.to_iso8601 d.LD.date) (Date.to_iso8601 target) (easter_offset target)
@@ -265,7 +280,37 @@ let test_no_transfer_lands_in_easter_octave () =
days)
sample_years;
Alcotest.(check (list string))
- "no day in [Easter, Easter+7] is ever a transfer's target, 2005-2050" [] (List.rev !violations)
+ "no day (other than the RG80-cited Major Litanies exception) in [Easter, Easter+7] is ever a transfer's \
+ target, 2005-2050" []
+ (List.rev !violations)
+
+(* The positive contrast to the property above: RG 80's own exception DOES
+ fire, exactly twice in the 2005-2050 sample (register: 194/8417 domain-
+ wide trigger years; this task's own report has the full count), both
+ landing on Easter+2 as RG 80's text requires -- proving the exemption
+ above is not merely masking an absent case. 2011 (Easter Monday = 25
+ April, Easter = 24 April) and 2038 (Easter Sunday = 25 April) are this
+ file's own worked examples (precedence_ef.ml's [major_litanies_slug]
+ citation). *)
+let test_major_litanies_transfers_inside_easter_octave_exactly_when_rg80_requires () =
+ let layer = real_layer () in
+ let landings = ref [] in
+ List.iter
+ (fun y ->
+ let days = Cal.year Rite_ef.context layer y in
+ Array.iter
+ (fun (d : (V.season, V.rank) LD.t) ->
+ List.iter
+ (fun (c, target) ->
+ if is_major_litanies_slug (slug_of c) then
+ landings := (Date.to_iso8601 d.LD.date, Date.to_iso8601 target) :: !landings)
+ d.LD.transferred_out)
+ days)
+ sample_years;
+ Alcotest.(check (list (pair string string)))
+ "RG80 fires exactly twice in 2005-2050, both landing on Easter+2"
+ [ ("2011-04-25", "2011-04-26"); ("2038-04-25", "2038-04-27") ]
+ (List.rev !landings)
(* Property 2, and the LIVE case: [PE.transfer_target] called directly, with
an origin that genuinely starts the search INSIDE Holy Week -- Holy
@@ -381,6 +426,8 @@ let suite =
test_transfer_search_does_not_raise_at_domain_ceiling;
Alcotest.test_case "no transfer ever lands inside [Easter, Easter+7], 2005-2050" `Quick
test_no_transfer_lands_in_easter_octave;
+ Alcotest.test_case "RG80: the Major Litanies DO transfer inside the octave, exactly twice in 2005-2050"
+ `Quick test_major_litanies_transfers_inside_easter_octave_exactly_when_rg80_requires;
Alcotest.test_case "transfer_target skips the whole Easter octave from inside Holy Week" `Quick
test_transfer_target_skips_the_whole_easter_octave;
Alcotest.test_case "the search genuinely enters the window (not vacuous)" `Quick