Wire the Matins psalmody-override category tier into real resolution
Deploy / deploy (push) Successful in 1m5s

getMatinsPsalmodyOverride is now a genuine 3-tier lookup: proper
(saints/) -> Common (categories/, joined via the winner's own
SaintRecord.common) -> the plain ferial fallback, reversing the earlier
"category files are documentation-only" decision now that two
categories are trusted. Category files gain an `aliases` field;
common-of-a-martyr.yml lists common-of-a-martyr-bishop, since the
reference engine's own Matins psalmody redirects that Common outright
to plain Martyr — so its three Duplex+ saints (Ignatius, Callistus,
Boniface, none with proper text of his own) get real Common-tier
content with no new authoring.

tests/hours/matins.test.ts's old ferial-fallback proof (St. Ignatius)
is rewritten as a Common-tier proof; a fresh ferial-fallback proof
(St. John Bosco, an unauthored Confessor category) replaces it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 06:52:08 -04:00
parent 9ba09964ea
commit 0a8bcef859
6 changed files with 127 additions and 66 deletions
+24
View File
@@ -1842,6 +1842,30 @@ Confessor-Bishop, Virgin, Doctor, etc. — the largest population, per the earli
each needs its own category file (one live `prayMatutinum` query against a no-proper-antiphon each needs its own category file (one live `prayMatutinum` query against a no-proper-antiphon
saint) before any of its own saints' proper antiphons can be authored against it. saint) before any of its own saints' proper antiphons can be authored against it.
### Matins psalmody overrides — category tier wired in as a real fallback (2026-08)
Reversed the "category files are documentation-only" decision above now that two categories
are trusted: `getMatinsPsalmodyOverride` is now a real three-tier lookup — **proper**
(`saints/*.yml`) → **Common** (`categories/*.yml`, joined via the winner's own
`SaintRecord.common` through a new `getSaintRecord` import) → **ferial fallback**
(`matins.ts`'s own plain weekday table, unchanged, now genuinely the last resort rather than
the default for every unauthored saint). This is "prefer real text over missing" applied one
tier further, once the tier itself had proof behind it. `MatinsPsalmodyOverride` and
`MatinsPsalmodyCategory` now share a `MatinsPsalmodyScheme` base (`nocturn1/2/3`) so the lookup
can return either without a cast.
Category files gained an `aliases` field: `common-of-a-martyr.yml` lists
`common-of-a-martyr-bishop`, so all three of its Duplex+ saints (Ignatius, Callistus, Boniface —
confirmed none has proper text of his own) now resolve to real, verified Common psalmody instead
of the ferial table, with zero new authoring.
`tests/hours/matins.test.ts`'s old "falls back to ferial" case (St. Ignatius, 2029-02-01) is
rewritten as a Common-tier proof (his Nocturn 2 correctly using the Common default's Ps 64, not
Lawrence's own proper Ps 16); a fresh genuine ferial-fallback proof was added (St. John Bosco,
Common of a Confessor — no category authored yet for that Common — 2026-01-31, confirmed a
clean Saturday with no Sunday collision). `npm test` (375 tests), `npm run build`, and
`tsc --noEmit` all pass.
### Calendar precedence: a tie against an octave's own elevated closing day now favors the octave (2026-08) ### Calendar precedence: a tie against an octave's own elevated closing day now favors the octave (2026-08)
Real bug found and fixed, same session as the Matins psalmody fix above, discovered while Real bug found and fixed, same session as the Matins psalmody fix above, discovered while
@@ -9,19 +9,23 @@
# this same Common-of-a-Martyr file -- the two categories share one # this same Common-of-a-Martyr file -- the two categories share one
# scheme, not two. # scheme, not two.
# #
# This file is documentation/reference content only, not yet wired into # `aliases` registers `common-of-a-martyr-bishop` under this same scheme
# `hours/matins-psalmody-overrides.ts`'s resolution -- per this project's # per the redirect confirmed above, rather than duplicating this file --
# "stay on the ferial fallback until proper antiphon text is authored # checked all 3 Duplex+ Common-of-a-Martyr-Bishop saints on the calendar
# per-saint" decision (2026-08), a saint with no entry under # (Ignatius, Callistus I, Boniface of Mainz) and none has a proper
# matins-psalmody-overrides/saints/ still falls all the way back to the # antiphon of his own, so this alias is how each of them gets real
# plain ferial weekday table, never to this generic Common text. Recorded # psalmody at all (`hours/matins-psalmody-overrides.ts`'s tiered
# here so the next Common-of-a-Martyr(-Bishop) saint's own proper file # lookup falls through proper -> Common -> ferial; this is their Common
# doesn't have to re-derive/re-verify these same shared psalm numbers and # tier).
# versicles from scratch, and as the reference point for what "differs #
# from Common" means when authoring one (see # Recorded so the next Common-of-a-Martyr(-Bishop) saint's own proper
# file doesn't have to re-derive/re-verify these same shared psalm
# numbers and versicles from scratch, and as the reference point for
# what "differs from Common" means when authoring one (see
# matins-psalmody-overrides/saints/st-lawrence.yml's own note on its one # matins-psalmody-overrides/saints/st-lawrence.yml's own note on its one
# genuine deviation, Ps 16 vs this file's Ps 64 in Nocturn 2). # genuine deviation, Ps 16 vs this file's Ps 64 in Nocturn 2).
id: common-of-a-martyr id: common-of-a-martyr
aliases: [common-of-a-martyr-bishop]
nocturn1: nocturn1:
groups: groups:
- psalms: [1] - psalms: [1]
@@ -10,11 +10,6 @@
# pattern already established for Common-of-a-Martyr (see that category # pattern already established for Common-of-a-Martyr (see that category
# file's own doc comment). # file's own doc comment).
# #
# Reference/documentation content only, not wired into
# `hours/matins-psalmody-overrides.ts`'s resolution -- see
# common-of-a-martyr.yml's own doc comment for why (the "stay on the
# ferial fallback until proper antiphon text is authored per-saint"
# decision, 2026-08).
id: common-of-an-apostle id: common-of-an-apostle
nocturn1: nocturn1:
groups: groups:
+49 -39
View File
@@ -1,31 +1,41 @@
import { getSaintRecord } from '../calendar/feasts';
/** Matins' own analogue of hours/lauds-psalmody-overrides.ts. Two data /** Matins' own analogue of hours/lauds-psalmody-overrides.ts. Two data
* stores, both keyed by id: * stores, both keyed by id, consulted in a three-tier fallback by
* `getMatinsPsalmodyOverride` below (assumes the caller has already
* gated on rank — this module is only ever consulted for a Sunday/
* Duplex+ three-nocturn day, never a plain ferial one):
* *
* - `data/hours/matins-psalmody-overrides/categories/*.yml` — the * 1. **Proper** — `data/hours/matins-psalmody-overrides/saints/*.yml`,
* generic Common-of-* Matins psalmody (psalm numbers, versicles, * real proper antiphon text for one specific saint (only authored
* canticle refs, and the plain Common antiphon text), one file per * where a saint genuinely has proper text of their own, confirmed
* `SaintRecord.common` category. Live-verified against a saint with no * against the live engine, not merely inherited Common text). Each
* proper antiphon of his own (so his own output *is* the category * carries its own `category` field cross-referencing the file below,
* default) — see e.g. `categories/common-of-a-martyr.yml`'s own doc * but is otherwise fully self-contained (own psalm numbers, not
* comment. Reference/documentation content only: not consulted by * merged with the category file at load time) — a saint's proper
* `getMatinsPsalmodyOverride` below, per this project's "stay on the * psalmody occasionally swaps one slot's psalm number from the
* ferial fallback until proper antiphon text is authored per-saint" * category default too (see `saints/st-lawrence.yml`'s own note), so
* decision (2026-08) — a saint without their own entry in `saints/` * per-saint files stay authoritative rather than being computed as a
* still falls all the way back to matins.ts's own plain ferial table, * diff.
* never to a category's generic antiphon text. Recorded so the next * 2. **Common** — `data/hours/matins-psalmody-overrides/categories/
* saint authored in an already-proven category doesn't have to * *.yml`, the generic Common-of-* Matins psalmody (psalm numbers,
* re-derive/re-verify the same shared psalm numbers and versicles. * versicles, canticle refs, and the plain Common antiphon text), one
* * file per `SaintRecord.common` category, live-verified against a
* - `data/hours/matins-psalmody-overrides/saints/*.yml` — real proper * saint with no proper antiphon of his own (so his own output *is*
* antiphon text for one specific saint (only authored where a saint * the category default) — see e.g. `categories/common-of-a-
* genuinely has proper text of their own, confirmed against the live * martyr.yml`'s own doc comment. A category file may declare
* engine, not merely inherited Common text). Each carries its own * `aliases` for a second `SaintRecord.common` id that shares its
* `category` field cross-referencing the file above, but is otherwise * exact scheme (e.g. Common-of-a-Martyr-Bishop's own Matins
* fully self-contained (own psalm numbers, not merged with the * psalmody redirects outright to Common-of-a-Martyr in the reference
* category file at load time) — a saint's proper psalmody occasionally * engine — one scheme, not two — so `common-of-a-martyr.yml` lists
* swaps one slot's psalm number from the category default too (see * `common-of-a-martyr-bishop` as an alias rather than duplicating the
* `saints/st-lawrence.yml`'s own note), so per-saint files stay * file).
* authoritative rather than being computed as a diff. * 3. **Ferial fallback** — no entry in either store yet; `matins.ts`'s
* own `ferialPsalmodyThreeNocturns`, redistributed into 3 nocturns.
* This is genuinely a stand-in (the plain weekday table, not this
* saint's or even this Common's own text) and is expected to shrink
* over time as more categories get authored, not as a permanent third
* class of content.
* *
* `id` is looked up against `hours/resolve-common.ts`'s * `id` is looked up against `hours/resolve-common.ts`'s
* `resolveOfficeWinner(day).id` when it's sanctoral, exactly like every * `resolveOfficeWinner(day).id` when it's sanctoral, exactly like every
@@ -45,18 +55,18 @@ export interface MatinsPsalmodyNocturn {
antiphon?: Partial<Record<string, string>>; antiphon?: Partial<Record<string, string>>;
versicle: { v: Partial<Record<string, string>>; r: Partial<Record<string, string>> }; versicle: { v: Partial<Record<string, string>>; r: Partial<Record<string, string>> };
} }
export interface MatinsPsalmodyOverride { export interface MatinsPsalmodyScheme {
id: string;
category?: string;
nocturn1: MatinsPsalmodyNocturn; nocturn1: MatinsPsalmodyNocturn;
nocturn2: MatinsPsalmodyNocturn; nocturn2: MatinsPsalmodyNocturn;
nocturn3: MatinsPsalmodyNocturn; nocturn3: MatinsPsalmodyNocturn;
} }
export interface MatinsPsalmodyCategory { export interface MatinsPsalmodyOverride extends MatinsPsalmodyScheme {
id: string; id: string;
nocturn1: MatinsPsalmodyNocturn; category?: string;
nocturn2: MatinsPsalmodyNocturn; }
nocturn3: MatinsPsalmodyNocturn; export interface MatinsPsalmodyCategory extends MatinsPsalmodyScheme {
id: string;
aliases?: string[];
} }
const saintModules = import.meta.glob<{ default: MatinsPsalmodyOverride }>( const saintModules = import.meta.glob<{ default: MatinsPsalmodyOverride }>(
@@ -76,12 +86,12 @@ for (const mod of Object.values(saintModules)) {
const categories = new Map<string, MatinsPsalmodyCategory>(); const categories = new Map<string, MatinsPsalmodyCategory>();
for (const mod of Object.values(categoryModules)) { for (const mod of Object.values(categoryModules)) {
categories.set(mod.default.id, mod.default); categories.set(mod.default.id, mod.default);
for (const alias of mod.default.aliases ?? []) {
categories.set(alias, mod.default);
}
} }
export function getMatinsPsalmodyOverride(id: string | undefined): MatinsPsalmodyOverride | undefined { export function getMatinsPsalmodyOverride(id: string | undefined): MatinsPsalmodyScheme | undefined {
return id ? overrides.get(id) : undefined; if (!id) return undefined;
} return overrides.get(id) ?? categories.get(getSaintRecord(id)?.common ?? '');
export function getMatinsPsalmodyCategory(id: string | undefined): MatinsPsalmodyCategory | undefined {
return id ? categories.get(id) : undefined;
} }
+6 -4
View File
@@ -315,10 +315,12 @@ export function resolveOrdo(date: string): ResolvedOrdo {
parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') }); parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') });
} else if (threeNocturns) { } else if (threeNocturns) {
// A Duplex+ weekday feast — genuinely different psalmody from a real // A Duplex+ weekday feast — genuinely different psalmody from a real
// Sunday's (see hours/matins-psalmody-overrides.ts's own doc // Sunday's, not the same content reused. getMatinsPsalmodyOverride
// comment), not the same content reused. Uses that saint's own // itself does the proper -> Common -> (nothing) tiering; only the
// Common-category override when authored, falling back to the plain // final "nothing authored at all" tier — the plain ferial weekday
// ferial weekday table (redistributed into 3 nocturns) otherwise. // table, redistributed into 3 nocturns — lives here (see
// hours/matins-psalmody-overrides.ts's own doc comment for the full
// three-tier picture).
const override = winner.kind === 'sanctoral' ? getMatinsPsalmodyOverride(winner.id) : undefined; const override = winner.kind === 'sanctoral' ? getMatinsPsalmodyOverride(winner.id) : undefined;
if (override) { if (override) {
parts.push(...sundayPsalmNocturn(override.nocturn1, day)); parts.push(...sundayPsalmNocturn(override.nocturn1, day));
+34 -8
View File
@@ -167,18 +167,44 @@ describe('resolveOrdo("matins", ...) Duplex+ weekday-feast (3-nocturn, non-Sunda
} }
}); });
it("falls back to the plain ferial weekday table, redistributed into 3 nocturns, for a Duplex+ weekday winner with no override authored", () => { it("falls through to its Common category's own generic psalmody for a Duplex+ weekday winner with no proper antiphon authored", () => {
// St. Ignatius of Antioch (Duplex, Common of a Martyr-Bishop, no // St. Ignatius of Antioch (Duplex, Common of a Martyr-Bishop, no
// matins-psalmody-overrides entry authored) -- Feb 1 2029, confirmed // proper antiphon of his own) -- Feb 1 2029, confirmed clean (no
// clean (no Sunday collision) per his own saint-file comment. // Sunday collision) per his own saint-file comment.
const fallback = resolveOrdo('matins', '2029-02-01'); // getMatinsPsalmodyOverride's Common tier resolves
// common-of-a-martyr-bishop via common-of-a-martyr.yml's own
// `aliases` (the reference engine's own Matins psalmody for
// Martyr-Bishop redirects outright to plain Martyr) -- real,
// verified Common text, not the plain ferial table.
const common = resolveOrdo('matins', '2029-02-01');
const psalms = common.parts.filter((p) => p.kind === 'psalm').map((p) => (p as { psalmNumber: number }).psalmNumber);
// Same Nocturn 1 as St. Lawrence's own (1,2,4,5,8,10), but Nocturn 2
// uses the Common default's Ps 64, not Lawrence's own proper Ps 16.
expect(psalms).toEqual([3, 94, 1, 2, 4, 5, 8, 10, 14, 20, 23, 63, 64, 91]);
expect(common.parts.some((p) => p.kind === 'te-deum')).toBe(true);
expect(common.parts.some((p) => p.kind === 'canticle')).toBe(true);
const psalmParts = common.parts.filter((p) => p.kind === 'psalm') as { psalmNumber: number; antiphon?: { status: Record<string, string> } }[];
const nocturnPsalms = psalmParts.filter((p) => ![3, 94].includes(p.psalmNumber));
// The generic Common antiphon text is real and verified, even though
// it is not proper to Ignatius himself.
for (const p of nocturnPsalms) {
expect(p.antiphon?.status.la).toBe('verified');
expect(p.antiphon?.status.en).toBe('verified');
}
});
it('falls all the way back to the plain ferial weekday table, redistributed into 3 nocturns, when neither a proper nor a Common entry is authored', () => {
// St. John Bosco (Duplex, Common of a Confessor -- no
// matins-psalmody-overrides category authored yet for either tier)
// -- 2026-01-31, a Saturday, confirmed clean (no Sunday collision).
const fallback = resolveOrdo('matins', '2026-01-31');
const psalms = fallback.parts.filter((p) => p.kind === 'psalm').map((p) => (p as { psalmNumber: number }).psalmNumber); const psalms = fallback.parts.filter((p) => p.kind === 'psalm').map((p) => (p as { psalmNumber: number }).psalmNumber);
// Ps 3 + 94, then Thursday's own 9-psalm ferial table (psalter- // Ps 3 + 94, then Saturday's own 9-psalm ferial table
// distribution.yml), not St. Lawrence's proper psalmody and not the // (psalter-distribution.yml), not any Common-of-a-Confessor scheme.
// Sunday scheme either.
expect(psalms[0]).toBe(3); expect(psalms[0]).toBe(3);
expect(psalms[1]).toBe(94); expect(psalms[1]).toBe(94);
expect(psalms.slice(2)).toEqual(getPsalmsFor('matins', 'thursday').map((r) => r.number)); expect(psalms.slice(2)).toEqual(getPsalmsFor('matins', 'saturday').map((r) => r.number));
expect(fallback.parts.some((p) => p.kind === 'te-deum')).toBe(true); expect(fallback.parts.some((p) => p.kind === 'te-deum')).toBe(true);
expect(fallback.parts.some((p) => p.kind === 'canticle')).toBe(false); expect(fallback.parts.some((p) => p.kind === 'canticle')).toBe(false);
}); });