diff options
Diffstat (limited to 'internal/calendar')
| -rw-r--r-- | internal/calendar/calendar.go | 141 | ||||
| -rw-r--r-- | internal/calendar/precedence_ef_repro_test.go | 58 | ||||
| -rw-r--r-- | internal/calendar/temporal_ef.go | 26 |
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) |
