Compare commits

..

2 Commits

Author SHA1 Message Date
will f4d4eb3738 Lower Matins 3-nocturn threshold from Duplex to Semiduplex
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
2026-09-02 07:07:12 -04:00
will 9da5a74301 Remove spurious mid-nocturn versicle from ferial Matins
The 1-nocturn ferial branch was rendering a V./R. versicle stuck
mid-list, as if marking a nocturn boundary that doesn't exist in a
single continuous nocturn. Live-verified against the reference engine
(Pofficium.pl, Monastic Tridentinum 1617) for Wednesday and Thursday:
the real office runs straight from the last psalm to the Capitulum,
with no versicle anywhere in the psalmody. The embedded mid-list V./R.
line in the source Psalmi matutinum.txt is inert there and was never
actually emitted by the engine — matins-ferial-antiphons.yml's
versicle field was a documented-but-unverified assumption, not real
content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X26BkFT3ARbtUGXuBmzUmD
2026-09-02 07:06:21 -04:00
4 changed files with 148 additions and 90 deletions
+38
View File
@@ -4838,3 +4838,41 @@ category of move as this app's existing late-canonization rank-deflation convent
the other direction — bringing his own count to 6. the other direction — bringing his own count to 6.
`npm test` (821 passed), `tsc --noEmit`, and `npm run build` all pass. `npm test` (821 passed), `tsc --noEmit`, and `npm run build` all pass.
### Matins ferial nocturn: mid-nocturn versicle removed, a real live-verified bug (2026-09-02)
User noticed the 1-nocturn ferial branch had a V./R. versicle stuck mid-list as if marking a
nocturn boundary — structurally incoherent for a single continuous nocturn.
`data/hours/matins-ferial-antiphons.yml`'s per-weekday `groups` carried a `versicle` field
ported from a mid-list V./R. line embedded in the source `Psalmi matutinum.txt`
(`[Daym1_]`-`[Daym6_]`), rendered partway through the single continuous 1-nocturn ferial day.
Live-verified against the reference engine (`Pofficium.pl`, `command=prayMatutinum`,
`version=Monastic Tridentinum 1617`) for Wednesday and Thursday: the real office runs straight
from the last psalm to the Capitulum, no versicle anywhere in the psalmody — that source line
is inert there, never actually emitted. Removed the field from all 6 weekdays and the
rendering code (`ferialAntiphonedNocturn`); the earlier "versicle placement" reasoning in that
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.
+23 -52
View File
@@ -1,7 +1,8 @@
# Antiphons + versicles for the plain ferial weekday Matins nocturn (no # Antiphons for the plain ferial weekday Matins nocturn (below Semiduplex,
# Sunday, no Duplex+ override) — previously rendered with bare psalms and # see hours/matins.ts's threeNocturns threshold) — previously rendered
# no antiphon at all (a genuine oversight, not a documented design choice # with bare psalms and no antiphon at all (a genuine oversight, not a
# like `ferialPsalmodyThreeNocturns`'s antiphon-less fallback). # documented design choice like `ferialPsalmodyThreeNocturns`'s
# antiphon-less fallback).
# #
# `psalter-distribution.yml`'s own `matins:` table is NOT the historical # `psalter-distribution.yml`'s own `matins:` table is NOT the historical
# per-weekday psalm set — its own header documents a deliberate "editorial # per-weekday psalm set — its own header documents a deliberate "editorial
@@ -43,16 +44,17 @@
# psalm, just spread over two verse ranges); any cross-psalm pair is now # psalm, just spread over two verse ranges); any cross-psalm pair is now
# split per the "One antiphon per psalm" note above. # split per the "One antiphon per psalm" note above.
# #
# Versicle placement: each weekday draws its 9 psalms from 1-2 different # No mid-nocturn versicle (fixed 2026-09-02): the source `Psalmi
# original source days (see each weekday's own comment below for exactly # matutinum.txt` files (`[Daym1_]`-`[Daym6_]`) embed a V./R. line partway
# which), but only one versicle fits per plain single-nocturn weekday — # through each weekday's psalm list, and an earlier version of this file
# taken from whichever source day contributes the majority of that # ported it as a real `versicle` field on the group where it happened to
# weekday's psalms, placed at the same relative position it held in that # fall, rendered mid-nocturn as if marking a nocturn boundary. Live-verified
# source day's own sequence (immediately after whichever group of this # against the reference engine (`Pofficium.pl`, `command=prayMatutinum`,
# weekday's psalms was originally its next-door neighbor, once the # `version=Monastic Tridentinum 1617`) for both Wednesday and Thursday: the
# psalms that moved out from between them are accounted for). None of the # real single-nocturn ferial office runs straight from the last psalm to
# cross-psalm pairs split by the 2026-09-01 change carried a versicle of # the Capitulum, with no versicle anywhere in the psalmody — that embedded
# their own, so no versicle needed repositioning. # line is inert in the source and was never actually emitted by the engine.
# Removed; do not reintroduce it without a live query proving otherwise.
# #
# Not live-verified against a running query (Monastic ferial ordinary time # Not live-verified against a running query (Monastic ferial ordinary time
# isn't independently re-derivable that way once psalter-distribution.yml's # isn't independently re-derivable that way once psalter-distribution.yml's
@@ -62,8 +64,8 @@
monday: monday:
# All 9 psalms are Daym1_'s own (Monastic Matins Feria II) — no reslicing # All 9 psalms are Daym1_'s own (Monastic Matins Feria II) — no reslicing
# across weekday boundaries here, so both antiphon grouping and the # across weekday boundaries here, so the antiphon grouping sits exactly
# versicle sit exactly where Daym1_ itself has them. # where Daym1_ itself has it.
groups: groups:
- psalms: [{ number: 32 }] - psalms: [{ number: 32 }]
antiphon: antiphon:
@@ -83,9 +85,6 @@ monday:
antiphon: antiphon:
la: "Revéla * Dómino viam meam." la: "Revéla * Dómino viam meam."
en: "Commit my way * to the Lord." en: "Commit my way * to the Lord."
versicle:
v: { la: "Dómine, in cælo misericórdia tua.", en: "O Lord, thy mercy is in heaven," }
r: { la: "Et véritas tua usque ad nubes.", en: "And thy truth reacheth even to the clouds." }
- psalms: [{ number: 37 }] - psalms: [{ number: 37 }]
antiphon: antiphon:
la: "Ne in ira tua * árguas me Dómine." la: "Ne in ira tua * árguas me Dómine."
@@ -107,11 +106,7 @@ monday:
tuesday: tuesday:
# 43-44 are Monday's own tail (Daym1_); 47-58 are Daym2_'s own opening (6 # 43-44 are Monday's own tail (Daym1_); 47-58 are Daym2_'s own opening (6
# of 9 psalms — the majority, hence Daym2_'s own versicle here); 59 is # of 9 psalms — the majority); 59 is Wednesday's own opening (Daym3_).
# Wednesday's own opening (Daym3_). The versicle sits right after 49,
# exactly where Daym2_ itself has it (between the "49,51" and "52,53"
# groups — 51/52/53 all moved elsewhere in the reslice, but 49 and 54
# remain each other's nearest neighbor once they're gone).
groups: groups:
- psalms: [{ number: 43 }] - psalms: [{ number: 43 }]
# New (2026-09-01): the source's shared antiphon for this pair # New (2026-09-01): the source's shared antiphon for this pair
@@ -138,9 +133,6 @@ tuesday:
antiphon: antiphon:
la: "Deus deórum * Dóminus locútus est." la: "Deus deórum * Dóminus locútus est."
en: "The God of gods * the Lord hath spoken." en: "The God of gods * the Lord hath spoken."
versicle:
v: { la: "Ímmola Deo sacrifícium laudis.", en: "Offer to God the sacrifice of praise." }
r: { la: "Et redde Altíssimo vota tua.", en: "And pay thy vows to the most High." }
- psalms: [{ number: 54 }] - psalms: [{ number: 54 }]
antiphon: antiphon:
la: "Inténde in me * et exáudi me." la: "Inténde in me * et exáudi me."
@@ -162,9 +154,7 @@ tuesday:
wednesday: wednesday:
# 65-72 are Daym3_'s own (7 of 9 — the majority); 73 is Thursday's own # 65-72 are Daym3_'s own (7 of 9 — the majority); 73 is Thursday's own
# opening (Daym4). The versicle sits between the "67ab" and "68ab" # opening (Daym4).
# groups, exactly where Daym3_ itself has it — nothing moved out from
# between those two groups in the reslice, so the position is unchanged.
groups: groups:
- psalms: [{ number: 65 }] - psalms: [{ number: 65 }]
antiphon: antiphon:
@@ -174,9 +164,6 @@ wednesday:
antiphon: antiphon:
la: "In ecclésiis, * benedícite Dóminum." la: "In ecclésiis, * benedícite Dóminum."
en: "In the churches * bless ye the Lord." en: "In the churches * bless ye the Lord."
versicle:
v: { la: "Benedícite, gentes, Deum nostrum.", en: "O bless our God, ye people." }
r: { la: "Et audítam fácite vocem laudis ejus.", en: "And make the voice of his praise to be heard." }
- psalms: [{ number: 68, verses: "2-16" }, { number: 68, verses: "17-37" }] - psalms: [{ number: 68, verses: "2-16" }, { number: 68, verses: "17-37" }]
antiphon: antiphon:
la: "Quǽrite Dóminum * et vivet ánima vestra." la: "Quǽrite Dóminum * et vivet ánima vestra."
@@ -204,9 +191,7 @@ wednesday:
thursday: thursday:
# All 9 psalms are Daym4's own — no reslicing across weekday boundaries # All 9 psalms are Daym4's own — no reslicing across weekday boundaries
# here either, so grouping and versicle position are both unchanged from # here either, so the grouping is unchanged from the source.
# the source (the versicle sits between the "78" and "79,80" groups,
# exactly as Daym4 itself has it).
groups: groups:
- psalms: [{ number: 76 }] - psalms: [{ number: 76 }]
antiphon: antiphon:
@@ -220,9 +205,6 @@ thursday:
antiphon: antiphon:
la: "Propítius esto * peccátis nostris, Dómine." la: "Propítius esto * peccátis nostris, Dómine."
en: "Forgive us our sins * O Lord." en: "Forgive us our sins * O Lord."
versicle:
v: { la: "Deus, in sancto via tua.", en: "Thy way, O God, is in the holy place." }
r: { la: "Quis Deus magnus sicut Deus noster.", en: "Who is the great God like our God?" }
- psalms: [{ number: 79 }] - psalms: [{ number: 79 }]
# New (2026-09-01): no real antiphon found elsewhere in the corpus # New (2026-09-01): no real antiphon found elsewhere in the corpus
# for Ps 79 alone; composed from its own opening verse (79:2). # for Ps 79 alone; composed from its own opening verse (79:2).
@@ -254,10 +236,7 @@ friday:
# elsewhere, so it's kept here on 93 alone; likewise 95's antiphon # elsewhere, so it's kept here on 93 alone; likewise 95's antiphon
# originally also covered 96, which the redistribution's own documented # originally also covered 96, which the redistribution's own documented
# hand-adjustment moved to Saturday to keep both halves of Ps 103 # hand-adjustment moved to Saturday to keep both halves of Ps 103
# together there); 101-103 are Daym6_'s own opening (4 of 9). The # together there); 101-103 are Daym6_'s own opening (4 of 9).
# versicle sits between the "93" and "95" groups, exactly where Daym5_
# itself has it (originally between "92/93" and "95/96" — 96 leaving for
# Saturday doesn't touch this boundary).
groups: groups:
- psalms: [{ number: 85 }] - psalms: [{ number: 85 }]
antiphon: antiphon:
@@ -271,9 +250,6 @@ friday:
antiphon: antiphon:
la: "Exaltáre, * qui júdicas terram." la: "Exaltáre, * qui júdicas terram."
en: "Lift up thyself, * thou that judgest the earth." en: "Lift up thyself, * thou that judgest the earth."
versicle:
v: { la: "Intret orátio mea in conspéctu tuo, Dómine.", en: "Let my prayer come before Thee, O Lord." }
r: { la: "Inclína aurem tuam ad precem meam.", en: "Incline Thine ear unto my cry." }
- psalms: [{ number: 95 }] - psalms: [{ number: 95 }]
antiphon: antiphon:
la: "Cantáte Dómino, * et benedícite nómini ejus." la: "Cantáte Dómino, * et benedícite nómini ejus."
@@ -297,9 +273,7 @@ saturday:
# Ps 96 is Daym5_'s own tail (moved here by the redistribution's own # Ps 96 is Daym5_'s own tail (moved here by the redistribution's own
# documented hand-adjustment, swapped out of Friday to let both halves # documented hand-adjustment, swapped out of Friday to let both halves
# of Ps 103 land there instead); 104-108 are Daym6_'s own tail (8 of 9 — # of Ps 103 land there instead); 104-108 are Daym6_'s own tail (8 of 9 —
# the majority, hence Daym6_'s own versicle here). The versicle sits # the majority).
# between the "104ab" and "105ab" groups, exactly where Daym6_ itself
# has it — nothing moved out from between those two groups.
groups: groups:
- psalms: [{ number: 96 }] - psalms: [{ number: 96 }]
antiphon: antiphon:
@@ -309,9 +283,6 @@ saturday:
antiphon: antiphon:
la: "Lætétur cor * quæréntium Dóminum." la: "Lætétur cor * quæréntium Dóminum."
en: "Let the heart of them rejoice, * that seek the Lord." en: "Let the heart of them rejoice, * that seek the Lord."
versicle:
v: { la: "Dómine, exáudi oratiónem meam.", en: "O Lord, hear my prayer." }
r: { la: "Et clamor meus ad te véniat.", en: "And let my cry come unto thee." }
- psalms: [{ number: 105, verses: "1-31" }, { number: 105, verses: "32-48" }] - psalms: [{ number: 105, verses: "1-31" }, { number: 105, verses: "32-48" }]
antiphon: antiphon:
la: "Vísita nos, * Dómine, in salutári tuo." la: "Vísita nos, * Dómine, in salutári tuo."
+39 -33
View File
@@ -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,
@@ -208,7 +208,6 @@ function sundayNocturnFor(nocturnKey: 'nocturn1' | 'nocturn2' | 'nocturn3', day:
interface FerialGroup { interface FerialGroup {
psalms: PsalmRef[]; psalms: PsalmRef[];
antiphon: BilingualText; antiphon: BilingualText;
versicle?: { v: BilingualText; r: BilingualText };
} }
interface FerialNocturn { interface FerialNocturn {
groups: FerialGroup[]; groups: FerialGroup[];
@@ -434,12 +433,14 @@ function resolveMatinsHymn(day: LiturgicalDay): ResolvedText {
/** The plain ferial weekday nocturn's real antiphoned psalmody (see /** The plain ferial weekday nocturn's real antiphoned psalmody (see
* data/hours/matins-ferial-antiphons.yml's own header for how each * data/hours/matins-ferial-antiphons.yml's own header for how each
* weekday's groups/versicle were reconciled against psalter- * weekday's groups were reconciled against psalter-distribution.yml's own
* distribution.yml's own "editorial redistribution" of the historical * "editorial redistribution" of the historical per-weekday psalm set).
* per-weekday psalm set). Every psalm-group carries its own antiphon * Every psalm-group carries its own antiphon (incipit-or-full opening,
* (incipit-or-full opening, same rank rule as Sunday's own nocturns), and * same rank rule as Sunday's own nocturns). No versicle: an earlier
* exactly one group per weekday carries a versicle+responsory, rendered * version of this ported a mid-list V./R. line embedded in the source
* as one combined V./R. block via `versicleText`. */ * `Psalmi matutinum.txt`, but that line is live-verified inert there — the
* real single-nocturn ferial office runs straight from the last psalm to
* the Capitulum (fixed 2026-09-02, see the data file's own header). */
function ferialAntiphonedNocturn(day: LiturgicalDay): ResolvedPart[] { function ferialAntiphonedNocturn(day: LiturgicalDay): ResolvedPart[] {
const nocturn = ferialAntiphons[day.weekday as Exclude<Weekday, 'sunday'>]; const nocturn = ferialAntiphons[day.weekday as Exclude<Weekday, 'sunday'>];
const winner = resolveOfficeWinner(day); const winner = resolveOfficeWinner(day);
@@ -459,22 +460,20 @@ function ferialAntiphonedNocturn(day: LiturgicalDay): ResolvedPart[] {
}); });
}); });
parts.push({ kind: 'antiphon', text: full }); parts.push({ kind: 'antiphon', text: full });
if (group.versicle) {
parts.push({ kind: 'versicle', text: versicleText(group.versicle) });
}
} }
return parts; return parts;
} }
/** Fallback for a Duplex+ weekday feast with no matins-psalmody-overrides /** Fallback for a Semiduplex+ weekday feast with no matins-psalmody-overrides
* entry authored for its winner yet: the plain ferial weekday table, * entry authored for its winner yet: the plain ferial weekday table,
* chunked into 3 nocturns instead of 1. Bare (no antiphons, no canticles) * chunked into 3 nocturns instead of 1. Bare (no antiphons, no canticles)
* when `common` isn't given — genuinely nothing authored at all for this * when `common` isn't given — genuinely nothing authored at all for this
* winner, still used as-is for a duplex-majus+ winner with no Common * winner, still used as-is for a duplex-majus+ winner with no Common
* category either. * category either.
* *
* When `common`/`commonId` *are* given (a duplex-rank winner whose Common * When `common`/`commonId` *are* given (a Semiduplex- or Duplex-rank
* category exists — user, 2026-08), each nocturn borrows that category's * winner whose Common category exists — user, 2026-08, threshold lowered
* to Semiduplex 2026-09-02), each nocturn borrows that category's
* own `nocturn{N}.versicle`: the ferial table only ever authors one * own `nocturn{N}.versicle`: the ferial table only ever authors one
* versicle for the whole (normally 1-nocturn) day, so a 3-nocturn split * versicle for the whole (normally 1-nocturn) day, so a 3-nocturn split
* leaves 2 of the 3 nocturns with none at all — Common's own 3 real * leaves 2 of the 3 nocturns with none at all — Common's own 3 real
@@ -548,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
@@ -735,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);
@@ -776,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;
+48 -5
View File
@@ -48,7 +48,7 @@ describe('resolveOrdo("matins", ...) ferial (1-nocturn) branch', () => {
expect(ordo.parts.some((p) => p.kind === 'versicle' && p.text.text.la?.includes('Te decet laus'))).toBe(false); expect(ordo.parts.some((p) => p.kind === 'versicle' && p.text.text.la?.includes('Te decet laus'))).toBe(false);
}); });
it("gives every nocturn psalm its own antiphon and includes Tuesday's own versicle (data/hours/matins-ferial-antiphons.yml)", () => { it('gives every nocturn psalm its own antiphon and has no mid-nocturn versicle (data/hours/matins-ferial-antiphons.yml)', () => {
// The nocturn's own psalms start at index 5 (after the two opening versicles/Ps3/heading/Ps94). // The nocturn's own psalms start at index 5 (after the two opening versicles/Ps3/heading/Ps94).
const nocturnPsalms = ordo.parts.slice(5).filter((p) => p.kind === 'psalm') as { const nocturnPsalms = ordo.parts.slice(5).filter((p) => p.kind === 'psalm') as {
psalmNumber: number; psalmNumber: number;
@@ -65,11 +65,11 @@ describe('resolveOrdo("matins", ...) ferial (1-nocturn) branch', () => {
expect(p.antiphon.text.en).toBeTruthy(); expect(p.antiphon.text.en).toBeTruthy();
} }
} }
// No versicle anywhere in the psalmody (fixed 2026-09-02) — the
// source's embedded mid-list V./R. line is live-verified inert; the
// real single-nocturn ferial office runs straight to the Capitulum.
const nocturnVersicles = ordo.parts.slice(5).filter((p) => p.kind === 'versicle'); const nocturnVersicles = ordo.parts.slice(5).filter((p) => p.kind === 'versicle');
expect(nocturnVersicles).toHaveLength(1); expect(nocturnVersicles).toHaveLength(0);
const versicleText = (nocturnVersicles[0] as { text: { text: Record<string, string> } }).text.text;
expect(versicleText.la).toContain('Ímmola Deo sacrifícium laudis');
expect(versicleText.la).toContain('Et redde Altíssimo vota tua');
}); });
it("resolves the user's own bible-plan readings for the day, not the historical lectionary", () => { it("resolves the user's own bible-plan readings for the day, not the historical lectionary", () => {
@@ -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),