diff options
Diffstat (limited to 'internal/calendar/calendar.go')
| -rw-r--r-- | internal/calendar/calendar.go | 124 |
1 files changed, 112 insertions, 12 deletions
diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index 33706d8..2d8611a 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,54 @@ 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. +// +// KNOWN GAP, not fixed here (recorded per RG 95, so the next person does not +// have to rediscover it from scratch): this function resolves ONE +// 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. +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 +283,40 @@ func transferIfImpededEF(cel Celebration, when time.Time) time.Time { if cel.Rank != RankClass1 { return when } - day := when + // Is cel actually impeded on `when` at all? Compare its OWN precedence + // (via precedenceEF, the same function pickEF uses to decide the day) + // against the temporal office's, rather than testing the temporal + // office's class in isolation -- a plain "is the temporal day I class" + // test wrongly impedes a candidate that would in fact WIN a tie against + // it: RG 91 entry 4 (the Immaculate Conception, the Assumption) sits + // ABOVE entry 6 (Sundays), so the Immaculate Conception is not impeded + // by the Advent Sunday it may fall on at all, unlike an ordinary I-class + // feast (entry 11, e.g. St Joseph), which is. occupiedByClass1 still + // covers the separate case of a competing FIXED I-class SANCTORAL + // feast, which this temporal-only comparison cannot see. + startTemporal := temporalEF(when) + tCand := candidate{Cel: startTemporal.Cel, Temporal: true, Season: startTemporal.Season, Sunday: startTemporal.Sunday} + sCand := candidate{Cel: cel, Temporal: false} + startImpeded := precedenceEF(tCand) < precedenceEF(sCand) || 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 + // A direct CLASS test, not precedenceEF's tie-break band: band + // encodes which of two EQUAL-class candidates wins a tie (e.g. a + // Sunday beats an equal-class feast, but a privileged Ember/late- + // Advent FERIA of the same class yields to one, defect 2) -- an + // unrelated question from RG 96's own "is this day itself I or II + // class" test. Reusing band here undercounted: a day within the + // Octave of the Nativity (26-31 Dec) is genuinely II class (RG 67), + // but as an ordinary, non-Sunday II-class temporal candidate its OWN + // band yields (5, not <=3) -- so band<=3 alone let an impeded I-class + // feast wrongly land there. isHighClass tests the class directly. + isHighClass := b.Cel.Rank == RankClass1 || b.Cel.Rank == RankClass2 + if isHighClass || occupiedByClass1Or2(day) { + day = day.AddDate(0, 0, 1) // still I- or II-class; keep walking continue } return day |
