Wire the Matins psalmody-override category tier into real resolution
Deploy / deploy (push) Successful in 1m5s
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:
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user