From e0156c1512a6b0d9a9de60a751553eccc7359bd7 Mon Sep 17 00:00:00 2001 From: Will Estes Date: Mon, 31 Aug 2026 22:01:33 -0400 Subject: [PATCH] Fix Vespers concurrentia to compare today's rank against tomorrow's directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit keepsOwnSecondVespers used a fixed duplex-2-classis floor on today's rank alone, so a lower-ranked but still-real sanctoral winner (e.g. Simplex) tomorrow could still steal First Vespers from a higher-ranked-but-below-the- floor winner today (semiduplex St. Raymond Nonnatus losing to simplex St. Giles on 2026-08-31). The real "De Concurrentia Officii" rubric compares the two days' ranks directly, with ties going to tomorrow — replace the floor with that comparison when both sides have a real sanctoral winner, keeping the floor only as a fallback against a plain Sunday. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014QEe9oTbn1UjQVAYcCoW1a --- src/calendar/vespers.ts | 55 +++++++++++++++++++++++++++------- tests/calendar/vespers.test.ts | 25 ++++++++++++++++ 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/calendar/vespers.ts b/src/calendar/vespers.ts index fe5f261..8541e58 100644 --- a/src/calendar/vespers.ts +++ b/src/calendar/vespers.ts @@ -12,7 +12,7 @@ import type { Commemoration, DayWinner, LiturgicalDay } from './types'; import { resolveDay } from './index'; import { addDays } from './date-math'; import { easterOffsetOf } from './temporal'; -import { isAtLeast } from './commemorations'; +import { compareFeastClass, isAtLeast } from './commemorations'; import { getSaintRecord } from './feasts'; import { getTemporalFeastRecord } from './temporal-feasts'; @@ -53,18 +53,27 @@ function hasFirstVespers(day: LiturgicalDay): boolean { 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, + * independent of any comparison against tomorrow's rank? Covers only the + * *absolute* cases — Sunday, a privileged feria, or a major fixed feast of + * the Lord. The ordinary sanctoral-vs-sanctoral case is decided separately, + * by direct rank comparison (see `resolveEveningDay`) rather than a fixed + * floor here: this function used to also require the winner be at least + * `duplex-2-classis`, but that's the wrong rule — the real "De Concurrentia + * Officii" rubric (divinum-officium-reference's rubrics.txt, Titulus XI) + * compares today's rank against tomorrow's directly, with ties going to + * tomorrow, not a fixed threshold on today alone. That floor produced a + * wrong result for 2026-08-31 -> 09-01: St. Raymond Nonnatus (Semiduplex, + * below the floor) lost his own Second Vespers to St. Giles (Simplex) even + * though Raymond outranks Giles. */ function keepsOwnSecondVespers(day: LiturgicalDay): boolean { - if ( + return ( day.weekday === 'sunday' || day.temporalCategory === 'privileged-feria-minor' || day.temporalCategory === 'privileged-feria' || day.temporalCategory === 'privileged-feria-major' || isMajorFixedFeastOfTheLord(day.date) - ) { - return true; - } - return day.winner.kind === 'sanctoral' && isAtLeast(day.winner.rank, 'duplex-2-classis'); + ); } /** @@ -200,6 +209,23 @@ function anticipated(today: LiturgicalDay, tomorrow: LiturgicalDay): LiturgicalD * would likewise displace an actual Sunday if one ever landed on Dec 24 or * an Ember Saturday. This is why it's a short, explicit list rather than a * threshold: these particular days are understood to be *absolute*. + * + * When neither side is covered by an absolute case and both today and + * tomorrow have a real sanctoral winner, the rubric's actual rule applies: + * direct rank comparison, with today keeping its own Second Vespers only if + * it *strictly* outranks tomorrow — an exact tie goes to tomorrow ("a + * Capitulo fit de sequenti cum commemoratione praecedentis"). Note this + * checks tomorrow's actual *winner*, not merely whether tomorrow's calendar + * weekday is Sunday — a plain Duplex can itself outright win an ordinary + * Sunday's daytime office (see commemorations.ts's header comment, e.g. St. + * Anne beating an ordinary Sunday on 2026-07-26), in which case it's that + * Duplex's own rank being compared, not some notion of "Sunday's rank". + * When tomorrow's winner is genuinely the Sunday itself (`kind !== + * 'sanctoral'`), today's sufficiently high sanctoral rank + * (`duplex-2-classis`+) still claims the evening outright per the rubric's + * "Duplex always keeps both Vespers" rule — handled by the + * `isAtLeast`-style fallback below, same floor as before, just no longer + * applied when tomorrow also has a real (comparable) sanctoral winner. */ export function resolveEveningDay(isoDate: string): LiturgicalDay { const today = resolveDay(isoDate); @@ -208,7 +234,19 @@ export function resolveEveningDay(isoDate: string): LiturgicalDay { if (isMajorFixedFeastOfTheLord(tomorrow.date)) { return tagVespersFrom(tomorrow); } + + let todayWins: boolean; if (keepsOwnSecondVespers(today)) { + todayWins = true; + } else if (today.winner.kind === 'sanctoral' && tomorrow.winner.kind === 'sanctoral') { + todayWins = compareFeastClass(today.winner.rank, tomorrow.winner.rank) > 0; + } else if (today.winner.kind === 'sanctoral' && isAtLeast(today.winner.rank, 'duplex-2-classis')) { + todayWins = true; + } else { + todayWins = !hasFirstVespers(tomorrow); + } + + if (todayWins) { // Today wins outright, but tomorrow is still commemorated here as // long as it's a real sanctoral winner — see // commemorationOfDisplacedSuccessor's own doc comment for why this @@ -216,8 +254,5 @@ export function resolveEveningDay(isoDate: string): LiturgicalDay { const commemoration = commemorationOfDisplacedSuccessor(today.winner, tomorrow.winner); return commemoration ? { ...today, commemorations: [...today.commemorations, commemoration] } : today; } - if (!hasFirstVespers(tomorrow)) { - return today; - } return anticipated(today, tomorrow); } diff --git a/tests/calendar/vespers.test.ts b/tests/calendar/vespers.test.ts index 4a248f4..3a7c089 100644 --- a/tests/calendar/vespers.test.ts +++ b/tests/calendar/vespers.test.ts @@ -90,4 +90,29 @@ describe('resolveEveningDay', () => { expect(day.date).toBe('2026-08-24'); expect(day.commemorations).toContainEqual(expect.objectContaining({ kind: 'sanctoral', id: 'st-louis' })); }); + + it('a higher-ranked today keeps its own Second Vespers against a lower-ranked tomorrow, even below the old duplex-2-classis floor', () => { + // Aug 31, 2026: St. Raymond Nonnatus (Semiduplex) vs. Sep 1's St. Giles + // (Simplex). Raymond outranks Giles, so Raymond should keep his own + // evening even though Semiduplex never cleared the old fixed + // duplex-2-classis floor -- the real "De Concurrentia Officii" rule is a + // direct comparison against tomorrow's rank, not a fixed threshold on + // today alone. Reported by the user 2026-08-31. + const day = resolveEveningDay('2026-08-31'); + expect(day.date).toBe('2026-08-31'); + expect(day.winner).toMatchObject({ kind: 'sanctoral', id: 'st-raymond-nonnatus' }); + expect(day.commemorations).toContainEqual(expect.objectContaining({ kind: 'sanctoral', id: 'st-giles', vespersNote: 'tomorrow' })); + }); + + it('an exact rank tie goes to tomorrow, per the rubric\'s tie-break', () => { + // Aug 25, 2026: St. Louis (Simplex) vs. Aug 26's St. Zephyrinus (also + // Simplex) -- live-verified (see hasFirstVespers's own doc comment): + // "Vespera de sequenti; nihil de præcedenti." Tomorrow wins on the tie, + // and Louis (Simplex) is excluded from commemoration, same as any other + // Simplex predecessor. + const day = resolveEveningDay('2026-08-25'); + expect(day.date).toBe('2026-08-26'); + expect(day.winner).toMatchObject({ kind: 'sanctoral', id: 'st-zephyrinus' }); + expect(day.commemorations.some((c) => c.kind === 'sanctoral' && c.id === 'st-louis')).toBe(false); + }); });