aboutsummaryrefslogtreecommitdiff
path: root/scripts/gen-sanctoral-ef.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 13:29:41 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-12 13:29:41 +0200
commit4a1db8df19425736fc82907c967586a3053a7ad1 (patch)
tree821d774eb61428d1e2b6937ef184454dd91ca8ff /scripts/gen-sanctoral-ef.go
parent4722236b26d3439ed56893cb2863121bb705c7cf (diff)
downloadlectio-4a1db8df19425736fc82907c967586a3053a7ad1.tar.gz
lectio-4a1db8df19425736fc82907c967586a3053a7ad1.zip
scripts/gen-sanctoral-ef: fix rank inference, dedup, and class tagging (defect 7)
Six root-cause fixes to the generator, all in scripts/gen-sanctoral-ef.go, then a full regeneration of internal/caldata/tridentine-calendar.ini (323 -> 325 entries) against live missalemeum data: 1. Rank misinferred as "commemoration". A saint never OBSERVED in any of the six reference years was unconditionally hardcoded to RankCommemoration. 15 III-class feasts, all falling 6 March - 5 April, were always demoted by Lent's own privileged ferias in every reference year -- a sampling artifact, not their true rank. Fixed properly, not by hardcoding the 15: the commemoration object's own id names the SAINT's true rank (idParts already extracted it; only the colour half was kept before). But trusting that rank unconditionally for every commemoration-only entry over-corrected: some saints (St Blaise, St Canute, and others) really are without an independent Mass in the 1960-reformed books, demoted even on an ordinary, unprivileged day -- live-confirmed against missalemeum, not inferred. refYearExplainsAbsence distinguishes the two: the id's rank is trusted only when every reference year's OWN temporal day (ignoring sanctoral data entirely) was already strong enough to explain the demotion -- I/II class, an Ember day, or a privileged Lent/Passiontide feria. A single unprivileged-day counter-example keeps RankCommemoration. 2. Two silent slug collisions. entries (the generator's global map) is keyed by slug alone; two different fixed dates whose English titles are identical ("St. Agnes" 21 Jan and 28 Jan; "St. Boniface" 5 June and 14 May) silently dropped the second one add() ever saw. Fixed at the dedup itself: a same-slug collision on a DIFFERENT date is now disambiguated (a small curated slugOverride for these two -- 28 January is the traditional "secundo" commemoration of the same saint; 14 May's Boniface is a different martyr from 5 June's Boniface of Mainz) rather than dropped. 3. Two early-exit bugs in harvestDate, both replaced with `continue`: returning on the FIRST reference year that showed an observed office discarded commemorations only visible in a LATER year (St Theodore, 9 November, missing only from 2025, the first year tried); the Christ-the-King skip used to abandon the whole date outright instead of only that one movable year (St Evaristus, 26 October, invisible because 2025 -- tried first -- happens to be the one reference year Christ the King lands there). 4. A new class of artifact the fix for (2) exposed rather than caused: a movable-transfer feast displayed on whatever civil date it actually landed on in one reference year (St Joseph pushed to 20 March by a Sunday of Lent; the Annunciation deferred past Holy Week; All Souls moved to the Monday; St Matthias shown on the 25th in a leap year) was being harvested as if it were a genuine fixed-date office for THAT transferred date. idHomeDate compares info.id's own embedded date against the mmdd being queried (live-confirmed: 2023-03-20's id is "sancti:03-19:1:w") and skips a mismatch. 5. classOf's substring matching, checked against the calendarium's own verbatim titles: "purification" was never a Lord check at all (calendarium "IN PURIFICATIONE B. MARIAE VIRG.", a BVM feast) and is removed; "holy name" alone matched both "Holy Name of Jesus" and "Most Holy Name of MARY" (calendarium "Sanctissimi Nominis Mariae") and is now conditioned on the title not also naming Mary; "baptism" is a new case -- "Commemoration of the Baptism of the Lord" (13 January) ends "of THE Lord", not "of OUR Lord", the only suffix classOf checked, so it was missing the marker entirely. 6. One curated exclusion, NOT a generator bug: missalemeum's own "St. Romanus" commemoration on 9 August has no calendarium support (the row there reads only "Vigilia, III classis.", no "Com." line) -- an upstream data quirk, recorded in knownSpuriousComm rather than silently reproduced. "St. Eusebius" on 14 August (already known, register M6) gets the same treatment for the same reason. Witnesses (internal/caldata/caldata_test.go): TestTridentineClassOfLord, TestTridentineLentRankNotCommemoration, TestTridentineGenuineCommemorationStaysCommemoration, TestTridentineNoMissingEntries, TestTridentineNoSpuriousRomanus, TestTridentineNoTransferArtifacts, plus TestTridentineLoads' own Purification class assertion corrected from its previous (wrong) "lord" expectation. All fail against the pre-fix ini with messages such as: purification-of-the-blessed-virgin-mary: class = "lord", want "" sts-felicitas-perpetua: rank = "commemoration", want class-3 (not commemoration) missing "theodore" (11-09) romanus present: the calendarium's 9 August row has no Com. line to support it See the report for the full set. internal/caldata/tridentine-calendar.ini is regenerated output (go run scripts/gen-sanctoral-ef.go against live missalemeum data), not hand-edited.
Diffstat (limited to 'scripts/gen-sanctoral-ef.go')
-rw-r--r--scripts/gen-sanctoral-ef.go268
1 files changed, 236 insertions, 32 deletions
diff --git a/scripts/gen-sanctoral-ef.go b/scripts/gen-sanctoral-ef.go
index e3ad18e..e5392d3 100644
--- a/scripts/gen-sanctoral-ef.go
+++ b/scripts/gen-sanctoral-ef.go
@@ -113,21 +113,40 @@ func rankOf(n int) calendar.Rank {
// classOf marks the feasts of the Lord (class lord). A II class feast of the
// Lord takes the place of a Sunday of the same class (1960 occurrence rules) —
-// e.g. the Purification (Presentation, Feb 2), the Exaltation of the Holy Cross
-// (Sep 14), the Dedication of the Lateran (Nov 9). Saint/BVM feasts of the same
-// class are only commemorated on a Sunday, so they need no marker.
+// e.g. the Exaltation of the Holy Cross (Sep 14), the Dedication of the
+// Archbasilica (Nov 9), the Commemoration of the Baptism (Jan 13). Saint/BVM
+// feasts of the same class are only commemorated on a Sunday, so they need no
+// marker.
+//
+// Two substring checks are deliberately narrower than they once were, and one
+// is new, all found by checking the calendarium's own verbatim titles
+// (missale-romanum-1962.pdf) against what this function produced:
+// - "purification" is NOT a lord check at all -- the calendarium's own
+// title is "IN PURIFICATIONE B. MARIAE VIRG.", a feast of the BLESSED
+// VIRGIN, not the Lord. It was wrongly matching here.
+// - "holy name" alone is ambiguous: it matches BOTH "Holy Name of Jesus"
+// (a feast of the Lord) and "Most Holy Name of Mary"/"Holy Name of Mary"
+// (a feast of the BVM, calendarium: "Sanctissimi Nominis Mariae") --
+// wrongly matching the latter too. Excluded whenever the title also
+// names Mary.
+// - "baptism" is a new case: "Commemoration of the Baptism of the Lord" (13
+// January, calendarium: "IN COMMEMORATIONE BAPTISMATIS D. N. I. C.") did
+// not match any existing case -- the HasSuffix check below requires "of
+// OUR Lord", but this title's own wording is "of THE Lord" -- so it was
+// missing the marker entirely.
func classOf(en string) string {
l := strings.ToLower(en)
switch {
case strings.Contains(l, "holy cross"), // Exaltation / Finding of the Holy Cross
strings.Contains(l, "transfiguration"),
- strings.Contains(l, "purification"), // the Presentation of the Lord
strings.Contains(l, "precious blood"),
- strings.Contains(l, "holy name"),
+ strings.Contains(l, "baptism"),
+ strings.Contains(l, "holy name") && !strings.Contains(l, "mary"),
strings.Contains(l, "dedication of the archbasilica"),
strings.Contains(l, "of our holy savior"),
strings.Contains(l, "of our lord jesus"),
- strings.HasSuffix(l, "of our lord"):
+ strings.HasSuffix(l, "of our lord"),
+ strings.HasSuffix(l, "of the lord"):
return "lord"
}
return ""
@@ -215,6 +234,32 @@ func idParts(id string) (calendar.Rank, string) {
return rankOf(n), colourOf(p[3])
}
+// idHomeDate extracts the "MM-DD" home date embedded in a missalemeum
+// info.id ("sancti:MM-DD[sfx]:RANK:COLOUR" -- sfx is an internal
+// disambiguator missalemeum sometimes appends, e.g. "01-28t", "11-09cc",
+// "11-02m1"; the first 5 characters are always the date). Returns "" if id
+// is too short to contain one.
+//
+// This is the fix for a class of bug the slug-collision fix above (in
+// main()) exposed rather than caused: a MOVABLE-transfer feast displayed on
+// whatever civil date it actually landed on in a given reference year (St
+// Joseph, 19 March, impeded by a Sunday of Lent and shown on the 20th; the
+// Annunciation deferred past Holy Week; All Souls moved to the Monday when 2
+// November is a Sunday; even St Matthias' fixed 24 February shown on the
+// 25th in a leap year) still carries its OWN proper date in info.id, not the
+// civil date queried. Comparing the two lets harvestDate recognise "this is
+// not really this date's own office" and skip it, instead of harvesting a
+// phantom fixed-date entry at the transferred civil date -- confirmed
+// live (`curl .../api/v5/proper/2023-03-20`): id "sancti:03-19:1:w" while
+// the date queried is 2023-03-20.
+func idHomeDate(id string) string {
+ p := strings.SplitN(id, ":", 2)
+ if len(p) < 2 || len(p[1]) < 5 {
+ return ""
+ }
+ return p[1][:5]
+}
+
func readingsFrom(d *mmDay) (first, gospel string) {
for _, s := range d.Sections {
if len(s.Body) == 0 || len(s.Body[0]) == 0 {
@@ -234,40 +279,165 @@ func readingsFrom(d *mmDay) (first, gospel string) {
return
}
+// knownSpuriousComm excludes specific missalemeum commemorations, keyed
+// "MM-DD/slug", that the 1962 calendarium's own row for that date does NOT
+// support -- confirmed by checking the primary text directly, not inferred.
+// 9 August's row reads only "Vigilia, III classis.", with no "Com." line, so
+// missalemeum's own "St. Romanus" commemoration that day has no calendarium
+// backing. This is a genuine upstream (missalemeum) data quirk, not something
+// derivable from the API response itself, so it is recorded here rather than
+// silently reproduced -- see the report for the primary-source citation.
+var knownSpuriousComm = map[string]bool{
+ "08-09/romanus": true,
+ // 14 August's row in the calendarium reads only "Vigilia, II classis.",
+ // no "Com." line; the genuine St Eusebius (Bishop and Martyr) is
+ // commemorated 16 December instead, where he is correctly present.
+ "08-14/eusebius": true,
+}
+
+// slugOverride gives a proper, distinct slug to a small number of
+// commemorations whose title slugifies IDENTICALLY to an unrelated feast on
+// a different fixed date. Confirmed against the calendarium: 28 January's
+// "St. Agnes" is the traditional SECOND commemoration of the 21 January
+// feast (the same saint, repeated, not a coincidence); 14 May's "St.
+// Boniface" is a different early martyr from 5 June's Boniface of Mainz, an
+// entirely different person whose title happens to abbreviate to the same
+// English string. Keyed "MM-DD/original-slug" -> replacement slug.
+var slugOverride = map[string]string{
+ "01-28/agnes": "agnes-secundo",
+ "05-14/boniface": "boniface-martyr",
+}
+
+// refYearExplainsAbsence reports whether the TEMPORAL day alone (no
+// sanctoral data at all -- an empty layer stack) on this specific reference
+// date was already strong enough that ANY class-1..4 saint would lose there
+// regardless of its own merit: a Sunday or a named I/II-class feast, an
+// Ember day, the late-Advent or Christmas-octave privilege (I or II class),
+// or a privileged Lent/Passiontide feria (III class, but still privileged
+// over an equal-or-lower-class saint per RG 109(e)).
+//
+// This is the discriminator between two different reasons a saint is never
+// OBSERVED in any of the six reference years:
+//
+// 1. Genuinely without an independent Mass in the 1960-reformed books --
+// reduced to an added commemoration on ANY day, including an ordinary,
+// unprivileged one. Confirmed live: St Blaise (3 Feb, an ordinary
+// Septuagesima-season feria, non-privileged), St Canute (19 Jan, an
+// ordinary Time-after-Epiphany feria) and others are shown by
+// missalemeum as mere commemorations even then -- this is real 1962
+// data, not a sampling artefact, and RankCommemoration is the correct,
+// honest rank for them.
+// 2. A real class-1..4 feast that merely never won in these six
+// PARTICULAR reference years because its fixed date happens to fall,
+// in every one of them, on a day already strong enough to beat any
+// saint of its class -- the 15-entry, 6 March-5 April case this fix
+// originally targeted (every one of those dates falls within Lent in
+// all six reference years). Here the id's own embedded rank is
+// trustworthy.
+//
+// harvestDate keeps a saint's commemoration id rank ONLY if every reference
+// year in which it was seen was case 1 above (i.e. this function returned
+// true every time) -- a single unprivileged-day counter-example is enough
+// to fall back to RankCommemoration.
+func refYearExplainsAbsence(date time.Time) bool {
+ sel := calendar.DefaultSelection()
+ sel.Form = "old"
+ day := calendar.Compute(date, sel, nil)
+ if day.Observed.Rank == calendar.RankClass1 || day.Observed.Rank == calendar.RankClass2 {
+ return true
+ }
+ return day.Season == calendar.Lent || day.Season == calendar.Passiontide
+}
+
+// commTracker accumulates one commemoration slug's data across reference
+// years: the entry itself (first sighting's title/colour/id-rank), and
+// whether EVERY year it was seen in was "explained" by refYearExplainsAbsence.
+type commTracker struct {
+ entry entry
+ allExplained bool
+}
+
// harvestDate returns the observed sanctoral office for a fixed MM-DD (nil if
// the date is always a feria/temporal) plus any sancti commemorations seen.
+//
+// Every one of the `years` reference years is scanned for BOTH the observed
+// office and commemorations -- neither loop exits early on the first hit.
+// Two real bugs lived in an earlier version that DID exit early:
+//
+// 1. Returning as soon as the FIRST reference year showed an observed
+// office discarded every commemoration that only showed up in a LATER
+// year (e.g. 9 November: 2025, the first year tried, happens to be the
+// one year of six with no "St. Theodore" commemoration alongside the
+// Dedication of the Archbasilica; a `return` there drops Theodore for
+// good).
+// 2. The tempFeastSkip/Christ-the-King check used to `return nil, comms`
+// outright -- correct for tempFeastSkip's three permanently-fixed dates
+// (every year behaves the same, so nothing is lost), but wrong for
+// Christ the King, which occupies a given MM-DD only in the one
+// reference year it happens to be the last Sunday of October (2025 for
+// 26 October, in this generator's own reference years): returning
+// immediately there discarded "St. Evaristus", visible only in the OTHER
+// five years. `continue` fixes both: the loop keeps trying every
+// remaining year regardless of what any single year showed.
func harvestDate(mmdd string) (*entry, []entry) {
- var comms []entry
- seenComm := map[string]bool{}
+ commTrack := map[string]*commTracker{}
+ var obs *entry
for _, y := range years {
date := fmt.Sprintf("%04d-%s", y, mmdd)
- if _, err := time.Parse("2006-01-02", date); err != nil {
+ refDate, err := time.Parse("2006-01-02", date)
+ if err != nil {
continue // e.g. 02-29 in a common year
}
d, err := fetch(date)
if err != nil {
continue
}
+ explained := refYearExplainsAbsence(refDate)
for _, c := range d.Info.Commemorations {
if !strings.HasPrefix(c.ID, "sancti:") || !isSaintTitle(c.Title) {
continue
}
+ if home := idHomeDate(c.ID); home != "" && home != mmdd {
+ continue // a transferred feast's commemoration, not a genuine one for THIS date
+ }
slug := slugify(c.Title)
- if slug == "" || seenComm[slug] {
+ if slug == "" || knownSpuriousComm[mmdd+"/"+slug] {
continue
}
- seenComm[slug] = true
- _, col := idParts(c.ID)
- // A saint never OBSERVED in any harvest year — only ever commemorated —
- // is a commemoration in the 1962 universal calendar: it does NOT have
- // its own Mass and yields to the ferial office (which is celebrated with
- // the saint commemorated). Rank it RankCommemoration, not the id's class
- // (missalemeum reuses class-3/4 for these), so a genuine IV class feast
- // still outranks the feria while a commemoration does not. If the same
- // slug is observed in another year, that observed entry supersedes this.
- comms = append(comms, entry{slug: slug, date: mmdd, colour: col, en: c.Title, rank: calendar.RankCommemoration})
+ if t, ok := commTrack[slug]; ok {
+ if !explained {
+ t.allExplained = false
+ }
+ continue // title/colour/id-rank already captured from the first sighting
+ }
+ // The COMMEMORATION object's own id names THAT SAINT's true rank
+ // (unlike the DAY's own info.id, which names the rank of whatever
+ // propers are reused that day, not the commemorated saint's) --
+ // idParts already extracts it; only the colour half used to be
+ // kept. Whether this rank is actually TRUSTED depends on
+ // `explained` across every year this slug is seen -- resolved
+ // after the year loop, see refYearExplainsAbsence's own doc
+ // comment.
+ rank, col := idParts(c.ID)
+ commTrack[slug] = &commTracker{
+ entry: entry{slug: slug, date: mmdd, colour: col, en: c.Title, rank: rank},
+ allExplained: explained,
+ }
+ }
+ if obs != nil {
+ continue // already have an observed office; keep scanning other years for MORE commemorations
}
if strings.HasPrefix(d.Info.ID, "sancti:") && isSaintTitle(d.Info.Title) {
+ if home := idHomeDate(d.Info.ID); home != "" && home != mmdd {
+ // A movable-transfer feast displayed on today's civil date in
+ // THIS particular reference year (St Joseph pushed to the
+ // 20th; the Annunciation deferred past Holy Week; All Souls
+ // moved to the Monday; St Matthias shown on the 25th in a
+ // leap year) -- not a genuine fixed office for mmdd itself.
+ // See idHomeDate's own doc comment for the live-verified
+ // evidence. Try the next reference year instead.
+ continue
+ }
// A few Lord's feasts live in missalemeum's sancti namespace but the
// EF temporal engine already computes them (Nativity, Circumcision,
// Epiphany); exclude them so they aren't duplicated in the sanctoral.
@@ -278,18 +448,31 @@ func harvestDate(mmdd string) (*entry, []entry) {
// Christ the King is movable (last Sunday of October) and computed
// by the temporal engine; missalemeum files it under sancti, so it
// would otherwise leak into the sanctoral at a spurious fixed date.
- return nil, comms
+ // `continue`, not `return`: this disqualifies only THIS year's
+ // observed-office candidacy, not the whole date (see doc comment).
+ continue
}
first, gospel := readingsFrom(d)
col := colourOf(strings.Join(d.Info.Colors, ""))
- return &entry{
+ obs = &entry{
slug: slugify(d.Info.Title), date: mmdd, colour: col, class: classOf(d.Info.Title),
en: d.Info.Title, first: first, gospel: gospel,
rank: rankOf(d.Info.Rank), observed: true,
- }, comms
+ }
}
}
- return nil, comms
+ comms := make([]entry, 0, len(commTrack))
+ for _, t := range commTrack {
+ e := t.entry
+ if !t.allExplained {
+ // At least one reference year showed this saint demoted even on
+ // an ordinary, unprivileged day -- genuinely commemoration-only
+ // (see refYearExplainsAbsence), not merely unlucky sampling.
+ e.rank = calendar.RankCommemoration
+ }
+ comms = append(comms, e)
+ }
+ return obs, comms
}
func main() {
@@ -331,11 +514,34 @@ func main() {
entries := map[string]entry{}
add := func(e entry) {
if cur, ok := entries[e.slug]; ok {
- if cur.observed && !e.observed {
- return // don't let a commemoration downgrade an observed office
- }
- if cur.observed && e.observed {
- return // first observed year wins
+ if cur.date == e.date {
+ // Same fixed date: this is the SAME feast, seen again in
+ // another reference year or pass -- the existing dedup rules
+ // apply (an observed office is never downgraded by a
+ // commemoration; the first observed year wins).
+ if cur.observed && !e.observed {
+ return
+ }
+ if cur.observed && e.observed {
+ return
+ }
+ } else {
+ // A DIFFERENT fixed date slugified to the identical string
+ // (e.g. "St. Boniface" on both 14 May and 5 June, or "St.
+ // Agnes" on both 21 and 28 January) -- two distinct
+ // celebrations, not the same one recurring. The map is keyed
+ // by slug, so silently keeping the first and dropping the
+ // second here is exactly how St Agnes secundo (28 Jan), St
+ // Boniface Martyr (14 May), and their like went missing
+ // before this fix. Disambiguate instead of dropping.
+ if ov, ok := slugOverride[e.date+"/"+e.slug]; ok {
+ e.slug = ov
+ } else {
+ e.slug = e.slug + "-" + strings.ReplaceAll(e.date, "-", "")
+ }
+ if _, stillCollides := entries[e.slug]; stillCollides {
+ return // extremely unlikely second collision; drop rather than clobber
+ }
}
}
entries[e.slug] = e
@@ -347,9 +553,7 @@ func main() {
}
for _, r := range results { // commemorations after, so observed offices win
for _, c := range r.comms {
- if _, ok := entries[c.slug]; !ok {
- add(c)
- }
+ add(c)
}
}