aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 13:06:18 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 13:06:18 +0200
commit61a2d658fea1c9a1640cb54bb203a8441bbb17a9 (patch)
tree184fa9834d91e2f6c773a5a7e40af14c85347168
parent00a32e549493b0fb51ae23e8a5e73aa54a5205b1 (diff)
downloadlectio-61a2d658fea1c9a1640cb54bb203a8441bbb17a9.tar.gz
lectio-61a2d658fea1c9a1640cb54bb203a8441bbb17a9.zip
precedence_ef: a II-class privileged feria yields to a feast (defect 2)
precedenceEF's equal-class tie-break only distinguished "ordinary III/IV-class feria yields" from "everything else wins" -- Sundays, named feasts, Lent/Passiontide ferias, AND II-class privileged ferias (the Ember days, the late-Advent 17-23 Dec ferias) were all bucketed into the same "wins its tie" branch. RG 91 disagrees at class 2: entry 15 (Sundays) sits ABOVE entry 16 (II-class feasts of the universal Church), so a Sunday wins -- but entry 16 sits ABOVE entry 18 (II-class ferias, including the Ember days), so those FERIAS yield instead, the opposite direction from a Sunday. St Matthew (21 September, II class) was losing to the September Ember Wednesday every time the two coincided; St Thomas (21 December, II class) was losing to an ordinary late-Advent feria the same way once the previous commit correctly promoted those ferias to II class. The fix distinguishes a II-class Sunday from a II-class feria using a Sunday flag that already existed on `candidate` (used by the OF path) but was never wired up for EF: temporal_ef.go's efCel always set Sunday: false, even on an actual Sunday. A new efSunday helper (efCel plus the flag) replaces the three efCel calls inside temporalEF's Sunday branch, and computeEF now propagates td.Sunday into the day's temporal candidate. precedence_ef_test.go's own pre-existing witness ("at equal class, the temporal office wins") encoded exactly the bug: a bare class-2 temporal candidate with no Sunday/season information, standing in for "the temporal office" in general. It is rewritten into two explicit cases (Sunday wins its tie; a privileged feria yields) plus the existing Lent/Passiontide-vs-ordinary III/IV-class case restated explicitly rather than left implicit. Witness (precedence_ef_repro_test.go): TestMatthewBeatsSeptemberEmberWednesday. Fails before this commit with: 2016-09-21 observed = "ef-september-ember-wed" want matthew (RG 91 entry 16 beats entry 18) 2022-09-21 observed = "ef-september-ember-wed" want matthew (RG 91 entry 16 beats entry 18) With this commit, all seven named defects are fixed: LECTIO_EF_ORACLE_STRICT=1 go test ./internal/calendar/... -run TestOracleEF passes (0 unallow-listed rank/colour mismatches over 730 days). The gate stays in place for this commit; a following commit removes it.
-rw-r--r--internal/calendar/precedence_ef.go49
-rw-r--r--internal/calendar/precedence_ef_repro_test.go16
-rw-r--r--internal/calendar/precedence_ef_test.go67
3 files changed, 110 insertions, 22 deletions
diff --git a/internal/calendar/precedence_ef.go b/internal/calendar/precedence_ef.go
index 4fda626..a1788be 100644
--- a/internal/calendar/precedence_ef.go
+++ b/internal/calendar/precedence_ef.go
@@ -22,24 +22,47 @@ func efRankOrder(r Rank) int {
}
// precedenceEF ranks an EF candidate for occurrence (lower = higher precedence):
-// by class first, then a tie-break at equal class (1960 occurrence table).
+// by class first, then a tie-break at equal class (RG 91's Table of
+// Precedence, read entry-by-entry within each class).
//
-// The tie-break is asymmetric by season. An ORDINARY feria (per annum, Advent,
-// Septuagesima) yields to an equal-class feast — the feast is celebrated and the
-// feria commemorated (e.g. St Francis Xavier, Dec 3, on an Advent feria). The
-// ferias of Lent and Passiontide are privileged: they outrank an equal-class
-// feast, which is only commemorated. Sundays and named temporal feasts likewise
-// win their ties. The *2 class spacing means the ±1 tie-break never crosses a
-// class boundary.
+// The tie-break is asymmetric by season AND, at class 2, by whether the
+// temporal day is a Sunday or a privileged FERIA:
+// - III/IV class: an ORDINARY feria (per annum, Advent to 16 Dec,
+// Septuagesima) yields to an equal-class feast (entries 24/25 above the
+// feria) -- the feast is celebrated, the feria commemorated (e.g. St
+// Francis Xavier, Dec 3). The ferias of Lent and Passiontide are
+// privileged (entry 22, ABOVE entry 24): they outrank an equal-class
+// feast, which is only commemorated.
+// - II class: an ordinary SUNDAY wins its tie (entry 15, above entry 16's
+// feasts) -- but a II-class privileged FERIA (the Ember days, the
+// late-Advent 17-23 Dec ferias, entry 18) sits BELOW entry 16 and yields
+// to an equal-class feast, the opposite of a Sunday's own tie. This is
+// the same table shape as III class's Advent/per-annum ferias, just
+// inverted for which II-class temporal days count as "privileged".
+// - I class: Sundays (entry 6) and I-class ferias (entry 7, Ash
+// Wednesday/Holy Week) both sit above entry 11's feasts, so they always
+// win -- there is no I-class "ordinary feria yields" case at all.
+//
+// The *2 class spacing means the ±1 tie-break never crosses a class boundary.
func precedenceEF(c candidate) int {
p := (6 - efRankOrder(c.Cel.Rank)) * 2 // class-1 -> 2, ferial -> 12
if c.Temporal {
- ordinaryFeria := (c.Cel.Rank == RankClass3 || c.Cel.Rank == RankClass4) &&
- c.Season != Lent && c.Season != Passiontide
- if ordinaryFeria {
- p++ // an ordinary feria yields to an equal-class feast
+ yieldsToFeast := false
+ switch c.Cel.Rank {
+ case RankClass3, RankClass4:
+ yieldsToFeast = c.Season != Lent && c.Season != Passiontide
+ case RankClass2:
+ // A II-class Sunday (entry 15) wins; a II-class privileged feria --
+ // Ember days, the late-Advent 17-23 Dec ferias (entry 18) -- yields
+ // to an equal-class feast (entry 16), e.g. St Matthew (21 Sep)
+ // beating the September Ember Wednesday, or St Thomas (21 Dec)
+ // beating an Advent late feria.
+ yieldsToFeast = !c.Sunday
+ }
+ if yieldsToFeast {
+ p++
} else {
- p-- // Sundays, named feasts, and penitential ferias win their ties
+ p-- // Sundays, I-class ferias, and penitential (Lent/Passiontide) ferias win their ties
}
} else if c.Cel.Class == ClassLord && c.Cel.Rank == RankClass2 {
// A II class feast of the Lord takes the place of a II class Sunday it
diff --git a/internal/calendar/precedence_ef_repro_test.go b/internal/calendar/precedence_ef_repro_test.go
index 1f38996..77919d9 100644
--- a/internal/calendar/precedence_ef_repro_test.go
+++ b/internal/calendar/precedence_ef_repro_test.go
@@ -65,3 +65,19 @@ func TestTransferSkipsBothIAndIIClass(t *testing.T) {
t.Errorf("2011-07-04 observed = %q want precious-blood-of-our-lord-jesus-christ (RG 96: first day that is neither I nor II class)", got)
}
}
+
+// TestMatthewBeatsSeptemberEmberWednesday: RG 91 -- St Matthew (21 September,
+// II class) and the September Ember Wednesday are both II class; the table
+// decides the tie. Entry 16 (II-class feasts of the universal Church) sits
+// ABOVE entry 18 (II-class ferias, including the Ember days) -- the feast
+// wins, the Ember feria is only commemorated.
+func TestMatthewBeatsSeptemberEmberWednesday(t *testing.T) {
+ // 21 September falls on the September Ember Wednesday whenever the third
+ // Sunday of September is the 18th -- 2016 and 2022 both qualify.
+ for _, date := range []string{"2016-09-21", "2022-09-21"} {
+ day := efCompute(date)
+ if day.Observed.Slug != "matthew" {
+ t.Errorf("%s observed = %q want matthew (RG 91 entry 16 beats entry 18)", date, day.Observed.Slug)
+ }
+ }
+}
diff --git a/internal/calendar/precedence_ef_test.go b/internal/calendar/precedence_ef_test.go
index 818b452..d5e254f 100644
--- a/internal/calendar/precedence_ef_test.go
+++ b/internal/calendar/precedence_ef_test.go
@@ -6,6 +6,14 @@ func efCand(rank Rank, temporal bool) candidate {
return candidate{Cel: Celebration{Rank: rank}, Temporal: temporal}
}
+// efTemporalCand builds a temporal candidate with the season/Sunday flags
+// precedenceEF's tie-break actually reads, so a test claiming to exercise
+// "a Sunday" or "a Lenten feria" genuinely sets those fields rather than
+// happening to pass for an unrelated reason (e.g. a class difference alone).
+func efTemporalCand(rank Rank, season Season, sunday bool) candidate {
+ return candidate{Cel: Celebration{Rank: rank}, Temporal: true, Season: season, Sunday: sunday}
+}
+
func TestPrecedenceEF(t *testing.T) {
c1 := efCand(RankClass1, false)
c3 := efCand(RankClass3, false)
@@ -14,16 +22,57 @@ func TestPrecedenceEF(t *testing.T) {
if !(precedenceEF(c1) < precedenceEF(c3) && precedenceEF(c3) < precedenceEF(c4) && precedenceEF(c4) < precedenceEF(comm)) {
t.Error("EF class ordering broken (class-1 > class-3 > class-4 > commemoration)")
}
- // at equal class, the temporal office wins.
- temp := efCand(RankClass2, true)
+ // a I-class feast beats a II-class Sunday (RG 91 entry 11 vs entry 15 --
+ // different classes, so this holds regardless of the tie-break, but the
+ // candidate is still marked Sunday so the test means what its name says).
+ sunday2 := efTemporalCand(RankClass2, TimeAfterPentecost, true)
+ feast1 := efCand(RankClass1, false)
+ obs, others := pickEF([]candidate{sunday2, feast1})
+ if obs.Cel.Rank != RankClass1 || len(others) != 1 {
+ t.Errorf("I-class feast should win over II-class Sunday, got %+v", obs.Cel)
+ }
+}
+
+// TestPrecedenceEFClass2SundayVsFeria: RG 91 entries 15/16/18 -- at class 2,
+// a SUNDAY wins its tie against an equal-class feast (entry 15 above 16), but
+// a privileged FERIA (the Ember days, the late-Advent 17-23 Dec ferias, entry
+// 18) YIELDS to one (entry 16 above 18) -- the opposite of the Sunday case.
+// This is defect 2's own witness: before the fix, precedenceEF treated every
+// non-ordinaryFeria temporal candidate the same way, so an Ember/late-Advent
+// feria wrongly won its tie exactly like a Sunday does.
+func TestPrecedenceEFClass2SundayVsFeria(t *testing.T) {
saint := efCand(RankClass2, false)
- obs, _ := pickEF([]candidate{saint, temp})
- if !obs.Temporal {
- t.Error("equal-class: temporal office should be observed")
+
+ sunday := efTemporalCand(RankClass2, TimeAfterPentecost, true)
+ obsSunday, _ := pickEF([]candidate{saint, sunday})
+ if !obsSunday.Temporal {
+ t.Errorf("a II-class Sunday should win its tie against an equal-class feast, got %+v observed", obsSunday.Cel)
}
- // a I-class feast beats a II-class Sunday.
- obs2, others := pickEF([]candidate{efCand(RankClass2, true), efCand(RankClass1, false)})
- if obs2.Cel.Rank != RankClass1 || len(others) != 1 {
- t.Errorf("I-class feast should win over II-class Sunday, got %+v", obs2.Cel)
+
+ emberFeria := efTemporalCand(RankClass2, TimeAfterPentecost, false) // e.g. the September Ember Wednesday
+ obsFeria, _ := pickEF([]candidate{saint, emberFeria})
+ if obsFeria.Temporal {
+ t.Errorf("a II-class privileged feria should yield its tie to an equal-class feast, got %+v observed (temporal)", obsFeria.Cel)
+ }
+}
+
+// TestPrecedenceEFClass3FeriaSeason: the pre-existing rule (unchanged by
+// defect 2's fix), stated explicitly rather than left implicit: an ORDINARY
+// III/IV-class feria (per annum, Advent to 16 Dec, Septuagesima) yields to an
+// equal-class feast; the ferias of Lent and Passiontide are privileged and
+// win instead.
+func TestPrecedenceEFClass3FeriaSeason(t *testing.T) {
+ saint := efCand(RankClass3, false)
+
+ ordinary := efTemporalCand(RankClass3, Advent, false)
+ obsOrdinary, _ := pickEF([]candidate{saint, ordinary})
+ if obsOrdinary.Temporal {
+ t.Errorf("an ordinary Advent feria should yield to an equal-class feast, got %+v observed (temporal)", obsOrdinary.Cel)
+ }
+
+ lenten := efTemporalCand(RankClass3, Lent, false)
+ obsLenten, _ := pickEF([]candidate{saint, lenten})
+ if !obsLenten.Temporal {
+ t.Errorf("a Lenten feria should win its tie against an equal-class feast, got %+v observed", obsLenten.Cel)
}
}