1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
|
package calendar_test
import (
"encoding/json"
"os"
"sort"
"strings"
"testing"
"time"
"github.com/lukaszkasprzak/lectio/internal/caldata"
"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 {
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
// given month) to lectio's EF season. Order matters. Two position-dependent
// subtleties: the Whit-octave weekdays ("Monday after Pentecost") are still
// Paschaltide, so only "Sunday after Pentecost" is Time after Pentecost; and the
// resumed Sundays after Epiphany (celebrated in autumn) sit within Time after
// Pentecost, so an "after Epiphany" phrase in the second half of the year maps
// 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) {
return true
}
}
return false
}
switch {
case has("vigil of christmas"): // last day of Advent
return calendar.Advent
case has("advent"):
return calendar.Advent
case has("sunday after pentecost"), has("trinity", "christ the king", "corpus christi"):
return calendar.TimeAfterPentecost
case has("after epiphany", "epiphany"):
if month >= 6 { // resumed Epiphany Sundays, celebrated within Time after Pentecost
return calendar.TimeAfterPentecost
}
return calendar.TimeAfterEpiphany
case has("passion", "palm", "holy week", "maundy", "good friday", "holy saturday"):
return calendar.Passiontide
case has("septuagesima", "sexagesima", "quinquagesima"):
return calendar.Septuagesima
case has("ash wednesday", "lent"):
return calendar.Lent
case has("pentecost", "ascension", "rogation", "after easter", "easter", "low sunday", "paschal", "whit"):
return calendar.Easter_ // incl. the Whit octave (Mon-Sat after Pentecost)
case has("christmas", "nativity", "circumcision", "holy name", "octave day of christ"):
return calendar.Christmas
default:
return "" // unknown phrase -> skip this day
}
}
// 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{
{
// The 1962 Missal's own universal CALENDARIUM ranks both of these as
// bare III-class feasts, with no commemoration rubric: "S. Ubaldi Ep.
// et Conf., III classis" (16 May) and "S. Didaci Conf., III classis"
// (13 November). Each also has its own Mass entry in the Proprium,
// taking its readings from a Common with only its Oratio proper --
// which a bare commemoration never has. missalemeum ranks both as
// commemorations, so on these dates it leaves the day unimpeded and
// shows a feria or the Saturday BVM Office; lectio now shows the feast.
//
// This entry MUST precede the BVM-Saturday entry below. When 16 May or
// 13 November falls on a Saturday, that entry's predicate (Saturday +
// an oracle title naming the B.V.M.) also matches, and would absorb
// this divergence under a reason that is no longer true of it -- it
// says "both engines agree only that the day is unimpeded", and the
// whole point here is that the day IS impeded, by a feast missalemeum
// does not rank as one.
//
// Found by auditing all 290 of colitur's fixed-date sanctoral entries
// against that calendarium; these two were the only status defects.
// colitur allow-list C38/M30, rules-register.md 6.25.
match: func(date string, day time.Time, o efOracleDay) bool {
md := date[5:]
return (md == "05-16" || md == "11-13") &&
!strings.Contains(strings.ToLower(o.Title), "ubald") &&
!strings.Contains(strings.ToLower(o.Title), "didac")
},
field: "rank+colour",
reason: "Missal calendarium ranks St Ubaldus (16 May) and St Didacus (13 Nov) III classis, not commemorations; missalemeum keeps them as commemorations and so leaves the day unimpeded (colitur C38/M30)",
},
{
// RG 128, "De colore violaceo": violet is used for vigils of II and III
// class outside Paschaltide. The Vigil of the Assumption (14 August, II
// class) and the Vigil of St Lawrence (9 August, III class) both
// qualify. missalemeum shows white and red respectively -- values
// lectio carried too, and colitur inherited from lectio, all three
// descending from Divinum Officium's data files. The rule's sole
// exception is named in the same place and is not either of these: the
// Ascension's vigil, the one II-class vigil INSIDE Paschaltide, which
// is white and is untouched.
//
// colitur allow-list C37/M29, rules-register.md 6.24.
// NOT on a Sunday. RG 33 omits a II/III-class vigil whenever its date
// falls on one, so lectio shows the Sunday and the divergence there is
// missalemeum's own RG 33 gap -- already allow-listed further down,
// with its own citation. Without this guard, 2026-08-09 (a Sunday whose
// oracle title still reads "Vigil of St. Lawrence") matches on the
// title alone and is stolen from that entry, reported under a colour
// rule that has nothing to do with it. Same failure this list's first
// entry exists to prevent, in the opposite direction.
match: func(date string, day time.Time, o efOracleDay) bool {
md := date[5:]
return (md == "08-09" || md == "08-14") &&
day.Weekday() != time.Sunday &&
strings.Contains(strings.ToLower(o.Title), "vigil")
},
field: "colour",
reason: "RG 128: violet for vigils of II and III class outside Paschaltide; missalemeum shows white/red (colitur C37/M29)",
},
{
// 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 {
t.Skip("EF oracle snapshot missing; run scripts/build-oracle-ef.sh")
}
var oracle map[string]efOracleDay
if err := json.Unmarshal(raw, &oracle); err != nil {
t.Fatal(err)
}
sel := calendar.DefaultSelection()
sel.Form = "old"
layers := []calendar.Layer{caldata.Tridentine()}
dates := make([]string, 0, len(oracle))
for d := range oracle {
dates = append(dates, d)
}
sort.Strings(dates)
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
if src == "" {
src = od.Title
}
day, _ := time.Parse("2006-01-02", date)
want := efSeasonFromString(src, int(day.Month()))
total++
got := calendar.Compute(day.UTC(), sel, layers)
// 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 shownSeason < 40 {
t.Errorf("%s: EF season got %q want %q (from %q)", date, got.Season, want, src)
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, "+
"%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)
}
}
// TestEFLectionaryKeysAreReachableSlugs asserts that every section name in the
// EF temporal lectionary is a slug the calendar can actually compute. A key
// nothing ever matches is dead data: the reading sits in the file, the lookup
// misses it, and the day silently falls back to the preceding Sunday's Mass —
// which looks like a plausible answer, so nothing downstream notices.
//
// This is not hypothetical. Three sections were filed as ef-lent-1-{wednesday,
// friday,saturday} while temporal_ef.go's efEmberSlug computes
// ef-lent-ember-{wed,fri,sat}, so the Lenten Ember days served Lent I Sunday's
// Mass for as long as the data existed. The keys were stale: they predate Lent
// being added to efEmberSlug, and scripts/genlect.go — which keys off
// day.Observed.Slug — would already write the correct names if re-run.
//
// It survived every existing test because the wrong answer is a real Mass, and
// it survived cross-checking against colitur (the sibling OCaml engine)
// because colitur had bootstrapped the same wrong keys from this very file.
// Two engines agreeing is not evidence when one was seeded from the other.
func TestEFLectionaryKeysAreReachableSlugs(t *testing.T) {
raw, err := os.ReadFile("../caldata/tridentine-lectionary.ini")
if err != nil {
t.Fatalf("read lectionary: %v", err)
}
keys := map[string]bool{}
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
keys[line[1:len(line)-1]] = true
}
}
if len(keys) == 0 {
t.Fatal("no sections parsed from the lectionary")
}
sel := calendar.DefaultSelection()
sel.Form = "old"
layers := []calendar.Layer{caldata.Tridentine()}
// Twelve years. Every key in the shipped file is reached within five (the
// movable ones recur annually; the Ember and vigil days are the only
// awkward cases and they occur every year too), so this is a bit over
// double the margin actually needed. Widening it costs test time linearly
// and buys nothing measurable — 46 years took 91s, this takes a quarter of
// that — but narrowing below five would start reporting live keys as dead.
seen := map[string]bool{}
for d := time.Date(2005, 1, 1, 0, 0, 0, 0, time.UTC); d.Year() <= 2016; d = d.AddDate(0, 0, 1) {
seen[calendar.Compute(d, sel, layers).Observed.Slug] = true
}
var dead []string
for k := range keys {
if !seen[k] {
dead = append(dead, k)
}
}
sort.Strings(dead)
if len(dead) > 0 {
t.Errorf("lectionary keys no computed slug ever matches (dead data, the day "+
"silently falls back to the preceding Sunday): %v", dead)
}
}
|