calendar: split privileged-feria into two real precedence tiers
Deploy / deploy (push) Successful in 41s

A plain Duplex saint was losing to any privileged feria (Ember days
included) since the occurrence engine only let duplex-1-classis+ get
even a commemoration there. Verified this was wrong: St. Gregory the
Great, plain Duplex, outright wins against a Lenten Ember Saturday in
the real Monastic 1617 engine.

Splits TemporalCategory's single privileged-feria into privileged-feria
(the lesser tier — Lent's Ember days, verified — now behaves like an
ordinary Sunday: Duplex+ wins outright) and privileged-feria-major (Ash
Wednesday, Holy Week, the Easter Octave, verified via St. Mark only ever
being commemorated there, never winning — behaves like privileged-
Sunday). Everywhere else the old single category was ambiguous
(Pentecost's own Ember days/Vigil/Octave, the Vigil of Christmas)
defaults to the stricter major tier pending verification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 15:51:09 -04:00
parent a2d86a4879
commit effe2b1333
6 changed files with 131 additions and 35 deletions
+33 -7
View File
@@ -8,10 +8,14 @@
// Modeled as explicit rules per category, not a numeric-weight comparison // Modeled as explicit rules per category, not a numeric-weight comparison
// like the reference engine's occurrence()/concurrence() — see // like the reference engine's occurrence()/concurrence() — see
// data/calendar/temporal-categories.yml's header for why. Verified in part // data/calendar/temporal-categories.yml's header for why. Verified in part
// against real dates (the Sunday-vs-Duplex threshold below was corrected // against real dates: the Sunday-vs-Duplex threshold was corrected after
// after finding St. Anthony Abbot, plain Duplex, outright winning against // finding St. Anthony Abbot, plain Duplex, outright winning against an
// an ordinary Sunday in the real Monastic 1617 engine — a genuine // ordinary Sunday in the real Monastic 1617 engine, and privileged-feria
// correction, not a guess); the rest is the result of a design discussion, // was later split into two real tiers after finding St. Gregory the Great,
// also plain Duplex, outright winning against a Lenten Ember Saturday
// (which the original single-tier model wrongly forbade) while St. Mark,
// Duplex II. classis, only ever gets commemorated — never wins outright —
// within the Easter Octave. The rest is the result of a design discussion,
// not yet independently verified against a primary source. // not yet independently verified against a primary source.
import type { Commemoration, DayWinner, FeastClass, SanctoralIdentity, TemporalCategory } from './types'; import type { Commemoration, DayWinner, FeastClass, SanctoralIdentity, TemporalCategory } from './types';
@@ -81,11 +85,33 @@ export function decideOccurrence(
return { winner: sanctoralWinner(sanctoral), commemorations: [] }; return { winner: sanctoralWinner(sanctoral), commemorations: [] };
case 'privileged-feria': case 'privileged-feria':
// Preferred to any feast whatsoever; admits no commemoration except // The lesser of the two privileged-feria tiers (Ember days outside
// one of the very highest class. // Holy Week/the Easter Octave, etc.) — behaves exactly like an
// ordinary Sunday: real standing of its own, but not enough to
// resist a sufficiently ranked feast the way privileged-feria-major
// does.
if (isAtLeast(sanctoral.rank, 'duplex')) {
return { winner: sanctoralWinner(sanctoral), commemorations: [{ kind: 'temporal', id: temporalId }] };
}
if (sanctoral.rank === 'simplex') {
return { winner: temporalWinner, commemorations: [sanctoralCommemoration(sanctoral)] };
}
return { return {
winner: temporalWinner, winner: temporalWinner,
commemorations: isAtLeast(sanctoral.rank, 'duplex-1-classis') ? [sanctoralCommemoration(sanctoral)] : [], commemorations: [],
transfer: { candidate: sanctoral, direction: transferDirectionOf(sanctoral.rank) },
};
case 'privileged-feria-major':
// Ash Wednesday, Holy Week, the Easter Octave, the Vigil of
// Christmas — never displaced, same as privileged-sunday.
if (isAtLeast(sanctoral.rank, 'duplex-majus')) {
return { winner: temporalWinner, commemorations: [sanctoralCommemoration(sanctoral)] };
}
return {
winner: temporalWinner,
commemorations: [],
transfer: { candidate: sanctoral, direction: transferDirectionOf(sanctoral.rank) },
}; };
case 'ordinary-sunday': case 'ordinary-sunday':
+8 -6
View File
@@ -48,12 +48,14 @@ function applyIncomingTransfer(
winner: DayWinner, winner: DayWinner,
commemorations: Commemoration[], commemorations: Commemoration[],
): DayWinner { ): DayWinner {
if (temporalCategory === 'privileged-feria') { if (temporalCategory === 'privileged-feria-major') {
// Deferred: a transfer landing on a privileged feria (the concrete // Deferred: a transfer landing on one of these (the concrete case is
// case is Holy Week, right after Palm Sunday) needs its own // Holy Week, right after Palm Sunday) needs its own Easter-date-keyed
// Easter-date-keyed lookup table, the same way the reference engine // lookup table, the same way the reference engine handles it — not
// handles it — not modeled yet. The transfer is simply not delivered // modeled yet. The transfer is simply not delivered here rather than
// here rather than guessed at. // guessed at. The lesser privileged-feria tier doesn't need this —
// ordinary collision/transfer logic is fine there, same as any
// ordinary-sunday landing.
return winner; return winner;
} }
if (winner.kind === 'temporal') { if (winner.kind === 'temporal') {
+18 -1
View File
@@ -70,7 +70,24 @@ export type FeastClass =
// category — reconstructed from general knowledge of the pre-1955 // category — reconstructed from general knowledge of the pre-1955
// tradition, not yet verified against a primary source, so expect // tradition, not yet verified against a primary source, so expect
// corrections. // corrections.
export type TemporalCategory = 'ordinary-feria' | 'privileged-feria' | 'ordinary-sunday' | 'privileged-sunday'; // `privileged-feria` and `privileged-feria-major` are two real, distinct
// tiers (confirmed by finding a plain Duplex saint, St. Gregory the Great,
// outright winning against a Lenten Ember Saturday in the real Monastic
// 1617 engine, which the original single-tier model wrongly forbade) —
// see calendar/commemorations.ts's rules for `privileged-feria` (behaves
// like `ordinary-sunday`: Duplex+ wins outright) vs `privileged-feria-major`
// (behaves like `privileged-sunday`: never displaced at all, confirmed by
// checking St. Mark, Duplex II. classis, merely commemorated rather than
// winning within the Easter Octave). See data/calendar/temporal-
// categories.yml for exactly which days fall in which tier — some of that
// split (e.g. whether Pentecost's own Ember days share Lent's lesser tier
// or Easter's major one) is still a reconstructed guess, not verified.
export type TemporalCategory =
| 'ordinary-feria'
| 'privileged-feria'
| 'privileged-feria-major'
| 'ordinary-sunday'
| 'privileged-sunday';
export interface SanctoralIdentity { export interface SanctoralIdentity {
id: string; id: string;
+6 -1
View File
@@ -42,7 +42,12 @@ function hasFirstVespers(day: LiturgicalDay): boolean {
/** 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? */
function keepsOwnSecondVespers(day: LiturgicalDay): boolean { function keepsOwnSecondVespers(day: LiturgicalDay): boolean {
if (day.weekday === 'sunday' || day.temporalCategory === 'privileged-feria' || isMajorFixedFeastOfTheLord(day.date)) { if (
day.weekday === 'sunday' ||
day.temporalCategory === 'privileged-feria' ||
day.temporalCategory === 'privileged-feria-major' ||
isMajorFixedFeastOfTheLord(day.date)
) {
return true; return true;
} }
return day.winner.kind === 'sanctoral' && isAtLeast(day.winner.rank, 'duplex-2-classis'); return day.winner.kind === 'sanctoral' && isAtLeast(day.winner.rank, 'duplex-2-classis');
+30 -14
View File
@@ -15,6 +15,18 @@
# easter-offsets.yml) and `fixedDates` (MM-DD) override that default for # easter-offsets.yml) and `fixedDates` (MM-DD) override that default for
# specific privileged ferias that don't line up with a whole season. # specific privileged ferias that don't line up with a whole season.
# #
# `privileged-feria` vs `privileged-feria-major`: two real tiers, not one
# — see calendar/types.ts's TemporalCategory doc comment for how this was
# found and calendar/commemorations.ts for the rule each gets. Lent's Ember
# days are directly verified as the lesser tier (a plain Duplex wins
# outright there); Ash Wednesday, Holy Week, and the Easter Octave are the
# major tier (Easter Octave directly verified — Holy Week and Ash
# Wednesday are strong prior knowledge, not independently re-verified
# here). Where this app *doesn't* have direct evidence either way —
# Pentecost's own Ember days/Vigil/Octave, the Vigil of Christmas — it
# defaults to the major (stricter) tier as the safer guess, pending
# verification.
#
# Known gap: Advent Ember days (Wed/Fri/Sat after Dec 13) and September # Known gap: Advent Ember days (Wed/Fri/Sat after Dec 13) and September
# Ember days (Wed/Fri/Sat near the Exaltation of the Cross, Sept 14) are # Ember days (Wed/Fri/Sat near the Exaltation of the Cross, Sept 14) are
# NOT modeled here — both are fixed-calendar "nearest such-and-such a date" # NOT modeled here — both are fixed-calendar "nearest such-and-such a date"
@@ -36,24 +48,28 @@ bySeason:
pentecost: { sunday: privileged-sunday, feria: ordinary-feria } pentecost: { sunday: privileged-sunday, feria: ordinary-feria }
trinitytide: { sunday: ordinary-sunday, feria: ordinary-feria } trinitytide: { sunday: ordinary-sunday, feria: ordinary-feria }
# Corpus Christi/Sacred Heart are themselves "feasts of the Lord" that # Corpus Christi/Sacred Heart are themselves "feasts of the Lord" that
# shouldn't cede to an ordinary saint — modeled here as privileged-feria # shouldn't cede to an ordinary saint — modeled here as
# (rather than injecting a separate synthetic FeastClass) since with the # privileged-feria-major (rather than injecting a separate synthetic
# small sanctoral calendar this app has, a real clash is unlikely and the # FeastClass) since with the small sanctoral calendar this app has, a
# practical effect (temporal wins) is the same either way. # real clash is unlikely and the practical effect (temporal wins) is the
corpus-christi: { sunday: ordinary-sunday, feria: privileged-feria } # same either way.
sacred-heart: { sunday: ordinary-sunday, feria: privileged-feria } corpus-christi: { sunday: ordinary-sunday, feria: privileged-feria-major }
sacred-heart: { sunday: ordinary-sunday, feria: privileged-feria-major }
offsets: offsets:
-46: privileged-feria # Ash Wednesday -46: privileged-feria-major # Ash Wednesday
-39: privileged-feria # Lent Ember Wednesday (Wed after 1st Sunday of Lent) -39: privileged-feria # Lent Ember Wednesday (Wed after 1st Sunday of Lent) — verified: Duplex wins outright here
-37: privileged-feria # Lent Ember Friday -37: privileged-feria # Lent Ember Friday
-36: privileged-feria # Lent Ember Saturday -36: privileged-feria # Lent Ember Saturday — verified: Duplex wins outright here (St. Gregory the Great)
48: privileged-feria # Vigil of Pentecost 48: privileged-feria-major # Vigil of Pentecost — unverified, defaulted to the stricter tier
offsetRanges: offsetRanges:
- { fromOffset: -6, toOffset: -1, category: privileged-feria } # ferias of Holy Week - { fromOffset: -6, toOffset: -1, category: privileged-feria-major } # ferias of Holy Week
- { fromOffset: 1, toOffset: 6, category: privileged-feria } # Easter octave - { fromOffset: 1, toOffset: 6, category: privileged-feria-major } # Easter octave — verified: even Duplex II. classis (St. Mark) only gets commemorated, never wins
- { fromOffset: 50, toOffset: 55, category: privileged-feria } # Pentecost octave (also covers Pentecost's own Ember days) # Pentecost octave (also covers Pentecost's own Ember days) — unverified,
# defaulted to the stricter tier; Lent's Ember days are confirmed
# lesser-tier, but that hasn't been checked for Pentecost's specifically.
- { fromOffset: 50, toOffset: 55, category: privileged-feria-major }
fixedDates: fixedDates:
"12-24": privileged-feria # Vigil of Christmas "12-24": privileged-feria-major # Vigil of Christmas — unverified, defaulted to the stricter tier
+36 -6
View File
@@ -42,13 +42,43 @@ describe('decideOccurrence — ordinary-feria', () => {
}); });
describe('decideOccurrence — privileged-feria', () => { describe('decideOccurrence — privileged-feria', () => {
it('yields to nothing short of the top class', () => { it('behaves exactly like an ordinary Sunday — duplex or higher wins outright, the feria is commemorated in return', () => {
const majus = decideOccurrence('privileged-feria', 'id', saint('duplex-2-classis')); // Verified: St. Gregory the Great, plain Duplex, outright wins against
expect(majus.winner).toEqual({ kind: 'temporal', id: 'id' }); // a Lenten Ember Saturday in the real Monastic 1617 engine.
expect(majus.commemorations).toEqual([]); const result = decideOccurrence('privileged-feria', 'id', saint('duplex'));
expect(result.winner).toEqual({ kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'duplex' });
expect(result.commemorations).toEqual([{ kind: 'temporal', id: 'id' }]);
});
const top = decideOccurrence('privileged-feria', 'id', saint('duplex-1-classis')); it('simplex stays and is commemorated, not transferred', () => {
expect(top.commemorations).toEqual([{ kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'duplex-1-classis' }]); const result = decideOccurrence('privileged-feria', 'id', saint('simplex'));
expect(result.winner).toEqual({ kind: 'temporal', id: 'id' });
expect(result.commemorations).toEqual([{ kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'simplex' }]);
});
it('semiduplex/vigil get nothing here — transfer instead', () => {
const result = decideOccurrence('privileged-feria', 'id', saint('semiduplex'));
expect(result.commemorations).toEqual([]);
expect(result.transfer).toEqual({ candidate: saint('semiduplex'), direction: 'forward' });
});
});
describe('decideOccurrence — privileged-feria-major', () => {
it('is never displaced, same as privileged-sunday — duplex-majus and up are commemorated', () => {
// Verified: St. Mark, Duplex II. classis, only ever commemorated —
// never wins outright — within the Easter Octave.
for (const rank of ['duplex-majus', 'duplex-2-classis', 'duplex-1-classis'] as const) {
const result = decideOccurrence('privileged-feria-major', 'id', saint(rank));
expect(result.winner).toEqual({ kind: 'temporal', id: 'id' });
expect(result.commemorations).toEqual([{ kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank }]);
}
});
it('yields nothing short of duplex-majus — transfers instead', () => {
const result = decideOccurrence('privileged-feria-major', 'id', saint('duplex'));
expect(result.winner).toEqual({ kind: 'temporal', id: 'id' });
expect(result.commemorations).toEqual([]);
expect(result.transfer).toEqual({ candidate: saint('duplex'), direction: 'forward' });
}); });
}); });