Model the Paschaltide alleluia suffix on Common-category antiphons
Deploy / deploy (push) Successful in 56s

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 14:19:32 -04:00
parent 75b731221e
commit 1a1b296c5f
3 changed files with 223 additions and 34 deletions
+68 -12
View File
@@ -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
+103 -19
View File
@@ -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<Record<string, string>> = { la: 'allelúja', en: 'alleluia' };
const text: Partial<Record<string, string>> = { ...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' } };
}
+52 -3
View File
@@ -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);
});
});