Lower Matins 3-nocturn threshold from Duplex to Semiduplex
Deploy / deploy (push) Successful in 1m31s
Deploy / deploy (push) Successful in 1m31s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X26BkFT3ARbtUGXuBmzUmD
This commit is contained in:
@@ -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.
|
See memory `vu_ferial_matins_antiphons` for full detail.
|
||||||
|
|
||||||
`npm test`, `tsc --noEmit`, and `npm run build` all pass.
|
`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.
|
||||||
|
|||||||
+27
-20
@@ -3,8 +3,8 @@
|
|||||||
// Builds its ordo programmatically per day rather than resolving a static
|
// Builds its ordo programmatically per day rather than resolving a static
|
||||||
// `data/hours/matins.yml` parts list the way every other hour does, because
|
// `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
|
// 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
|
// Semiduplex+ feast, with a *variable* number of readings) doesn't fit
|
||||||
// pattern.
|
// that pattern.
|
||||||
//
|
//
|
||||||
// Critical framing (see memory `vu-not-a-reconstruction` / `vu-matins-
|
// Critical framing (see memory `vu-not-a-reconstruction` / `vu-matins-
|
||||||
// design`, and TODO.md's own Matins section): this is NOT a historical
|
// 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 { getPsalmsFor, type PsalmRef } from '../psalter/distribution';
|
||||||
import { getScriptureVerses } from '../scripture';
|
import { getScriptureVerses } from '../scripture';
|
||||||
import { getOpeningVersicleId } from './opening-versicle';
|
import { getOpeningVersicleId } from './opening-versicle';
|
||||||
import { isDoubleOrHigher, applyFlexaMark } from './antiphon';
|
import { applyFlexaMark } from './antiphon';
|
||||||
import {
|
import {
|
||||||
resolveCommon,
|
resolveCommon,
|
||||||
getDayCollect,
|
getDayCollect,
|
||||||
@@ -547,8 +547,8 @@ function withSingleAntiphon(refs: PsalmRef[], antiphonText: ResolvedText, winner
|
|||||||
* gathered for `day` — the office winner (if sanctoral), every
|
* gathered for `day` — the office winner (if sanctoral), every
|
||||||
* commemorated saint (a transferred-in feast already appears as `day.winner`
|
* commemorated saint (a transferred-in feast already appears as `day.winner`
|
||||||
* once `resolveDay` has applied the transfer, so it needs no separate
|
* 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+
|
* lookup here), and — only on a real 3-nocturn day (Sunday, or a
|
||||||
* feast) — the plain temporal id itself (that Sunday's own Moralia-in-
|
* 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
|
* Job-style commentary) plus, for dates from the 1st Sunday of August
|
||||||
* through the eve of Advent, the calendar-month/week id (`month-week-<id>`,
|
* through the eve of Advent, the calendar-month/week id (`month-week-<id>`,
|
||||||
* calendar/month-week-id.ts's monthWeekId): the real historical Nocturn 2
|
* 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 day = resolveDay(date);
|
||||||
const winner = resolveOfficeWinner(day);
|
const winner = resolveOfficeWinner(day);
|
||||||
const temporalId = resolveTemporalId(date);
|
const temporalId = resolveTemporalId(date);
|
||||||
// Every Sunday, unconditionally, or a Duplex+ sanctoral winner — the
|
// Every Sunday, unconditionally, or a Semiduplex+ sanctoral winner — the
|
||||||
// user's own chosen threshold (2026-08), not gated on whether any
|
// user's own chosen threshold (2026-08, lowered from Duplex to
|
||||||
// content is actually authored yet, same "eligible, not content-gated"
|
// Semiduplex 2026-09-02), not gated on whether any content is actually
|
||||||
// convention every other per-feast override in this app already uses
|
// authored yet, same "eligible, not content-gated" convention every
|
||||||
// (see hours/resolve-common.ts's getOfficeOverrideId).
|
// other per-feast override in this app already uses (see
|
||||||
const threeNocturns = day.weekday === 'sunday' || isDoubleOrHigher(winner);
|
// 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 pool = buildReadingPool(day, temporalId, date, threeNocturns);
|
||||||
const [nocturn1Readings, nocturn2Readings, nocturn3Readings] = distributeIntoNocturns(pool, threeNocturns ? 3 : 1);
|
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: 'te-deum', text: resolveCommon('te-deum') });
|
||||||
parts.push({ kind: 'versicle', text: resolveCommon('te-decet-laus') });
|
parts.push({ kind: 'versicle', text: resolveCommon('te-decet-laus') });
|
||||||
} else if (threeNocturns) {
|
} else if (threeNocturns) {
|
||||||
// A Duplex+ weekday feast — genuinely different psalmody from a real
|
// A Semiduplex+ weekday feast — genuinely different psalmody from a
|
||||||
// Sunday's, not the same content reused.
|
// real Sunday's, not the same content reused.
|
||||||
// - Saint-specific proper: use its own full scheme, any rank.
|
// - Saint-specific proper: use its own full scheme, any rank.
|
||||||
// - No proper, rank duplex-majus+: Common's own full scheme (own
|
// - No proper, rank duplex-majus+: Common's own full scheme (own
|
||||||
// psalm numbers, canticles) if authored; else the fully bare
|
// psalm numbers, canticles) if authored; else the fully bare
|
||||||
// ferialPsalmodyThreeNocturns stand-in. Unaffected by the
|
// ferialPsalmodyThreeNocturns stand-in. Unaffected by the
|
||||||
// duplex-and-below carve-out below (user, 2026-08).
|
// below-duplex-majus carve-out below (user, 2026-08).
|
||||||
// - No proper, rank exactly duplex (the floor of 3-nocturn
|
// - No proper, rank Semiduplex or Duplex (below the floor of the
|
||||||
// eligibility): ferial psalm numbers, not Common's own — but
|
// duplex-majus+ tier, but at or above the floor of 3-nocturn
|
||||||
// Common's per-nocturn versicle, and each nocturn's own Terce/
|
// eligibility itself, lowered to Semiduplex 2026-09-02): ferial
|
||||||
// Sext/None antiphon for that category, if authored (see
|
// psalm numbers, not Common's own — but Common's per-nocturn
|
||||||
// ferialPsalmodyThreeNocturns's own doc comment for why those
|
// versicle, and each nocturn's own Terce/Sext/None antiphon for
|
||||||
// three, not Common's own Matins Nocturn antiphons).
|
// 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 properOverride = winner.kind === 'sanctoral' ? getMatinsSaintOverride(winner.id) : undefined;
|
||||||
const isAboveDuplex = winner.kind === 'sanctoral' && isAtLeast(winner.rank, 'duplex-majus');
|
const isAboveDuplex = winner.kind === 'sanctoral' && isAtLeast(winner.rank, 'duplex-majus');
|
||||||
const commonOverride = winner.kind === 'sanctoral' && !properOverride ? getMatinsCommonOverride(winner.id) : undefined;
|
const commonOverride = winner.kind === 'sanctoral' && !properOverride ? getMatinsCommonOverride(winner.id) : undefined;
|
||||||
|
|||||||
@@ -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<string, string>; status: Record<string, string> } }[];
|
||||||
|
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<string, string> } }[];
|
||||||
|
// 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', () => {
|
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",
|
// 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),
|
// not transferred that year: Nov 30 falls on a Monday, not a Sunday),
|
||||||
|
|||||||
Reference in New Issue
Block a user