diff options
Diffstat (limited to 'scripts/gen-sanctoral-ef.go')
| -rw-r--r-- | scripts/gen-sanctoral-ef.go | 268 |
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) } } |
