// Precedence/occurrence resolution: given a day's temporal-cycle standing // (calendar/types.ts's TemporalCategory) and a candidate sanctoral feast (if // any), which one is actually kept, what (if anything) is commemorated // alongside it, and whether the candidate needs to be transferred to an // adjacent day instead (see calendar/transfer.ts for what actually happens // with that signal). // // 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 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'; const FEAST_CLASS_ORDER: FeastClass[] = [ 'simplex', 'vigil', 'semiduplex', 'duplex', 'duplex-majus', 'duplex-2-classis', 'duplex-1-classis', ]; export function compareFeastClass(a: FeastClass, b: FeastClass): number { return FEAST_CLASS_ORDER.indexOf(a) - FEAST_CLASS_ORDER.indexOf(b); } export function isAtLeast(rank: FeastClass, threshold: FeastClass): boolean { return compareFeastClass(rank, threshold) >= 0; } /** * The minimum rank a real sanctoral candidate needs to win each temporal * category outright, per `decideOccurrence`'s own branches above — * factored out so calendar/octaves.ts can ask the identical question of an * *octave*'s own effective standing (not a real occurring saint) on a day * where nothing else won. `undefined` where nothing ever wins outright no * matter how high-ranked (`privileged-feria-major`/`privileged-sunday` — * Ash Wednesday, Holy Week, a privileged Sunday — only ever get a * commemoration at best, per those branches above). `ordinary-feria` has * no real threshold at all (any real content wins), included here as * `'simplex'` (the weakest real rank) only for completeness — callers * needing that case already have their own unconditional path and don't * need to call this. */ export function minimumOutrightWinningRank(category: TemporalCategory): FeastClass | undefined { switch (category) { case 'ordinary-feria': return 'simplex'; case 'privileged-feria-minor': return 'semiduplex'; case 'privileged-feria': case 'ordinary-sunday': return 'duplex'; case 'privileged-feria-major': case 'privileged-sunday': return undefined; } } /** Vigils belong to the day *before* their feast, so an impeded vigil is * shifted backward rather than forward like everything else — see * calendar/transfer.ts. */ function transferDirectionOf(rank: FeastClass): 'forward' | 'backward' { return rank === 'vigil' ? 'backward' : 'forward'; } export interface OccurrenceResult { /** This day's own resolution, with the candidate absent if it's being transferred. */ winner: DayWinner; commemorations: Commemoration[]; /** Set when `sanctoral` doesn't win or get commemorated here at all — it * needs to be resolved against an adjacent day instead. */ transfer?: { candidate: SanctoralIdentity; direction: 'forward' | 'backward' }; } function sanctoralCommemoration(candidate: SanctoralIdentity): Commemoration { return { kind: 'sanctoral', id: candidate.id, name: candidate.name, nameLa: candidate.nameLa, rank: candidate.rank }; } function sanctoralWinner(candidate: SanctoralIdentity): DayWinner { return { kind: 'sanctoral', id: candidate.id, name: candidate.name, nameLa: candidate.nameLa, rank: candidate.rank }; } /** * `temporalId` is this day's own temporal-propers id (see * calendar/temporal-id.ts) — needed so a temporal winner or commemoration * can actually be looked up later (`propers.getTemporalProper`), not just * named "temporal" in the abstract. */ export function decideOccurrence( temporal: TemporalCategory, temporalId: string, sanctoral: SanctoralIdentity | null, ): OccurrenceResult { const temporalWinner: DayWinner = { kind: 'temporal', id: temporalId }; if (!sanctoral) { return { winner: temporalWinner, commemorations: [] }; } switch (temporal) { case 'ordinary-feria': // An ordinary feria has no standing of its own to defend — any real // feast, however low-ranked, is kept in its place. return { winner: sanctoralWinner(sanctoral), commemorations: [] }; case 'privileged-feria-minor': // Advent's own ordinary ferias (verified: St. Bibiana, Simplex, // merely commemorated under an Advent feria; St. Nicholas, // Semiduplex, wins outright with the feria commemorated in return). // Weaker than `privileged-feria`'s Duplex+ threshold — Semiduplex is // already enough here — and unlike every other feria tier, nothing // ever transfers: whichever side loses still gets a commemoration. if (isAtLeast(sanctoral.rank, 'semiduplex')) { return { winner: sanctoralWinner(sanctoral), commemorations: [{ kind: 'temporal', id: temporalId }] }; } return { winner: temporalWinner, commemorations: [sanctoralCommemoration(sanctoral)] }; case 'privileged-feria': // 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: [], transfer: { candidate: sanctoral, direction: transferDirectionOf(sanctoral.rank) }, }; case 'privileged-feria-major': // Ash Wednesday, Holy Week, the Easter Octave, Low Sunday, the // Vigils of Christmas/Pentecost — never displaced, and never gives // even a bare commemoration to any rank, same as privileged-sunday. // The "duplex-majus+ still gets a nod" branch this case used to // have was wrong, not just unverified: the one case that seemed to // support it (St. Mark, Duplex II. classis, "merely commemorated" // within the Easter Octave) turns out to have been a misreading — // live-verified 2026-09-01, direct instruction ("you dont // commemorate -anything- from palm sunday through low sunday"), // confirmed with two more dates on top of the Annunciation/Palm- // Easter-Sunday evidence already in the privileged-sunday case // below: the Annunciation (Duplex I. classis) transfers with zero // commemoration on Holy Thursday itself in years it lands there // (2027/2032/2043), and Chair of St. Peter at Antioch (Duplex // majus) transfers off Ash Wednesday the same way (2034) — so the // exception was wrong for Ash Wednesday too, not just Holy Week. return { winner: temporalWinner, commemorations: [], transfer: { candidate: sanctoral, direction: transferDirectionOf(sanctoral.rank) }, }; case 'ordinary-sunday': if (isAtLeast(sanctoral.rank, 'duplex')) { // Duplex+ wins outright; the Sunday itself is commemorated in return. return { winner: sanctoralWinner(sanctoral), commemorations: [{ kind: 'temporal', id: temporalId }] }; } if (sanctoral.rank === 'simplex') { // Too minor to warrant its own day, but a plain commemoration // doesn't cheapen it the way it would a Semiduplex. return { winner: temporalWinner, commemorations: [sanctoralCommemoration(sanctoral)] }; } // Semiduplex or Vigil: no room here at all, in either direction — // per direct instruction, an ordinary Sunday's own standing pushes // a Semiduplex feast off to the next open day (typically the // Monday right after) rather than downgrading it to a bare // commemoration. This `transfer` signal is only half of the // mechanism: it's just this day noting "I couldn't hold onto this // candidate." The other half — a later day actually claiming a // transferred-in candidate — lives entirely in calendar/index.ts's // resolveDay: for *every* date it resolves, it independently // recomputes `resolveNativeOccurrence` for both the day before and // the day after, checks whether *that* neighbor's own native // occurrence produced a `transfer` pointed this direction, and only // then folds it in via `applyIncomingTransfer`. So "Monday looks // back at the sanctoral calendar to see if Sunday's own candidate // needs a home" isn't a separate mechanism to build — it's the // existing generic transfer-consumption check, which already runs // for every day regardless of category. return { winner: temporalWinner, commemorations: [], transfer: { candidate: sanctoral, direction: transferDirectionOf(sanctoral.rank) }, }; case 'privileged-sunday': // A privileged Sunday wants nothing at all, no matter how high the // candidate ranks — not even the bare commemoration an ordinary // Sunday would allow a Simplex, and no exception for duplex-majus+ — // everything transfers. Live-verified 2026-09-01, three independent // dates: the Immaculate Conception (Duplex I. classis) transfers off // Advent II with no commemoration at all (real dates 2024/2030/2041), // and the Annunciation (also Duplex I. classis) transfers off both // Palm Sunday and Easter Sunday itself the same way (2029/2035/2040/ // 2046) — confirmed by direct instruction as the correct behavior, // correcting an earlier, never-actually-verified "duplex-majus+ // still gets a nod" guess. (Where a transferred candidate actually // lands is a separate, still-generic `transferDirectionOf`/next- // open-day mechanism — the Annunciation's own real rule of jumping // *past* the whole Easter Octave rather than landing inside it is a // known follow-on, blocked on the not-yet-built Easter-octave // mechanism; see TODO.md.) return { winner: temporalWinner, commemorations: [], transfer: { candidate: sanctoral, direction: transferDirectionOf(sanctoral.rank) }, }; } }