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
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)
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
# scheme, not two.
#
# This file is documentation/reference content only, not yet wired into
# `hours/matins-psalmody-overrides.ts`'s resolution -- per this project's
# "stay on the ferial fallback until proper antiphon text is authored
# per-saint" decision (2026-08), a saint with no entry under
# matins-psalmody-overrides/saints/ still falls all the way back to the
# plain ferial weekday table, never to this generic Common text. Recorded
# here 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
# `aliases` registers `common-of-a-martyr-bishop` under this same scheme
# per the redirect confirmed above, rather than duplicating this file --
# checked all 3 Duplex+ Common-of-a-Martyr-Bishop saints on the calendar
# (Ignatius, Callistus I, Boniface of Mainz) and none has a proper
# antiphon of his own, so this alias is how each of them gets real
# psalmody at all (`hours/matins-psalmody-overrides.ts`'s tiered
# lookup falls through proper -> Common -> ferial; this is their Common
# tier).
#
# 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
# genuine deviation, Ps 16 vs this file's Ps 64 in Nocturn 2).
id: common-of-a-martyr
aliases: [common-of-a-martyr-bishop]
nocturn1:
groups:
- psalms: [1]
@@ -10,11 +10,6 @@
# pattern already established for Common-of-a-Martyr (see that category
# 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
nocturn1:
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
* 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
* generic Common-of-* Matins psalmody (psalm numbers, versicles,
* canticle refs, and the plain Common antiphon text), one file per
* `SaintRecord.common` category. Live-verified against a saint with no
* proper antiphon of his own (so his own output *is* the category
* default) — see e.g. `categories/common-of-a-martyr.yml`'s own doc
* comment. Reference/documentation content only: not consulted by
* `getMatinsPsalmodyOverride` below, per this project's "stay on the
* ferial fallback until proper antiphon text is authored per-saint"
* decision (2026-08) — a saint without their own entry in `saints/`
* still falls all the way back to matins.ts's own plain ferial table,
* never to a category's generic antiphon text. Recorded so the next
* saint authored in an already-proven category doesn't have to
* re-derive/re-verify the same shared psalm numbers and versicles.
*
* - `data/hours/matins-psalmody-overrides/saints/*.yml` — real proper
* antiphon text for one specific saint (only authored where a saint
* genuinely has proper text of their own, confirmed against the live
* engine, not merely inherited Common text). Each carries its own
* `category` field cross-referencing the file above, but is otherwise
* fully self-contained (own psalm numbers, not merged with the
* category file at load time) — a saint's proper psalmody occasionally
* swaps one slot's psalm number from the category default too (see
* `saints/st-lawrence.yml`'s own note), so per-saint files stay
* authoritative rather than being computed as a diff.
* 1. **Proper** — `data/hours/matins-psalmody-overrides/saints/*.yml`,
* real proper antiphon text for one specific saint (only authored
* where a saint genuinely has proper text of their own, confirmed
* against the live engine, not merely inherited Common text). Each
* carries its own `category` field cross-referencing the file below,
* but is otherwise fully self-contained (own psalm numbers, not
* merged with the category file at load time) — a saint's proper
* psalmody occasionally swaps one slot's psalm number from the
* category default too (see `saints/st-lawrence.yml`'s own note), so
* per-saint files stay authoritative rather than being computed as a
* diff.
* 2. **Common** — `data/hours/matins-psalmody-overrides/categories/
* *.yml`, the generic Common-of-* Matins psalmody (psalm numbers,
* versicles, canticle refs, and the plain Common antiphon text), one
* file per `SaintRecord.common` category, live-verified against a
* saint with no proper antiphon of his own (so his own output *is*
* the category default) — see e.g. `categories/common-of-a-
* martyr.yml`'s own doc comment. A category file may declare
* `aliases` for a second `SaintRecord.common` id that shares its
* exact scheme (e.g. Common-of-a-Martyr-Bishop's own Matins
* psalmody redirects outright to Common-of-a-Martyr in the reference
* engine — one scheme, not two — so `common-of-a-martyr.yml` lists
* `common-of-a-martyr-bishop` as an alias rather than duplicating the
* file).
* 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
* `resolveOfficeWinner(day).id` when it's sanctoral, exactly like every
@@ -45,18 +55,18 @@ export interface MatinsPsalmodyNocturn {
antiphon?: Partial<Record<string, string>>;
versicle: { v: Partial<Record<string, string>>; r: Partial<Record<string, string>> };
}
export interface MatinsPsalmodyOverride {
id: string;
category?: string;
export interface MatinsPsalmodyScheme {
nocturn1: MatinsPsalmodyNocturn;
nocturn2: MatinsPsalmodyNocturn;
nocturn3: MatinsPsalmodyNocturn;
}
export interface MatinsPsalmodyCategory {
export interface MatinsPsalmodyOverride extends MatinsPsalmodyScheme {
id: string;
nocturn1: MatinsPsalmodyNocturn;
nocturn2: MatinsPsalmodyNocturn;
nocturn3: MatinsPsalmodyNocturn;
category?: string;
}
export interface MatinsPsalmodyCategory extends MatinsPsalmodyScheme {
id: string;
aliases?: string[];
}
const saintModules = import.meta.glob<{ default: MatinsPsalmodyOverride }>(
@@ -76,12 +86,12 @@ for (const mod of Object.values(saintModules)) {
const categories = new Map<string, MatinsPsalmodyCategory>();
for (const mod of Object.values(categoryModules)) {
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 {
return id ? overrides.get(id) : undefined;
}
export function getMatinsPsalmodyCategory(id: string | undefined): MatinsPsalmodyCategory | undefined {
return id ? categories.get(id) : undefined;
export function getMatinsPsalmodyOverride(id: string | undefined): MatinsPsalmodyScheme | undefined {
if (!id) return undefined;
return overrides.get(id) ?? categories.get(getSaintRecord(id)?.common ?? '');
}
+6 -4
View File
@@ -315,10 +315,12 @@ export function resolveOrdo(date: string): ResolvedOrdo {
parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') });
} else if (threeNocturns) {
// A Duplex+ weekday feast — genuinely different psalmody from a real
// Sunday's (see hours/matins-psalmody-overrides.ts's own doc
// comment), not the same content reused. Uses that saint's own
// Common-category override when authored, falling back to the plain
// ferial weekday table (redistributed into 3 nocturns) otherwise.
// Sunday's, not the same content reused. getMatinsPsalmodyOverride
// itself does the proper -> Common -> (nothing) tiering; only the
// final "nothing authored at all" tier — the plain ferial weekday
// 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;
if (override) {
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
// matins-psalmody-overrides entry authored) -- Feb 1 2029, confirmed
// clean (no Sunday collision) per his own saint-file comment.
const fallback = resolveOrdo('matins', '2029-02-01');
// proper antiphon of his own) -- Feb 1 2029, confirmed clean (no
// Sunday collision) per his own saint-file comment.
// 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);
// Ps 3 + 94, then Thursday's own 9-psalm ferial table (psalter-
// distribution.yml), not St. Lawrence's proper psalmody and not the
// Sunday scheme either.
// Ps 3 + 94, then Saturday's own 9-psalm ferial table
// (psalter-distribution.yml), not any Common-of-a-Confessor scheme.
expect(psalms[0]).toBe(3);
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 === 'canticle')).toBe(false);
});