aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-18 17:33:20 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-18 17:33:20 +0200
commite713da2d796eabc55d407ad2f8488861e7598798 (patch)
tree792d9911af15e2f875046cfcbb09b8a6e015dde3
parentd27336dc65c7348dcda639b7e43e8948e7bb1caa (diff)
downloadlectio-e713da2d796eabc55d407ad2f8488861e7598798.tar.gz
lectio-e713da2d796eabc55d407ad2f8488861e7598798.zip
fix(ef): two I-class feasts can no longer transfer onto the same day
RG 97/98 require a year's impeded I-class transfers to be resolved together. transferIfImpededEF resolved each one independently, so when an early Easter impedes both St Joseph (19 March) and the Annunciation (25 March), both walked to the Monday after Low Sunday -- and St Joseph, losing pickEF there, was observed on NO day of 2008, 2035 or 2046 at all. He appeared only as a commemoration in the Annunciation's tail. RG 96(a) settles it outright, and it is not a tie-break: the Annunciation transferred after Easter has a sedes propria -- "quando est transferendum post Pascha, transfertur, tamquam in sedem propriam, in feriam II post dominicam in albis". It claims the Monday by law; the rest queue in RG 98's order (table position, then the office impeded first) and walk around it. New efTransferPlan resolves the whole year's transfers as a set and hands each feast its target; transferIfImpededEF keeps the single-candidate walk and All Souls. The stale doc comment recording this as a known unfixable limitation is replaced -- its reasoning argued RG 97/98 tie-breaks, when RG 96(a) decides the only collision the universal calendar actually produces. 2008-04-01, 2035-04-03, 2046-04-03 ef-easter-2-tuesday -> St Joseph Matches colitur on all three. Mutation-tested: bypassing the plan reddens the new test on all three years. fix(ef): 13 January's Baptism is omitted when Holy Family falls on it The Holy Family Mass propers, verbatim in both photographic scans of the 1962 Missal: "Si festum S. Familiae occurrerit die 13 ianuarii, Missa dicitur de festo S. Familiae, sine commemoratione Baptismatis D.N.I.C., et sine commemoratione dominicae." Sine commemoratione: the Baptism is omitted outright, not merely outranked, so the candidate is dropped rather than left in Others. Structurally the same answer RG 91 entry 14 gives (Festa Domini II classis: primum mobilia, deinde fixa), but the propers' rubric is the direct authority for the omission. lectio kept the Baptism as the observed office on all seven such years in 2005-2050 -- 2008, 2013, 2019, 2030, 2036, 2041, 2047 -- and never observed the Holy Family Mass on 13 January at all. It already had the Holy Family Mass on every other Sunday after Epiphany, so this was the one date it could not reach. 13 rows of the 16801-day differential fixture change; colitur agrees with all of them.
-rw-r--r--internal/calendar/calendar.go141
-rw-r--r--internal/calendar/precedence_ef_repro_test.go58
-rw-r--r--internal/calendar/temporal_ef.go26
3 files changed, 203 insertions, 22 deletions
diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go
index 2d8611a..d918547 100644
--- a/internal/calendar/calendar.go
+++ b/internal/calendar/calendar.go
@@ -62,6 +62,12 @@ func computeEF(date time.Time, sel Selection, layers []Layer) LiturgicalDay {
}
isClass1 := func(r Rank) bool { return r == RankClass1 }
isClass1Or2 := func(r Rank) bool { return r == RankClass1 || r == RankClass2 }
+ occ1 := func(d time.Time, except string) bool { return occupiedByRank(d, except, isClass1) }
+ occ1Or2 := func(d time.Time, except string) bool { return occupiedByRank(d, except, isClass1Or2) }
+ // RG 97/98: the year's impeded I-class transfers are resolved as a set,
+ // not one at a time, so two feasts impeded by the same early Easter cannot
+ // both claim the same free day and lose one of themselves.
+ plan := efTransferPlan(year, merged, sel, occ1, occ1Or2)
cands := []candidate{{Cel: td.Cel, Temporal: true, Season: td.Season, Sunday: td.Sunday}}
for slug, rc := range merged {
cel := buildCelebration(slug, rc)
@@ -69,9 +75,18 @@ func computeEF(date time.Time, sel Selection, layers []Layer) LiturgicalDay {
if !ok {
continue
}
- effective := transferIfImpededEF(cel, when,
- func(d time.Time) bool { return occupiedByRank(d, cel.Slug, isClass1) },
- func(d time.Time) bool { return occupiedByRank(d, cel.Slug, isClass1Or2) })
+ // The Holy Family propers' own rubric: on a 13 January that IS the
+ // Sunday after Epiphany the Baptism is omitted "sine commemoratione",
+ // so the candidate is dropped rather than ranked. See efBaptismOmitted.
+ if efBaptismOmitted(date) && strings.Contains(cel.Slug, "commemoration-of-the-baptism") {
+ continue
+ }
+ effective, planned := plan[cel.Slug]
+ if !planned {
+ effective = transferIfImpededEF(cel, when,
+ func(d time.Time) bool { return occ1(d, cel.Slug) },
+ func(d time.Time) bool { return occ1Or2(d, cel.Slug) })
+ }
if sameDay(effective, date) {
cands = append(cands, candidate{Cel: cel, Temporal: false, Season: td.Season})
}
@@ -226,6 +241,98 @@ func transferIfImpeded(cel Celebration, when time.Time, sel Selection) time.Time
return day
}
+// efTransferPlan resolves ALL of a year's impeded I-class transfers together,
+// which RG 97/98 require and which resolving them one at a time cannot do.
+//
+// RG 96(a) is applied first and is decisive for the only collision the
+// universal calendar actually produces: the Annunciation, when transferred
+// after Easter, has a SEDES PROPRIA -- "festum Annuntiationis B. Mariae Virg.,
+// quando est transferendum post Pascha, transfertur, tamquam in sedem propriam,
+// in feriam II post dominicam in albis". A proper seat is fixed by law, so it
+// does not queue and no tie-break decides it: it claims the Monday after Low
+// Sunday and everything else walks around it.
+//
+// The remainder then transfer in RG 98's order -- "servetur ordo quo in tabella
+// praecedentiae inscribuntur; in paritate autem Officium prius impeditum
+// praecedit" (table order; at equal position the office impeded FIRST goes
+// first) -- each claiming its target so the next walks past it.
+//
+// Worked example, 2035 (Easter 25 March, Low Sunday 1 April): the Annunciation
+// takes its proper seat on 2 April; St Joseph, impeded on the 19th, walks past
+// Holy Week, the Easter octave and that claimed Monday to 3 April. Before this,
+// St Joseph was observed on no day of 2008, 2035 or 2046 at all.
+func efTransferPlan(year int, merged map[string]RawCelebration, sel Selection,
+ occupiedByClass1, occupiedByClass1Or2 func(time.Time, string) bool) map[string]time.Time {
+
+ type pending struct {
+ slug string
+ cel Celebration
+ when time.Time // the date it is impeded ON
+ band int // RG 91 table position, lower = higher dignity
+ }
+ var queue []pending
+ plan := map[string]time.Time{}
+ claimed := map[string]bool{}
+ claim := func(d time.Time) { claimed[d.Format("2006-01-02")] = true }
+
+ easter := Easter(year)
+ lowSundayMonday := easter.AddDate(0, 0, 8) // feria II post dominicam in albis
+
+ for slug, rc := range merged {
+ cel := buildCelebration(slug, rc)
+ if cel.Rank != RankClass1 {
+ continue
+ }
+ when, ok := celebrationDate(cel, year, sel)
+ if !ok {
+ continue
+ }
+ st := temporalEF(when)
+ tCand := candidate{Cel: st.Cel, Temporal: true, Season: st.Season, Sunday: st.Sunday}
+ sCand := candidate{Cel: cel, Temporal: false}
+ if precedenceEF(tCand) >= precedenceEF(sCand) && !occupiedByClass1(when, cel.Slug) {
+ continue // not impeded; stays put
+ }
+ // RG 96(a): a proper seat, claimed before anything queues. Guarded to
+ // a transfer that is genuinely "post Pascha" -- 25 March inside Holy
+ // Week or the Easter octave -- not to any impediment whatever.
+ if strings.Contains(cel.Slug, "annunciation") &&
+ !when.Before(easter.AddDate(0, 0, -7)) && !when.After(easter.AddDate(0, 0, 7)) {
+ plan[cel.Slug] = lowSundayMonday
+ claim(lowSundayMonday)
+ continue
+ }
+ queue = append(queue, pending{slug: cel.Slug, cel: cel, when: when, band: precedenceEF(sCand)})
+ }
+
+ // RG 98: table order, then the office impeded first.
+ sort.Slice(queue, func(i, j int) bool {
+ if queue[i].band != queue[j].band {
+ return queue[i].band < queue[j].band
+ }
+ if !queue[i].when.Equal(queue[j].when) {
+ return queue[i].when.Before(queue[j].when)
+ }
+ return queue[i].slug < queue[j].slug
+ })
+
+ for _, p := range queue {
+ day := p.when.AddDate(0, 0, 1)
+ for i := 0; i < 60; i++ {
+ b := temporalEF(day)
+ isHighClass := b.Cel.Rank == RankClass1 || b.Cel.Rank == RankClass2
+ if isHighClass || occupiedByClass1Or2(day, p.cel.Slug) || claimed[day.Format("2006-01-02")] {
+ day = day.AddDate(0, 0, 1)
+ continue
+ }
+ break
+ }
+ plan[p.slug] = day
+ claim(day)
+ }
+ return plan
+}
+
// transferIfImpededEF moves an EF sanctoral feast off a date it cannot be kept
// on. Two rules cover the 1962 cases: All Souls, when Nov 2 is a Sunday, is
// transferred to the Monday (Nov 3); and a I class feast genuinely impeded is
@@ -256,25 +363,15 @@ func transferIfImpeded(cel Celebration, when time.Time, sel Selection) time.Time
// candidate's own transfer walk in isolation. It has no way to notice that a
// SECOND, separately-transferred I-class candidate has landed on the exact
// same destination day. Concretely: St Joseph (19 March) and the
-// Annunciation (25 March) can both be walked, independently, to the Monday
-// after Low Sunday in the same year (2008, 2035, 2046 -- e.g. 2035-04-02:
-// `annunciation-of-the-blessed-virgin-mary … +joseph-spouse-of-the-bl-virgin-mary`).
-// RG 95 grants the right of translation "solummodo festis I classis" (to
-// I-class feasts ONLY) -- so BOTH have that right, and the day they land on
-// together is itself an ordinary occurrence collision RG 97/98 governs (the
-// higher table position is kept, the other transfers FURTHER): this
-// function does not re-walk the loser, it lets pickEF's plain alphabetical
-// slug tie-break settle it, so the loser is merely commemorated instead of
-// continuing its own walk one more day. RG 98 itself supplies the missing
-// determinism rule for the tie this collision even needs: "in paritate
-// autem Officium prius impeditum praecedit" -- at equal table position, the
-// office impeded FIRST takes precedence -- which is chronological (Joseph,
-// impeded on the 19th, before the Annunciation's own walk begins on the
-// 25th) and may favour Joseph over pickEF's alphabetical fallback. Neither
-// RG 98's tie rule nor the re-walk RG 97 implies is implemented; fixing this
-// properly means resolving occurrence between two ALREADY-TRANSFERRED
-// candidates, not a single one, which is out of this function's current
-// shape.
+// Annunciation (25 March) are both impeded in an early-Easter year (2008,
+// 2035, 2046) and, resolved independently, both walk to the Monday after Low
+// Sunday -- where one of them then loses pickEF and vanishes from the year
+// entirely. RG 95 grants the right of translation "solummodo festis I
+// classis", so both genuinely have it and both must land SOMEWHERE.
+//
+// That collision is no longer settled here: efTransferPlan resolves the year's
+// impeded I-class feasts together, as RG 97/98 require, and hands each one its
+// target. This function keeps the single-candidate walk and All Souls only.
func transferIfImpededEF(cel Celebration, when time.Time, occupiedByClass1, occupiedByClass1Or2 func(time.Time) bool) time.Time {
if when.Month() == time.November && when.Day() == 2 && when.Weekday() == time.Sunday &&
strings.Contains(cel.Slug, "all-souls") {
diff --git a/internal/calendar/precedence_ef_repro_test.go b/internal/calendar/precedence_ef_repro_test.go
index 11fd6ca..1408c7c 100644
--- a/internal/calendar/precedence_ef_repro_test.go
+++ b/internal/calendar/precedence_ef_repro_test.go
@@ -11,6 +11,7 @@ package calendar_test
// covered separately, package-internal, in precedence_ef_test.go).
import (
+ "strings"
"testing"
"time"
@@ -158,3 +159,60 @@ func TestPurificationBeatsFebruarySunday(t *testing.T) {
}
}
}
+
+// TestEFTwoTransfersDoNotCollide: RG 97/98 -- when an early Easter impedes BOTH
+// St Joseph (19 March) and the Annunciation (25 March), each must land on a day
+// of its own. Resolving the two walks independently sent both to the Monday
+// after Low Sunday, where St Joseph lost pickEF and was observed on NO day of
+// 2008, 2035 or 2046 at all.
+//
+// RG 96(a) fixes the Annunciation's target outright -- "quando est transferendum
+// post Pascha, transfertur, tamquam in sedem propriam, in feriam II post
+// dominicam in albis" -- so it is not a tie-break: the Annunciation takes the
+// Monday by law and St Joseph walks to the Tuesday.
+func TestEFTwoTransfersDoNotCollide(t *testing.T) {
+ for _, c := range []struct{ annunciation, joseph string }{
+ {"2008-03-31", "2008-04-01"},
+ {"2035-04-02", "2035-04-03"},
+ {"2046-04-02", "2046-04-03"},
+ } {
+ for _, w := range []struct{ date, want string }{
+ {c.annunciation, "annunciation-of-the-blessed-virgin-mary"},
+ {c.joseph, "joseph-spouse-of-the-bl-virgin-mary"},
+ } {
+ got := efCompute(w.date).Observed.Slug
+ if got != w.want {
+ t.Errorf("%s observed = %q, want %q", w.date, got, w.want)
+ }
+ }
+ }
+}
+
+// TestEFBaptismOmittedOnHolyFamilySunday: the Holy Family Mass propers, verbatim
+// in both photographic scans of the 1962 Missal -- "Si festum S. Familiae
+// occurrerit die 13 ianuarii, Missa dicitur de festo S. Familiae, sine
+// commemoratione Baptismatis D.N.I.C., et sine commemoratione dominicae."
+//
+// So on a 13 January that is itself the Sunday after Epiphany the day is the
+// Holy Family (Col 3:12-17 / Luke 2:42-52) and the Baptism is omitted OUTRIGHT,
+// not commemorated -- Others must be empty of it. lectio previously kept the
+// Baptism as the observed office on both such years in 2005-2050.
+func TestEFBaptismOmittedOnHolyFamilySunday(t *testing.T) {
+ for _, y := range []string{"2008", "2013", "2019", "2030", "2036", "2041", "2047"} {
+ d := efCompute(y + "-01-13")
+ if d.Observed.Slug != "ef-time-after-epiphany-sunday-1" {
+ t.Errorf("%s-01-13 observed = %q, want ef-time-after-epiphany-sunday-1 (Holy Family)", y, d.Observed.Slug)
+ }
+ for _, o := range d.Others {
+ if strings.Contains(o.Slug, "baptism") {
+ t.Errorf("%s-01-13 commemorates %q; the rubric says sine commemoratione Baptismatis", y, o.Slug)
+ }
+ }
+ }
+ // A 13 January that is NOT the Sunday after Epiphany keeps the Baptism.
+ for _, y := range []string{"2026", "2035"} {
+ if got := efCompute(y + "-01-13").Observed.Slug; got != "commemoration-of-the-baptism-of-the-lord" {
+ t.Errorf("%s-01-13 observed = %q, want commemoration-of-the-baptism-of-the-lord", y, got)
+ }
+ }
+}
diff --git a/internal/calendar/temporal_ef.go b/internal/calendar/temporal_ef.go
index 0cd4b41..4ac446a 100644
--- a/internal/calendar/temporal_ef.go
+++ b/internal/calendar/temporal_ef.go
@@ -372,6 +372,32 @@ func firstSundayAfterEpiphany(y int) time.Time {
return d
}
+// efBaptismOmitted reports whether 13 January's Commemoration of the Baptism of
+// Our Lord is omitted entirely this year because the feast of the Holy Family
+// falls on that date. The Holy Family Mass propers carry the rule verbatim, in
+// both photographic scans of the 1962 Missal:
+//
+// "Si festum S. Familiae occurrerit die 13 ianuarii, Missa dicitur de festo
+// S. Familiae, sine commemoratione Baptismatis D.N.I.C., et sine
+// commemoratione dominicae."
+//
+// The Baptism does not merely lose the day -- it is omitted, "sine
+// commemoratione" -- which is why this drops the candidate outright instead of
+// letting precedenceEF rank it and leave it in Others.
+//
+// Structurally the same answer RG 91 entry 14 gives ("Festa Domini II classis":
+// primum mobilia, deinde fixa -- the movable Holy Family ahead of the fixed
+// Baptism), but the propers' rubric is the direct authority for the omission,
+// and it is the half that RG 91 alone would not settle.
+//
+// Live in 7 of the 46 years 2005-2050 (2008, 2013, 2019, 2030, 2036, 2041,
+// 2047); lectio kept the Baptism on every one of them and never observed the
+// Holy Family Mass there at all.
+func efBaptismOmitted(date time.Time) bool {
+ return date.Month() == time.January && date.Day() == 13 &&
+ sameDay(date, firstSundayAfterEpiphany(date.Year()))
+}
+
// thirdSundayOfSeptember returns the third Sunday in September of year y.
func thirdSundayOfSeptember(y int) time.Time {
d := time.Date(y, time.September, 1, 0, 0, 0, 0, time.UTC)