diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-12 15:27:00 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-12 15:27:00 +0200 |
| commit | c655c525ef07deafb113a291489b396c81d7ce4a (patch) | |
| tree | 5dd82811c100de6daa995bebd115a12434296508 | |
| parent | ef171295a5d7b7cc40ad7367e142de4234a4eb54 (diff) | |
| download | lectio-c655c525ef07deafb113a291489b396c81d7ce4a.tar.gz lectio-c655c525ef07deafb113a291489b396c81d7ce4a.zip | |
gen-sanctoral-ef, caldata: fix round 3 -- guard coverage, rank pins, name test, Anastasia
Second external re-review returned Spec pass on round 2 (all ten prior
findings addressed, C1/C2 fixed at the root, mechanism claims held
under mutation) and found two blocking items in the I3 guard itself,
plus three recommended hardenings. All verified independently before
fixing.
BLOCKING 1: refYearExplainsAbsence's own doc comment said the id-rank
exclusion covers "6-31 December"; the code implemented `Day() >= 26`.
A typo (26 -> 6), but the wrong kind to have on the one comment this
whole review chain exists to keep honest -- fixed as part of the
rewrite below, not left as an isolated one-character correction.
BLOCKING 2: the guard covered only 6 of the 13 MM-DD values round 1's
own temporal_ef.go changes actually made refYearExplainsAbsence
couple to. Measured, not just reasoned about: running the function's
body against the branch-point engine versus HEAD over the six
reference years flips its verdict on 50 dates across 12-17 through
12-23 (round 1's late-Advent promotion, RG 91 entry 18, a 140-day
change) AND 12-26 through 12-31 (the Nativity-octave promotion, RG
67/68, already guarded). The late-Advent half was unguarded.
No live bug today -- confirmed by regenerating: the ini is
BYTE-IDENTICAL before and after this fix. The only sanctoral entry in
17-23 December is `thomas` (21 December), reached via harvestDate's
`obs` (observed-office) path, never refYearExplainsAbsence's
commemoration-id-trust path at all. Widened the guard to 17-31
December anyway, on the side of the known-safe default
(RankCommemoration) rather than wait for the first real entry in that
range to repeat I3's exact failure shape. The two sub-ranges are NOT
the same strength of evidence, and the doc comment says so explicitly
now: 26-31 December rests on a positive RG 68(d)/(e) citation naming
Thomas Becket and Silvester directly as bare "Commemoratio, no
class"; 17-23 December is a precautionary exclusion with no equivalent
citation -- RG 91 entry 18 ranks the FERIA, not any commemoration
that might coincide with it, and (per defect 2's own finding) a
genuine class-2 feast there would actually WIN against the ferias's
own privilege, so "the day is class-2" does not reliably explain a
class-2 saint's absence here the way it does for the Nativity octave's
two named cases. Recorded as a real asymmetry, not smoothed over.
RECOMMENDED 3: rank = commemoration is now pinned for all four entries
this task's two review rounds touched -- romanus, eusebius-confessor
(C2), thomas-becket, silvester (I3) -- not just their presence and
date. `git grep` before this commit returned zero hits for
thomas-becket or silvester rank anywhere in *_test.go; a future
regeneration could have rewritten either back to class-4, the exact
I3 failure mode, with the suite green throughout.
RECOMMENDED 4: TestTridentineNamesPreservedAcrossRegeneration
rewritten on both axes the review found loose. It counted name.pl
only -- a hardcoded single language, the identical "whitelist of two"
mistake C1's own generator fix exists to stop repeating, just moved
into the test instead. Now DISCOVERS every name.<lang> key actually
present in the data (so a regeneration dropping name.la, or losing a
future name.de, is caught the same way as name.pl) and asserts the
discovered language set is exactly {en, pl} -- not just each count.
The >= 315 floor had 7 entries of slack (the review proved 6 missing
Polish names still passed it); tightened to the exact, independently
verified true count (322), zero slack.
RECOMMENDED 5: the 25 December Anastasia gap and its cause are now
recorded, not just her absence. Two compounding, independently
checked causes: fetchOnce reads only `data[0]`, missalemeum's FIRST
of Christmas Day's three Masses, so a commemoration attached to a
second or third Mass (the calendarium's own "In secunda Missa:
Commemoratio S. Anastasiae Mart.") is structurally invisible to this
harvester for ANY date, not just this one -- but ALSO, checked live,
missalemeum's own "commemorations" list is empty on all three of the
date's records, not just the first, so a fetchOnce rewritten to merge
all of a date's Masses would still not recover her. Documented at
both fetchOnce's own definition and the PRIMARY-SOURCE NOTE, since
the two facts belong to different questions (a real code limitation
worth fixing someday vs. an upstream data gap this generator cannot
close at all).
Witnesses: TestTridentineCommemorationRanksPinned (new),
TestTridentineRomanusAndEusebiusPresent (extended with rank),
TestTridentineNamesPreservedAcrossRegeneration (rewritten). No pre-fix
failure to report for the two new/tightened tests specifically --
these are regression GUARDS against a bug class already fixed in
round 2, not fixes for a currently-red state; their value is in what
they would now catch, not what they catch today. internal/caldata/
tridentine-calendar.ini is NOT regenerated by this commit -- confirmed
byte-identical before and after re-running the generator, exactly as
"no live bug today" predicts.
| -rw-r--r-- | internal/caldata/caldata_test.go | 114 | ||||
| -rw-r--r-- | scripts/gen-sanctoral-ef.go | 101 |
2 files changed, 174 insertions, 41 deletions
diff --git a/internal/caldata/caldata_test.go b/internal/caldata/caldata_test.go index 2ba052d..7dbbf84 100644 --- a/internal/caldata/caldata_test.go +++ b/internal/caldata/caldata_test.go @@ -246,18 +246,56 @@ func TestTridentineRomanusAndEusebiusPresent(t *testing.T) { } else if rc.Fields["date"] != "08-09" { t.Errorf("romanus: date = %q, want 08-09", rc.Fields["date"]) } - want := map[string]string{ - "eusebius-confessor": "08-14", // "S. Eusebii Conf." -- a Confessor - "eusebius": "12-16", // "S. Eusebii Ep. et Mart." -- a Bishop and Martyr, a different person + want := map[string]struct{ date, rank string }{ + // St Eusebius Confessor (14 Aug) and St Romanus (9 Aug, checked + // above) are both bare "Commemoratio" in the calendarium (no class + // of their own) -- pinned here, not just presence/date, per + // TestTridentineCommemorationRanksPinned's own reasoning below: + // romanus's own rank is pinned there instead, since that test + // groups every commemoration-rank entry this task's fixes touched + // in one place. eusebius-confessor is listed here because it is + // the OTHER half of this specific test's own slug-disambiguation + // story. + "eusebius-confessor": {"08-14", "commemoration"}, // "S. Eusebii Conf." -- a Confessor + "eusebius": {"12-16", "class-3"}, // "S. Eusebii Ep. et Mart." -- a Bishop and Martyr, a different person, unaffected by this round } - for slug, date := range want { + for slug, w := range want { rc, ok := l.Cels[slug] if !ok { - t.Errorf("missing %q (%s)", slug, date) + t.Errorf("missing %q (%s)", slug, w.date) continue } - if rc.Fields["date"] != date { - t.Errorf("%s: date = %q, want %q", slug, rc.Fields["date"], date) + if rc.Fields["date"] != w.date { + t.Errorf("%s: date = %q, want %q", slug, rc.Fields["date"], w.date) + } + if rc.Fields["rank"] != w.rank { + t.Errorf("%s: rank = %q, want %q", slug, rc.Fields["rank"], w.rank) + } + } +} + +// TestTridentineCommemorationRanksPinned: nothing in this test suite +// asserted `rank` for thomas-becket, silvester, or romanus before this -- +// `TestTridentineRomanusAndEusebiusPresent` above pinned presence and date +// for romanus but not rank, and Thomas Becket/Silvester (the I3 fix, RG +// 68(d)/(e)) had no rank assertion anywhere at all. So a future +// regeneration could silently rewrite any of the three back to class-4 -- +// the EXACT I3 failure mode -- with the suite green throughout. Pinned +// together because they are the same failure shape (a bare "Commemoratio" +// in the calendarium, no class of its own, silently promoted by +// refYearExplainsAbsence's own coupling to temporal_ef.go, see that +// function's doc comment) even though they were found in two different +// review rounds (romanus in C2, Thomas Becket/Silvester in I3). +func TestTridentineCommemorationRanksPinned(t *testing.T) { + l := Tridentine() + for _, slug := range []string{"romanus", "thomas-becket", "silvester"} { + rc, ok := l.Cels[slug] + if !ok { + t.Errorf("missing %q", slug) + continue + } + if rc.Fields["rank"] != "commemoration" { + t.Errorf("%s: rank = %q, want commemoration", slug, rc.Fields["rank"]) } } } @@ -300,24 +338,62 @@ func TestTridentineNoTransferArtifacts(t *testing.T) { // reaching mobile.Day(date, "ef", version, "pl") -- a shipped dlectio entry // point -- with no error anywhere in the chain. // -// Checks both a broad coverage floor (not exactly 322: a handful of entries -// added by this same task's own fixes -- St Agnes secundo, St Boniface -// Martyr, St Evaristus, St Theodore, St Romanus, St Eusebius Confessor -- -// never had a curated Polish name to preserve in the first place, so 322 is -// not achievable) and one specific, checkable value (the coordinator's own -// example) so a coverage-only guard cannot itself be satisfied by silently -// wrong values. +// CORRECTED after review: the first version of this test counted name.pl +// only (a hardcoded single language -- the exact "whitelist of two +// languages" mistake C1's own fix was written to stop repeating, just +// moved into the test), and its coverage floor (>= 315) had 7 entries of +// slack -- the review proved dropping 6 entries' Polish names still passed +// it. Fixed on both axes: the language set is DISCOVERED from the data +// (every "name.<lang>" key actually present, not a hardcoded list, so a +// regeneration dropping name.la or introducing a future name.de is +// checked the same way as name.pl), and the floor for each discovered +// language is its EXACT true count, not a loose approximation -- verified +// directly against the branch point (`git show 2386a45:...`) once, by +// hand, and hardcoded as the answer, not derived at test time from data +// that could itself be wrong. +// +// True counts, independently verified: name.en on every entry (327, all +// regenerated fresh from missalemeum, including the 5 entries this task's +// own fixes added); name.pl on exactly 322 (preserved from the branch +// point; the 5 new entries -- St Agnes secundo, St Boniface Martyr, St +// Evaristus, St Theodore, St Eusebius Confessor -- never had a curated +// Polish name to preserve in the first place, so 322, not 327, is the +// correct target, not a shortfall); no OTHER name.<lang> exists in the +// branch point at all (name.la is read by the generator but the +// bootstrapped file has in fact never carried one), so the discovered +// language set itself must be exactly {en, pl} -- a regeneration that +// silently introduced or lost an entire language key, not just some +// values within one, is caught by this assertion, not only by the count. func TestTridentineNamesPreservedAcrossRegeneration(t *testing.T) { l := Tridentine() - n := 0 + counts := map[string]int{} for _, rc := range l.Cels { - if rc.Fields["name.pl"] != "" { - n++ + for k, v := range rc.Fields { + if v == "" || !strings.HasPrefix(k, "name.") { + continue + } + lang := strings.TrimPrefix(k, "name.") + counts[lang]++ } } - if n < 315 { - t.Errorf("name.pl coverage = %d entries, want >= 315 (was 322 before any of this task's fixes; a regeneration must preserve it, not merely not-crash)", n) + wantLangs := map[string]bool{"en": true, "pl": true} + for lang := range counts { + if !wantLangs[lang] { + t.Errorf("unexpected name.%s present (%d entries) -- discovered language set must be exactly {en, pl}", lang, counts[lang]) + } } + for lang := range wantLangs { + if counts[lang] == 0 { + t.Errorf("name.%s entirely absent -- discovered language set must be exactly {en, pl}", lang) + } + } + if got := counts["en"]; got != len(l.Cels) { + t.Errorf("name.en coverage = %d entries, want %d (every entry, all freshly regenerated)", got, len(l.Cels)) + } + if got := counts["pl"]; got != 322 { + t.Errorf("name.pl coverage = %d entries, want exactly 322 (the branch point's own true count; the 5 entries this task added have no curated Polish name to preserve, so more or fewer than 322 is a bug either way)", got) + } + rc, ok := l.Cels["assumption-of-the-blessed-virgin-mary"] if !ok { t.Fatal("missing assumption-of-the-blessed-virgin-mary") diff --git a/scripts/gen-sanctoral-ef.go b/scripts/gen-sanctoral-ef.go index 0812472..0a43869 100644 --- a/scripts/gen-sanctoral-ef.go +++ b/scripts/gen-sanctoral-ef.go @@ -233,6 +233,19 @@ type mmDay struct { } `json:"sections"` } +// fetchOnce fetches one date's proper. KNOWN LIMITATION: missalemeum +// returns one array element PER MASS on a date with more than one (25 +// December: three, ids ...m1/...m2/...m3) and this reads only `data[0]`, +// the first -- so a commemoration attached specifically to a second or +// third Mass (the calendarium's own "In secunda Missa: Commemoratio..." +// pattern, e.g. St Anastasia on Christmas Day) is structurally invisible +// to every caller of this function, not just harvestDate's own use of it. +// See the PRIMARY-SOURCE NOTE above harvestDate for the fuller account -- +// in Anastasia's specific case this is moot (missalemeum's own +// "commemorations" list is empty on all three of the date's records, not +// just the first, so fixing this would not by itself recover her), but the +// limitation is real and would matter for any date whose SECOND or third +// Mass genuinely does carry a commemoration missalemeum's API records. func fetchOnce(date string) (*mmDay, error) { req, _ := http.NewRequest("GET", "https://www.missalemeum.com/en/api/v5/proper/"+date, nil) req.Header.Set("User-Agent", ua) @@ -350,6 +363,28 @@ func readingsFrom(d *mmDay) (first, gospel string) { // wins. (14 August's "St. Eusebius, Conf." and 16 December's "St. Eusebius, // Ep. et Mart." are two different people, both genuinely in the calendarium // -- see slugOverride below, not a reason to drop either.) +// +// Of the four confirming examples above, three (Donatus, Romanus, Eusebius) +// are fixed by this generator as of this note. **25 December's Anastasia is +// NOT, and cannot be from this data source alone** -- a different, NOT +// generator-fixable limitation, recorded so the next person does not +// mistake her continued absence for an oversight of the fix above (she was +// also missing at the branch point, so this is not a regression either): +// the photographic scan places her "In secunda Missa: Commemoratio S. +// Anastasiae Mart." -- specifically the SECOND of Christmas Day's three +// Masses (missalemeum's own "2026-12-25" query independently confirmed to +// return exactly three records, ids ...m1/...m2/...m3). Two compounding +// problems, not one: fetchOnce (below) reads only `data[0]`, the FIRST +// Mass, so this harvester cannot structurally see a commemoration attached +// to a date's second or third Mass at all, for ANY date, not just this +// one -- but ALSO, checked directly (not merely inferred from the +// symptom), missalemeum's OWN "commemorations" list is empty on all THREE +// of the 25 December records, not just the first -- so even a fetchOnce +// rewritten to merge all of a date's Masses would still not recover her: +// missalemeum's own data lacks her here, the same "transcription-shaped" +// gap as Donatus/Romanus/Eusebius, just in the live API rather than the +// static PDF this time. Not fixed: no source this generator reads carries +// her. // slugOverride gives a proper, distinct slug to a small number of // commemorations whose title slugifies IDENTICALLY to an unrelated feast on @@ -388,27 +423,48 @@ var slugOverride = map[string]string{ // lower, per the calendarium), but the function does not itself enforce // it, so a future entry could reach it. // - It is a proxy for "why was this saint never observed", not a citation. -// 6-31 December is excluded from ever trusting the id-derived rank -// below, REGARDLESS of what this heuristic would otherwise say, because -// RG 68(d)/(e) settles two specific cases (Thomas Becket, 29 December; -// Silvester, 31 December) directly: the calendarium's own row names -// each a bare "Commemoratio" with NO class of its own, even though the -// DAY they fall on is II class (a day within the Nativity Octave). A -// rank-blind heuristic reading the day's own class here would (and, -// before this exclusion, did) wrongly conclude "the day was strong -// enough to explain the demotion, so the id's rank is trustworthy" -- -// right about the day, wrong about what the rubric actually says about -// THESE two named commemorations specifically. +// 17-31 December is excluded from ever trusting the id-derived rank +// below, REGARDLESS of what this heuristic would otherwise say -- +// covering BOTH of round 1's own late-Advent/Christmas-octave rank +// promotions, on two DIFFERENT strengths of evidence, recorded +// separately because they are not the same case: +// -- 26-31 December (RG 68(d)/(e), a positive citation): "die 29 +// decembris, fit commemoratio S. Thomae Episcopi et Mart.; die 31 +// decembris, fit commemoratio S. Silvestri I Papae et Conf." -- the +// calendarium names each a bare "Commemoratio" with NO class of its +// own, even though the DAY they fall on is II class (within the +// Nativity Octave). Two REAL entries (Thomas Becket, Silvester) are +// live here; see the call site's own comment. +// -- 17-23 December (RG 91 entry 18, a PRECAUTIONARY exclusion, not a +// positive citation): round 1 also promoted these late-Advent +// ferias from class-3 to class-2 (same lineage, same coupling +// shape as 26-31 December's own promotion). No live entry tests +// this range today -- the sole sanctoral entry there, `thomas` +// (21 December), is an OBSERVED class-2 feast reached via the +// harvestDate `obs` path, not this function's id-rank-trust path +// at all -- but unlike 26-31 December, there is no RG citation +// stating that a saint commemorated here has NO independent class; +// RG 91 entry 18 only ranks the FERIA, and (per defect 2's own +// finding) a genuine class-2 FEAST commemorated here would +// actually WIN against it (entry 16 above entry 18), so "the day +// is class-2" does not reliably explain a class-2 saint's absence +// the way it does for 26-31 December's own two named cases. +// Excluded anyway, on the side of the KNOWN-safe default +// (RankCommemoration) rather than risk repeating the identical +// failure shape the day this range's own first real entry arrives. // -// The 6-31 December exclusion is also a warning about a structural hazard, -// not just a one-off fix: this function calls calendar.Compute, i.e. it -// reads the ENGINE's OWN computed temporal ranks to decide what DATA to -// generate. A change to temporal_ef.go's ranking (e.g. promoting 26-31 -// December from class-4 to class-2, RG 67/68, landed in this same lineage) -// can silently flip this function's verdict and rewrite generated data with -// no code change to this file at all. Any future temporal_ef.go rank change -// should re-check this function's own boundary cases, not just its own -// tests. +// This is also a warning about a structural hazard, not just a boundary +// fix: this function calls calendar.Compute, i.e. it reads the ENGINE's OWN +// computed temporal ranks to decide what DATA to generate. A change to +// temporal_ef.go's ranking (e.g. round 1's own 17-23/26-31 December +// promotions, RG 91 entry 18 / RG 67-68) can silently flip this function's +// verdict and rewrite generated data with no code change to this file at +// all -- confirmed by direct measurement, not just reasoned about: running +// this function's body against the branch-point engine versus the current +// one over the six reference years flips the verdict on 50 dates across +// exactly these 13 MM-DD values (12-17 through 12-23, 12-26 through +// 12-31), no others. Any future temporal_ef.go rank change should re-check +// this function's own boundary cases, not just its own tests. // // harvestDate keeps a saint's commemoration id rank ONLY if every reference // year in which it was seen returned true here -- a single false (an @@ -418,8 +474,9 @@ var slugOverride = map[string]string{ // RankCommemoration, proving the id's rank is a candidate, not a verdict -- // see the call site's own comment). func refYearExplainsAbsence(date time.Time) bool { - if date.Month() == time.December && date.Day() >= 26 && date.Day() <= 31 { - // RG 68(d)/(e): "die 29 decembris, fit commemoratio S. Thomae + if date.Month() == time.December && date.Day() >= 17 && date.Day() <= 31 { + // RG 68(d)/(e) (26-31 Dec) and RG 91 entry 18 (17-23 Dec, + // precautionary): "die 29 decembris, fit commemoratio S. Thomae // Episcopi et Mart.; die 31 decembris, fit commemoratio S. // Silvestri I Papae et Conf." -- both named as a bare // "Commemoratio", no class. See this function's own doc comment. |
