diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-18 17:33:20 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-18 17:33:20 +0200 |
| commit | e713da2d796eabc55d407ad2f8488861e7598798 (patch) | |
| tree | 792d9911af15e2f875046cfcbb09b8a6e015dde3 /internal/calendar/calendar.go | |
| parent | d27336dc65c7348dcda639b7e43e8948e7bb1caa (diff) | |
| download | lectio-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.
Diffstat (limited to 'internal/calendar/calendar.go')
| -rw-r--r-- | internal/calendar/calendar.go | 141 |
1 files changed, 119 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") { |
