Add Matins hymn Common-of-Saints tier and a real invitatory-antiphon mechanism

resolveMatinsHymn gained a Common-category tier (override -> octave ->
Common category -> season -> ferial), reusing matins-psalmody-
overrides.ts's own getSaintRecord(id)?.common lookup pattern. The
invitatory antiphon (invitatoryParts), previously one hardcoded fixed
text for every day of the year with only full-vs-incipit doubling
varying by rank, now goes through a real resolveMatinsInvitatoryText
(override -> Common category -> season -> ferial, no octave tier) before
the doubling step.

Content authored this pass: Common-of-an-Apostle only, both hymn
(matins-hymn-common-of-an-apostle.yml, "Aeterna Christi munera") and
invitatory antiphon (matins-invitatory-common-of-an-apostle.yml, "Regem
Apostolorum Dominum"), both read directly from the reference engine's
Commune/C1.txt. Live-verified: St. Bartholomew (2026-08-24) and St.
Andrew (2026-11-30) both now resolve to this Common's text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F25189JqjXddUhU9hM9nUS
This commit is contained in:
2026-08-24 07:14:06 -04:00
parent 8f55ec6931
commit 449472d5aa
4 changed files with 259 additions and 7 deletions
@@ -0,0 +1,101 @@
# The Common-of-an-Apostle's own Matins hymn ("Ætérna Christi múnera" /
# "Th' eternal gifts of Christ the King"), read from the reference engine's
# own Commune/C1.txt [Hymnus Matutinum] block (both Latin and English
# present and complete there). Any winning apostle with no proper Matins
# hymn of his own authored falls to this Common tier (hours/matins.ts's
# resolveMatinsHymn) — e.g. St. Bartholomew (2026-08-24, live-verified):
# Monastic 1617 gives him no [Hymnus Matutinum] override of his own, so
# this Common's own text genuinely renders.
id: matins-hymn-common-of-an-apostle
text:
la: >-
Ætérna Christi múnera,
Apostolórum glóriam,
Palmas et hymnos débitos
Lætis canámus méntibus.
Ecclesiárum Príncipes,
Belli triumpháles duces,
Cæléstis aulæ mílites,
Et vera mundi lúmina.
Devóta Sanctórum fides,
Invícta spes credéntium,
Perfécta Christi cáritas
Mundi tyránnum cónterit.
In his Patérna glória,
In his triúmphat Fílius,
In his volúntas Spíritus,
Cælum replétur gáudio.
Patri, simúlque Fílio,
Tibíque Sancte Spíritus,
Sicut fuit, sit júgiter
Sæclum per omne glória. Amen.
en: >-
Th' eternal gifts of Christ the King,
The Apostles' glory, let us sing;
And all with hearts of gladness raise
Due hymns of thankful love and praise.
For they the Church's princes are,
Triumphant leaders in the war,
The heavenly King's own warrior band,
True lights to lighten every land.
Theirs was the steadfast faith of saints,
The hope that never yields nor faints,
The love of Christ in perfect glow,
That lay the prince of this world low.
In them the Father's glory shone,
In them the Spirit's will was done,
The Son himself exults in them;
Joy fills the new Jerusalem.
Praise to the Father, with the Son,
And Holy Spirit, Three in One;
As ever was in ages past,
And so shall be while ages last. Amen.
status:
la: verified
en: verified
@@ -0,0 +1,13 @@
# The Common-of-an-Apostle's own invitatory antiphon ("Regem Apostolórum
# Dóminum" / "The Lord, the King of Apostles"), read from the reference
# engine's own Commune/C1.txt [Invit] line (both languages). Any winning
# apostle with no proper invitatory antiphon of his own authored falls to
# this Common tier (hours/matins.ts's resolveMatinsInvitatoryText) — e.g.
# St. Bartholomew (2026-08-24, live-verified).
id: matins-invitatory-common-of-an-apostle
text:
la: "Regem Apostolórum Dóminum, * Veníte, adorémus."
en: "The Lord, the King of Apostles, * O come, let us adore."
status:
la: verified
en: verified
+72 -7
View File
@@ -80,6 +80,7 @@ import {
splitNamedAntiphon, splitNamedAntiphon,
openingAntiphon, openingAntiphon,
} from './resolve-common'; } from './resolve-common';
import { getSaintRecord } from '../calendar/feasts';
import { getBiblePlanReadings } from '../propers/bible-plan'; import { getBiblePlanReadings } from '../propers/bible-plan';
import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-readings'; import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-readings';
import { getOctaveReading } from '../propers/octave-readings'; import { getOctaveReading } from '../propers/octave-readings';
@@ -168,14 +169,56 @@ function psalmRefParts(refs: PsalmRef[]): ResolvedPart[] {
})); }));
} }
/** The invitatory antiphon's own text — same override > Common-category >
* seasonal > ferial precedence as `resolveMatinsHymn`, minus the octave
* tier: no per-octave invitatory-antiphon override exists anywhere in the
* reference source (unlike the hymn, which genuinely persists all week on
* some octaves) — an octave day already falls through correctly to its
* governing saint's own Common/season tier here, so adding an octave tier
* would have nothing to fire on. Keeps the existing ferial fallback id
* (`matins-invitatory-antiphon`) as-is rather than renaming it to match
* the hymn's `-ferial` convention — it's already `verified` and its own
* header already documents itself as the fallback-of-record; renaming
* would be pure churn.
*
* Doubling (`isDoubleOrHigher`, via `openingAntiphon`) is a separate, final
* step applied to whichever tier's text wins here — never part of content
* selection itself. */
function resolveMatinsInvitatoryText(day: LiturgicalDay): ResolvedText {
const overrideId = getOfficeOverrideId(day);
if (overrideId) {
const proper = resolveCommon(`matins-invitatory-${overrideId}`);
if (proper.status.la !== 'missing' || proper.status.en !== 'missing') {
return proper;
}
}
const winner = resolveOfficeWinner(day);
const commonId = winner.kind === 'sanctoral' ? getSaintRecord(winner.id)?.common : undefined;
if (commonId) {
const common = resolveCommon(`matins-invitatory-${commonId}`);
if (common.status.la !== 'missing' || common.status.en !== 'missing') {
return common;
}
}
const seasonSuffix = seasonalOfficeSuffix(day.season);
if (seasonSuffix) {
const seasonal = resolveCommon(`matins-invitatory-${seasonSuffix}`);
if (seasonal.status.la !== 'missing' || seasonal.status.en !== 'missing') {
return seasonal;
}
}
return resolveCommon('matins-invitatory-antiphon');
}
/** The Invitatory (Ps 94) — real practice interleaves its antiphon as a /** The Invitatory (Ps 94) — real practice interleaves its antiphon as a
* repeating refrain between verse groups; per direct instruction this app * repeating refrain between verse groups; per direct instruction this app
* frames it like any other psalm antiphon instead (one opening, one full * frames it like any other psalm antiphon instead (one opening, one full
* repeat after) — see data/propers/common/matins-invitatory-antiphon.yml's * repeat after) — see data/propers/common/matins-invitatory-antiphon.yml's
* own header. */ * own header. */
function invitatoryParts(day: LiturgicalDay): ResolvedPart[] { function invitatoryParts(day: LiturgicalDay): ResolvedPart[] {
const { incipit, full } = splitNamedAntiphon(resolveCommon('matins-invitatory-antiphon')); const text = resolveMatinsInvitatoryText(day);
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit; const opening = openingAntiphon(text, resolveOfficeWinner(day));
const { full } = splitNamedAntiphon(text);
return [ return [
psalmPartWithAntiphon(94, opening), psalmPartWithAntiphon(94, opening),
{ kind: 'antiphon', text: full }, { kind: 'antiphon', text: full },
@@ -229,11 +272,16 @@ function sundayCanticleNocturn(group: SundayNocturn, day: LiturgicalDay): Resolv
* getOfficeOverrideId eligibility Lauds/Vespers' own resolveOffice uses), * getOfficeOverrideId eligibility Lauds/Vespers' own resolveOffice uses),
* when authored; else, on a day within an active octave whose own feast * when authored; else, on a day within an active octave whose own feast
* has a hymn authored, that octave's hymn (`matins-hymn-${octave.id}`) — * has a hymn authored, that octave's hymn (`matins-hymn-${octave.id}`) —
* see below; else falls to a *seasonal* default (Advent/Lent/ * see below; else the winner's Common-of-Saints hymn (`matins-hymn-
* Passiontide/Paschaltide, none authored yet), else the plain year-round * ${commonId}`, via `SaintRecord.common`, e.g. "Aeterna Christi munera"
* ferial hymn — same override > octave > season > ferial precedence every * for any Apostle with no proper hymn of his own — same category-lookup
* other hour's own office bundle uses (see lauds.ts's resolveOffice), just * pattern as matins-psalmody-overrides.ts's getMatinsPsalmodyOverride);
* never fully wired up here before. * else falls to a *seasonal* default (Advent/Lent/Passiontide/
* Paschaltide, none authored yet), else the plain year-round ferial hymn —
* override > octave > Common > season > ferial. Common slots between
* octave and season: an octave's own proper hymn is more specific than any
* Common and must keep winning; a Common-of-Saints hymn is more specific
* than a bare season and must win over it.
* *
* Concrete motivating case (user, 2026-08-21/22): the Assumption's octave * Concrete motivating case (user, 2026-08-21/22): the Assumption's octave
* (`Sancti/08-21bmv.txt`'s own `[Rule] ex Sancti/08-15`) genuinely keeps * (`Sancti/08-21bmv.txt`'s own `[Rule] ex Sancti/08-15`) genuinely keeps
@@ -264,6 +312,23 @@ function resolveMatinsHymn(day: LiturgicalDay): ResolvedText {
return octaveHymn; return octaveHymn;
} }
} }
// Common-of-Saints tier (2026-08): a winning saint with no proper hymn of
// his own (most of them) still often shares a real, generic hymn with
// every other saint of his Common (e.g. "Aeterna Christi munera" for any
// Apostle) — same getSaintRecord(id)?.common lookup already proven by
// matins-psalmody-overrides.ts's getMatinsPsalmodyOverride. Keyed off the
// actual winner, not `overrideId` above: `getOfficeOverrideId` only
// returns an id for duplex-majus+ winners (an eligibility gate for the
// per-saint-proper tier), so reusing it here would silently skip this
// tier for any lower-ranked sanctoral winner.
const winner = resolveOfficeWinner(day);
const commonId = winner.kind === 'sanctoral' ? getSaintRecord(winner.id)?.common : undefined;
if (commonId) {
const commonHymn = resolveCommon(`matins-hymn-${commonId}`);
if (commonHymn.status.la !== 'missing' || commonHymn.status.en !== 'missing') {
return commonHymn;
}
}
const seasonSuffix = seasonalOfficeSuffix(day.season); const seasonSuffix = seasonalOfficeSuffix(day.season);
if (seasonSuffix) { if (seasonSuffix) {
const seasonal = resolveCommon(`matins-hymn-${seasonSuffix}`); const seasonal = resolveCommon(`matins-hymn-${seasonSuffix}`);
+73
View File
@@ -525,3 +525,76 @@ describe('resolveOrdo("matins", ...) nocturn-reading temporal/month-week suppres
const lessons = ordo.parts.filter((p) => p.kind === 'lesson') as { text: { status: Record<string, string> } }[]; const lessons = ordo.parts.filter((p) => p.kind === 'lesson') as { text: { status: Record<string, string> } }[];
expect(lessons.length).toBeGreaterThan(0); expect(lessons.length).toBeGreaterThan(0);
expect(lessons.some((l) => l.text.status.la !== 'missing' && l.text.status.en !== 'missing')).toBe(true); expect(lessons.some((l) => l.text.status.la !== 'missing' && l.text.status.en !== 'missing')).toBe(true);
});
});
// Coverage gap fix (no bug found): the pre-existing ferial-branch test
// above only asserted the opening invitatory antiphon *contains* "Veníte,"
// true for both the incipit ("Ant. Veníte.") and full ("Ant. Veníte, *
// Exsultémus Dómino.") forms -- it never actually distinguished doubled vs.
// undoubled. isDoubleOrHigher/openingAntiphon traced out correct in source
// for every rank tested here; these tests just make the distinction real.
describe('resolveOrdo("matins", ...) invitatory antiphon doubling by rank', () => {
function openingAntiphonLatin(date: string): string | undefined {
const ordo = resolveOrdo('matins', date);
const invitatoryPsalm = ordo.parts.find((p) => p.kind === 'psalm' && (p as { psalmNumber: number }).psalmNumber === 94) as
| { antiphon?: { text: Record<string, string> } }
| undefined;
return invitatoryPsalm?.antiphon?.text.la;
}
it('a Duplex+ weekday winner opens with the FULL (not incipit-only) invitatory antiphon (St. Lawrence, 2026-08-17: ferial text, no proper/Common of his own; St. Bartholomew, duplex-2-classis, 2026-08-24: Common-of-an-Apostle text)', () => {
expect(openingAntiphonLatin('2026-08-17')).toContain('Exsultémus');
expect(openingAntiphonLatin('2026-08-24')).toContain('Regem Apostolórum Dóminum, * Veníte');
});
it('a plain ferial day opens with the incipit only', () => {
expect(openingAntiphonLatin(FERIAL_DATE)).not.toContain('Exsultémus');
});
it("a Simplex saint merely commemorated (not winning) under an Advent feria still opens with the incipit only — the winner's own rank governs, not any commemorated saint's (St. Bibiana, 2025-12-02)", () => {
expect(openingAntiphonLatin('2025-12-02')).not.toContain('Exsultémus');
});
});
// New mechanism (2026-08): resolveMatinsHymn gained a Common-of-Saints
// tier (override -> octave -> Common category -> season -> ferial), and
// the invitatory antiphon -- previously one hardcoded fixed text for every
// day of the year -- now goes through the same shape of tiering
// (resolveMatinsInvitatoryText: override -> Common category -> season ->
// ferial). Both confirmed against the reference engine's own Commune/
// C1.txt (Common of an Apostle): hymn "Ætérna Christi múnera," invitatory
// "Regem Apostolórum Dóminum." St. Bartholomew has no proper Matins hymn
// or invitatory of his own authored, so this Common tier is what actually
// renders for him.
describe('resolveOrdo("matins", ...) Common-of-an-Apostle hymn + invitatory (2026-08-24 fix)', () => {
function hymnLatin(date: string): string | undefined {
const ordo = resolveOrdo('matins', date);
const hymn = ordo.parts.find((p) => p.kind === 'hymn') as { text: { text: { la: string } } } | undefined;
return hymn?.text.text.la;
}
function invitatoryFullLatin(date: string): string | undefined {
const ordo = resolveOrdo('matins', date);
const antiphonParts = ordo.parts.filter((p) => p.kind === 'antiphon') as { text: { text: Record<string, string> } }[];
return antiphonParts[0]?.text.text.la;
}
it("St. Bartholomew's own day (2026-08-24, no proper Matins hymn of his own) picks up the Common-of-an-Apostle hymn", () => {
expect(hymnLatin('2026-08-24')).toContain('Ætérna Christi múnera');
});
it("St. Bartholomew's invitatory antiphon resolves to the Common-of-an-Apostle text", () => {
expect(invitatoryFullLatin('2026-08-24')).toContain('Regem Apostolórum Dóminum');
});
it('St. Andrew (2026-11-30, also common-of-an-apostle, no proper hymn/invitatory of his own) resolves to the same Common text -- confirms the tier generalizes beyond Bartholomew', () => {
expect(hymnLatin('2026-11-30')).toContain('Ætérna Christi múnera');
expect(invitatoryFullLatin('2026-11-30')).toContain('Regem Apostolórum Dóminum');
});
it('a plain ferial day is unaffected -- still falls through to the plain ferial hymn and invitatory antiphon', () => {
expect(hymnLatin(FERIAL_DATE)).toContain('Somno');
expect(invitatoryFullLatin(FERIAL_DATE)).toContain('Veníte');
expect(invitatoryFullLatin(FERIAL_DATE)).not.toContain('Regem Apostolórum');
});
});