3f07d6e8d3
Deploy / deploy (push) Successful in 1m4s
Three related day-label bugs, all found via Aug 19's mislabeled Assumption octave commemoration: - A sanctoral winner that displaces the only active octave now shows the octave's real "Nth Day within the Octave of X" phrasing (no rank, since it's riding along under the winning feast), not a bare feast name. - An octave's own closing day now titles itself "Octave of X", matching the real DO's "in octava" vs. "infra octavam" distinction, instead of "8th Day within the Octave of X". - A sufficiently-ranked octave can now outrank a privileged temporal season (e.g. the Immaculate Conception's octave outright winning several of its days against Advent, live-verified against Divino Afflatu 1954), via a new shared octaveGoverningPrivilegedDay helper used by both the day label and the actual office-content resolver. Deliberately excludes Christmastide, whose own stacked octaves are structurally already that season's temporal content rather than a foreign add-on. Updates a Matins test fixture that had unknowingly relied on the Dec 15 bug (Advent ferial + a lone commemorated saint) and splits it into a clean ferial case plus a dedicated commemorated-saint case. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
191 lines
8.4 KiB
TypeScript
191 lines
8.4 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, rank: candidate.rank };
|
|
}
|
|
|
|
function sanctoralWinner(candidate: SanctoralIdentity): DayWinner {
|
|
return { kind: 'sanctoral', id: candidate.id, name: candidate.name, 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, 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':
|
|
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 —
|
|
// better to preserve the feast whole on another day than downgrade
|
|
// it to a bare commemoration.
|
|
return {
|
|
winner: temporalWinner,
|
|
commemorations: [],
|
|
transfer: { candidate: sanctoral, direction: transferDirectionOf(sanctoral.rank) },
|
|
};
|
|
|
|
case 'privileged-sunday':
|
|
if (isAtLeast(sanctoral.rank, 'duplex-majus')) {
|
|
// Never displaced, but a sufficiently high feast still gets a nod.
|
|
return { winner: temporalWinner, commemorations: [sanctoralCommemoration(sanctoral)] };
|
|
}
|
|
// Below duplex-majus, a privileged Sunday wants nothing at all —
|
|
// not even the bare commemoration an ordinary Sunday would allow a
|
|
// Simplex — so everything here transfers.
|
|
return {
|
|
winner: temporalWinner,
|
|
commemorations: [],
|
|
transfer: { candidate: sanctoral, direction: transferDirectionOf(sanctoral.rank) },
|
|
};
|
|
}
|
|
}
|