From 27acee1930dc728b286c655cb66bd5e44804df6f Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Tue, 18 Aug 2026 16:02:17 +0200 Subject: fix(ef): Christmas Time runs to 13 January (RG 72) RG 72: "Tempus natalicium decurrit a I Vesperis Nativitatis Domini usque ad diem 13 ianuarii INCLUSIVE." 6-13 January is the Epiphany section OF Christmas Time, not the start of Time after Epiphany. RG 119(a) backs the same boundary from the colour side, white "usque ad expletum tempus Epiphaniae". lectio ended Christmas Time on 5 January, so eight days a year came out green that should be white, and Epiphany itself reported the wrong season. NOT a one-line boundary change, which is why an earlier attempt was reverted rather than shipped. efSeason both named the slugs and reported the season, so moving the boundary renamed every slug in the window -- ef-time-after-epiphany-* became ef-christmas-* -- and orphaned the ef-time-after-epiphany-sunday-1 lectionary key. The two are now separate axes: efSlugSeason names slugs and numbers weeks; unchanged behaviour efSeason the liturgical season, which the colour follows; RG 72's The split is not a new idea in this file. The resumed-Sundays branch already does exactly this and says so -- "Season stays time-after-pentecost (calendrical); the Epiphany slug only routes the readings" -- it simply had no name, so the one place that needed it could not reuse it. Result, checked against colitur across all 365 days of 2026: SEASON now agrees on every single day, where it previously differed on eight. Colour differences drop to nine, every one a Saturday and every one the BVM Saturday Office (RG 78, colitur C17) which lectio does not build. Slugs and week numbers are byte-identical to before. The oracle's season axis now consults the allow-list and excludes excused days from its threshold. Neither was true before, because no cited divergence had ever BEEN a season difference -- the January window showed up as a colour one, since the wrong season produced the wrong colour too. With the season right, what is left is a season difference against missalemeum, and an axis whose threshold is a t.Fatalf would have failed regardless of any allow-list. TestTemporalEFChristmastideBoundary pins the boundary AND the split, mutation- tested both ways: reverting the boundary reddens it, and re-merging the two seasons so the slug is built from the liturgical one reddens it differently, naming the orphaned lectionary key. --- internal/calendar/oracle_ef_test.go | 37 ++++++++++++++++++----- internal/calendar/temporal_ef.go | 48 +++++++++++++++++++++++++---- internal/calendar/temporal_ef_test.go | 57 +++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 13 deletions(-) (limited to 'internal') diff --git a/internal/calendar/oracle_ef_test.go b/internal/calendar/oracle_ef_test.go index b74d935..b765a34 100644 --- a/internal/calendar/oracle_ef_test.go +++ b/internal/calendar/oracle_ef_test.go @@ -249,11 +249,18 @@ var efAllowList = []efAllow{ // defects, and not touched here: efSeason is a season-boundary // function with much wider reach than any of the seven, and changing // it is out of this task's scope. + // CORRECTED 2026-08-18: lectio now follows RG 72 and reads 6-13 January + // as Christmas Time, so this divergence has CHANGED SIDES. It is + // missalemeum that calls those days "time after Epiphany" now, and + // what this excuses is a SEASON difference rather than the colour one + // it was written for -- the colour follows correctly once the season + // is right, which is why the six colour rows this used to cover are + // simply gone. match: func(date string, day time.Time, o efOracleDay) bool { return day.Month() == time.January && day.Day() >= 6 && day.Day() <= 13 }, - field: "colour", - reason: "RG 72-73/RG 119(a): 6-13 Jan is Christmastide, not lectio's time-after-Epiphany reading (efSeason, not one of the seven defects; see colitur rules-register.md §3c item 1)", + field: "season", + reason: "RG 72: Christmas Time runs 'usque ad diem 13 ianuarii inclusive', so 6-13 Jan is the Epiphany section OF Christmas Time; missalemeum reads it as time after Epiphany (colitur C1)", }, { // RG 33: "Vigilia II aut III classis penitus omittitur, si occurrat in @@ -330,7 +337,7 @@ func TestOracleEF(t *testing.T) { } sort.Strings(dates) - var seasonMiss, rankMiss, colourMiss, rankAllowed, colourAllowed, skipped, total int + var seasonMiss, seasonAllowed, rankMiss, colourMiss, rankAllowed, colourAllowed, skipped, total int shownSeason, shownRank, shownColour := 0, 0, 0 for _, date := range dates { od := oracle[date] @@ -356,10 +363,25 @@ func TestOracleEF(t *testing.T) { if want == "" { skipped++ } else if got.Season != want { - seasonMiss++ - if shownSeason < 40 { - t.Errorf("%s: EF season got %q want %q (from %q)", date, got.Season, want, src) - shownSeason++ + // The season axis consults the allow-list, and an excused day is + // NOT counted in seasonMiss. Neither was true before: until the + // RG 72 boundary was fixed no cited divergence had ever been a + // SEASON one -- the 6-13 January window showed up as a colour + // difference, because the wrong season produced the wrong colour + // too. With the season right the colour follows, and what is left + // is a season difference with no way to say "this one is known". + // Counting an excused day in seasonMiss would be worse than + // useless: the threshold below is a t.Fatalf, so the allow-list + // would print a reason and fail the suite anyway. + if ok, why := efAllowed(date, day, od, "season"); ok { + seasonAllowed++ + t.Logf("[allow-listed] %s: EF season got %q want %q — %s", date, got.Season, want, why) + } else { + seasonMiss++ + if shownSeason < 40 { + t.Errorf("%s: EF season got %q want %q (from %q)", date, got.Season, want, src) + shownSeason++ + } } } @@ -419,6 +441,7 @@ func TestOracleEF(t *testing.T) { t.Logf("EF oracle: %d checked, %d skipped(unmapped), %d season mismatches, "+ "%d rank mismatches (%d allow-listed), %d colour mismatches (%d allow-listed)", total, skipped, seasonMiss, rankMiss, rankAllowed, colourMiss, colourAllowed) + t.Logf("EF oracle: %d season mismatches allow-listed", seasonAllowed) if seasonMiss > 0 { t.Fatalf("%d/%d EF season mismatches vs missalemeum — temporalEF is wrong", seasonMiss, total) } diff --git a/internal/calendar/temporal_ef.go b/internal/calendar/temporal_ef.go index 3d6c72c..e8630b9 100644 --- a/internal/calendar/temporal_ef.go +++ b/internal/calendar/temporal_ef.go @@ -35,7 +35,13 @@ func efColour(s Season) Colour { } // efSeason returns the 1962 season for date, by chronological date boundaries. -func efSeason(date time.Time, y int, easter time.Time) Season { +// efSlugSeason is the season used to NAME slugs and NUMBER weeks. It is not +// always the liturgical season: 6-13 January is liturgically Christmas Time +// (RG 72, see efSeason) while its days are still named and numbered from +// Epiphany, which is what the lectionary keys and every downstream consumer +// expect. Splitting the two is the whole point -- before this, one function +// served both and the wrong boundary in one was the wrong boundary in both. +func efSlugSeason(date time.Time, y int, easter time.Time) Season { adventThis := adventStart(y) christmasThis := time.Date(y, 12, 25, 0, 0, 0, 0, time.UTC) jan6 := time.Date(y, 1, 6, 0, 0, 0, 0, time.UTC) @@ -86,10 +92,34 @@ func efSunday(season Season, slug string, col Colour, rank Rank, week int) tempo // temporalEF computes the 1962 temporal identity of date. Season is exact // (oracle-validated); ranks/weeks are best-effort for display. +// efSeason is the LITURGICAL season, which is what the colour follows. +// +// It differs from efSlugSeason on exactly one window. RG 72: "Tempus +// natalicium decurrit a I Vesperis Nativitatis Domini usque ad diem 13 +// ianuarii INCLUSIVE" -- Christmas Time runs to 13 January, so 6-13 January is +// the Epiphany SECTION OF Christmas Time, not the start of Time after +// Epiphany. RG 119(a) backs the same boundary from the colour side, white +// "usque ad expletum tempus Epiphaniae". Those eight days were coming out +// green because this distinction did not exist. +// +// The slug season is deliberately left alone there: the days keep their +// Epiphany names and week numbers, which is what the lectionary keys and the +// generated downstream tables are built on. colitur, which models the two +// axes separately from the start, does the same -- season christmastide, +// slug ef-time-after-epiphany-sunday-1. +func efSeason(date time.Time, y int, easter time.Time) Season { + if s := efSlugSeason(date, y, easter); s == TimeAfterEpiphany && + date.Month() == time.January && date.Day() <= 13 { + return Christmas + } + return efSlugSeason(date, y, easter) +} + func temporalEF(date time.Time) temporalDay { date = date.UTC().Truncate(24 * time.Hour) y := date.Year() easter := Easter(y) + slugSeason := efSlugSeason(date, y, easter) season := efSeason(date, y, easter) col := efColour(season) // The Octave of Pentecost (Whit Monday through the Ember Saturday, easter+50 @@ -130,7 +160,9 @@ func temporalEF(date time.Time) temporalDay { case date.Month() == time.January && date.Day() == 1: return efCel(Christmas, "ef-circumcision", White, RankClass1, 0) case date.Month() == time.January && date.Day() == 6: - return efCel(TimeAfterEpiphany, "ef-epiphany", White, RankClass1, 0) + // Christmas, not TimeAfterEpiphany: RG 72 puts Epiphany INSIDE Christmas + // Time, which runs to 13 January. The slug is unchanged. + return efCel(Christmas, "ef-epiphany", White, RankClass1, 0) case sameDay(date, easter.AddDate(0, 0, -46)): return efCel(Lent, "ef-ash-wednesday", Violet, RankClass1, 0) case sameDay(date, easter.AddDate(0, 0, -14)): @@ -161,7 +193,7 @@ func temporalEF(date time.Time) temporalDay { // Sundays vs ferias of the current season. if sun { - week := efWeek(date, season, y, easter) + week := efWeek(date, slugSeason, y, easter) rank := RankClass2 sundayColour := col if season == Advent || season == Lent { @@ -196,14 +228,18 @@ func temporalEF(date time.Time) temporalDay { return efSunday(TimeAfterPentecost, "ef-time-after-epiphany-sunday-"+strconv.Itoa(e), Green, rank, e) } } - return efSunday(season, "ef-"+string(season)+"-sunday-"+strconv.Itoa(week), sundayColour, rank, week) + // Season reported liturgically, slug named from the slug season -- the + // same split the resumed-Sunday branch just above already makes for + // its own reason ("Season stays time-after-pentecost; the Epiphany + // slug only routes the readings"). + return efSunday(season, "ef-"+string(slugSeason)+"-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). if season == Lent && date.Before(easter.AddDate(0, 0, -42)) { return efCel(Lent, "ef-lent-after-ashes-"+weekdayLower(date), Violet, RankClass3, 0) } - week := efWeek(date, season, y, easter) + week := efWeek(date, slugSeason, y, easter) rank := RankClass4 // per-annum feria if season == Advent || season == Lent || season == Passiontide { // III class ferias (1960 rubrics): Advent to Dec 16, and Lent/Passiontide. @@ -240,7 +276,7 @@ func temporalEF(date time.Time) temporalDay { // Unique ferial slug (season-week-weekday) so proper ferias (Lent, Advent, // Holy Week, Ember days) can key their own readings; green-season ferias // simply have no lectionary entry and fall back to the Sunday. - return efCel(season, "ef-"+string(season)+"-"+strconv.Itoa(week)+"-"+weekdayLower(date), col, rank, week) + return efCel(season, "ef-"+string(slugSeason)+"-"+strconv.Itoa(week)+"-"+weekdayLower(date), col, rank, week) } // thirdSundayOfSeptember returns the third Sunday in September of year y. diff --git a/internal/calendar/temporal_ef_test.go b/internal/calendar/temporal_ef_test.go index db6e6aa..7dd7400 100644 --- a/internal/calendar/temporal_ef_test.go +++ b/internal/calendar/temporal_ef_test.go @@ -147,3 +147,60 @@ func TestTemporalEFEmberDayRanks(t *testing.T) { } } } + +// TestTemporalEFChristmastideBoundary pins RG 72's boundary and, just as +// importantly, the SEPARATION it required. +// +// RG 72: "Tempus natalicium decurrit a I Vesperis Nativitatis Domini usque ad +// diem 13 ianuarii INCLUSIVE." Christmas Time runs to 13 January, so 6-13 +// January is the Epiphany section OF Christmas Time, not the beginning of Time +// after Epiphany. RG 119(a) backs it from the colour side, white "usque ad +// expletum tempus Epiphaniae". +// +// lectio ended Christmas Time on 5 January, so those eight days came out +// green. The fix could not be the boundary constant alone: efSeason both named +// the slugs and reported the season, so moving it renamed every slug in the +// window and orphaned the ef-time-after-epiphany-sunday-1 lectionary key. The +// season and the slug are now separate axes -- efSeason and efSlugSeason -- +// and this test pins BOTH, because a future "simplification" that merges them +// again would pass any test that checked only one. +func TestTemporalEFChristmastideBoundary(t *testing.T) { + for _, c := range []struct { + date, season, slug string + }{ + // Epiphany itself is inside Christmas Time, not the start of what + // follows it. Its slug is unchanged. + {"2026-01-06", "christmas", "ef-epiphany"}, + // The window: season christmas, slugs and week numbers still Epiphany's. + {"2026-01-07", "christmas", "ef-time-after-epiphany-1-wednesday"}, + {"2026-01-11", "christmas", "ef-time-after-epiphany-sunday-1"}, + {"2026-01-12", "christmas", "ef-time-after-epiphany-1-monday"}, + // 13 January is INCLUSIVE -- the last day of Christmas Time. It carries + // the Commemoration of the Baptism, so only the season is asserted. + {"2026-01-13", "christmas", ""}, + // 14 January is the first day that is genuinely Time after Epiphany. + {"2026-01-14", "time-after-epiphany", ""}, + // The other end, unchanged: 5 January is Christmas Time by the old + // boundary too, so a regression that moved the boundary the wrong way + // would not show here -- which is why 14 January above is asserted. + {"2026-01-05", "christmas", ""}, + } { + got := temporalEF(d(c.date)) + if string(got.Season) != c.season { + t.Errorf("%s: season = %q, want %q (RG 72: Christmas Time runs to 13 January inclusive)", + c.date, got.Season, c.season) + } + if c.slug != "" && got.Cel.Slug != c.slug { + t.Errorf("%s: slug = %q, want %q -- the SLUG season must stay Epiphany's here; "+ + "if this fails with an ef-christmas-* slug, efSeason and efSlugSeason have been "+ + "merged again and the lectionary keys are orphaned", c.date, got.Cel.Slug, c.slug) + } + } + // The colour follows from the season, and is the reason any of this + // matters: eight days a year were green that should be white. + for _, day := range []string{"2026-01-07", "2026-01-09", "2026-01-12"} { + if got := temporalEF(d(day)).Cel.Colour; got != White { + t.Errorf("%s: colour = %s, want white (RG 119(a), following from the season)", day, got) + } + } +} -- cgit v1.3