Compare commits

...

2 Commits

Author SHA1 Message Date
will 7fb37f6237 Fix First Vespers rank threshold: any sanctoral winner qualifies
Deploy / deploy (push) Successful in 1m22s
hasFirstVespers() required Semiduplex-or-higher, so a bare Simplex
feast could never anticipate the evening before it. Live-verified
this was too strict: St. Zephyrinus (Aug 26, plain Simplex) claims
First Vespers over St. Louis (Aug 25, also Simplex) in the reference
engine -- "Vespera de sequenti; nihil de præcedenti." A Simplex feast
has no Second Vespers of its own, but it does still get First Vespers;
there's no rank floor at all, only keepsOwnSecondVespers' separate
question of whether the preceding day outranks the claim.

This also surfaces St. Zephyrinus's Magnificat antiphon, which now
actually gets used (St. Louis, previously shown by the old bug, is
permanently boxed out of ever governing real Vespers in this merged
calendar -- Bartholomew outranks him the evening before, Zephyrinus
the evening after, every year -- so his own Magnificat antiphon was
never authored). Added st-zephyrinus-magnificat-antiphon.yml, live-
verified bilingual text ("Iste Sanctus...").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014axryJBrRswYh2niA7WCUc
2026-08-26 06:45:31 -04:00
will 5298cbe4a3 Add suffrages to Vespers, shared with Lauds' existing mechanism
Vespers had no suffrages (Holy Cross, BVM, Joseph, Apostles, Peace)
at all -- types.ts even documented it as Lauds-only. Live-verified
against the reference engine (Monastic Tridentinum 1617): the
"Suffragium" block after the day's collect(s) renders byte-identical
after both Lauds and Vespers, so this was an honest content gap, not
a deliberate omission.

