aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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)