Fix Matins reading-pool ordering/leak bugs and redundant day-label commemoration

- nocturnReadingIds no longer lets a demoted commemoration's temporal id
  (equal to the plain governing-Sunday temporalId) sneak a Sunday's own
  homily into an unrelated weekday's pool via the commemorations loop —
  a second code path than the one a prior fix gated, affecting 18
  days/year.
- Reading pool order now follows id priority (winner first, then every
  temporal commemoration, then every sanctoral commemoration) instead of
  raw nocturn-tag/array order, matching the day label's own
  temporal-then-sanctoral convention.
- gospelReadingPart now falls back to the paired homily's own responsory
  when the Gospel entry has none of its own, instead of silently
  dropping it.
- Patristic source labels now show "(for <occasion>)" so a reader can
  tell which occasion a pooled reading belongs to on a multi-reading day.
- collectCommemorations no longer restates the plain generic temporal
  label (e.g. "Saturday in the 15th week after Trinity") once anything
  else is already being celebrated — general, not Ember-specific.
  Updated 4 day-label tests that encoded the old behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKC2QQGLYo2UTiDjDyWEkW
This commit is contained in:
2026-09-05 12:13:41 -04:00
parent f8bd7f3140
commit 65a1f359e6
3 changed files with 157 additions and 51 deletions
+17 -4
View File
@@ -78,7 +78,7 @@ const EMBER_DAY_NAMES: Record<string, Bi> = {
'ember-advent-saturday': bi('Ember Saturday in Advent', 'Sabbato Quattuor Temporum in Adventu'), 'ember-advent-saturday': bi('Ember Saturday in Advent', 'Sabbato Quattuor Temporum in Adventu'),
}; };
function emberDayLabel(id: string): Bi | undefined { export function emberDayLabel(id: string): Bi | undefined {
return EMBER_DAY_NAMES[id]; return EMBER_DAY_NAMES[id];
} }
@@ -488,9 +488,22 @@ function collectCommemorations(
sanctoral.push(name); sanctoral.push(name);
} }
} else if (c.kind === 'temporal') { } else if (c.kind === 'temporal') {
if (c.id === temporalId) { // `c.id === temporalId` (the plain governing weekday, e.g. "Saturday
temporal.push(anchorDayName(day, false) ?? temporalLabel(day)); // in the 15th week after Trinity") is deliberately never shown here:
} else { // this function only ever runs to list *secondary* identities
// alongside an already-displayed primary winner (a plain temporal
// day that wins outright never reaches `day.commemorations` at all —
// its own name is the header itself, via a different branch of
// `getDayLabel`). Once something else is already being celebrated,
// restating "this is also just the Nth ordinary weekday" tells the
// reader nothing they don't already know from the winner+rank shown
// — unlike a *named* temporal identity (an Ember day, a vigil), which
// genuinely adds information. Not Ember-specific (a 2026-09-05 first
// pass wrongly scoped this to "only when an Ember commemoration is
// also present" — user, 2026-09-05: "it has nothing to do with
// ember-ness"); this plain generic label is dropped unconditionally
// whenever it shows up as a mere commemoration, Ember day or not.
if (c.id !== temporalId) {
const emberName = emberDayLabel(c.id); const emberName = emberDayLabel(c.id);
if (emberName) { if (emberName) {
temporal.push(emberName); temporal.push(emberName);
+108 -22
View File
@@ -71,7 +71,7 @@ import type { ResolvedOrdo, ResolvedPart, ResolvedText, ResolvedVerse } from './
import type { LiturgicalDay, DayWinner } from '../calendar/types'; import type { LiturgicalDay, DayWinner } from '../calendar/types';
import { resolveDay, resolveTemporalId, monthWeekId, activeOctavesFor, resolveActiveOctave, isAtLeast } from '../calendar'; import { resolveDay, resolveTemporalId, monthWeekId, activeOctavesFor, resolveActiveOctave, isAtLeast } from '../calendar';
import { isInTriduum } from '../calendar/temporal'; import { isInTriduum } from '../calendar/temporal';
import { getDayLabel } from '../calendar/day-label'; import { getDayLabel, emberDayLabel } from '../calendar/day-label';
import { getPsalmVerses } from '../psalter'; 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';
@@ -692,9 +692,37 @@ function nocturnReadingIds(day: LiturgicalDay, temporalId: string, date: string,
if (day.winner.kind === 'sanctoral' || day.winner.id !== temporalId || threeNocturns) { if (day.winner.kind === 'sanctoral' || day.winner.id !== temporalId || threeNocturns) {
ids.add(day.winner.id); ids.add(day.winner.id);
} }
// Two passes, not one: every *temporal* commemoration's id is added
// before any *sanctoral* commemoration's, matching `day-label.ts`'s own
// `collectCommemorations` convention (`[...temporal, ...sanctoral]`) —
// so a reading's pool position agrees with where its own occasion is
// named in the day's displayed label. `day.commemorations`' own array
// order doesn't already reflect this grouping (live-verified as a real
// bug, 2026-09-19: St. Januarius's readings listed the Vigil of St.
// Matthew's homily before Ember Saturday's, backwards from the label's
// own "... — Ember Saturday in September — Vigil of St. Matthew
// (transferred)"), so it must be imposed here rather than assumed.
for (const c of day.commemorations) { for (const c of day.commemorations) {
// Same reasoning as day.winner.id just above. // Same reasoning as day.winner.id just above, but a *commemorated*
if (c.kind === 'sanctoral' || (c.kind === 'temporal' && (c.id !== temporalId || threeNocturns))) ids.add(c.id); // (not winning) temporal identity that equals the plain governing-
// Sunday `temporalId` is exactly the "Commemoratio Feriæ" case the
// `day.weekday === 'sunday'` gate below exists to guard (see that
// gate's own long comment) — a Duplex+ saint winning outright on a
// *weekday* (e.g. St. Joseph of Cupertino, Friday Sept 18) still has
// `threeNocturns` true, so `c.id !== temporalId || threeNocturns`
// alone let the week's separate governing-Sunday homily (Ambrose's,
// live-verified 2026-09-05) sneak back into a weekday's pool through
// this loop even though the gate below correctly kept it out of the
// `temporalKept` block. `c.id !== temporalId` must hold on its own
// unless today genuinely *is* that Sunday.
if (c.kind === 'temporal' && (c.id !== temporalId || (threeNocturns && day.weekday === 'sunday'))) {
ids.add(c.id);
}
}
for (const c of day.commemorations) {
if (c.kind === 'sanctoral') {
ids.add(c.id);
}
} }
// The plain temporalId/month-week content is the *governing Sunday's own* // The plain temporalId/month-week content is the *governing Sunday's own*
// Nocturn 2/3 patristic material, real content for that Sunday itself — // Nocturn 2/3 patristic material, real content for that Sunday itself —
@@ -753,6 +781,28 @@ function fallbackHagiographicLabel(id: string): string | undefined {
return name.startsWith('The ') ? `On the ${name.slice(4)}` : `On ${name}`; return name.startsWith('The ') ? `On the ${name.slice(4)}` : `On ${name}`;
} }
/** The human-readable occasion a `nocturn-readings` id stands for — a
* saint's or named temporal feast's own display name, or an Ember day's
* (which has no `TemporalFeastRecord` of its own, see `emberDayLabel`'s own
* doc comment). Used to annotate an authored patristic `source` with *why*
* it's here, since a single day can pool several ids' worth of readings
* (e.g. a commemorated Ember day's own Gospel homily alongside the winning
* saint's own) and a bare "Pope St. Gregory the Great, Homily 33" doesn't
* tell a reader which occasion that homily belongs to. */
function occasionName(id: string): string | undefined {
return getSaintRecord(id)?.name ?? getTemporalFeastRecord(id)?.name ?? emberDayLabel(id)?.en;
}
/** Appends `(for <occasion>)` to an authored patristic `source` string,
* when the contributing id resolves to a human-readable occasion name —
* see `occasionName`. A no-op when `source` is undefined (the hagiographic
* fallback case, which already names its own saint). */
function withOccasion(source: string | undefined, id: string): string | undefined {
if (!source) return source;
const occasion = occasionName(id);
return occasion ? `${source} (for ${occasion})` : source;
}
/** `index` is this reading's position among this same id's own /** `index` is this reading's position among this same id's own
* nocturn-readings (not the whole day's pool) — the cycling key for * nocturn-readings (not the whole day's pool) — the cycling key for
* `getResponsoryForCommon` when `r` has no proper `responsory` of its own, * `getResponsoryForCommon` when `r` has no proper `responsory` of its own,
@@ -764,7 +814,7 @@ function nocturnReadingPart(r: NocturnReading, id: string, index: number): Resol
return { return {
kind: 'lesson', kind: 'lesson',
text: { text: r.text, status: r.status, citation: r.citation }, text: { text: r.text, status: r.status, citation: r.citation },
label: r.source ?? fallbackHagiographicLabel(id), label: withOccasion(r.source, id) ?? fallbackHagiographicLabel(id),
responsory: responsoryText ? { text: responsoryText, status: { la: 'verified', en: 'verified' } } : undefined, responsory: responsoryText ? { text: responsoryText, status: { la: 'verified', en: 'verified' } } : undefined,
}; };
} }
@@ -779,15 +829,23 @@ function nocturnReadingPart(r: NocturnReading, id: string, index: number): Resol
* were). */ * were). */
function gospelReadingPart(r: NocturnReading, homily: NocturnReading | undefined, id: string, index: number): ResolvedPart { function gospelReadingPart(r: NocturnReading, homily: NocturnReading | undefined, id: string, index: number): ResolvedPart {
const incipit = getGospelIncipitFromCitation(r.citation); const incipit = getGospelIncipitFromCitation(r.citation);
const responsoryText = r.responsory ?? getResponsoryForCommon(getSaintRecord(id)?.common, index); // A responsory authored on the *homily* (the reference engine's own
// convention — e.g. post-pentecost-16.yml's Ambrose homily, or a saint's
// own Gospel-homily pair) belongs to the pericope+homily as a whole, not
// just the bare Gospel text, and must fall back here before the generic
// Common-category pool — this file's own header says a governing
// Sunday's homily "and its responsory" are pooled together; only `r`'s
// own field was ever checked, silently dropping every authored homily
// responsory (found 2026-09-05 while live-verifying an unrelated report).
const responsoryText = r.responsory ?? homily?.responsory ?? getResponsoryForCommon(getSaintRecord(id)?.common, index);
return { return {
kind: 'gospel', kind: 'gospel',
text: { text: r.text, status: r.status, citation: r.citation }, text: { text: r.text, status: r.status, citation: r.citation },
source: r.source, source: withOccasion(r.source, id),
label: incipit ? { la: incipit.la, en: incipit.en } : undefined, label: incipit ? { la: incipit.la, en: incipit.en } : undefined,
responsory: responsoryText ? { text: responsoryText, status: { la: 'verified', en: 'verified' } } : undefined, responsory: responsoryText ? { text: responsoryText, status: { la: 'verified', en: 'verified' } } : undefined,
homily: homily homily: homily
? { source: homily.source, text: { text: homily.text, status: homily.status, citation: homily.citation } } ? { source: withOccasion(homily.source, id), text: { text: homily.text, status: homily.status, citation: homily.citation } }
: undefined, : undefined,
}; };
} }
@@ -827,18 +885,40 @@ function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string,
: { kind: 'lesson', text: { text: r.text, status: r.status, citation: r.citation }, responsory, label }, : { kind: 'lesson', text: { text: r.text, status: r.status, citation: r.citation }, responsory, label },
); );
} }
// Grouped by the reading's own `nocturn` tag (ascending), not by which id
// contributed it — a later post-Pentecost Sunday's Nocturn 2 now comes
// from a different source (the month-week id) than its Nocturn 3 (its own
// temporalId file), and pooling in plain id order would put that Nocturn
// 3 content ahead of the Nocturn 2 content supplied by a later-processed
// id. Within each nocturn-number group, id order (and each file's own
// reading order) is preserved, matching this pool's usual priority rule.
// Not hardcoded to [2, 3]: Ember days' own nocturn-readings files use
// `nocturn: 1` (their single-nocturn structure), so every tag present
// must be handled, not just the usual Sunday/feast pair.
const ids = nocturnReadingIds(day, temporalId, date, threeNocturns); const ids = nocturnReadingIds(day, temporalId, date, threeNocturns);
const byNocturn = new Map<number, ResolvedPart[]>(); // Ordering is by *id priority* (winner first, then commemorations in
// `day.commemorations` order — a winning saint's own content always
// precedes a demoted commemoration's, matching the day-label convention
// of listing the winner first), with one deliberate exception: the
// temporalId itself and `month-week-${monthWeek}` are split halves of
// ONE governing Sunday's own Nocturn 2/3 content (see
// `nocturnReadingIds`'s `temporalKept` block) — pooling those two in
// plain id-priority order would put the temporalId file's own Nocturn 3
// content ahead of month-week's Nocturn 2 content, so this pair alone is
// grouped by raw `nocturn` tag (ascending) instead. Every other id is its
// own atomic block: previously *every* id was grouped globally by raw
// `nocturn` tag, which wrongly sorted a winning saint's own content after
// a demoted commemoration's whenever the commemoration's file happened to
// use a lower tag — e.g. an Ember day's single-nocturn file always tags
// `nocturn: 1`, sorting it ahead of a winning Duplex+ saint's own
// nocturn:2/3 content even though the saint, not the Ember day, is what's
// actually being celebrated (user, 2026-09-05, re: St. Joseph of
// Cupertino/Ember Friday, Sept 18). Within a single id's own block,
// `nocturn` tag order (== that file's own reading order) is still used to
// order its own multiple readings relative to each other.
const monthWeek = monthWeekId(date);
const splitIds = new Set(threeNocturns && day.weekday === 'sunday' && monthWeek ? [temporalId, `month-week-${monthWeek}`] : []);
let splitPriority = -1;
const priorityOf = new Map<string, number>();
ids.forEach((id, i) => {
if (splitIds.has(id)) {
if (splitPriority === -1) splitPriority = i;
priorityOf.set(id, splitPriority);
} else {
priorityOf.set(id, i);
}
});
const byGroup = new Map<string, ResolvedPart[]>();
// The reference engine's own Tempora files sometimes independently reuse // The reference engine's own Tempora files sometimes independently reuse
// the exact same excerpt across two different Sunday-numbering schemes // the exact same excerpt across two different Sunday-numbering schemes
// (e.g. a Pentecost-numbered Sunday and a calendar-month Sunday landing on // (e.g. a Pentecost-numbered Sunday and a calendar-month Sunday landing on
@@ -856,7 +936,8 @@ function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string,
const textKey = reading.text.la ?? reading.text.en ?? ''; const textKey = reading.text.la ?? reading.text.en ?? '';
if (textKey && seenReadingText.has(textKey)) continue; if (textKey && seenReadingText.has(textKey)) continue;
if (textKey) seenReadingText.add(textKey); if (textKey) seenReadingText.add(textKey);
const bucket = byNocturn.get(reading.nocturn) ?? []; const groupKey = `${priorityOf.get(id)}:${reading.nocturn}`;
const bucket = byGroup.get(groupKey) ?? [];
if (reading.isGospel) { if (reading.isGospel) {
// A genuine separate homily on this pericope is the very next // A genuine separate homily on this pericope is the very next
// reading in the same file, in the same nocturn, not itself flagged // reading in the same file, in the same nocturn, not itself flagged
@@ -872,11 +953,16 @@ function buildReadingPool(day: LiturgicalDay, temporalId: string, date: string,
} else { } else {
bucket.push(nocturnReadingPart(reading, id, commonPoolIndex++)); bucket.push(nocturnReadingPart(reading, id, commonPoolIndex++));
} }
byNocturn.set(reading.nocturn, bucket); byGroup.set(groupKey, bucket);
} }
} }
for (const nocturnNumber of [...byNocturn.keys()].sort((a, b) => a - b)) { const sortedGroupKeys = [...byGroup.keys()].sort((a, b) => {
parts.push(...byNocturn.get(nocturnNumber)!); const [aPriority, aNocturn] = a.split(':').map(Number);
const [bPriority, bNocturn] = b.split(':').map(Number);
return aPriority! - bPriority! || aNocturn! - bNocturn!;
});
for (const key of sortedGroupKeys) {
parts.push(...byGroup.get(key)!);
} }
for (const octave of activeOctavesFor(date)) { for (const octave of activeOctavesFor(date)) {
const reading = getOctaveReading(octave.id, octave.dayNumber); const reading = getOctaveReading(octave.id, octave.dayNumber);
+32 -25
View File
@@ -80,8 +80,12 @@ describe('getDayLabel — ordinal temporal label', () => {
// for a Duplex II. classis) — live-verified 2026-09-01, the real // for a Duplex II. classis) — live-verified 2026-09-01, the real
// Monastic 1617 engine shows "S. Andreæ Apostoli ~ Duplex II. classis" // Monastic 1617 engine shows "S. Andreæ Apostoli ~ Duplex II. classis"
// there outright, "Tempora: Feria II infra Hebdomadam I Adventus" // there outright, "Tempora: Feria II infra Hebdomadam I Adventus"
// underneath, matching this. // underneath, matching this. The plain "In the 1st week of Advent"
expect(getDayLabel(resolveDay('2025-12-01')).en).toBe('St. Andrew, Apostle (Duplex II Class, transferred) — In the 1st week of Advent'); // feria commemoration this day also carries internally (day.commemorations
// still has it) is no longer shown in the label (2026-09-05: a plain
// temporal commemoration is dropped from the label whenever anything
// else is already being celebrated — general, not Ember-specific).
expect(getDayLabel(resolveDay('2025-12-01')).en).toBe('St. Andrew, Apostle (Duplex II Class, transferred)');
// Dec 4 (St. Barbara, merely commemorated, not high-ranked enough to // Dec 4 (St. Barbara, merely commemorated, not high-ranked enough to
// win outright) is a clean example of the season-ordinal label itself. // win outright) is a clean example of the season-ordinal label itself.
// Advent drops the redundant leading weekday word now that the header // Advent drops the redundant leading weekday word now that the header
@@ -180,29 +184,29 @@ describe('getDayLabel — resumed post-Epiphany Sunday (overflow years)', () =>
// one, carry no special first-class standing of their own), so // one, carry no special first-class standing of their own), so
// commemorations.ts's `ordinary-sunday` case's plain Duplex+ threshold // commemorations.ts's `ordinary-sunday` case's plain Duplex+ threshold
// is what lets her win outright rather than being commemorated — a // is what lets her win outright rather than being commemorated — a
// pre-existing rule, not new behavior from adding her. This case // pre-existing rule, not new behavior from adding her. The displaced
// additionally confirms the displaced Sunday is still commemorated in // Sunday is still commemorated internally (day.commemorations), using
// return, using the same fixed 23rd-after-Trinity ordinal, not a raw // the same fixed 23rd-after-Trinity ordinal, not a raw elapsed-week
// elapsed-week count recomputed for 1943. // count recomputed for 1943 — but per the 2026-09-05 label rule, a
expect(getDayLabel(resolveDay('1943-11-21')).en).toBe( // plain temporal commemoration like this one is no longer shown in the
'The Presentation of the Blessed Virgin Mary (Duplex Majus) — The 23rd Sunday after Trinity', // label once something else already has the primary slot.
); expect(getDayLabel(resolveDay('1943-11-21')).en).toBe('The Presentation of the Blessed Virgin Mary (Duplex Majus)');
}); });
}); });
describe('getDayLabel — commemorated Sunday/feria under a sanctoral winner', () => { describe('getDayLabel — commemorated Sunday/feria under a sanctoral winner', () => {
it('a saint winning outright on a privileged-feria-minor day still leaves that feria commemorated', () => { it('a saint winning outright on a privileged-feria-minor day still leaves that feria commemorated internally, but the label no longer restates it', () => {
// 2027-12-07, a Tuesday of Advent: St. Ambrose (Duplex) clears // 2027-12-07, a Tuesday of Advent: St. Ambrose (Duplex) clears
// privileged-feria-minor's own Semiduplex+ threshold and wins // privileged-feria-minor's own Semiduplex+ threshold and wins
// outright, but Advent's own feria is still commemorated in return // outright, and Advent's own feria is still commemorated in return
// (`decideOccurrence`'s `privileged-feria-minor` branch) -- live- // internally (`decideOccurrence`'s `privileged-feria-minor` branch) --
// verified shape, same mechanism as the ordinary-Sunday case above, // live-verified shape, same mechanism as the ordinary-Sunday case
// for the feria-tier branch instead. // above, for the feria-tier branch instead. But per the 2026-09-05
// Advent drops the redundant leading weekday word (see the Dec 1 case // label rule, a plain temporal commemoration (a bare "In the Nth week
// above). // of Advent") is dropped from the label once something else already
expect(getDayLabel(resolveDay('2027-12-07')).en).toBe( // has the primary slot -- it isn't Ember-specific, and it applies here
'St. Ambrose, Bishop, Confessor and Doctor of the Church (Duplex) — In the 2nd week of Advent', // too even though this is a feria, not an Ember day.
); expect(getDayLabel(resolveDay('2027-12-07')).en).toBe('St. Ambrose, Bishop, Confessor and Doctor of the Church (Duplex)');
}); });
}); });
@@ -289,7 +293,7 @@ describe('getDayLabel — active octave', () => {
); );
}); });
it('a sanctoral winner on a Sunday still leaves the Sunday commemorated, but neither octave it also happens to overlap -- not an octave name at all', () => { it('a sanctoral winner on a Sunday still leaves the Sunday commemorated internally, but neither octave it also happens to overlap -- not an octave name at all, nor the plain Sunday ordinal any more', () => {
// 2026-08-16 is a Sunday genuinely within both St. Lawrence's and the // 2026-08-16 is a Sunday genuinely within both St. Lawrence's and the
// Assumption's overlapping octave windows -- St. Joachim (Duplex II // Assumption's overlapping octave windows -- St. Joachim (Duplex II
// Class) wins outright per the ordinary-Sunday Duplex+ threshold, not // Class) wins outright per the ordinary-Sunday Duplex+ threshold, not
@@ -297,14 +301,17 @@ describe('getDayLabel — active octave', () => {
// this case existed). Live-verified against Divino Afflatu 1954: the // this case existed). Live-verified against Divino Afflatu 1954: the
// commemoration line reads only "Dominica XII Post Pentecosten" (this // commemoration line reads only "Dominica XII Post Pentecosten" (this
// app's own Trinity-counted display is one week off from that // app's own Trinity-counted display is one week off from that
// Pentecost-counted number, so "11th ... after Trinity" here) -- no // Pentecost-counted number, so "11th ... after Trinity" internally) --
// octave named at all, even though both are technically active, same // no octave named at all, even though both are technically active,
// as this app's own established `ordinary-feria`-only gate for // same as this app's own established `ordinary-feria`-only gate for
// octave mentions alongside a sanctoral winner. // octave mentions alongside a sanctoral winner. Per the 2026-09-05
// label rule, the plain Sunday commemoration itself is now also
// dropped from the label (still present in day.commemorations) once
// a sanctoral winner already has the primary slot.
const day = resolveDay('2026-08-16'); const day = resolveDay('2026-08-16');
expect(day.weekday).toBe('sunday'); expect(day.weekday).toBe('sunday');
expect(getDayLabel(day).en).toBe( expect(getDayLabel(day).en).toBe(
'St. Joachim, Confessor, Father of the Blessed Virgin Mary (Duplex II Class) — The 11th Sunday after Trinity', 'St. Joachim, Confessor, Father of the Blessed Virgin Mary (Duplex II Class)',
); );
expect(getDayLabel(day).en).not.toContain('Octave'); expect(getDayLabel(day).en).not.toContain('Octave');
}); });