Files
vu/src/calendar/commemorations.ts
T
will fc4ac33903
Deploy / deploy (push) Successful in 1m32s
Fix privileged-feria-major: nothing is ever commemorated, any rank
The Easter octave cascade built last commit reused the existing
"duplex-majus+ still gets a bare commemoration" branch unchanged,
trusting its own citation (St. Mark within the Easter Octave). That
citation was a misreading. Live-verified: the Annunciation (Duplex I.
classis) transfers with zero commemoration on Holy Thursday itself, and
Chair of St. Peter at Antioch does the same on Ash Wednesday - the
exception was wrong everywhere this category is used, not just Holy
Week. privileged-feria-major now matches privileged-sunday exactly:
unconditional transfer, no exceptions, any rank.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XMSokiTD2Qc5vPP2YQu3Q
2026-09-01 14:08:15 -04:00

225 lines
11 KiB
TypeScript

// 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) },
};
}
}