summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 13:04:53 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 13:04:53 +0200
commitf40eb498c612aeda2ff7f3d5b898e4d1ddcec352 (patch)
tree968dcfc8fb263abadaf715833af9a8acda0a60a3 /internal
parentca3403893bfbebb325fe3ab6a98081640574bf2a (diff)
downloadlectio-f40eb498c612aeda2ff7f3d5b898e4d1ddcec352.tar.gz
lectio-f40eb498c612aeda2ff7f3d5b898e4d1ddcec352.zip
temporal_ef: Sunday ranks, Rose Sundays, Holy Thursday white
Four fixes to internal/calendar/temporal_ef.go, all found by the strengthened oracle test (previous commit): 1. Sunday ranks (defect 4). The generic Sunday branch assigned class-2 to every Sunday except Advent I. RG 11-12 / RG 91 entry 6: every Sunday of Advent and every Sunday of Lent is I class (as are Passiontide's own two Sundays and Low Sunday, already correct via their named-feast cases). Fixes St Joseph wrongly taking a Sunday of Lent (defect 3, precedence_ef_repro_test.go's TestJosephYieldsToSundayOfLent) as a direct consequence: once Lent Sundays are I class, Joseph (also I class) no longer wins the tie outright and correctly transfers to the 20th via the existing RG 96 walk -- no separate code change was needed for defect 3. 2. Rose (defect 5). efColour had no Rose case at all; Gaudete (Advent III) and Laetare (Lent IV) now get Rose on that Sunday specifically (RG 131: rose vestments may be used "in Officio et Missa diei dominici tantum", for that Sunday's Office and Mass only), not the surrounding Sundays. 3. Holy Thursday's colour (defect 6). RG 128(b) names the Missa in Cena Domini as a whole-Mass exception to Passiontide's violet; RG 122 states the same fact affirmatively, in the White section itself. Good Friday and Holy Saturday, either side, are unchanged (still violet -- their own black/no-colour treatment is a separate, unmodelled gap, noted in precedence_ef.go's own doc comments). 4. Beyond the seven, found by the same strengthened test and fixed for the same RG 91 entry 18 reason defect 2 (next commit) relies on: the Ember days of Lent had no case in efEmberSlug at all (only September and Advent did), so they fell through to the ordinary III-class Lenten-feria rank instead of the II class RG 91 entry 18 requires. The late-Advent ferias (17-23 Dec, RG 91 entry 18) and the days within the Octave of the Nativity (26-31 Dec, RG 67-68) had no elevation at all, defaulting to III/IV class. All three are one-line, unambiguous, primary-cited additions to the same rank logic already being touched here -- left unfixed, the strengthened oracle test could only reach green by allow-listing them as if they were defensible divergences, which they are not. Witnesses (temporal_ef_test.go): TestTemporalEFSundayRanks, TestTemporalEFRoseSundays, TestTemporalEFHolyThursdayColour, TestTemporalEFEmberDayRanks. All fail before this commit; see the report for the exact pre-fix failure messages.
Diffstat (limited to 'internal')
-rw-r--r--internal/calendar/precedence_ef_repro_test.go45
-rw-r--r--internal/calendar/temporal_ef.go81
-rw-r--r--internal/calendar/temporal_ef_test.go92
3 files changed, 200 insertions, 18 deletions
diff --git a/internal/calendar/precedence_ef_repro_test.go b/internal/calendar/precedence_ef_repro_test.go
new file mode 100644
index 0000000..6c333d0
--- /dev/null
+++ b/internal/calendar/precedence_ef_repro_test.go
@@ -0,0 +1,45 @@
+package calendar_test
+
+// Reproduction tests for three named EF precedence defects (colitur's
+// differential/oracle audit against lectio's ~66-line EF precedence
+// approximation, internal/calendar/precedence_ef.go). Each test fails before
+// its fix and passes after -- see the report for the exact pre-fix failure
+// message. These exercise the REAL tridentine sanctoral data
+// (caldata.Tridentine()) through the public Compute entry point, since the
+// bugs are about how real fixed-date feasts interact with the temporal
+// calendar, not about precedenceEF's arithmetic in isolation (that is
+// covered separately, package-internal, in precedence_ef_test.go).
+
+import (
+ "testing"
+ "time"
+
+ "github.com/lukaszkasprzak/lectio/internal/caldata"
+ "github.com/lukaszkasprzak/lectio/internal/calendar"
+)
+
+func efCompute(date string) calendar.LiturgicalDay {
+ sel := calendar.DefaultSelection()
+ sel.Form = "old"
+ d, _ := time.Parse("2006-01-02", date)
+ return calendar.Compute(d.UTC(), sel, []calendar.Layer{caldata.Tridentine()})
+}
+
+// TestJosephYieldsToSundayOfLent: RG 91 entry 6 (Sundays of Lent, I class)
+// vs entry 11 (I-class feasts of the universal Church not above) -- the
+// Sunday holds; St Joseph (19 March) transfers to the next free day (RG 95,
+// RG 96), landing on the 20th. Before the fix (defect 4: Sunday ranks), Lent
+// Sundays were wrongly II class, so Joseph (I class) won outright every time
+// this collision occurred.
+func TestJosephYieldsToSundayOfLent(t *testing.T) {
+ for _, year := range []string{"2006", "2017", "2023", "2028", "2034", "2045"} {
+ sunday := efCompute(year + "-03-19")
+ if sunday.Observed.Slug == "joseph-spouse-of-the-bl-virgin-mary" {
+ t.Errorf("%s-03-19 observed = %q want the Lent Sunday (RG 91 entry 6 beats entry 11)", year, sunday.Observed.Slug)
+ }
+ next := efCompute(year + "-03-20")
+ if next.Observed.Slug != "joseph-spouse-of-the-bl-virgin-mary" {
+ t.Errorf("%s-03-20 observed = %q want joseph-spouse-of-the-bl-virgin-mary (transferred one day, RG 96)", year, next.Observed.Slug)
+ }
+ }
+}
diff --git a/internal/calendar/temporal_ef.go b/internal/calendar/temporal_ef.go
index 7420ebd..c649405 100644
--- a/internal/calendar/temporal_ef.go
+++ b/internal/calendar/temporal_ef.go
@@ -74,6 +74,16 @@ func efCel(season Season, slug string, col Colour, rank Rank, week int) temporal
}
}
+// efSunday is efCel plus the Sunday flag, for the temporal candidates that
+// are actual Sundays -- precedenceEF's tie-break needs to tell a Sunday (RG
+// 91 entry 15, wins its tie) apart from an equal-class privileged FERIA
+// (entry 18, the Ember/late-Advent days, which yields).
+func efSunday(season Season, slug string, col Colour, rank Rank, week int) temporalDay {
+ day := efCel(season, slug, col, rank, week)
+ day.Sunday = true
+ return day
+}
+
// temporalEF computes the 1962 temporal identity of date. Season is exact
// (oracle-validated); ranks/weeks are best-effort for display.
func temporalEF(date time.Time) temporalDay {
@@ -87,6 +97,15 @@ func temporalEF(date time.Time) temporalDay {
if !date.Before(easter.AddDate(0, 0, 50)) && !date.After(easter.AddDate(0, 0, 55)) {
col = Red
}
+ // Holy Thursday (the Mass of Chrism and the Mass in Cena Domini) is white,
+ // a whole-Mass exception to Passiontide's violet -- RG 128(b)'s own named
+ // exception list, and RG 122 stating the same fact affirmatively in the
+ // White section. Good Friday and Holy Saturday, either side, are NOT
+ // exceptions here (RG 132's black for Good Friday's liturgical action is a
+ // separate, unmodelled gap -- see precedence_ef.go's doc comments).
+ if sameDay(date, easter.AddDate(0, 0, -3)) {
+ col = White
+ }
sun := date.Weekday() == time.Sunday
// Major feasts of the Lord (I class): nice titles + colour.
@@ -129,8 +148,20 @@ func temporalEF(date time.Time) temporalDay {
if sun {
week := efWeek(date, season, y, easter)
rank := RankClass2
- if season == Advent && sameDay(date, adventStart(y)) {
- rank = RankClass1 // 1st Sunday of Advent
+ sundayColour := col
+ if season == Advent || season == Lent {
+ // RG 11-12 / RG 91 entry 6: every Sunday of Advent and every Sunday
+ // of Lent is I class -- not only Advent I, the sole case previously
+ // handled (Passiontide's own two Sundays, Passion and Palm, are
+ // already I class via the named-feast switch above; Low Sunday
+ // likewise).
+ rank = RankClass1
+ switch {
+ case season == Advent && week == 3:
+ sundayColour = Rose // Gaudete -- RG 131, that Sunday's Office/Mass only
+ case season == Lent && week == 4:
+ sundayColour = Rose // Laetare -- RG 131, that Sunday's Office/Mass only
+ }
}
// Resumed Sundays after Pentecost (1960 Rubrics): when Easter is early
// there are more than 24 Sundays after Pentecost. The LAST Sunday before
@@ -140,17 +171,17 @@ func temporalEF(date time.Time) temporalDay {
if season == TimeAfterPentecost {
lastSun := adventStart(y).AddDate(0, 0, -7)
if sameDay(date, lastSun) {
- return efCel(TimeAfterPentecost, "ef-time-after-pentecost-sunday-24", col, rank, 24)
+ return efSunday(TimeAfterPentecost, "ef-time-after-pentecost-sunday-24", col, rank, 24)
}
if week > 23 {
p := daysBetween(easter.AddDate(0, 0, 49), lastSun) / 7 // total Sundays after Pentecost
e := week - p + 7 // resumed Sunday after Epiphany
// Season stays time-after-pentecost (calendrical); the Epiphany
// slug only routes the readings to the resumed Mass.
- return efCel(TimeAfterPentecost, "ef-time-after-epiphany-sunday-"+strconv.Itoa(e), Green, rank, e)
+ return efSunday(TimeAfterPentecost, "ef-time-after-epiphany-sunday-"+strconv.Itoa(e), Green, rank, e)
}
}
- return efCel(season, "ef-"+string(season)+"-sunday-"+strconv.Itoa(week), col, rank, week)
+ return efSunday(season, "ef-"+string(season)+"-sunday-"+strconv.Itoa(week), sundayColour, rank, week)
}
// The days between Ash Wednesday and the 1st Sunday of Lent have their own
// proper Masses (not part of a numbered Lenten week).
@@ -166,6 +197,17 @@ func temporalEF(date time.Time) temporalDay {
// saint (e.g. the Conversion of St Paul, Jan 25) displaces them.
rank = RankClass3
}
+ if season == Advent && date.Month() == time.December && date.Day() >= 17 && date.Day() <= 23 {
+ // RG 91 entry 18: the late-Advent ferias (17-23 Dec) are II class
+ // privileged ferias, not the ordinary III class of the rest of Advent.
+ rank = RankClass2
+ }
+ if season == Christmas && date.Month() == time.December && date.Day() >= 26 {
+ // RG 67/68: "Dies infra octavam sunt II classis" -- days within the
+ // Octave of the Nativity (26-31 Dec) are II class; the octave day
+ // itself (1 Jan) is I class, already handled above as a named feast.
+ rank = RankClass2
+ }
if efPrivilegedFeria(date, easter) {
// The days of Holy Week and the privileged octaves of Easter and
// Pentecost are I class; no saint's feast is admitted (the feast is
@@ -173,12 +215,11 @@ func temporalEF(date time.Time) temporalDay {
// the temporal office win, matching the 1962 occurrence rules.
rank = RankClass1
}
- // The Ember Days of September and Advent (Wednesday, Friday, Saturday after
- // the third Sunday of the month/season) are II class privileged ferias in
- // violet; they displace a III class saint (only commemorated). The Lenten
- // Ember days are already III class Lenten ferias, and the Whitsun Ember days
- // fall inside the I class Pentecost octave handled above.
- if es, ok := efEmberSlug(date, y); ok {
+ // The Ember Days of September, Advent and Lent (Wednesday, Friday, Saturday
+ // after the anchor Sunday) are II class privileged ferias in violet; they
+ // displace a III class saint (only commemorated) -- RG 91 entry 18. The
+ // Whitsun Ember days fall inside the I class Pentecost octave handled above.
+ if es, ok := efEmberSlug(date, y, easter); ok {
return efCel(season, es, Violet, RankClass2, week)
}
// Unique ferial slug (season-week-weekday) so proper ferias (Lent, Advent,
@@ -197,22 +238,26 @@ func thirdSundayOfSeptember(y int) time.Time {
}
// efEmberSlug returns the Ember-day slug for date, if it is an Ember Wednesday,
-// Friday or Saturday of September or Advent (the day after the third Sunday +
-// 3/5/6). Returns ("", false) otherwise.
-func efEmberSlug(date time.Time, y int) (string, bool) {
+// Friday or Saturday of September, Advent or Lent (the day after the anchor
+// Sunday + 3/5/6: the third Sunday of September, Advent III, or Lent I --
+// MR1962, "De anno et eius partibus", Quatuor Tempora). Returns ("", false)
+// otherwise. The Whitsun Ember days are not listed here: they fall inside the
+// I class Pentecost octave, handled separately by efPrivilegedFeria.
+func efEmberSlug(date time.Time, y int, easter time.Time) (string, bool) {
for _, e := range []struct {
- third time.Time
+ anchor time.Time
season string
}{
{thirdSundayOfSeptember(y), "september"},
{adventStart(y).AddDate(0, 0, 14), "advent"}, // 3rd Sunday of Advent
+ {easter.AddDate(0, 0, -42), "lent"}, // 1st Sunday of Lent
} {
switch {
- case sameDay(date, e.third.AddDate(0, 0, 3)):
+ case sameDay(date, e.anchor.AddDate(0, 0, 3)):
return "ef-" + e.season + "-ember-wed", true
- case sameDay(date, e.third.AddDate(0, 0, 5)):
+ case sameDay(date, e.anchor.AddDate(0, 0, 5)):
return "ef-" + e.season + "-ember-fri", true
- case sameDay(date, e.third.AddDate(0, 0, 6)):
+ case sameDay(date, e.anchor.AddDate(0, 0, 6)):
return "ef-" + e.season + "-ember-sat", true
}
}
diff --git a/internal/calendar/temporal_ef_test.go b/internal/calendar/temporal_ef_test.go
index 6d632e2..8b1ffa5 100644
--- a/internal/calendar/temporal_ef_test.go
+++ b/internal/calendar/temporal_ef_test.go
@@ -38,3 +38,95 @@ func TestTemporalEFChristTheKing(t *testing.T) {
t.Errorf("efChristTheKing(2025) = %s want 2025-10-26", got)
}
}
+
+// TestTemporalEFSundayRanks: RG 11-12 / RG 91 entry 6 -- Sundays of Advent,
+// Lent and Passiontide, and Low Sunday, are I class; all others (including
+// Septuagesima's own three Sundays and the ordinary Sundays after Epiphany/
+// Pentecost) stay II class. Before the fix, only Advent I was I class; every
+// other Advent/Lent Sunday was left at the generic-Sunday II-class default.
+func TestTemporalEFSundayRanks(t *testing.T) {
+ cases := []struct {
+ date string
+ want Rank
+ }{
+ {"2025-11-30", RankClass1}, // Advent I
+ {"2025-12-07", RankClass1}, // Advent II
+ {"2025-12-14", RankClass1}, // Advent III (Gaudete)
+ {"2025-12-21", RankClass1}, // Advent IV
+ {"2025-03-09", RankClass1}, // Lent I
+ {"2025-03-16", RankClass1}, // Lent II
+ {"2025-03-23", RankClass1}, // Lent III
+ {"2025-03-30", RankClass1}, // Lent IV (Laetare)
+ {"2025-04-06", RankClass1}, // Passion Sunday
+ {"2025-04-13", RankClass1}, // Palm Sunday
+ {"2025-04-27", RankClass1}, // Low Sunday
+ {"2025-02-16", RankClass2}, // Septuagesima Sunday -- NOT I class
+ {"2025-02-23", RankClass2}, // Sexagesima Sunday -- NOT I class
+ {"2025-03-02", RankClass2}, // Quinquagesima Sunday -- NOT I class
+ {"2025-06-22", RankClass2}, // II Sunday after Pentecost -- ordinary II class
+ }
+ for _, c := range cases {
+ got := temporalEF(d(c.date))
+ if got.Cel.Rank != c.want {
+ t.Errorf("temporalEF(%s).Cel.Rank = %s, want %s (%s)", c.date, got.Cel.Rank, c.want, got.Cel.Slug)
+ }
+ }
+}
+
+// TestTemporalEFRoseSundays: RG 131 -- rose is permitted on Gaudete (Advent
+// III) and Laetare (Lent IV), for that Sunday's own Office and Mass only.
+// Before the fix, efColour had no Rose case at all.
+func TestTemporalEFRoseSundays(t *testing.T) {
+ if got := temporalEF(d("2025-12-14")).Cel.Colour; got != Rose {
+ t.Errorf("Gaudete (2025-12-14) colour = %s, want rose", got)
+ }
+ if got := temporalEF(d("2025-03-30")).Cel.Colour; got != Rose {
+ t.Errorf("Laetare (2025-03-30) colour = %s, want rose", got)
+ }
+ // The Advent/Lent Sundays either side stay violet -- rose is an indult for
+ // that one Sunday, not a season colour.
+ if got := temporalEF(d("2025-12-07")).Cel.Colour; got != Violet {
+ t.Errorf("Advent II (2025-12-07) colour = %s, want violet", got)
+ }
+ if got := temporalEF(d("2025-03-23")).Cel.Colour; got != Violet {
+ t.Errorf("Lent III (2025-03-23) colour = %s, want violet", got)
+ }
+}
+
+// TestTemporalEFHolyThursdayColour: RG 128(b)/RG 122 -- Holy Thursday's Mass
+// of Chrism and Mass in Cena Domini are white, a whole-day exception to
+// Passiontide's violet. Good Friday and Holy Saturday, either side, stay
+// violet (their own black/no-colour exceptions are a separate, unmodelled
+// gap -- see RG 132, precedence_ef.go's own doc comments).
+func TestTemporalEFHolyThursdayColour(t *testing.T) {
+ easter := d("2025-04-20")
+ holyThu := easter.AddDate(0, 0, -3)
+ goodFri := easter.AddDate(0, 0, -2)
+ holySat := easter.AddDate(0, 0, -1)
+ if got := temporalEF(holyThu).Cel.Colour; got != White {
+ t.Errorf("Holy Thursday colour = %s, want white", got)
+ }
+ if got := temporalEF(goodFri).Cel.Colour; got != Violet {
+ t.Errorf("Good Friday colour = %s, want violet (unchanged)", got)
+ }
+ if got := temporalEF(holySat).Cel.Colour; got != Violet {
+ t.Errorf("Holy Saturday colour = %s, want violet (unchanged)", got)
+ }
+}
+
+// TestTemporalEFEmberDayRanks: RG 91 entry 18 -- the Ember days of Lent (and,
+// unchanged, September and Advent) are II-class privileged ferias, not the
+// ordinary III-class Lenten ferias around them. Before the fix, efEmberSlug
+// had no Lent case at all, so Lent's Ember Wed/Fri/Sat fell through to the
+// ordinary III-class Lenten-feria rank.
+func TestTemporalEFEmberDayRanks(t *testing.T) {
+ // Lent I Sunday 2025 = 2025-03-09; Ember Wed/Fri/Sat = +3/+5/+6.
+ lentI := d("2025-03-09")
+ for _, off := range []int{3, 5, 6} {
+ day := lentI.AddDate(0, 0, off)
+ got := temporalEF(day)
+ if got.Cel.Rank != RankClass2 {
+ t.Errorf("Lent Ember day %s rank = %s, want class-2 (%s)", day.Format("2006-01-02"), got.Cel.Rank, got.Cel.Slug)
+ }
+ }
+}