From effe2b1333303ca5fd29bc5af3bdf9e836046006 Mon Sep 17 00:00:00 2001 From: Will Estes Date: Mon, 10 Aug 2026 15:51:09 -0400 Subject: [PATCH] calendar: split privileged-feria into two real precedence tiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/calendar/commemorations.ts | 40 +++++++++++++++++---- src/calendar/index.ts | 14 ++++---- src/calendar/types.ts | 19 +++++++++- src/calendar/vespers.ts | 7 +++- src/data/calendar/temporal-categories.yml | 44 +++++++++++++++-------- tests/calendar/commemorations.test.ts | 42 ++++++++++++++++++---- 6 files changed, 131 insertions(+), 35 deletions(-) diff --git a/src/calendar/commemorations.ts b/src/calendar/commemorations.ts index e19bb96..af93cd2 100644 --- a/src/calendar/commemorations.ts +++ b/src/calendar/commemorations.ts @@ -8,10 +8,14 @@ // Modeled as explicit rules per category, not a numeric-weight comparison // like the reference engine's occurrence()/concurrence() — see // data/calendar/temporal-categories.yml's header for why. Verified in part -// against real dates (the Sunday-vs-Duplex threshold below was corrected -// after finding St. Anthony Abbot, plain Duplex, outright winning against -// an ordinary Sunday in the real Monastic 1617 engine — a genuine -// correction, not a guess); the rest is the result of a design discussion, +// against real dates: the Sunday-vs-Duplex threshold was corrected after +// finding St. Anthony Abbot, plain Duplex, outright winning against an +// ordinary Sunday in the real Monastic 1617 engine, and privileged-feria +// 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. import type { Commemoration, DayWinner, FeastClass, SanctoralIdentity, TemporalCategory } from './types'; @@ -81,11 +85,33 @@ export function decideOccurrence( return { winner: sanctoralWinner(sanctoral), commemorations: [] }; case 'privileged-feria': - // Preferred to any feast whatsoever; admits no commemoration except - // one of the very highest class. + // The lesser of the two privileged-feria tiers (Ember days outside + // 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 { 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': diff --git a/src/calendar/index.ts b/src/calendar/index.ts index fe197d3..264fd56 100644 --- a/src/calendar/index.ts +++ b/src/calendar/index.ts @@ -48,12 +48,14 @@ function applyIncomingTransfer( winner: DayWinner, commemorations: Commemoration[], ): DayWinner { - if (temporalCategory === 'privileged-feria') { - // Deferred: a transfer landing on a privileged feria (the concrete - // case is Holy Week, right after Palm Sunday) needs its own - // Easter-date-keyed lookup table, the same way the reference engine - // handles it — not modeled yet. The transfer is simply not delivered - // here rather than guessed at. + if (temporalCategory === 'privileged-feria-major') { + // Deferred: a transfer landing on one of these (the concrete case is + // Holy Week, right after Palm Sunday) needs its own Easter-date-keyed + // lookup table, the same way the reference engine handles it — not + // modeled yet. The transfer is simply not delivered here rather than + // 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; } if (winner.kind === 'temporal') { diff --git a/src/calendar/types.ts b/src/calendar/types.ts index e4f2c09..7a8523a 100644 --- a/src/calendar/types.ts +++ b/src/calendar/types.ts @@ -70,7 +70,24 @@ export type FeastClass = // category — reconstructed from general knowledge of the pre-1955 // tradition, not yet verified against a primary source, so expect // 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 { id: string; diff --git a/src/calendar/vespers.ts b/src/calendar/vespers.ts index e85c183..e3cfca6 100644 --- a/src/calendar/vespers.ts +++ b/src/calendar/vespers.ts @@ -42,7 +42,12 @@ function hasFirstVespers(day: LiturgicalDay): boolean { /** Does this day keep its own Second Vespers regardless of what tomorrow is? */ 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 day.winner.kind === 'sanctoral' && isAtLeast(day.winner.rank, 'duplex-2-classis'); diff --git a/src/data/calendar/temporal-categories.yml b/src/data/calendar/temporal-categories.yml index 6a554c3..088d748 100644 --- a/src/data/calendar/temporal-categories.yml +++ b/src/data/calendar/temporal-categories.yml @@ -15,6 +15,18 @@ # easter-offsets.yml) and `fixedDates` (MM-DD) override that default for # 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 # 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" @@ -36,24 +48,28 @@ bySeason: pentecost: { sunday: privileged-sunday, feria: ordinary-feria } trinitytide: { sunday: ordinary-sunday, feria: ordinary-feria } # Corpus Christi/Sacred Heart are themselves "feasts of the Lord" that - # shouldn't cede to an ordinary saint — modeled here as privileged-feria - # (rather than injecting a separate synthetic FeastClass) since with the - # small sanctoral calendar this app has, a real clash is unlikely and the - # practical effect (temporal wins) is the same either way. - corpus-christi: { sunday: ordinary-sunday, feria: privileged-feria } - sacred-heart: { sunday: ordinary-sunday, feria: privileged-feria } + # shouldn't cede to an ordinary saint — modeled here as + # privileged-feria-major (rather than injecting a separate synthetic + # FeastClass) since with the small sanctoral calendar this app has, a + # real clash is unlikely and the practical effect (temporal wins) is the + # same either way. + corpus-christi: { sunday: ordinary-sunday, feria: privileged-feria-major } + sacred-heart: { sunday: ordinary-sunday, feria: privileged-feria-major } offsets: - -46: privileged-feria # Ash Wednesday - -39: privileged-feria # Lent Ember Wednesday (Wed after 1st Sunday of Lent) + -46: privileged-feria-major # Ash Wednesday + -39: privileged-feria # Lent Ember Wednesday (Wed after 1st Sunday of Lent) — verified: Duplex wins outright here -37: privileged-feria # Lent Ember Friday - -36: privileged-feria # Lent Ember Saturday - 48: privileged-feria # Vigil of Pentecost + -36: privileged-feria # Lent Ember Saturday — verified: Duplex wins outright here (St. Gregory the Great) + 48: privileged-feria-major # Vigil of Pentecost — unverified, defaulted to the stricter tier offsetRanges: - - { fromOffset: -6, toOffset: -1, category: privileged-feria } # ferias of Holy Week - - { fromOffset: 1, toOffset: 6, category: privileged-feria } # Easter octave - - { fromOffset: 50, toOffset: 55, category: privileged-feria } # Pentecost octave (also covers Pentecost's own Ember days) + - { fromOffset: -6, toOffset: -1, category: privileged-feria-major } # ferias of Holy Week + - { fromOffset: 1, toOffset: 6, category: privileged-feria-major } # Easter octave — verified: even Duplex II. classis (St. Mark) only gets commemorated, never wins + # 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: - "12-24": privileged-feria # Vigil of Christmas + "12-24": privileged-feria-major # Vigil of Christmas — unverified, defaulted to the stricter tier diff --git a/tests/calendar/commemorations.test.ts b/tests/calendar/commemorations.test.ts index 51cdecf..f2ca6bb 100644 --- a/tests/calendar/commemorations.test.ts +++ b/tests/calendar/commemorations.test.ts @@ -42,13 +42,43 @@ describe('decideOccurrence — ordinary-feria', () => { }); describe('decideOccurrence — privileged-feria', () => { - it('yields to nothing short of the top class', () => { - const majus = decideOccurrence('privileged-feria', 'id', saint('duplex-2-classis')); - expect(majus.winner).toEqual({ kind: 'temporal', id: 'id' }); - expect(majus.commemorations).toEqual([]); + it('behaves exactly like an ordinary Sunday — duplex or higher wins outright, the feria is commemorated in return', () => { + // Verified: St. Gregory the Great, plain Duplex, outright wins against + // a Lenten Ember Saturday in the real Monastic 1617 engine. + 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')); - expect(top.commemorations).toEqual([{ kind: 'sanctoral', id: 'x', name: 'St. Ereden', rank: 'duplex-1-classis' }]); + it('simplex stays and is commemorated, not transferred', () => { + 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' }); }); });