From b4d44c88fe3c92c5fb06b43fe94be0049059ef6e Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Wed, 12 Aug 2026 13:46:01 +0200 Subject: calendar, precedence_ef: two more RG 91 gaps exposed by defect 4 Found during the mandated 2005-2050 before/after verification (not one of the seven, but caused by defect 4's own fix, so fixed in the same pass rather than left as a self-introduced regression). Neither is a hypothetical: both are live in the fixed calendar and both were individually confirmed via cmd/lectio-ef-dump before being written up as tests. 1. RG 91 entry 4 (Immaculate Conception, Assumption BVM) sits ABOVE entry 6 (Sundays of Advent/Lent/Passiontide) -- unlike an ORDINARY I-class feast (entry 11, e.g. St Joseph), the Immaculate Conception (8 December) is not impeded by an Advent Sunday at all. Before defect 4, Advent Sundays were wrongly II class, so this was accidentally right (I beats II outright, no tie reached); once Sundays became I class the tie-break mattered for the first time, and precedenceEF had no branch for it -- the Sunday wrongly won. 2. RG 91 entry 5 (Vigil & Octave day of the Nativity) sits above entry 6 the same way, and its own gap was worse than a wrong winner: the Vigil of Christmas (24 December) falling on Advent IV, once defect 4 made that a genuine I-class tie, sent the Vigil into transferIfImpededEF's forward walk -- which has no way to re-place a transfer crossing the Dec 31/Jan 1 boundary (celebrationDate re-resolves a fixed date using the YEAR OF THE DAY BEING QUERIED, so a walk landing in the following January can never match the query that produced it). The Vigil did not move to the wrong day; it vanished for the whole year, for every year 24 December is a Sunday (2006, 2017, 2023, 2028, 2034, 2045). Both fixed the same way as the existing II-class-feast-of-the-Lord bonus in precedenceEF (a one-line precedence adjustment keyed on slug), generalised into a single beatsClass1Sunday helper covering both RG 91 entries. A separate, unrelated bug surfaced by the SAME verification pass and fixed alongside it: transferIfImpededEF's destination check reused precedenceEF's tie-break BAND to decide "is this day I or II class", but band encodes a different question (which of two EQUAL-class candidates wins a tie) -- an ordinary, non-Sunday II-class temporal candidate YIELDS under defect 2's own fix (band 5), even though it is genuinely II class. A day within the Octave of the Nativity (26-31 Dec, RG 67) is exactly such a day, so band<=3 alone let a transfer wrongly land inside it (a real reproduction: "vigil-of-christmas" would have landed on 29 December 2006 instead of vanishing outright, caught while tracing the entry-5 bug above). Replaced with a direct class test (isHighClass), which is both correct and simpler -- it no longer needs the Sunday flag at all for this particular check. Witnesses (precedence_ef_repro_test.go): TestImmaculateConceptionBeatsAdventSunday, TestVigilOfChristmasSurvivesAdventSunday. Fail before this commit with: 2013-12-08 observed = "ef-advent-sunday-2" want immaculate-conception-of-the-blessed-virgin-mary (RG 91 entry 4 beats entry 6) 2006-12-24 observed = "ef-advent-sunday-4" want vigil-of-christmas (RG 91 entry 5 beats entry 6; must not vanish) (full set: 2013/2019/2024 for the first, 2006/2017/2023/2028 for the second) --- internal/calendar/calendar.go | 29 +++++++++++++++++--- internal/calendar/precedence_ef.go | 37 ++++++++++++++++++++++++- internal/calendar/precedence_ef_repro_test.go | 39 +++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index f364321..19c0efb 100644 --- a/internal/calendar/calendar.go +++ b/internal/calendar/calendar.go @@ -258,16 +258,39 @@ func transferIfImpededEF(cel Celebration, when time.Time, occupiedByClass1, occu if cel.Rank != RankClass1 { return 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) - startImpeded := startTemporal.Cel.Rank == RankClass1 || occupiedByClass1(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) - band := precedenceEF(candidate{Cel: b.Cel, Temporal: true, Season: b.Season, Sunday: b.Sunday}) - if band <= 3 || occupiedByClass1Or2(day) { + // 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 } diff --git a/internal/calendar/precedence_ef.go b/internal/calendar/precedence_ef.go index a1788be..9f5016f 100644 --- a/internal/calendar/precedence_ef.go +++ b/internal/calendar/precedence_ef.go @@ -1,6 +1,9 @@ package calendar -import "sort" +import ( + "sort" + "strings" +) // efRankOrder orders EF (1960) ranks: class-1 highest, then class-2..4, // commemoration, ferial lowest. @@ -69,10 +72,42 @@ func precedenceEF(c candidate) int { // falls on (unlike a saint's feast, which is only commemorated). Give it // the edge over the Sunday's tie-break bonus. p -= 2 + } else if c.Cel.Rank == RankClass1 && beatsClass1Sunday(c.Cel.Slug) { + // RG 91 entries 4 (Immaculate Conception, Assumption BVM) and 5 + // (Vigil & Octave day of the Nativity) both sit ABOVE entry 6 + // (Sundays of Advent/Lent/Passiontide, Low Sunday) -- unlike an + // ORDINARY I-class feast (entry 11, e.g. St Joseph, the Precious + // Blood), which yields to a I-class Sunday, these win the tie. + // Found while verifying defect 4 (Sunday ranks): before that fix, + // Advent/Lent Sundays were wrongly II class, so these feasts beat + // them outright on base class alone, accidentally right; once + // Sundays became I class the tie-break mattered, and without this + // branch the Sunday would wrongly win. Two live cases in the fixed + // calendar: the Immaculate Conception (8 December) on an Advent + // Sunday, and the Vigil of Christmas (24 December) on Advent IV -- + // the latter is not merely a wrong winner but a WORSE bug without + // this branch: transferIfImpededEF has no way to re-place a + // transfer that crosses the Dec31/Jan1 boundary (celebrationDate + // re-resolves a fixed date using the YEAR OF THE DAY BEING QUERIED, + // so a walk landing in January of the following year can never + // match the query that produced it), so the Vigil simply vanished + // for the year instead of landing on the wrong day. The Assumption + // (15 August) never falls in Advent or Lent, so its own share of + // this branch is citation-complete but not live. + p -= 2 } return p } +// beatsClass1Sunday reports whether slug is one of RG 91's entries 4 or 5 -- +// the Immaculate Conception, the Assumption, or the Vigil of Christmas -- +// all of which sit above entry 6's Sundays in the Table of Precedence. +func beatsClass1Sunday(slug string) bool { + return strings.Contains(slug, "immaculate-conception") || + strings.Contains(slug, "assumption-of-the-blessed-virgin-mary") || + slug == "vigil-of-christmas" +} + // pickEF returns the observed EF celebration and the commemorations, ordered // deterministically by precedence then slug. func pickEF(cands []candidate) (candidate, []candidate) { diff --git a/internal/calendar/precedence_ef_repro_test.go b/internal/calendar/precedence_ef_repro_test.go index 77919d9..09cadbd 100644 --- a/internal/calendar/precedence_ef_repro_test.go +++ b/internal/calendar/precedence_ef_repro_test.go @@ -81,3 +81,42 @@ func TestMatthewBeatsSeptemberEmberWednesday(t *testing.T) { } } } + +// TestImmaculateConceptionBeatsAdventSunday: found, not named among the +// seven, while verifying defect 4 (Sunday ranks) against the real sanctoral +// data. RG 91 entry 4 (Immaculate Conception, Assumption BVM) sits ABOVE +// entry 6 (Sundays of Advent/Lent/Passiontide) -- unlike an ORDINARY +// I-class feast (entry 11, e.g. St Joseph), the Immaculate Conception +// (8 December) is not impeded by the Advent Sunday it falls on at all. Before +// defect 4's own fix, Advent Sundays were wrongly II class, so this was +// accidentally right (I class beats II class outright); once Sundays became +// I class the tie mattered for the first time. +func TestImmaculateConceptionBeatsAdventSunday(t *testing.T) { + for _, year := range []string{"2013", "2019", "2024"} { + day := efCompute(year + "-12-08") + if day.Observed.Slug != "immaculate-conception-of-the-blessed-virgin-mary" { + t.Errorf("%s-12-08 observed = %q want immaculate-conception-of-the-blessed-virgin-mary (RG 91 entry 4 beats entry 6)", year, day.Observed.Slug) + } + } +} + +// TestVigilOfChristmasSurvivesAdventSunday: found, not named among the +// seven, while verifying defect 4 against the real sanctoral data -- and +// worse than a wrong winner. RG 91 entry 5 (Vigil & Octave day of the +// Nativity) also sits above entry 6, so the Vigil of Christmas (24 December) +// is not impeded by falling on Advent IV either. Without this, defect 4 +// alone would have made the Advent Sunday win a genuine tie, sending the +// Vigil into transferIfImpededEF's forward walk -- which has no way to +// re-place a transfer that crosses the Dec31/Jan1 boundary (celebrationDate +// re-resolves a fixed date using the year of whatever day is being queried, +// so a walk landing in the following January can never match the query that +// produced it). The Vigil did not move to the wrong day; it vanished for the +// whole year. +func TestVigilOfChristmasSurvivesAdventSunday(t *testing.T) { + for _, year := range []string{"2006", "2017", "2023", "2028"} { + day := efCompute(year + "-12-24") + if day.Observed.Slug != "vigil-of-christmas" { + t.Errorf("%s-12-24 observed = %q want vigil-of-christmas (RG 91 entry 5 beats entry 6; must not vanish)", year, day.Observed.Slug) + } + } +} -- cgit v1.3