Fix Vespers concurrentia to compare today's rank against tomorrow's directly
Deploy / deploy (push) Successful in 1m27s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QEe9oTbn1UjQVAYcCoW1a
This commit is contained in:
2026-08-31 22:01:33 -04:00
parent 06fc8ef0ec
commit e0156c1512
2 changed files with 70 additions and 10 deletions
+45 -10
View File
@@ -12,7 +12,7 @@ import type { Commemoration, DayWinner, LiturgicalDay } from './types';
import { resolveDay } from './index'; import { resolveDay } from './index';
import { addDays } from './date-math'; import { addDays } from './date-math';
import { easterOffsetOf } from './temporal'; import { easterOffsetOf } from './temporal';
import { isAtLeast } from './commemorations'; import { compareFeastClass, isAtLeast } from './commemorations';
import { getSaintRecord } from './feasts'; import { getSaintRecord } from './feasts';
import { getTemporalFeastRecord } from './temporal-feasts'; import { getTemporalFeastRecord } from './temporal-feasts';
@@ -53,18 +53,27 @@ function hasFirstVespers(day: LiturgicalDay): boolean {
return day.winner.kind === 'sanctoral'; 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 { function keepsOwnSecondVespers(day: LiturgicalDay): boolean {
if ( return (
day.weekday === 'sunday' || day.weekday === 'sunday' ||
day.temporalCategory === 'privileged-feria-minor' || day.temporalCategory === 'privileged-feria-minor' ||
day.temporalCategory === 'privileged-feria' || day.temporalCategory === 'privileged-feria' ||
day.temporalCategory === 'privileged-feria-major' || day.temporalCategory === 'privileged-feria-major' ||
isMajorFixedFeastOfTheLord(day.date) 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 * 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 * an Ember Saturday. This is why it's a short, explicit list rather than a
* threshold: these particular days are understood to be *absolute*. * 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 { export function resolveEveningDay(isoDate: string): LiturgicalDay {
const today = resolveDay(isoDate); const today = resolveDay(isoDate);
@@ -208,7 +234,19 @@ export function resolveEveningDay(isoDate: string): LiturgicalDay {
if (isMajorFixedFeastOfTheLord(tomorrow.date)) { if (isMajorFixedFeastOfTheLord(tomorrow.date)) {
return tagVespersFrom(tomorrow); return tagVespersFrom(tomorrow);
} }
let todayWins: boolean;
if (keepsOwnSecondVespers(today)) { 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 // Today wins outright, but tomorrow is still commemorated here as
// long as it's a real sanctoral winner — see // long as it's a real sanctoral winner — see
// commemorationOfDisplacedSuccessor's own doc comment for why this // 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); const commemoration = commemorationOfDisplacedSuccessor(today.winner, tomorrow.winner);
return commemoration ? { ...today, commemorations: [...today.commemorations, commemoration] } : today; return commemoration ? { ...today, commemorations: [...today.commemorations, commemoration] } : today;
} }
if (!hasFirstVespers(tomorrow)) {
return today;
}
return anticipated(today, tomorrow); return anticipated(today, tomorrow);
} }
+25
View File
@@ -90,4 +90,29 @@ describe('resolveEveningDay', () => {
expect(day.date).toBe('2026-08-24'); expect(day.date).toBe('2026-08-24');
expect(day.commemorations).toContainEqual(expect.objectContaining({ kind: 'sanctoral', id: 'st-louis' })); 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);
});
}); });