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,
openingAntiphon,
} from './resolve-common';
import { getSaintRecord } from '../calendar/feasts';
import { getBiblePlanReadings } from '../propers/bible-plan';
import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-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
* repeating refrain between verse groups; per direct instruction this app
* frames it like any other psalm antiphon instead (one opening, one full
* repeat after) — see data/propers/common/matins-invitatory-antiphon.yml's
* own header. */
function invitatoryParts(day: LiturgicalDay): ResolvedPart[] {
const { incipit, full } = splitNamedAntiphon(resolveCommon('matins-invitatory-antiphon'));
const opening = isDoubleOrHigher(resolveOfficeWinner(day)) ? full : incipit;
const text = resolveMatinsInvitatoryText(day);
const opening = openingAntiphon(text, resolveOfficeWinner(day));
const { full } = splitNamedAntiphon(text);
return [
psalmPartWithAntiphon(94, opening),
{ kind: 'antiphon', text: full },
@@ -229,11 +272,16 @@ function sundayCanticleNocturn(group: SundayNocturn, day: LiturgicalDay): Resolv
* getOfficeOverrideId eligibility Lauds/Vespers' own resolveOffice uses),
* 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}`) —
* see below; else falls to a *seasonal* default (Advent/Lent/
* Passiontide/Paschaltide, none authored yet), else the plain year-round
* ferial hymn — same override > octave > season > ferial precedence every
* other hour's own office bundle uses (see lauds.ts's resolveOffice), just
* never fully wired up here before.
* see below; else the winner's Common-of-Saints hymn (`matins-hymn-
* ${commonId}`, via `SaintRecord.common`, e.g. "Aeterna Christi munera"
* for any Apostle with no proper hymn of his own — same category-lookup
* pattern as matins-psalmody-overrides.ts's getMatinsPsalmodyOverride);
* 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
* (`Sancti/08-21bmv.txt`'s own `[Rule] ex Sancti/08-15`) genuinely keeps
@@ -264,6 +312,23 @@ function resolveMatinsHymn(day: LiturgicalDay): ResolvedText {
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);
if (seasonSuffix) {
const seasonal = resolveCommon(`matins-hymn-${seasonSuffix}`);