aboutsummaryrefslogtreecommitdiff
path: root/internal/calendar/oracle_ef_test.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 17:15:03 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 17:15:03 +0200
commit3b32c002d3eddda5ece9422442717657b9fee63b (patch)
tree5dd82811c100de6daa995bebd115a12434296508 /internal/calendar/oracle_ef_test.go
parent2386a4551aec94252168a8554269ee423d516882 (diff)
parentc655c525ef07deafb113a291489b396c81d7ce4a (diff)
downloadlectio-3b32c002d3eddda5ece9422442717657b9fee63b.tar.gz
lectio-3b32c002d3eddda5ece9422442717657b9fee63b.zip
Merge branch 'fix/ef-precedence-defects': seven EF calendar defects
Fixes seven defects in the EF calendar, each found by differential and oracle comparison against an independently-written engine and each backed by a cited paragraph of the Rubricae Generales verified against the 1962 Missal: RG 96 transfers must skip II-class days (the Precious Blood was landing on the Visitation) RG 91 a II-class privileged feria yields to an equal-class feast (St Matthew was losing to the September Ember Wednesday) RG 11 all Sundays of Advent, Lent and Passiontide are I class (only Advent I was), which also settles St Joseph on a Lent Sunday RG 131 Rose on Gaudete and Laetare, which the EF path never emitted RG 122 Holy Thursday is white and the sanctoral generator itself, whose rank inference had tagged 15 III-class feasts as commemorations, dropped four entries to slug collisions, and mis-tagged three class fields. The EF oracle test asserted Season only, which is why none of this ever failed. It now asserts rank and colour, with a cited allow-list. Two commemorations had been deleted on a primary-source claim the primary source contradicts: the Archivum Liturgicum transcription silently drops commemoration lines where the photographic scans carry them. Six of the seven affected dates are now present; the seventh (25 December) is blocked by an ingestion limit that reads only the first of three Christmas Masses, recorded at its cause. Known gap, recorded in source: RG 95/97/98 chained transfers, where two I-class feasts translate to the same day.
Diffstat (limited to 'internal/calendar/oracle_ef_test.go')
-rw-r--r--internal/calendar/oracle_ef_test.go259
1 files changed, 242 insertions, 17 deletions
diff --git a/internal/calendar/oracle_ef_test.go b/internal/calendar/oracle_ef_test.go
index ce4badd..6d63220 100644
--- a/internal/calendar/oracle_ef_test.go
+++ b/internal/calendar/oracle_ef_test.go
@@ -12,11 +12,51 @@ import (
"github.com/lukaszkasprzak/lectio/internal/calendar"
)
+// efOracleDay is one day of missalemeum's per-date proper API, as captured in
+// sources/snapshot.tar.gz (missalemeum/en/YYYY-MM-DD.json, 2026-01-01 ..
+// 2027-12-31, 730 days) and flattened by scripts/build-oracle-ef.sh.
+//
+// Three things about this data that will otherwise cost hours (see that
+// script's own header for the full account):
+//
+// 1. ID looks like "sancti:MM-DD:rank:colour", but its embedded rank is the
+// rank of the PROPERS USED that day, not the day's own rank -- e.g.
+// 2026-01-02 is a class-4 feria carrying id "sancti:01-01:1:w" because it
+// reuses the Circumcision's propers. Kept here for provenance/debugging
+// ONLY. Rank and Colour comparisons below use Rank/Colours, never ID.
+// 2. Colours is an array -- 14 of 730 days carry two values (Gaudete/Laetare
+// "pv", Palm Sunday "rv", Good Friday "bv", Holy Saturday "vw") -- so the
+// comparison is membership, not equality.
+// 3. A two-colour value on a weekday can be an artifact of proper reuse (a
+// feria inside Gaudete/Laetare week reusing the Sunday's own propers)
+// rather than a claim about that weekday's own colour. No special-casing
+// is needed for this: lectio's single ferial colour (violet) is already a
+// member of the reused set (e.g. ["p","v"]), so a plain membership check
+// absorbs it.
type efOracleDay struct {
- Tempora string `json:"tempora"`
- Title string `json:"title"`
- Rank int `json:"rank"`
- Colour string `json:"colour"`
+ ID string `json:"id"`
+ Tempora string `json:"tempora"`
+ Title string `json:"title"`
+ Rank int `json:"rank"`
+ Colours []string `json:"colours"`
+}
+
+// efColourLetters maps missalemeum's single-letter colour codes to lectio's
+// Colour vocabulary (scripts/gen-sanctoral-ef.go's colourWord table, same
+// mapping, kept in sync deliberately rather than imported across a
+// package/build-tag boundary).
+var efColourLetters = map[string]calendar.Colour{
+ "w": calendar.White, "r": calendar.Red, "g": calendar.Green,
+ "v": calendar.Violet, "b": calendar.Black, "p": calendar.Rose,
+}
+
+// efRankNumbers maps missalemeum's 1-4 rank number to lectio's EF Rank
+// vocabulary. missalemeum has no separate "commemoration" rank number --
+// RankCommemoration is a lectio-side concept for a losing sanctoral
+// candidate, never the oracle's claim about the OBSERVED day.
+var efRankNumbers = map[int]calendar.Rank{
+ 1: calendar.RankClass1, 2: calendar.RankClass2,
+ 3: calendar.RankClass3, 4: calendar.RankClass4,
}
// efSeasonFromString maps a missalemeum tempora/title phrase (for a day in the
@@ -28,6 +68,14 @@ type efOracleDay struct {
// there, not to Time after Epiphany.
func efSeasonFromString(s string, month int) calendar.Season {
s = strings.ToLower(s)
+ // missalemeum's own EN-locale text still carries the occasional Latin
+ // ligature (e.g. "Feria V after Sexagesimæ") -- confirmed in the
+ // committed snapshot: "Sexagesimæ" appears 10 times, "Sexagesima" (no
+ // ligature) only 4, and the ligatured form matched no case below,
+ // silently mapping those 10 days to season "" (skipped from coverage
+ // entirely, not merely a season miss). Normalising here fixes every
+ // current and future ligature in one place, not just this one word.
+ s = strings.ReplaceAll(s, "æ", "ae")
has := func(subs ...string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
@@ -63,9 +111,116 @@ func efSeasonFromString(s string, month int) calendar.Season {
}
}
-// TestOracleEF diffs the EF engine against missalemeum (Divinum Officium data).
-// Season is asserted strictly (validates temporalEF); rank/colour are reported
-// informationally (partial 1962 sanctoral).
+// efAllow is one cited allow-list entry: a genuine, defensible divergence
+// between lectio's EF engine and the missalemeum oracle on Rank and/or
+// Colour, kept out of the pass/fail count deliberately rather than silently
+// skipped. Every entry names the date(s) it covers, which field(s) it
+// excuses, and why -- with an RG citation where the divergence is a rubric
+// question, or a plain "not built" note where it is a documented scope gap
+// (see internal/calendar/precedence_ef.go and temporal_ef.go's own doc
+// comments, and colitur's rules-register.md, for the fuller accounts).
+type efAllow struct {
+ date string // "" = pattern-matched by match(), see below
+ match func(date string, day time.Time, oracle efOracleDay) bool
+ field string // "rank", "colour", or "rank+colour"
+ reason string
+}
+
+var efAllowList = []efAllow{
+ {
+ // RG 91 entry 27, "Officium sanctae Mariae in sabbato": the votive
+ // BVM Office on an otherwise-unoccupied IV-class Saturday. Not built
+ // in lectio (temporal_ef.go constructs only a bare ferial slug on
+ // these Saturdays; the vocabulary comment in colitur's own register
+ // documents the same gap on its side). missalemeum's rank/colour for
+ // these Saturdays reflects the BVM Office (usually white); lectio's
+ // reflects the plain per-annum feria (green, class-4). A real,
+ // scoped, pre-existing gap -- not one of the seven defects, and not
+ // attempted here (a Plan-scale feature, per the sibling project's own
+ // parking of the identical item).
+ match: func(date string, day time.Time, o efOracleDay) bool {
+ return day.Weekday() == time.Saturday &&
+ strings.Contains(strings.ToLower(o.Title), "b. v. m")
+ },
+ field: "rank+colour",
+ reason: "RG 91 entry 27 (BVM Office on Saturday) is not built in lectio; both engines agree only that the day is unimpeded",
+ },
+ {
+ // RG 72-73 place 6-13 January within Christmastide itself ("a I
+ // Vesperis Nativitatis Domini usque ad diem 13 ianuarii inclusive");
+ // RG 119(a) backs the same boundary from the white-colour side
+ // ("...usque ad expletum tempus Epiphaniae"). lectio's efSeason
+ // (temporal_ef.go) instead starts "time after Epiphany" on 6 January,
+ // so a plain feria in this window gets green/TimeAfterEpiphany
+ // instead of white/Christmastide. This is a real, PRE-EXISTING season-
+ // boundary divergence -- colitur's own rules-register.md documents
+ // the identical gap on its side (§3c item 1) as deliberate and
+ // resolved in the OTHER engine's favour, not one of the seven named
+ // 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.
+ 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)",
+ },
+ {
+ // RG 33: "Vigilia II aut III classis penitus omittitur, si occurrat in
+ // dominica quavis..." -- a II/III-class vigil is ENTIRELY OMITTED on
+ // any Sunday. 9 August 2026 is a Sunday; the Vigil of St Lawrence (III
+ // class) must vanish and the Sunday itself be observed -- which is
+ // exactly what lectio does (see cmd/lectio-ef-dump: 2026-08-09
+ // observed is the II-class Sunday, the vigil demoted to a losing
+ // candidate). missalemeum's OWN data shows the vigil still winning
+ // that Sunday, an RG 33 gap on missalemeum's side, not lectio's --
+ // already identified and adjudicated in the primary source's favour
+ // by the sibling project (rules-register.md §6, "the romanus-vs-
+ // vigil-of-st-lawrence 9 August Sunday tie-break", M1).
+ date: "2026-08-09",
+ field: "rank+colour",
+ reason: "RG 33: a II/III-class vigil is omitted on any Sunday; missalemeum itself shows an RG 33 gap here (colitur rules-register.md §6, M1) -- lectio's Sunday-observed answer is the Missal-correct one",
+ },
+ {
+ // OPEN, not adjudicated by any primary text found so far. RG 91's
+ // plain table gives St Joseph (19 March, I class) the day outright
+ // over an ordinary III-class Friday of Passion Week -- no RG 96
+ // collision requiring a transfer -- yet missalemeum shows Joseph
+ // entirely displaced in 2027 specifically (the Friday's own office
+ // observed, the Seven Sorrows commemorated instead). The sibling
+ // project's much deeper primary-source pass over the SAME question
+ // left it explicitly open (colitur rules-register.md §6, M13: "three
+ // possibilities, none confirmed") rather than guess at an unfound
+ // rubric. Not attempted here either, for the same reason -- adjudicating
+ // it would mean finding a citation that a more thorough primary-source
+ // audit did not.
+ date: "2027-03-19",
+ field: "rank+colour",
+ reason: "unresolved even by primary-source review (colitur rules-register.md §6, M13); not adjudicated here either way",
+ },
+}
+
+func efAllowed(date string, day time.Time, oracle efOracleDay, field string) (bool, string) {
+ for _, a := range efAllowList {
+ if a.date != "" && a.date != date {
+ continue
+ }
+ if a.match != nil && !a.match(date, day, oracle) {
+ continue
+ }
+ if a.field == field || a.field == "rank+colour" {
+ return true, a.reason
+ }
+ }
+ return false, ""
+}
+
+// TestOracleEF diffs the EF engine against missalemeum (Divinum Officium data)
+// over 730 days (2026-01-01 .. 2027-12-31). Season, Rank and Colour are all
+// asserted strictly (membership for Colour, since missalemeum's colour is an
+// array); a prior version of this test asserted Season only, which is exactly
+// why several precedence/colour defects (see precedence_ef.go, temporal_ef.go)
+// shipped without ever failing a test.
func TestOracleEF(t *testing.T) {
raw, err := os.ReadFile("testdata/oracle-ef.json")
if err != nil {
@@ -85,8 +240,8 @@ func TestOracleEF(t *testing.T) {
}
sort.Strings(dates)
- var seasonMiss, skipped, total int
- shown := 0
+ var seasonMiss, rankMiss, colourMiss, rankAllowed, colourAllowed, skipped, total int
+ shownSeason, shownRank, shownColour := 0, 0, 0
for _, date := range dates {
od := oracle[date]
src := od.Tempora
@@ -95,22 +250,92 @@ func TestOracleEF(t *testing.T) {
}
day, _ := time.Parse("2006-01-02", date)
want := efSeasonFromString(src, int(day.Month()))
- if want == "" {
- skipped++
- continue
- }
total++
got := calendar.Compute(day.UTC(), sel, layers)
- if got.Season != want {
+
+ // An unrecognised season phrase skips the SEASON comparison only --
+ // it must not also skip Rank/Colour for that day. A `continue` here
+ // once did exactly that, silently zeroing oracle coverage for every
+ // day whose phrase efSeasonFromString doesn't recognise (Holy
+ // Thursday -- "maundy"/"holy week" don't match "Holy Thursday"
+ // itself; the six September Ember days -- no "ember" case exists at
+ // all, only Advent/Lent Ember days pass by an incidental substring
+ // match on "advent"/"lent"). Reverting the Holy Thursday colour fix
+ // (temporal_ef.go) left this test green under the old `continue`;
+ // it does not under this one.
+ if want == "" {
+ skipped++
+ } else if got.Season != want {
seasonMiss++
- if shown < 25 {
+ if shownSeason < 40 {
t.Errorf("%s: EF season got %q want %q (from %q)", date, got.Season, want, src)
- shown++
+ shownSeason++
+ }
+ }
+
+ wantRank, rankKnown := efRankNumbers[od.Rank]
+ if rankKnown && got.Observed.Rank != wantRank {
+ if ok, reason := efAllowed(date, day, od, "rank"); ok {
+ rankAllowed++
+ t.Logf("[allow-listed] %s: EF rank got %q want %q — %s", date, got.Observed.Rank, wantRank, reason)
+ } else {
+ rankMiss++
+ if shownRank < 40 {
+ t.Errorf("%s: EF rank got %q want %q (oracle rank %d, title %q)",
+ date, got.Observed.Rank, wantRank, od.Rank, od.Title)
+ shownRank++
+ }
+ }
+ }
+
+ colourOK := false
+ for _, c := range od.Colours {
+ if efColourLetters[c] == got.Colour {
+ colourOK = true
+ break
+ }
+ }
+ // Membership alone cannot catch a Rose regression: violet is a
+ // member of every rose/violet pair by construction (Gaudete/
+ // Laetare), so "got violet, want one of [rose violet]" passes even
+ // if RG 131's Rose support (temporal_ef.go) were reverted entirely.
+ // On the day the rose/violet pair actually names -- oracle rank 1,
+ // the Sunday itself, not a weekday reusing its propers (which
+ // carries a lower rank and is deliberately membership-only, this
+ // file's own doc comment item 3) -- a colour set containing rose
+ // demands rose specifically, not merely "some member".
+ if colourOK && od.Rank == 1 && got.Colour != calendar.Rose {
+ for _, c := range od.Colours {
+ if c == "p" {
+ colourOK = false
+ break
+ }
+ }
+ }
+ if !colourOK && len(od.Colours) > 0 {
+ if ok, reason := efAllowed(date, day, od, "colour"); ok {
+ colourAllowed++
+ t.Logf("[allow-listed] %s: EF colour got %q want one of %v — %s", date, got.Colour, od.Colours, reason)
+ } else {
+ colourMiss++
+ if shownColour < 40 {
+ t.Errorf("%s: EF colour got %q want one of %v (title %q)",
+ date, got.Colour, od.Colours, od.Title)
+ shownColour++
+ }
}
}
}
- t.Logf("EF oracle: %d checked, %d skipped(unmapped), %d season mismatches", total, skipped, seasonMiss)
+ 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)
if seasonMiss > 0 {
t.Fatalf("%d/%d EF season mismatches vs missalemeum — temporalEF is wrong", seasonMiss, total)
}
+ if rankMiss > 0 {
+ t.Fatalf("%d/%d EF rank mismatches vs missalemeum (not allow-listed)", rankMiss, total)
+ }
+ if colourMiss > 0 {
+ t.Fatalf("%d/%d EF colour mismatches vs missalemeum (not allow-listed)", colourMiss, total)
+ }
}