From f4d4eb3738c6aac88a7a7284dc470be819cb20ec Mon Sep 17 00:00:00 2001 From: Will Estes Date: Wed, 2 Sep 2026 07:07:12 -0400 Subject: [PATCH] Lower Matins 3-nocturn threshold from Duplex to Semiduplex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Semiduplex and Duplex should share the same 3-nocturn/3-psalms-each shape (distinct from Sunday/Duplex-majus+'s 6/6/3-plus-canticles scheme) — the previous Duplex floor meant a Semiduplex winner like St. Stephen of Hungary wrongly fell all the way through to the 1-nocturn ferial branch. New threshold is a separate check from isDoubleOrHigher, which drives the unrelated antiphon-doubling rule elsewhere and stays floored at Duplex — the two thresholds only coincided by accident before this change. No new content authoring was needed: ferialPsalmodyThreeNocturns already chunks the ferial 9-psalm pool into 3/3/3 as the fallback for a plain-Duplex winner with no override authored, and Semiduplex winners now route into that same already-working branch. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01X26BkFT3ARbtUGXuBmzUmD --- TODO.md | 19 +++++++++++++++ src/hours/matins.ts | 47 ++++++++++++++++++++++---------------- tests/hours/matins.test.ts | 43 ++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 20 deletions(-) diff --git a/TODO.md b/TODO.md index 53b3cfa..d868a69 100644 --- a/TODO.md +++ b/TODO.md @@ -4857,3 +4857,22 @@ file's header was a documented-but-unverified assumption, not real content. See memory `vu_ferial_matins_antiphons` for full detail. `npm test`, `tsc --noEmit`, and `npm run build` all pass. + +### Matins: 3-nocturn threshold lowered from Duplex to Semiduplex (2026-09-02) + +Same session, a separate fix: the 3-nocturn gate (`hours/matins.ts`'s `threeNocturns`) was +floored at Duplex (`isDoubleOrHigher`). User's own design call, prompted by noticing today's +Matins (St. Stephen of Hungary, Semiduplex) wrongly rendering as 1-nocturn: Semiduplex and +Duplex should share the same 3-nocturn/3-psalms-each shape (distinct from Sunday/Duplex-majus ++'s 6/6/3-plus-canticles scheme). Lowered the floor with a new, separate check +(`isAtLeast(winner.rank, 'semiduplex')`) rather than touching `isDoubleOrHigher` itself, which +drives an unrelated antiphon-doubling rule elsewhere and must stay floored at Duplex. No new +content authoring was needed — `ferialPsalmodyThreeNocturns` already existed and already +chunks the ferial 9-psalm pool into 3/3/3 as the fallback for a plain-Duplex winner with no +override authored; Semiduplex winners now fall into that same already-working branch. Proved +via St. Stephen of Hungary (2026-09-02, Common of a Confessor): 3 real nocturns, Common's own +Terce/Sext/None antiphons+versicles, Te Deum present. + +See memory `vu_matins_semiduplex_threshold` for full detail. + +`npm test` (823 passed), `tsc --noEmit`, and `npm run build` all pass. diff --git a/src/hours/matins.ts b/src/hours/matins.ts index 9ea651d..f929b38 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -3,8 +3,8 @@ // Builds its ordo programmatically per day rather than resolving a static // `data/hours/matins.yml` parts list the way every other hour does, because // the real shape (1 nocturn on a plain ferial day vs. 3 on a Sunday or a -// Duplex+ feast, with a *variable* number of readings) doesn't fit that -// pattern. +// Semiduplex+ feast, with a *variable* number of readings) doesn't fit +// that pattern. // // Critical framing (see memory `vu-not-a-reconstruction` / `vu-matins- // design`, and TODO.md's own Matins section): this is NOT a historical @@ -76,7 +76,7 @@ import { getPsalmVerses } from '../psalter'; import { getPsalmsFor, type PsalmRef } from '../psalter/distribution'; import { getScriptureVerses } from '../scripture'; import { getOpeningVersicleId } from './opening-versicle'; -import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; +import { applyFlexaMark } from './antiphon'; import { resolveCommon, getDayCollect, @@ -547,8 +547,8 @@ function withSingleAntiphon(refs: PsalmRef[], antiphonText: ResolvedText, winner * gathered for `day` — the office winner (if sanctoral), every * commemorated saint (a transferred-in feast already appears as `day.winner` * once `resolveDay` has applied the transfer, so it needs no separate - * lookup here), and — only on a real 3-nocturn day (Sunday, or a Duplex+ - * feast) — the plain temporal id itself (that Sunday's own Moralia-in- + * lookup here), and — only on a real 3-nocturn day (Sunday, or a + * Semiduplex+ feast) — the plain temporal id itself (that Sunday's own Moralia-in- * Job-style commentary) plus, for dates from the 1st Sunday of August * through the eve of Advent, the calendar-month/week id (`month-week-`, * calendar/month-week-id.ts's monthWeekId): the real historical Nocturn 2 @@ -734,12 +734,17 @@ export function resolveOrdo(date: string): ResolvedOrdo { const day = resolveDay(date); const winner = resolveOfficeWinner(day); const temporalId = resolveTemporalId(date); - // Every Sunday, unconditionally, or a Duplex+ sanctoral winner — the - // user's own chosen threshold (2026-08), not gated on whether any - // content is actually authored yet, same "eligible, not content-gated" - // convention every other per-feast override in this app already uses - // (see hours/resolve-common.ts's getOfficeOverrideId). - const threeNocturns = day.weekday === 'sunday' || isDoubleOrHigher(winner); + // Every Sunday, unconditionally, or a Semiduplex+ sanctoral winner — the + // user's own chosen threshold (2026-08, lowered from Duplex to + // Semiduplex 2026-09-02), not gated on whether any content is actually + // authored yet, same "eligible, not content-gated" convention every + // other per-feast override in this app already uses (see + // hours/resolve-common.ts's getOfficeOverrideId). Deliberately a + // separate check from `isDoubleOrHigher` (antiphon.ts) — that function + // drives the unrelated antiphon-doubling rule (full vs. incipit antiphon + // before the psalms) and stays floored at Duplex; the two thresholds + // only coincided before this change by accident, not by shared meaning. + const threeNocturns = day.weekday === 'sunday' || (winner.kind === 'sanctoral' && isAtLeast(winner.rank, 'semiduplex')); const pool = buildReadingPool(day, temporalId, date, threeNocturns); const [nocturn1Readings, nocturn2Readings, nocturn3Readings] = distributeIntoNocturns(pool, threeNocturns ? 3 : 1); @@ -775,19 +780,21 @@ export function resolveOrdo(date: string): ResolvedOrdo { parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') }); parts.push({ kind: 'versicle', text: resolveCommon('te-decet-laus') }); } else if (threeNocturns) { - // A Duplex+ weekday feast — genuinely different psalmody from a real - // Sunday's, not the same content reused. + // A Semiduplex+ weekday feast — genuinely different psalmody from a + // real Sunday's, not the same content reused. // - Saint-specific proper: use its own full scheme, any rank. // - No proper, rank duplex-majus+: Common's own full scheme (own // psalm numbers, canticles) if authored; else the fully bare // ferialPsalmodyThreeNocturns stand-in. Unaffected by the - // duplex-and-below carve-out below (user, 2026-08). - // - No proper, rank exactly duplex (the floor of 3-nocturn - // eligibility): ferial psalm numbers, not Common's own — but - // Common's per-nocturn versicle, and each nocturn's own Terce/ - // Sext/None antiphon for that category, if authored (see - // ferialPsalmodyThreeNocturns's own doc comment for why those - // three, not Common's own Matins Nocturn antiphons). + // below-duplex-majus carve-out below (user, 2026-08). + // - No proper, rank Semiduplex or Duplex (below the floor of the + // duplex-majus+ tier, but at or above the floor of 3-nocturn + // eligibility itself, lowered to Semiduplex 2026-09-02): ferial + // psalm numbers, not Common's own — but Common's per-nocturn + // versicle, and each nocturn's own Terce/Sext/None antiphon for + // that category, if authored (see ferialPsalmodyThreeNocturns's + // own doc comment for why those three, not Common's own Matins + // Nocturn antiphons). const properOverride = winner.kind === 'sanctoral' ? getMatinsSaintOverride(winner.id) : undefined; const isAboveDuplex = winner.kind === 'sanctoral' && isAtLeast(winner.rank, 'duplex-majus'); const commonOverride = winner.kind === 'sanctoral' && !properOverride ? getMatinsCommonOverride(winner.id) : undefined; diff --git a/tests/hours/matins.test.ts b/tests/hours/matins.test.ts index 2e8ab14..83fc797 100644 --- a/tests/hours/matins.test.ts +++ b/tests/hours/matins.test.ts @@ -527,6 +527,49 @@ describe('resolveOrdo("matins", ...) Duplex+ weekday-feast (3-nocturn, non-Sunda }); }); +describe('resolveOrdo("matins", ...) Semiduplex weekday-feast (3-nocturn threshold lowered 2026-09-02)', () => { + // St. Stephen of Hungary (Semiduplex, Common of a Confessor Not a + // Bishop, no proper Matins antiphon of his own) -- 2026-09-02, a + // Wednesday. Threshold was Duplex-or-higher before this fix, so a + // Semiduplex winner used to fall all the way through to the plain + // 1-nocturn ferial branch; the user's own design intent (2026-09-02) is + // that Semiduplex and Duplex share the same 3-nocturn/3-psalms-each + // shape, distinct from the 6/6/3-plus-canticles Sunday/Duplex-majus+ + // scheme. Reuses the same already-working ferialPsalmodyThreeNocturns + // branch a plain-Duplex winner gets (see the describe block above) -- + // no new content authoring was needed for this fix. + const ordo = resolveOrdo('matins', '2026-09-02'); + + it('splits the ferial 9-psalm pool into 3 nocturns of 3 each, plus a Te Deum, not the 1-nocturn ferial branch', () => { + const psalms = ordo.parts.filter((p) => p.kind === 'psalm').map((p) => (p as { psalmNumber: number }).psalmNumber); + expect(psalms).toEqual([3, 94, 65, 67, 67, 68, 68, 70, 71, 72, 73]); + expect(ordo.parts.some((p) => p.kind === 'te-deum')).toBe(true); + // No canticles -- reserved for Sunday/Duplex-majus+, not this tier. + expect(ordo.parts.some((p) => p.kind === 'canticle')).toBe(false); + }); + + it("uses Common-of-a-Confessor's own Terce/Sext/None antiphons and versicles, one distinct pair per nocturn", () => { + const psalmParts = ordo.parts.filter((p) => p.kind === 'psalm') as { psalmNumber: number; antiphon?: { text: Record; status: Record } }[]; + const nocturnPsalms = psalmParts.filter((p) => ![3, 94].includes(p.psalmNumber)); + expect(nocturnPsalms[0]?.antiphon?.text.la).toContain('Euge, serve bone'); + expect(nocturnPsalms[3]?.antiphon?.text.la).toContain('Fidélis servus'); + expect(nocturnPsalms[6]?.antiphon?.text.la).toContain('Serve bone'); + for (const i of [0, 3, 6]) { + expect(nocturnPsalms[i]?.antiphon?.status.la).toBe('verified'); + expect(nocturnPsalms[i]?.antiphon?.status.en).toBe('verified'); + } + const versicles = ordo.parts.filter((p) => p.kind === 'versicle') as { text: { text: Record } }[]; + // The 3 nocturn-closing versicles (excluding the opening pair and the + // final Te decet laus) are Common-of-a-Confessor's own, one per + // nocturn -- not a stray mid-psalmody line. + const nocturnVersicles = versicles.slice(2, 5); + expect(nocturnVersicles).toHaveLength(3); + expect(nocturnVersicles[0]?.text.text.la).toContain('Amávit eum Dóminus'); + expect(nocturnVersicles[1]?.text.text.la).toContain('Os justi meditábitur'); + expect(nocturnVersicles[2]?.text.text.la).toContain('Lex Dei ejus in corde ipsíus'); + }); +}); + describe('resolveOrdo("matins", ...) second Duplex+ weekday-feast proof — St. Andrew, Common of an Apostle', () => { // 2026-11-30 -- St. Andrew's own day outright ("S. Andreæ Apostoli", // not transferred that year: Nov 30 falls on a Monday, not a Sunday),