From 1a1b296c5f589658e4fb1e100066a8b1d4da293a Mon Sep 17 00:00:00 2001 From: Will Estes Date: Thu, 13 Aug 2026 14:19:32 -0400 Subject: [PATCH] Model the Paschaltide alleluia suffix on Common-category antiphons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-querying settled the "sometimes 1, sometimes 2 allelúja" question: categories without their own dedicated Paschaltide source-text chain (Confessor-Bishop/Doctor, Abbot, Virgin, ...) get a mechanical single suffix appended at render time, not a rank/octave-dependent count. Co-Authored-By: Claude Sonnet 5 --- TODO.md | 80 ++++++++++++++++--- src/hours/resolve-common.ts | 122 ++++++++++++++++++++++++----- tests/hours/resolve-common.test.ts | 55 ++++++++++++- 3 files changed, 223 insertions(+), 34 deletions(-) diff --git a/TODO.md b/TODO.md index 7230fee..4a41733 100644 --- a/TODO.md +++ b/TODO.md @@ -606,18 +606,74 @@ tries his own id first). English wasn't rendered at all for any of his 6 new texts (same Latin-duplicated-into-English gap as St. Scholastica/ St. Benedict), so all 6 are `status.en: draft`. -**One loose thread noticed, not chased**: St. Athanasius (May 2) and -St. Leo I (April 11), both tested in years that happened to fall within -Eastertide, rendered their shared `common-of-a-confessor-bishop` text -with a trailing "`allelúja`" appended to every antiphon — but the other -4 Doctor saints (tested in non-Paschaltide months) didn't. This isn't -the same kind of thing as the already-modeled `-paschaltide` Common -variants (`common-of-apostles-paschaltide` etc., which are *wholly -different wording*, not the same text plus a suffix) — looks more like -a general seasonal "append allelúja in Paschaltide" rule this app -doesn't model at any level yet. Not blocking (the plain, correctly- -verified text still renders in every other season, which is most of the -year), but worth a look if Eastertide rendering fidelity matters later. +**A loose thread noticed, then chased down and fixed (2026-08)**: St. +Athanasius (May 2) and St. Leo I (April 11), both tested in years that +happened to fall within Eastertide, rendered their shared +`common-of-a-confessor-bishop` text with a trailing "`allelúja`" +appended to every antiphon — but the other 4 Doctor saints (tested in +non-Paschaltide months) didn't. Live-querying further (a full sweep of +Common categories across the Easter/Ascension/Pentecost octaves and the +ordinary weeks around them, reading the reference source's raw +`Commune/*p.txt` chains directly rather than guessing from rendered +pages) settled it precisely: + +- A handful of categories (Apostles, Martyr, Martyr-Bishop, Several + Martyrs, Pope-Martyrs) have their own dedicated `Cxxp.txt` source + chain — a genuinely *different* text for Paschaltide, not the same + text plus a suffix (confirmed by reading the raw files: e.g. the + "Fíliæ Jerúsalem..." Pope-Martyrs Benedictus antiphon comes from a + Matins responsory reused for Lauds specifically during this window, + nowhere near the non-Paschaltide chain's own "Mártyrum chorus..." + text). Only `common-of-apostles` has this wholesale text actually + authored into this app's data (`*-paschaltide.yml`, from an earlier + pass) — the other four still fall through to the mechanical suffix + below until someone authors their real text, which is a strictly + better approximation than the unmodified default. +- Every *other* category (Confessor-Bishop/Doctor, Abbot, Virgin, and + presumably Dedication/Vigil/Angel, untested but structurally the + same) has no dedicated chain at all — the reference engine just + mechanically appends a single ", allelúja"/", alleluia" to the + otherwise-unchanged base text, for the whole Eastertide → Ascensiontide + → Pentecost-octave-week span. Live-verified across three unrelated + categories (St. Athanasius/Confessor-Bishop, St. Robert/Abbot, St. + Catherine of Siena/Virgin) — never doubled. +- The "sometimes 1, sometimes 2" appearance wasn't actually the same + category flipping — it was different *ranks of saint* getting + different octave privileges. Only martyr-class saints (already in the + dedicated-chain group above) are ever eligible for even a + commemoration inside a true privileged octave (confirmed: St. + Anicetus, commemorated on a Semiduplex day inside the actual Easter + octave, correctly showing the doubled dedicated-chain text). + Confessor/Abbot/Virgin-class saints never get that far — checked St. + Leo I and St. Basil the Great both landing on/near a real octave in + some year, and both are fully transferred, no commemoration at all — + so the plain-suffix categories structurally can never be observed + inside a real octave, only in the ordinary weeks around it. +- One practical check this settled without needing a code change: + `common-of-pope-martyrs` (Ss. Soter & Caius, Ss. Cletus & Marcellinus) + is itself one of the dedicated-chain categories, so its one stored + file is technically the Paschaltide-only text, not a universal + default — but tracing both saints' fixed dates against every possible + Easter date confirms this is harmless: whenever their date would fall + outside Eastertide, it lands inside the Sacred Triduum instead + (always fully transferred, never rendered), so the stored text is + correct for every real occurrence. + +Built: `hours/resolve-common.ts`'s `appendPaschaltideAlleluia`/ +`withPaschaltideAlleluia`, applied to the Common-category fallback +branch of `getBenedictusAntiphon` and `resolveMinorHourAntiphon` (not +the `-paschaltide`-file branch, which already had its own correct +wholesale text; not a saint's own unique `${propers}-antiphon`, which +would need its own live-requery to confirm one way or the other rather +than assuming the Common-category finding transfers; not capitula, +whose closing versicles get the same suffix in the reference engine too +— confirmed, St. Robert's Terce capitulum — but whose text is one +embedded R./V. blob per language rather than a clean appendable tail, +not attempted this pass). Guards against double-appending on a category +whose stored text already ends in "allelúja" (`common-of-pope-martyrs`, +per above). 4 new tests in `tests/hours/resolve-common.test.ts` +(`Paschaltide alleluia suffix on Common-category antiphons`), 300 tests +passing. Verified end-to-end: a runtime smoke test (folded into `tests/hours/resolve-common.test.ts`) covering the same-person reuse diff --git a/src/hours/resolve-common.ts b/src/hours/resolve-common.ts index 6a18948..17650c2 100644 --- a/src/hours/resolve-common.ts +++ b/src/hours/resolve-common.ts @@ -132,19 +132,95 @@ export function getMinorHourOverrideId(day: LiturgicalDay): string | undefined { } /** - * "Tempore Paschali" — the reference engine's own window for a Common's - * Paschaltide variant (Commune/C1p.txt etc.: only Apostles, a Martyr- - * Bishop, and Several Martyrs get one — Confessors/Virgins/Doctors never - * do, matching the real tradition's own restriction of Alleluia/victory - * imagery to apostles and martyrs specifically). Deliberately the same - * three-season set as Compline's own Marian-antiphon table - * (`data/hours/marian-antiphon-by-season.yml`'s `eastertide`/ - * `ascensiontide`/`pentecost` all mapping to Regina Caeli, not just - * `eastertide` alone) — "Paschalis" runs through the end of the Pentecost - * octave in both cases, not just to Ascension. + * "Tempore Paschali" — the reference engine's own window for Paschaltide + * text changes. Deliberately the same three-season set as Compline's own + * Marian-antiphon table (`data/hours/marian-antiphon-by-season.yml`'s + * `eastertide`/`ascensiontide`/`pentecost` all mapping to Regina Caeli, not + * just `eastertide` alone) — "Paschalis" runs through the end of the + * Pentecost octave in both cases, not just to Ascension. + * + * RESOLVED (2026-08): the earlier version of this comment claimed only + * Apostles/Martyr-Bishop/Several-Martyrs get a Paschaltide change at all, + * and that Confessors/Virgins/Doctors never do. Live-querying settled + * this more precisely, per the "St. Athanasius/allelúja" investigation + * (TODO.md): the reference source (Commune/C1p.txt, C2p.txt, C2bp.txt, + * C3p.txt, C3bp.txt) really does give *some* categories — Apostles, + * Martyr, Martyr-Bishop, Several Martyrs, Pope-Martyrs — their own + * wholesale-different Paschaltide text (often, not always, doubling the + * alleluia as genuinely different content, confirmed by reading the raw + * chain rather than guessing from the rendered page). But categories + * *without* one of those (Confessor-Bishop/Doctor, Abbot, Virgin — all + * three live-verified this pass: St. Athanasius, St. Robert, St. + * Catherine of Siena) still change during this same window: the engine + * mechanically appends a single ", allelúja"/", alleluia" to the closing + * clause of the otherwise-unchanged base text. `appendPaschaltideAlleluia` + * below models that fallback; the dedicated-chain categories are still + * handled the older way, by trying a same-id `-paschaltide` file first + * (only authored for `common-of-apostles` so far — the other four + * dedicated-chain categories fall through to the mechanical suffix too + * until someone authors their real wholesale text, which is a strictly + * better approximation than rendering the bare non-Paschaltide default). */ const PASCHALTIDE_SEASONS = new Set(['eastertide', 'ascensiontide', 'pentecost']); +/** + * The reference engine's mechanical Paschaltide fallback for a Common + * antiphon with no dedicated wholesale-different text of its own: a + * single ", allelúja."/", alleluia." tacked onto the closing clause, + * text otherwise byte-identical to the non-Paschaltide default. Live- + * verified across three unrelated categories (St. Athanasius/Confessor- + * Bishop, St. Robert/Abbot, St. Catherine of Siena/Virgin) — never + * doubled, and the app's stored antiphon text already carries the + * incipit/full split as one string with an embedded "*" + * (`splitNamedAntiphon`/`splitAntiphon`), so appending to the very end + * only ever lands in the "full" half, matching the reference engine's own + * behavior exactly (its *incipit*-only rendering, sung before the psalm, + * never carries the suffix — only the closing, full-text rendering after + * it does). + * + * Skips a language whose stored text already ends in "allelúja"/ + * "alleluia" — `common-of-pope-martyrs` is the one category this + * actually matters for: its own stored files were, unavoidably, captured + * from a live query already inside Paschaltide (its only two members' + * fixed dates structurally can never be queried at any other real point + * in the calendar without landing in the Sacred Triduum instead, where + * they're always fully transferred and never render at all — see + * TODO.md) — so what's stored there already *is* the Paschaltide text, + * and blindly appending here would double up. + * + * Deliberately scoped to antiphons only, not capitula: a capitulum's own + * closing versicles get the same suffix in the reference engine too + * (confirmed, St. Robert's Terce capitulum), but that text is one + * embedded R./V. blob per language, not a clean appendable tail, and + * reproducing that safely wasn't attempted this pass — see TODO.md. + */ +function appendPaschaltideAlleluia(resolved: ResolvedText): ResolvedText { + const suffixByLang: Partial> = { la: 'allelúja', en: 'alleluia' }; + const text: Partial> = { ...resolved.text }; + for (const [lang, suffix] of Object.entries(suffixByLang)) { + const t = text[lang]; + if (!t || /allel(?:úja|uia)\.?\s*$/i.test(t)) { + continue; + } + text[lang] = `${t.replace(/\.\s*$/, '')}, ${suffix}.`; + } + return { ...resolved, text }; +} + +/** Applies appendPaschaltideAlleluia only when the day actually falls in + * Paschaltide and there's real text to append it to — the shared guard + * both resolveMinorHourAntiphon's Common fallback and getBenedictusAntiphon + * use, so neither has to repeat the two checks inline. */ +function withPaschaltideAlleluia(resolved: ResolvedText, day: LiturgicalDay): ResolvedText { + if (!PASCHALTIDE_SEASONS.has(day.season)) { + return resolved; + } + if (resolved.status.la === 'missing' && resolved.status.en === 'missing') { + return resolved; + } + return appendPaschaltideAlleluia(resolved); +} + /** A Little Hour's (or Prime's) plain per-weekday antiphon, overridden by * a duplex-majus+ feast's own proper (`${hourId}-antiphon-${id}`) when * authored, then by that saint's shared Common (`${hourId}-antiphon- @@ -155,7 +231,9 @@ const PASCHALTIDE_SEASONS = new Set(['eastertide', 'ascensiontide', 'pentecost'] * been authored — same honest "not gated behind whether content exists, * just eligible to override at all" fallback as every other override in * this codebase: an eligible feast with none of these authored yet just - * falls through to the plain weekday default silently. + * falls through to the plain weekday default silently. The plain (non- + * `-paschaltide`) Common fallback itself still picks up a seasonal change + * in Paschaltide — see `withPaschaltideAlleluia`. */ export function resolveMinorHourAntiphon( hourId: string, @@ -178,7 +256,7 @@ export function resolveMinorHourAntiphon( } const common = resolveCommon(`${hourId}-antiphon-${commonId}`); if (common.status.la !== 'missing' || common.status.en !== 'missing') { - return common; + return withPaschaltideAlleluia(common, day); } } } @@ -340,12 +418,18 @@ export function getDayCollects(day: LiturgicalDay): ResolvedPart[] { * antiphon` (not authored yet for any temporal id — resolves "missing", * same pending convention as everywhere else) for a temporal one, then * `saint.benedictusCommon` (`benedictus-antiphon-${benedictusCommon}.yml`, - * see SaintRecord's own doc comment) when neither exists. Uses - * resolveOfficeWinner, not the raw `day.winner` — on an octave day this - * is the octave's own feast's antiphon (live-verified: St. Lawrence's - * "In cratícula", not the commemorated St. Clare's, and not the plain - * temporal day's), not a *commemorated* saint's, which is a separate, - * weaker standing (see getDayCollects/sanctoralCommemorationPart). + * see SaintRecord's own doc comment) when neither exists — run through + * `withPaschaltideAlleluia` on the way out, so a Common-category + * Benedictus antiphon picks up the same seasonal suffix its Prime/Terce/ + * Sext/None counterparts do (see PASCHALTIDE_SEASONS's own doc comment); + * a saint's own unique `${propers}-antiphon` deliberately isn't — that + * would need its own live-requery to confirm one way or the other, not + * assumed from the Common-category finding. Uses resolveOfficeWinner, not + * the raw `day.winner` — on an octave day this is the octave's own + * feast's antiphon (live-verified: St. Lawrence's "In cratícula", not the + * commemorated St. Clare's, and not the plain temporal day's), not a + * *commemorated* saint's, which is a separate, weaker standing (see + * getDayCollects/sanctoralCommemorationPart). */ export function getBenedictusAntiphon(day: LiturgicalDay): ResolvedText { const winner = resolveOfficeWinner(day); @@ -360,7 +444,7 @@ export function getBenedictusAntiphon(day: LiturgicalDay): ResolvedText { } } if (saint?.benedictusCommon) { - return resolveCommon(`benedictus-antiphon-${saint.benedictusCommon}`); + return withPaschaltideAlleluia(resolveCommon(`benedictus-antiphon-${saint.benedictusCommon}`), day); } return { text: {}, status: { la: 'missing', en: 'missing' } }; } diff --git a/tests/hours/resolve-common.test.ts b/tests/hours/resolve-common.test.ts index b84c53f..35573b1 100644 --- a/tests/hours/resolve-common.test.ts +++ b/tests/hours/resolve-common.test.ts @@ -7,13 +7,13 @@ import { resolveMinorHourChapter, splitNamedAntiphon, } from '../../src/hours/resolve-common'; -import type { LiturgicalDay } from '../../src/calendar/types'; +import type { LiturgicalDay, Season } from '../../src/calendar/types'; -function syntheticSanctoralDay(id: string): LiturgicalDay { +function syntheticSanctoralDay(id: string, season: Season = 'trinitytide'): LiturgicalDay { return { date: '2026-01-01', weekday: 'thursday', - season: 'trinitytide', + season, temporalCategory: 'ordinary-feria', winner: { kind: 'sanctoral', id, name: id, rank: 'simplex' }, commemorations: [], @@ -256,3 +256,52 @@ describe('splitNamedAntiphon', () => { expect(repeat?.kind === 'antiphon' ? repeat.text.status.en : undefined).toBe('draft'); }); }); + +describe('Paschaltide alleluia suffix on Common-category antiphons (2026-08)', () => { + // Live-query-verified (St. Athanasius/Confessor-Bishop, St. Robert/Abbot, + // St. Catherine of Siena/Virgin): a Common category with no dedicated + // wholesale-different Paschaltide text still gets a mechanical single + // ", allelúja"/", alleluia" appended to its otherwise-unchanged base text + // during Eastertide/Ascensiontide/Pentecost-octave-week. St. Ambrose + // (common-of-a-confessor-bishop, propers: null) exercises the Common + // fallback branch directly rather than a per-saint proper file. + it("appends the seasonal alleluia to a Common Benedictus antiphon in Paschaltide", () => { + const eastertide = getBenedictusAntiphon(syntheticSanctoralDay('st-ambrose', 'eastertide')); + expect(eastertide.text.la).toBe( + 'Euge, serve bone * et fidélis, quia in pauca fuísti fidélis, supra multa te constítuam, dicit Dóminus, allelúja.', + ); + expect(eastertide.text.en).toBe( + 'Well done, thou good and faithful servant * thou hast been faithful over a few things, I will make thee ruler over many things, saith the Lord, alleluia.', + ); + expect(eastertide.status).toEqual({ la: 'verified', en: 'verified' }); + }); + + it('leaves the same Common Benedictus antiphon unchanged outside Paschaltide', () => { + const ordinary = getBenedictusAntiphon(syntheticSanctoralDay('st-ambrose', 'trinitytide')); + expect(ordinary.text.la).toBe( + 'Euge, serve bone * et fidélis, quia in pauca fuísti fidélis, supra multa te constítuam, dicit Dóminus.', + ); + expect(ordinary.text.la).not.toContain('allelúja'); + }); + + it('appends the suffix on the minor-hour Common fallback too (ascensiontide, not just eastertide)', () => { + const prime = resolveMinorHourAntiphon('prime', syntheticSanctoralDay('st-ambrose', 'ascensiontide'), {}); + expect(prime.text.la?.endsWith('allelúja.')).toBe(true); + expect(prime.text.la).not.toMatch(/allelúja.*allelúja/); // never doubled for this category + }); + + it("doesn't double up on a Common category whose stored text is already the (unavoidably Paschaltide-only) native text", () => { + // common-of-pope-martyrs (Ss. Soter and Caius) -- its own stored files + // were necessarily captured from a live query already inside + // Paschaltide (see TODO.md: their fixed dates can only ever land in + // real Eastertide or the Sacred Triduum, never elsewhere), so the + // text already ends "allelúja, allelúja." -- appending again would be + // wrong. + const day = syntheticSanctoralDay('ss-soter-and-caius', 'eastertide'); + const benedictus = getBenedictusAntiphon(day); + expect(benedictus.text.la).toBe( + 'Fíliæ Jerúsalem, * veníte et vidéte Mártyres cum corónis, quibus coronávit eos Dóminus in die solemnitátis et lætítiæ, allelúja, allelúja.', + ); + expect(benedictus.text.la?.match(/allelúja/g)?.length).toBe(2); + }); +});