Extracted the suffrage ids/labels and full omission logic (Double-or-
higher, Christ the King, Advent/Christmastide/Passiontide, active
octaves, Cross/Marian-Saturday special cases) out of lauds.ts into a
shared resolveSuffrages() in resolve-common.ts, and wired it into
vespers.ts/vespers.yml the same way Lauds already uses it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014axryJBrRswYh2niA7WCUc
2026-08-26 06:45:20 -04:00
7 changed files with 127 additions and 87 deletions
+10 -5
View File
@@ -40,11 +40,16 @@ function hasFirstVespers(day: LiturgicalDay): boolean {
} }
// Live-verified (2026-08): Ss. Cyriacus/Largus/Smaragdus (Aug 8, plain // Live-verified (2026-08): Ss. Cyriacus/Largus/Smaragdus (Aug 8, plain
// Semiduplex) claims First Vespers over St. Donatus's Aug 7 — "Vespera // Semiduplex) claims First Vespers over St. Donatus's Aug 7 — "Vespera
// de sequenti" in the real Monastic 1617 engine — so Semiduplex clears // de sequenti" in the real Monastic 1617 engine. Originally thought
// this bar, not duplex-majus as originally guessed (unverified at the // Semiduplex was the actual cutoff, but re-verified 2026-08-25: St.
// time). Matches keepsOwnSecondVespers's own privileged-feria-minor // Zephyrinus (Aug 26, plain Simplex) likewise claims First Vespers over
// threshold just below, which uses the same cutoff. // St. Louis's Aug 25 (also Simplex) — "Vespera de sequenti; nihil de
return day.winner.kind === 'sanctoral' && isAtLeast(day.winner.rank, 'semiduplex'); // præcedenti" — so *any* real sanctoral winner, however low-ranked,
// clears this bar; there's no rank floor at all, only the separate
// keepsOwnSecondVespers question of whether the day *before* it
// outranks the claim. Every FeastClass (including `vigil`, untested
// directly but the same "lowest tier" logic) now qualifies.
return day.winner.kind === 'sanctoral';
} }
/** Does this day keep its own Second Vespers regardless of what tomorrow is? */ /** Does this day keep its own Second Vespers regardless of what tomorrow is? */
+9
View File
@@ -18,6 +18,13 @@
# identical at both hours by live query) versus the fuller Tridentine # identical at both hours by live query) versus the fuller Tridentine
# 1906/1910 ferial Preces on a ferial or vigil day — see hours/types.ts's # 1906/1910 ferial Preces on a ferial or vigil day — see hours/types.ts's
# 'vespers-preces' doc comment. # 'vespers-preces' doc comment.
#
# `suffrages` (Holy Cross, BVM, Joseph, Ss. Peter & Paul, Peace) reuses
# Lauds' own mechanism and content ids wholesale — live-verified
# 2026-08-25 against Monastic Tridentinum 1617 that the "Suffragium"
# block after Vespers' collect is byte-identical to the one after Lauds',
# not a Lauds-specific thing. See hours/resolve-common.ts's
# resolveSuffrages for the full omission logic.
id: vespers id: vespers
parts: parts:
- kind: opening-versicle - kind: opening-versicle
@@ -26,5 +33,7 @@ parts:
- kind: magnificat - kind: magnificat
- kind: vespers-preces - kind: vespers-preces
- kind: day-collects - kind: day-collects
- kind: suffrages
omitOnDouble: true
- kind: preces - kind: preces
textRef: { source: common, id: lauds-conclusio } textRef: { source: common, id: lauds-conclusio }
@@ -0,0 +1,12 @@
# Verified against Divinum Officium (Monastic Tridentinum 1617), Vespers
# live query, St. Zephyrinus's own First Vespers (anticipated the evening
# of Aug 25, per the corrected `hasFirstVespers` rule — see
# src/calendar/vespers.ts). The "Iste Sanctus" formula, common to a
# Martyr not Pope/Bishop-rank office.
id: st-zephyrinus-magnificat-antiphon
text:
la: "Iste Sanctus pro lege Dei sui certávit usque ad mortem, et a verbis impiórum non tímuit: fundátus enim erat supra firmam petram."
en: "This man is holy for he hath striven for the law of his God even unto death, and hath not feared for the words of the ungodly; for he had his foundation upon a strong rock."
status:
la: verified
en: verified
+5 -74
View File
@@ -1,14 +1,13 @@
import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types'; import type { HourDefinition, HourPart, ResolvedOrdo, ResolvedPart, ResolvedText } from './types';
import type { LiturgicalDay, Weekday } from '../calendar/types'; import type { LiturgicalDay, Weekday } from '../calendar/types';
import { resolveDay, isSundayOrFeast, activeOctavesFor } from '../calendar'; import { resolveDay } from '../calendar';
import { getDayLabel } from '../calendar/day-label'; import { getDayLabel } from '../calendar/day-label';
import { getTemporalFeastRecord } from '../calendar/temporal-feasts';
import { getPsalmVerses } from '../psalter'; import { getPsalmVerses } from '../psalter';
import { getCanticle } from './lauds-canticles'; import { getCanticle } from './lauds-canticles';
import { getLaudsPsalmodyOverride, type LaudsPsalmodyOverride } from './lauds-psalmody-overrides'; import { getLaudsPsalmodyOverride, type LaudsPsalmodyOverride } from './lauds-psalmody-overrides';
import { getOpeningVersicleId } from './opening-versicle'; import { getOpeningVersicleId } from './opening-versicle';
import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon'; import { getMarianAntiphonId, getMarianAntiphonLabel } from './marian-antiphon';
import { isDoubleOrHigher, applyFlexaMark } from './antiphon'; import { applyFlexaMark } from './antiphon';
import { import {
resolveCommon, resolveCommon,
getDayCollects, getDayCollects,
@@ -22,6 +21,7 @@ import {
getOfficeOverrideId, getOfficeOverrideId,
resolveResponsory, resolveResponsory,
resolveOfficeBundle, resolveOfficeBundle,
resolveSuffrages,
} from './resolve-common'; } from './resolve-common';
import laudsDefinitionData from '../data/hours/lauds.yml'; import laudsDefinitionData from '../data/hours/lauds.yml';
import laudsAntiphonsData from '../data/hours/lauds-antiphons.yml'; import laudsAntiphonsData from '../data/hours/lauds-antiphons.yml';
@@ -210,21 +210,6 @@ function resolveOffice(day: LiturgicalDay): ResolvedPart[] {
]; ];
} }
const SUFFRAGES = [
'lauds-suffrage-cross',
'lauds-suffrage-bvm',
'lauds-suffrage-joseph',
'lauds-suffrage-apostles',
'lauds-suffrage-peace',
];
const SUFFRAGE_LABELS: Record<string, string> = {
'lauds-suffrage-cross': 'Of the Holy Cross',
'lauds-suffrage-bvm': 'Of the Blessed Virgin Mary',
'lauds-suffrage-joseph': 'Of St. Joseph',
'lauds-suffrage-apostles': 'Of the Holy Apostles Peter and Paul',
'lauds-suffrage-peace': 'For Peace',
};
function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] { function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
switch (part.kind) { switch (part.kind) {
case 'opening-versicle': case 'opening-versicle':
@@ -246,62 +231,8 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }]; return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }];
case 'day-collects': case 'day-collects':
return getDayCollects(day); return getDayCollects(day);
case 'suffrages': { case 'suffrages':
// isDoubleOrHigher only ever looks at a sanctoral rank — Christ the return resolveSuffrages(day, part.omitOnDouble);
// King is modeled as a named temporal winner (see
// ALWAYS_OVERRIDE_TEMPORAL_IDS above) with no FeastClass to check at
// all, so it needs its own explicit inclusion here. Confirmed live:
// "Suffragium{omittitur}" — the whole set drops, same as any other
// Double-or-higher day.
const isChristTheKing = day.winner.kind === 'temporal' && day.winner.id === 'christ-the-king';
// Confirmed live against Monastic Tridentinum 1617 directly (this
// isn't a Tridentine-1906-only quirk -- the same exclusions hold
// under the app's own primary rubric track): suffrages are omitted
// *entirely* during Advent, Christmastide, and Passiontide (not all
// of Lent -- ordinary Lent ferias still get them, checked directly
// against 2026-02-20; only the last two weeks), and on any day
// within an active octave, however low that octave's own rank is
// -- checked directly against day 3 of St. Lawrence's own
// (Semiduplex) octave, which still omits them despite being well
// below the Duplex+ rank threshold below.
const isSeasonallyExcluded = day.season === 'advent' || day.season === 'christmastide' || day.season === 'passiontide';
const isWithinAnOctave = activeOctavesFor(day.date).length > 0;
if (part.omitOnDouble && (isDoubleOrHigher(day.winner) || isChristTheKing || isSeasonallyExcluded || isWithinAnOctave)) {
return [];
}
// "Of the Holy Cross" is the real *ferial*-office suffrage, sourced
// from Tridentine 1906/1910 (a different track than the other four,
// which come from Monastic 1617 -- that track has no Cross suffrage
// at Lauds at all) -- live-verified against a plain ferial win, a
// plain Sunday win, a low-rank saint's own win (even bare Simplex),
// and Marian Saturday: Cross shows only when the bare temporal
// feria itself is what's being prayed -- the exact *opposite* of
// "Sunday or a feast of the Lord." In this app's own vocabulary,
// that's `!isSundayOrFeast(day)` (the same ferial/festive split
// Prime's capitulum already keys off) with one more exclusion for a
// named temporal identity like Marian Saturday, which isn't Sunday
// or a sanctoral winner either but still isn't a bare ferial office.
const showCross = !isSundayOrFeast(day) && !(day.winner.kind === 'temporal' && getTemporalFeastRecord(day.winner.id));
// "Of the Blessed Virgin Mary" is omitted specifically when the
// day's own office is already Marian (would be redundant to
// suffrage Mary again on her own day) -- confirmed live on Marian
// Saturday (Simplex-strength, so it reaches this far rather than
// being excluded outright above); the only case this app currently
// has of a day using the Marian common below the suffrage-omitting
// Duplex+ threshold (every actual Marian *feast* modeled so far is
// Duplex-majus+ and already returns above).
const isMarianSaturday = day.winner.kind === 'temporal' && day.winner.id === 'marian-saturday';
const ids = SUFFRAGES.filter((id) => {
if (id === 'lauds-suffrage-cross') return showCross;
if (id === 'lauds-suffrage-bvm') return !isMarianSaturday;
return true;
});
return ids.map((id) => ({
kind: 'preces' as const,
text: resolveCommon(id),
label: SUFFRAGE_LABELS[id],
}));
}
case 'versicle': case 'versicle':
case 'chapter': case 'chapter':
case 'responsory': case 'responsory':
+79 -1
View File
@@ -3,7 +3,7 @@ import type { Commemoration, DayWinner, LiturgicalDay, Weekday } from '../calend
import type { ProperText } from '../propers'; import type { ProperText } from '../propers';
import { getCommonProper, getTemporalProper } from '../propers'; import { getCommonProper, getTemporalProper } from '../propers';
import { getSaintRecord } from '../calendar/feasts'; import { getSaintRecord } from '../calendar/feasts';
import { resolveActiveOctave, activeOctavesFor, octaveGoverningPrivilegedDay, isAtLeast } from '../calendar'; import { resolveActiveOctave, activeOctavesFor, octaveGoverningPrivilegedDay, isAtLeast, isSundayOrFeast } from '../calendar';
import { getTemporalFeastRecord } from '../calendar/temporal-feasts'; import { getTemporalFeastRecord } from '../calendar/temporal-feasts';
import { isInTriduum } from '../calendar/temporal'; import { isInTriduum } from '../calendar/temporal';
import { splitAntiphon, isDoubleOrHigher } from './antiphon'; import { splitAntiphon, isDoubleOrHigher } from './antiphon';
@@ -618,6 +618,84 @@ export function getDayCollects(day: LiturgicalDay): ResolvedPart[] {
return parts; return parts;
} }
const SUFFRAGE_IDS = ['lauds-suffrage-cross', 'lauds-suffrage-bvm', 'lauds-suffrage-joseph', 'lauds-suffrage-apostles', 'lauds-suffrage-peace'];
const SUFFRAGE_LABELS: Record<string, string> = {
'lauds-suffrage-cross': 'Of the Holy Cross',
'lauds-suffrage-bvm': 'Of the Blessed Virgin Mary',
'lauds-suffrage-joseph': 'Of St. Joseph',
'lauds-suffrage-apostles': 'Of the Holy Apostles Peter and Paul',
'lauds-suffrage-peace': 'For Peace',
};
/**
* The four fixed Tridentine suffrages (Holy Cross, BVM, Joseph, Ss. Peter
* & Paul, Peace), said after the day's collect(s) — shared by Lauds and
* Vespers (live-verified 2026-08-25: Monastic Tridentinum 1617 renders
* the identical "Suffragium" block, same four antiphons/prayers, after
* *both* hours, not just Lauds — vu previously only had this mechanism
* wired up at Lauds, an honest content gap rather than a deliberate
* Lauds-only design). `omitOnDouble` gates the whole set at once (real
* practice: suffrages drop on a Double-or-higher feast) — see below for
* the caveat on what real practice also suppresses them for that isn't
* modeled.
*/
export function resolveSuffrages(day: LiturgicalDay, omitOnDouble: boolean | undefined): ResolvedPart[] {
// isDoubleOrHigher only ever looks at a sanctoral rank — Christ the
// King is modeled as a named temporal winner (see
// ALWAYS_OVERRIDE_TEMPORAL_IDS above) with no FeastClass to check at
// all, so it needs its own explicit inclusion here. Confirmed live:
// "Suffragium{omittitur}" — the whole set drops, same as any other
// Double-or-higher day.
const isChristTheKing = day.winner.kind === 'temporal' && day.winner.id === 'christ-the-king';
// Confirmed live against Monastic Tridentinum 1617 directly (this
// isn't a Tridentine-1906-only quirk -- the same exclusions hold
// under the app's own primary rubric track): suffrages are omitted
// *entirely* during Advent, Christmastide, and Passiontide (not all
// of Lent -- ordinary Lent ferias still get them, checked directly
// against 2026-02-20; only the last two weeks), and on any day
// within an active octave, however low that octave's own rank is
// -- checked directly against day 3 of St. Lawrence's own
// (Semiduplex) octave, which still omits them despite being well
// below the Duplex+ rank threshold below.
const isSeasonallyExcluded = day.season === 'advent' || day.season === 'christmastide' || day.season === 'passiontide';
const isWithinAnOctave = activeOctavesFor(day.date).length > 0;
if (omitOnDouble && (isDoubleOrHigher(day.winner) || isChristTheKing || isSeasonallyExcluded || isWithinAnOctave)) {
return [];
}
// "Of the Holy Cross" is the real *ferial*-office suffrage, sourced
// from Tridentine 1906/1910 (a different track than the other four,
// which come from Monastic 1617 -- that track has no Cross suffrage
// at Lauds at all) -- live-verified against a plain ferial win, a
// plain Sunday win, a low-rank saint's own win (even bare Simplex),
// and Marian Saturday: Cross shows only when the bare temporal
// feria itself is what's being prayed -- the exact *opposite* of
// "Sunday or a feast of the Lord." In this app's own vocabulary,
// that's `!isSundayOrFeast(day)` (the same ferial/festive split
// Prime's capitulum already keys off) with one more exclusion for a
// named temporal identity like Marian Saturday, which isn't Sunday
// or a sanctoral winner either but still isn't a bare ferial office.
const showCross = !isSundayOrFeast(day) && !(day.winner.kind === 'temporal' && getTemporalFeastRecord(day.winner.id));
// "Of the Blessed Virgin Mary" is omitted specifically when the
// day's own office is already Marian (would be redundant to
// suffrage Mary again on her own day) -- confirmed live on Marian
// Saturday (Simplex-strength, so it reaches this far rather than
// being excluded outright above); the only case this app currently
// has of a day using the Marian common below the suffrage-omitting
// Duplex+ threshold (every actual Marian *feast* modeled so far is
// Duplex-majus+ and already returns above).
const isMarianSaturday = day.winner.kind === 'temporal' && day.winner.id === 'marian-saturday';
const ids = SUFFRAGE_IDS.filter((id) => {
if (id === 'lauds-suffrage-cross') return showCross;
if (id === 'lauds-suffrage-bvm') return !isMarianSaturday;
return true;
});
return ids.map((id) => ({
kind: 'preces' as const,
text: resolveCommon(id),
label: SUFFRAGE_LABELS[id],
}));
}
/** /**
* Lauds' Benedictus antiphon, resolved the same way as getDayCollect: a * Lauds' Benedictus antiphon, resolved the same way as getDayCollect: a
* per-saint `${propers}-antiphon` (already authored during the sanctoral * per-saint `${propers}-antiphon` (already authored during the sanctoral
+9 -6
View File
@@ -140,12 +140,15 @@ export type HourPart =
// full-after-Creed... no Creed at Lauds, just before+after framing shape // full-after-Creed... no Creed at Lauds, just before+after framing shape
// as nunc-dimittis, gated the same way by isDoubleOrHigher. // as nunc-dimittis, gated the same way by isDoubleOrHigher.
| { kind: 'benedictus' } | { kind: 'benedictus' }
// Lauds only. The four fixed Tridentine suffrages (Holy Cross, BVM, // Lauds and Vespers. The four fixed Tridentine suffrages (Holy Cross,
// Ss. Peter & Paul, Peace) said after the day's collect(s) — see // BVM, Ss. Peter & Paul, Peace) said after the day's collect(s) — see
// data/propers/common/lauds-suffrage-*.yml. `omitOnDouble` gates the // data/propers/common/lauds-suffrage-*.yml (reused byte-identical at
// whole set at once (real practice: suffrages drop on a Double-or-higher // Vespers, same fixed formula after either hour, live-verified
// feast) — an honest simplification, see hours/lauds.ts for the caveat // 2026-08-25 — see hours/resolve-common.ts's resolveSuffrages).
// on what real practice also suppresses them for that isn't modeled. // `omitOnDouble` gates the whole set at once (real practice: suffrages
// drop on a Double-or-higher feast) — an honest simplification, see
// resolveSuffrages for the caveat on what real practice also
// suppresses them for that isn't modeled.
| { kind: 'suffrages'; omitOnDouble?: true } | { kind: 'suffrages'; omitOnDouble?: true }
// Lauds only. The Tridentine 1906/1910 "Preces feriales" litany (Kyrie, // Lauds only. The Tridentine 1906/1910 "Preces feriales" litany (Kyrie,
// Pater noster, a long chain of versicles, Psalm 129, closing versicles) // Pater noster, a long chain of versicles, Psalm 129, closing versicles)
+3 -1
View File
@@ -17,6 +17,7 @@ import {
isFerialOrVigil, isFerialOrVigil,
resolveResponsory, resolveResponsory,
resolveOfficeBundle, resolveOfficeBundle,
resolveSuffrages,
} from './resolve-common'; } from './resolve-common';
import vespersDefinitionData from '../data/hours/vespers.yml'; import vespersDefinitionData from '../data/hours/vespers.yml';
import vespersAntiphonsData from '../data/hours/vespers-antiphons.yml'; import vespersAntiphonsData from '../data/hours/vespers-antiphons.yml';
@@ -176,6 +177,8 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
} }
case 'day-collects': case 'day-collects':
return getDayCollects(day); return getDayCollects(day);
case 'suffrages':
return resolveSuffrages(day, part.omitOnDouble);
case 'preces': case 'preces':
return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }]; return [{ kind: 'preces', text: resolveCommon(part.textRef.id), label: part.label }];
case 'versicle': case 'versicle':
@@ -199,7 +202,6 @@ function resolvePart(part: HourPart, day: LiturgicalDay): ResolvedPart[] {
case 'lauds-psalmody': case 'lauds-psalmody':
case 'lauds-office': case 'lauds-office':
case 'benedictus': case 'benedictus':
case 'suffrages':
case 'lauds-preces': case 'lauds-preces':
case 'marian-antiphon': case 'marian-antiphon':
throw new Error(`Vespers' ordo doesn't support a '${part.kind}' part`); throw new Error(`Vespers' ordo doesn't support a '${part.kind}' part`);