From 0a8bcef859114c5acb65812c2d46ff8aa54d161e Mon Sep 17 00:00:00 2001 From: Will Estes Date: Tue, 18 Aug 2026 06:52:08 -0400 Subject: [PATCH] Wire the Matins psalmody-override category tier into real resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- TODO.md | 24 +++++ .../categories/common-of-a-martyr.yml | 24 ++--- .../categories/common-of-an-apostle.yml | 5 -- src/hours/matins-psalmody-overrides.ts | 88 +++++++++++-------- src/hours/matins.ts | 10 ++- tests/hours/matins.test.ts | 42 +++++++-- 6 files changed, 127 insertions(+), 66 deletions(-) diff --git a/TODO.md b/TODO.md index 758e95d..8547128 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/src/data/hours/matins-psalmody-overrides/categories/common-of-a-martyr.yml b/src/data/hours/matins-psalmody-overrides/categories/common-of-a-martyr.yml index 33a5dcc..7db4166 100644 --- a/src/data/hours/matins-psalmody-overrides/categories/common-of-a-martyr.yml +++ b/src/data/hours/matins-psalmody-overrides/categories/common-of-a-martyr.yml @@ -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] diff --git a/src/data/hours/matins-psalmody-overrides/categories/common-of-an-apostle.yml b/src/data/hours/matins-psalmody-overrides/categories/common-of-an-apostle.yml index 9c766af..2c99aa3 100644 --- a/src/data/hours/matins-psalmody-overrides/categories/common-of-an-apostle.yml +++ b/src/data/hours/matins-psalmody-overrides/categories/common-of-an-apostle.yml @@ -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: diff --git a/src/hours/matins-psalmody-overrides.ts b/src/hours/matins-psalmody-overrides.ts index 40d1cf1..8ae92a1 100644 --- a/src/hours/matins-psalmody-overrides.ts +++ b/src/hours/matins-psalmody-overrides.ts @@ -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>; versicle: { v: Partial>; r: Partial> }; } -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(); 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 ?? ''); } diff --git a/src/hours/matins.ts b/src/hours/matins.ts index 47a3572..4fcf1bd 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -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)); diff --git a/tests/hours/matins.test.ts b/tests/hours/matins.test.ts index b586c05..fe2c2b0 100644 --- a/tests/hours/matins.test.ts +++ b/tests/hours/matins.test.ts @@ -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 } }[]; + 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); });