From 00a32e549493b0fb51ae23e8a5e73aa54a5205b1 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 12 Aug 2026 13:05:34 +0200 Subject: calendar: RG 96 transfers must skip II-class days too (defect 1) transferIfImpededEF's forward walk only skipped days whose temporal office was I class. RG 96 requires skipping to "the next following day that is not I or II class" -- II class also blocks. 2011: the Sacred Heart (Friday after the Corpus Christi octave) falls on 1 July and impedes the Precious Blood, also fixed on 1 July. The walk landed the Precious Blood on 2 July, displacing the Visitation (II class, fixed) outright, and never reached 3 July (an ordinary II-class Sunday) at all. Two changes were needed together, not one: 1. The walk's continuation threshold widens from "temporal band <= 2" to "<= 3", which is what actually captures every II-class temporal day (an ordinary Sunday, or a II-class named feast) as well as every I-class one -- band 3 is precedenceEF's own value for any non-Sunday-privileged II-class temporal candidate, unchanged by this commit. 2. A FIXED sanctoral II-class feast (the Visitation) has no temporal band at all -- the walk needs to also check whether some OTHER fixed celebration, class 1 or 2, already resolves onto the candidate day. computeEF now builds this check once (occupiedByRank, parameterised by which ranks count) and passes it in. Widening (1) alone is wrong on its own: a class-1 feast is NEVER actually impeded by a mere II-class day (I class always outranks II class outright, no tie exists) -- naively applying the wide "<=3" threshold to decide whether the ORIGINAL date is impeded, not just where to land afterwards, wrongly bumped unimpeded feasts landing on an ordinary Sunday (caught while testing this: All Saints, 1 Nov 2026, a Sunday that year, was wrongly pushed to 3 Nov). transferIfImpededEF now uses two different thresholds for two different questions -- class 1 only to decide IF a candidate is impeded at all, class 1 OR 2 to decide where an already-impeded one may land -- see its own doc comment for the full reasoning. Witness (precedence_ef_repro_test.go): TestTransferSkipsBothIAndIIClass. Fails before this commit with: 2011-07-02 observed = "precious-blood-of-our-lord-jesus-christ" want visitation-of-the-blessed-virgin-mary (RG 96: the Precious Blood must skip past it, not displace it) 2011-07-04 observed = "ef-time-after-pentecost-3-monday" want precious-blood-of-our-lord-jesus-christ (RG 96: first day that is neither I nor II class) --- internal/calendar/calendar.go | 76 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 64 insertions(+), 12 deletions(-) (limited to 'internal/calendar/calendar.go') diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index 33706d8..f364321 100644 --- a/internal/calendar/calendar.go +++ b/internal/calendar/calendar.go @@ -35,14 +35,43 @@ func TemporalSlug(date time.Time, sel Selection) string { func computeEF(date time.Time, sel Selection, layers []Layer) LiturgicalDay { td := temporalEF(date) merged := mergeLayers(layers) - cands := []candidate{{Cel: td.Cel, Temporal: true, Season: td.Season}} + year := date.Year() + // occupiedByRank reports whether some OTHER fixed-date sanctoral + // celebration whose rank passes `allowed` resolves onto d this year. + // transferIfImpededEF uses this at two different thresholds: class 1 + // only, to decide whether a candidate is impeded in the first place (a + // class-2 occupant never impedes a class-1 feast -- class 1 always beats + // class 2 outright, no tie exists); and class 1 OR 2, for RG 96's "next + // day that is not I or II class" once a transfer is already under way + // (e.g. the Visitation, 2 July, blocking the Precious Blood's transfer + // off 1 July in 2011). + occupiedByRank := func(d time.Time, exceptSlug string, allowed func(Rank) bool) bool { + for slug2, rc2 := range merged { + if slug2 == exceptSlug { + continue + } + cel2 := buildCelebration(slug2, rc2) + if !allowed(cel2.Rank) { + continue + } + if when2, ok := celebrationDate(cel2, year, sel); ok && sameDay(when2, d) { + return true + } + } + return false + } + isClass1 := func(r Rank) bool { return r == RankClass1 } + isClass1Or2 := func(r Rank) bool { return r == RankClass1 || r == RankClass2 } + cands := []candidate{{Cel: td.Cel, Temporal: true, Season: td.Season, Sunday: td.Sunday}} for slug, rc := range merged { cel := buildCelebration(slug, rc) - when, ok := celebrationDate(cel, date.Year(), sel) + when, ok := celebrationDate(cel, year, sel) if !ok { continue } - effective := transferIfImpededEF(cel, when) + 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) }) if sameDay(effective, date) { cands = append(cands, candidate{Cel: cel, Temporal: false, Season: td.Season}) } @@ -199,12 +228,29 @@ func transferIfImpeded(cel Celebration, when time.Time, sel Selection) time.Time // 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 impeded by a I class -// temporal day (Holy Week, the Easter octave) is pushed forward to the next free -// day — for the Annunciation in Holy Week this lands on the Monday after Low -// Sunday, past the whole privileged octave. Lower-rank feasts are only -// commemorated, never transferred. -func transferIfImpededEF(cel Celebration, when time.Time) time.Time { +// transferred to the Monday (Nov 3); and a I class feast genuinely impeded is +// pushed forward to the next day that is itself neither I nor II class (RG +// 96: "the next following day that is not I or II class"). Lower-rank feasts +// are only commemorated, never transferred (RG 95). +// +// occupiedByClass1 and occupiedByClass1Or2 are deliberately different +// thresholds, not the same check reused twice: +// - a candidate is impeded IN THE FIRST PLACE only by a genuine class-1 +// collision -- the temporal office of `when` is itself I class (a Sunday +// of Advent/Lent/Passiontide, Holy Week, a named I-class feast), or +// another FIXED I-class sanctoral feast already sits on `when` (RG +// 97/98). A II-class occupant, temporal or sanctoral, never impedes a +// I-class feast at all -- I class always outranks II class outright, no +// tie-break is even reached -- so using the wider I-OR-II threshold here +// would (and, before this fix, did: e.g. All Saints, 1 Nov, was wrongly +// bumped to 3 Nov merely for landing on an ordinary II-class Sunday it +// would have won outright) send an unimpeded I-class feast on an +// unnecessary walk. +// - once a transfer is under way, the DESTINATION must avoid landing on +// ANY I- or II-class day (RG 96's own wider "not I or II class" text) -- +// e.g. the Visitation, 2 July, blocks the Precious Blood's transfer off 1 +// July in 2011, and 3 July's ordinary Sunday blocks it a second time. +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") { return when.AddDate(0, 0, 1) @@ -212,11 +258,17 @@ func transferIfImpededEF(cel Celebration, when time.Time) time.Time { if cel.Rank != RankClass1 { return when } - day := when + startTemporal := temporalEF(when) + startImpeded := startTemporal.Cel.Rank == RankClass1 || occupiedByClass1(when) + if !startImpeded { + return when + } + day := when.AddDate(0, 0, 1) for i := 0; i < 30; i++ { b := temporalEF(day) - if precedenceEF(candidate{Cel: b.Cel, Temporal: true, Season: b.Season}) <= 2 { - day = day.AddDate(0, 0, 1) // impeded by a I class temporal day; push forward + band := precedenceEF(candidate{Cel: b.Cel, Temporal: true, Season: b.Season, Sunday: b.Sunday}) + if band <= 3 || occupiedByClass1Or2(day) { + day = day.AddDate(0, 0, 1) // still I- or II-class; keep walking continue } return day -- cgit v1.3