Fix Vespers concurrentia to compare today's rank against tomorrow's directly
Deploy / deploy (push) Successful in 1m27s
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:
+45
-10
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user