Redesign the Easter-octave backlog: floor-gated, one per day
Deploy / deploy (push) Successful in 1m28s

Replaced the multi-day cascading queue with the actual intended design:
only candidates at or above duplex-majus are worth carrying out of the
Palm-Sunday-through-Low-Sunday span at all - below that, a candidate
simply lapses for the year. Of those that clear the floor, the Nth day
after Low Sunday celebrates the Nth one (native-date order), winning
outright with no rank fight, demoting whatever's natively there to a
commemoration - one per day, positional, not a free-day search.

Caught a real bug while re-verifying: the ordinary single-hop transfer
check was independently leaking Low Sunday's own signal into the next
day, letting a below-floor candidate bypass the new gate. Fixed by
excluding the whole span from that path.

Inventoried the only duplex-majus+ candidates that can ever fall in the
reachable window (St. Benedict, St. Gabriel the Archangel, the
Annunciation, St. Mark, Ss. Philip & James), then bumped St. Patrick's
own rank to duplex-majus by direct instruction, adding him as the 6th.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017XMSokiTD2Qc5vPP2YQu3Q
This commit is contained in:
2026-09-01 15:06:55 -04:00
parent fc4ac33903
commit 5b3dc2e9d5
6 changed files with 241 additions and 180 deletions
+42
View File
@@ -4796,3 +4796,45 @@ Mar 21; St. Gabriel the Archangel, Duplex majus, native Mar 24) are now also cor
the same span that year and queue ahead of her. the same span that year and queue ahead of her.
`npm test` (822 passed), `tsc --noEmit`, and `npm run build` all pass. `npm test` (822 passed), `tsc --noEmit`, and `npm run build` all pass.
### Easter-octave backlog redesigned — one-per-day, floor-gated, no rank fight (2026-09-01)
Same day, a real design conversation rather than another live-verification pass: user rejected
the multi-day cascading queue built above ("that's not a plan... we cant do a batch
commemorations. we can do one a day."). New shape, per direct instruction:
- **Gate**: only candidates at or above `duplex-majus` are worth carrying out of the span at
all — below that, a candidate simply lapses for the year, no trace, same as it already gets
none *within* the span itself. (Confirmed as the right floor by inventorying every fixed-date
saint that could ever fall in the whole reachable window, March 15 - May 2: only 5 ever clear
it — St. Benedict, St. Gabriel the Archangel, the Annunciation, St. Mark, Ss. Philip & James
— everything else in that range is plain Duplex or below.)
- **Landing**: the Nth day after Low Sunday celebrates the Nth candidate that cleared the floor
that year, in native-date order — winning outright, no rank comparison against whatever would
natively be there, which is simply demoted to a commemoration. One candidate per day,
positional, not a search for the next free day.
Replaced `resolveForwardTransferLanding`'s generic multi-day queue with two pieces:
`easterOctaveBacklogFor(year)` (computes that year's whole backlog once, filtered to the floor)
and `applyEasterOctaveBacklog` (the Nth-day lookup), both in `calendar/index.ts`. The ordinary
single-hop yesterday/tomorrow transfer check (used everywhere else) is back to its original
form — the whole redesign is now a dedicated, narrowly-scoped mechanism (same architectural
pattern as `applyMarianSaturday`/`applyAdventFourVigilCommemoration`), not a generalized queue.
**Real bug caught while re-verifying**: the ordinary single-hop check was independently picking
up Low Sunday's own transfer signal and landing/commemorating a below-floor candidate (St.
Fidelis, plain Duplex) the very next day — bypassing the new floor entirely. Fixed by excluding
the whole Palm-Sunday-through-Low-Sunday span from that ordinary path (scoped by `easterOffsetOf`,
not by category, so it doesn't also swallow an unrelated privileged Sunday elsewhere in the year
like Advent II).
Rewrote the tests this broke to real, live-verified outcomes for the new shape (2033: nothing
that year clears the floor, so the whole backlog is empty and every impeded candidate lapses;
2035: three candidates clear it and land the 1st/2nd/3rd day after Low Sunday in order). Then,
having inventoried the 5 always-reachable duplex-majus+ candidates together, one more direct
instruction: St. Patrick's own rank is bumped from the source's plain Duplex to duplex-majus
(`data/calendar/saints/st-patrick.yml`) — a deliberate departure, not a transcription, same
category of move as this app's existing late-canonization rank-deflation convention, just in
the other direction — bringing his own count to 6.
`npm test` (821 passed), `tsc --noEmit`, and `npm run build` all pass.
+106 -92
View File
@@ -1,9 +1,9 @@
import type { Commemoration, DayWinner, LiturgicalDay, SanctoralIdentity, TemporalCategory } from './types'; import type { Commemoration, DayWinner, FeastClass, LiturgicalDay, SanctoralIdentity, TemporalCategory } from './types';
import { weekdayOf } from './weekday'; import { weekdayOf } from './weekday';
import { resolveSeason, resolveTemporalCategory } from './temporal'; import { resolveSeason, resolveTemporalCategory, easterOffsetOf } from './temporal';
import { resolveTemporalId } from './temporal-id'; import { resolveTemporalId } from './temporal-id';
import { getSanctoralCandidatesFor } from './feasts'; import { getSanctoralCandidatesFor } from './feasts';
import { decideOccurrence, compareFeastClass, type OccurrenceResult } from './commemorations'; import { decideOccurrence, compareFeastClass, isAtLeast, type OccurrenceResult } from './commemorations';
import { resolveCollision } from './collision'; import { resolveCollision } from './collision';
import { addDays, toIsoDate } from './date-math'; import { addDays, toIsoDate } from './date-math';
import { easterSunday } from './easter'; import { easterSunday } from './easter';
@@ -14,12 +14,12 @@ import { applyEmberDay } from './ember-days';
/** /**
* A day's occurrence considered on its own — no awareness of what an * A day's occurrence considered on its own — no awareness of what an
* adjacent day might be trying to transfer in. `resolveDay` calls this * adjacent day might be trying to transfer in. `resolveDay` calls this
* for the date itself, for tomorrow (a backward-direction Vigil transfer, * for the date itself and for yesterday/tomorrow (to check for an
* single-hop), and — via `resolveForwardTransferLanding` — for up to * inbound transfer) without ever recursing into their own adjacent-day
* `MAX_FORWARD_TRANSFER_LOOKBACK_DAYS` days *before* the date (a forward * checks — that's what keeps this from being mutually recursive. The one
* transfer can chain through more than one impeded day) — without ever * exception, `easterOctaveBacklogFor`, deliberately calls this for up to
* recursing into any of those days' own adjacent-day checks in turn — * 15 fixed days (Palm Sunday through Low Sunday) regardless of `isoDate`
* that's what keeps this from being mutually/infinitely recursive. * — see its own doc comment.
*/ */
function resolveNativeOccurrence(isoDate: string): { temporalCategory: TemporalCategory; result: OccurrenceResult } { function resolveNativeOccurrence(isoDate: string): { temporalCategory: TemporalCategory; result: OccurrenceResult } {
const weekday = weekdayOf(isoDate); const weekday = weekdayOf(isoDate);
@@ -62,22 +62,17 @@ function applyIncomingTransfer(
fromDate: string, fromDate: string,
): DayWinner { ): DayWinner {
if (temporalCategory === 'privileged-feria-major') { if (temporalCategory === 'privileged-feria-major') {
// Never a landing spot, no matter the candidate's rank or the day's // Never a landing spot for any rank, and never even a bare
// own winner — unlike every other category, a transferred-in // commemoration (commemorations.ts's own case already guarantees
// candidate doesn't even get the bare "duplex-majus+ still gets a // this — a `winner.kind === 'sanctoral'` day never actually happens
// nod" commemoration a *native* occurrence of that rank would (live- // in this category, so `decideOccurrence` alone would already
// verified 2026-09-01: the Annunciation, Duplex I. classis, passes // produce this same no-op; kept explicit as a documented short-
// through the entire Easter Octave with zero commemoration anywhere // circuit). A candidate impeded here — Holy Week, the Easter Octave,
// in it, unlike a same-ranked native occurrence there — e.g. St. // Low Sunday — doesn't chain forward day-by-day the way an ordinary
// Mark, Duplex II. classis, native within the Octave in some years, // transfer does; see `applyEasterOctaveBacklog` in `resolveDay` for
// which *does* get commemorated). This single-hop refusal is the // its own dedicated mechanism instead. The lesser privileged-feria
// building block `resolveForwardTransferLanding` below chains // tier doesn't need any of this — ordinary collision/transfer logic
// through the whole privileged span (Holy Week + Easter Day + the // is fine there, same as any ordinary-sunday landing.
// Octave + Low Sunday) to find where a candidate actually lands —
// this function itself stays single-hop on purpose, same as the
// ordinary categories below. 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;
} }
// Tags the returned winner with where it transferred from, but only when // Tags the returned winner with where it transferred from, but only when
@@ -100,9 +95,10 @@ function applyIncomingTransfer(
// not just take over because nothing else was assigned here. // not just take over because nothing else was assigned here.
const decided = decideOccurrence(temporalCategory, winner.id, candidate); const decided = decideOccurrence(temporalCategory, winner.id, candidate);
commemorations.push(...tagCommemorations(decided.commemorations)); commemorations.push(...tagCommemorations(decided.commemorations));
// If the candidate fails here too (decided.transfer set), the caller // If the candidate fails here too (decided.transfer set), it's simply
// (resolveForwardTransferLanding, for a forward-direction candidate) // not delivered — every category reaching this branch only ever
// keeps it queued and tries the next day — that's the real chain. // needs a single hop (see `applyEasterOctaveBacklog` for the one
// multi-day case, handled separately, not through here).
return tagIfLanded(decided.winner); return tagIfLanded(decided.winner);
} }
const collision = resolveCollision(candidate, winner); const collision = resolveCollision(candidate, winner);
@@ -110,75 +106,72 @@ function applyIncomingTransfer(
return tagIfLanded(collision.winner); return tagIfLanded(collision.winner);
} }
// How far forward a still-unlanded candidate is allowed to keep looking // The minimum rank worth carrying out of the Palm-Sunday-through-Low-
// for a day — generous headroom over the worst real case found so far // Sunday span at all (see `applyEasterOctaveBacklog` below) — anything
// (St. George, 2033: native Apr 23, impeded through the rest of Holy // weaker than this just lapses for the year, same as it already gets no
// Week + Easter Day + the Octave + Low Sunday, then further blocked by // trace *within* the span itself (commemorations.ts's `privileged-feria-
// 5 more already-occupied days in a row, landing May 4 — an 11-day // major`/`privileged-sunday` cases). Per direct instruction (2026-09-01):
// chain). Not expected to ever bind; exists so a pathological/malformed // this is the same threshold the sibling in-place-commemoration rule
// sanctoral-calendar entry fails a candidate quietly (never lands, same // used before it was found wrong — that number wasn't the mistake, only
// "not delivered rather than guessed at" stance as everywhere else in // which question it was answering.
// this file) instead of the lookup silently walking forever. const EASTER_OCTAVE_BACKLOG_FLOOR: FeastClass = 'duplex-majus';
const MAX_FORWARD_TRANSFER_LOOKBACK_DAYS = 60;
/** /**
* A forward-direction transfer (the vigil/backward case stays single-hop, * Every candidate impeded anywhere from Palm Sunday through Low Sunday
* see `resolveDay` below) can chain through more than one impeded day in * inclusive (`easterOffsetOf` -7 through +7) that clears
* a row — the concrete, and so far only observed, case is anything * `EASTER_OCTAVE_BACKLOG_FLOOR`, in native-date order — the backlog
* impeded within Holy Week/Easter Day/the Easter Octave/Low Sunday (all * `applyEasterOctaveBacklog` draws from. Judged only by each day's own
* `privileged-feria-major` or `privileged-sunday`, none of which ever * `resolveNativeOccurrence`, never a fully `resolveDay`-resolved one
* accept a landing, per `applyIncomingTransfer`'s own single-hop refusal * (same convention `applyIncomingTransfer` already uses).
* above): live-verified 2033, three different candidates (St. Leo I,
* Ss. Soter & Caius, St. George) all transfer, skip the entire span, and
* queue up on the days immediately after it — in strict native-date
* order, each waiting for the nearest day not already claimed by a real
* native occurrence or an earlier-queued candidate ahead of it. That's a
* real FIFO queue, not just "the one most recent candidate," since
* multiple can be pending at once (all three above were, simultaneously,
* that same fortnight).
*
* Simulates that queue's state chronologically from
* `MAX_FORWARD_TRANSFER_LOOKBACK_DAYS` before `isoDate` up through
* `isoDate` itself (exclusive of any day on/after `isoDate` — those
* aren't resolved yet), then makes one landing attempt for `isoDate`
* against whichever candidate is oldest in the resulting queue, if any.
* Each day in the walk is judged only by its own `resolveNativeOccurrence`
* (never a fully-`resolveDay`-resolved day, including for the historical
* days being walked over) — consistent with `applyIncomingTransfer`
* itself, which was already only ever given a freshly native-resolved
* winner/commemorations, not a fully resolved one, even before this.
*/ */
function resolveForwardTransferLanding( function easterOctaveBacklogFor(year: number): { candidate: SanctoralIdentity; fromDate: string }[] {
isoDate: string, const easterIso = toIsoDate(easterSunday(year));
temporalCategory: TemporalCategory, const backlog: { candidate: SanctoralIdentity; fromDate: string }[] = [];
winner: DayWinner, for (let offset = -7; offset <= 7; offset++) {
commemorations: Commemoration[], const date = addDays(easterIso, offset);
): DayWinner { const candidate = resolveNativeOccurrence(date).result.transfer?.candidate;
const queue: { candidate: SanctoralIdentity; fromDate: string }[] = []; if (candidate && isAtLeast(candidate.rank, EASTER_OCTAVE_BACKLOG_FLOOR)) {
let cursor = addDays(isoDate, -MAX_FORWARD_TRANSFER_LOOKBACK_DAYS); backlog.push({ candidate, fromDate: date });
while (cursor < isoDate) {
const native = resolveNativeOccurrence(cursor);
const oldest = queue[0];
if (oldest) {
const scratchCommemorations: Commemoration[] = [];
const attempt = applyIncomingTransfer(oldest.candidate, native.temporalCategory, native.result.winner, scratchCommemorations, oldest.fromDate);
const landed =
(attempt.kind === 'sanctoral' && attempt.id === oldest.candidate.id) ||
scratchCommemorations.some((c) => c.kind === 'sanctoral' && c.id === oldest.candidate.id);
if (landed) {
queue.shift();
}
} }
if (native.result.transfer?.direction === 'forward') {
queue.push({ candidate: native.result.transfer.candidate, fromDate: cursor });
}
cursor = addDays(cursor, 1);
} }
const oldest = queue[0]; return backlog;
if (!oldest) { }
// Generous headroom over any realistic backlog length (the whole span is
// 15 days; vu's own calendar has never produced more than a handful of
// duplex-majus+ candidates in it) — exists purely so a date far removed
// from any Easter doesn't pay for an `easterOctaveBacklogFor` computation
// it could never need.
const MAX_EASTER_OCTAVE_BACKLOG_DAYS = 60;
/**
* The Nth day after Low Sunday celebrates the Nth candidate in that
* year's backlog (native-date order) — winning outright, no rank fight
* against whatever would have natively been there, which is demoted to a
* commemoration instead — one candidate per day until the backlog is
* exhausted. Per direct instruction (2026-09-01): not a generic multi-day
* cascade/queue (this app already has real precedent for stacking
* commemorations, so a spread-out cascade wasn't the right shape), and
* not a same-day batch either (only one candidate surfaces per day, same
* as any other transfer landing in this app) — a fixed, deterministic
* position for each backlog candidate rather than a "first day not
* already spoken for" search.
*/
function applyEasterOctaveBacklog(isoDate: string, winner: DayWinner, commemorations: Commemoration[]): DayWinner {
const offset = easterOffsetOf(isoDate);
if (offset <= 7 || offset > 7 + MAX_EASTER_OCTAVE_BACKLOG_DAYS) {
return winner; return winner;
} }
return applyIncomingTransfer(oldest.candidate, temporalCategory, winner, commemorations, oldest.fromDate); const n = offset - 7;
const backlog = easterOctaveBacklogFor(Number(isoDate.slice(0, 4)));
const entry = backlog[n - 1];
if (!entry) {
return winner;
}
if (winner.kind === 'sanctoral') {
commemorations.push({ kind: 'sanctoral', id: winner.id, name: winner.name, nameLa: winner.nameLa, rank: winner.rank });
}
return { kind: 'sanctoral', ...entry.candidate, transferredFrom: entry.fromDate };
} }
/** /**
@@ -195,13 +188,34 @@ export function resolveDay(isoDate: string): LiturgicalDay {
let winner = result.winner; let winner = result.winner;
const commemorations = [...result.commemorations]; const commemorations = [...result.commemorations];
winner = resolveForwardTransferLanding(isoDate, temporalCategory, winner, commemorations); const yesterdayIso = addDays(isoDate, -1);
const yesterday = resolveNativeOccurrence(yesterdayIso);
// A candidate impeded anywhere from Palm Sunday through Low Sunday
// (`easterOffsetOf` -7 through +7) is exclusively
// `applyEasterOctaveBacklog`'s to place — skipped here so it isn't
// *also* picked up by this ordinary single-hop check (real bug found
// 2026-09-01: Low Sunday's own transfer was leaking into the very next
// day's ordinary yesterday-check, landing/commemorating a candidate
// below the backlog's own rank floor). Scoped to that specific span,
// not `privileged-sunday` generally — an ordinary privileged Sunday
// elsewhere (Advent, SeptuagesimaPassiontide) still uses this normal
// single-hop path same as before.
const yesterdayOffset = easterOffsetOf(yesterdayIso);
const yesterdayInEasterSpan = yesterdayOffset >= -7 && yesterdayOffset <= 7;
if (yesterday.result.transfer?.direction === 'forward' && !yesterdayInEasterSpan) {
winner = applyIncomingTransfer(yesterday.result.transfer.candidate, temporalCategory, winner, commemorations, yesterdayIso);
}
const tomorrowIso = addDays(isoDate, 1); const tomorrowIso = addDays(isoDate, 1);
const tomorrow = resolveNativeOccurrence(tomorrowIso); const tomorrow = resolveNativeOccurrence(tomorrowIso);
if (tomorrow.result.transfer?.direction === 'backward') { if (tomorrow.result.transfer?.direction === 'backward') {
winner = applyIncomingTransfer(tomorrow.result.transfer.candidate, temporalCategory, winner, commemorations, tomorrowIso); winner = applyIncomingTransfer(tomorrow.result.transfer.candidate, temporalCategory, winner, commemorations, tomorrowIso);
} }
// The one case that single yesterday-check can't reach: anything
// impeded within Holy Week/Easter Day/the Octave/Low Sunday itself
// (all `privileged-feria-major`/`privileged-sunday`, which never
// accept a landing at all) needs its own dedicated mechanism — see
// `applyEasterOctaveBacklog`'s own doc comment.
winner = applyEasterOctaveBacklog(isoDate, winner, commemorations);
winner = applyOctaves(isoDate, winner, commemorations); winner = applyOctaves(isoDate, winner, commemorations);
winner = applyEmberDay(isoDate, winner, commemorations); winner = applyEmberDay(isoDate, winner, commemorations);
+7 -5
View File
@@ -224,11 +224,13 @@ export interface LiturgicalDay {
* omission, this office moved elsewhere" without needing the caller to * omission, this office moved elsewhere" without needing the caller to
* separately go find where; the actual landing date is discoverable * separately go find where; the actual landing date is discoverable
* from the landing day's own winner/commemoration, tagged with * from the landing day's own winner/commemoration, tagged with
* `transferredFrom` (see calendar/index.ts's `resolveForwardTransferLanding`, * `transferredFrom`. An ordinary transfer (calendar/index.ts's
* which does chain forward through more than one impeded day when * `applyIncomingTransfer`, called from `resolveDay`) only ever needs
* needed — e.g. anything impeded within Holy Week/the Easter Octave — * the immediately adjacent day; anything impeded within Holy Week/the
* up to its own `MAX_FORWARD_TRANSFER_LOOKBACK_DAYS` bound). Distinct * Easter Octave/Low Sunday goes through the separate, dedicated
* from — and not implied by — `Commemoration`, since a transferred-away * `applyEasterOctaveBacklog` mechanism instead, which can place a
* candidate many days later. Distinct from — and not implied by —
* `Commemoration`, since a transferred-away
* candidate gets no commemoration on its own native date at all. */ * candidate gets no commemoration on its own native date at all. */
transferredAway?: { candidate: SanctoralIdentity }; transferredAway?: { candidate: SanctoralIdentity };
} }
+9 -3
View File
@@ -1,12 +1,18 @@
# Read directly from the reference engine: untagged/default [Rank] # Reference source: untagged/default [Rank] block of
# block of `web/www/horas/Latin/Sancti/03-17.txt` -- "vide C4;;Duplex;;3;;". # `web/www/horas/Latin/Sancti/03-17.txt` -- "vide C4;;Duplex;;3;;" (plain
# Duplex). Rank bumped to duplex-majus here by direct instruction
# (2026-09-01) -- a deliberate departure from the source, not a
# transcription of it (see CLAUDE.md's "Not a reconstruction" -- this app
# doesn't take the reference's own rank at face value everywhere, e.g.
# the existing late-canonization deflation convention; this is the same
# kind of deliberate move in the other direction).
# Own proper [Oratio]; no [Ant 1] of its own, so the Benedictus antiphon # Own proper [Oratio]; no [Ant 1] of its own, so the Benedictus antiphon
# falls back to Common of a Confessor Bishop (C4), matching # falls back to Common of a Confessor Bishop (C4), matching
# st-andrew-corsini.yml. No Lectio7-9 in the source (6-lesson office). # st-andrew-corsini.yml. No Lectio7-9 in the source (6-lesson office).
id: st-patrick id: st-patrick
name: "St. Patrick, Bishop and Confessor" name: "St. Patrick, Bishop and Confessor"
nameLa: "Sanctus Patricius, Episcopus et Confessor" nameLa: "Sanctus Patricius, Episcopus et Confessor"
rank: duplex rank: duplex-majus
common: common-of-a-confessor-bishop common: common-of-a-confessor-bishop
propers: "st-patrick" propers: "st-patrick"
minorHoursCommon: common-of-a-confessor-bishop minorHoursCommon: common-of-a-confessor-bishop
+15 -6
View File
@@ -18,21 +18,27 @@ describe('April sanctoral pull (first pass)', () => {
expect(getDayCollect(day).status.en).toBe('verified'); expect(getDayCollect(day).status.en).toBe('verified');
}); });
it('St. Mark gets nothing at all within the Easter Octave — not even a bare commemoration — and transfers past it instead', () => { it('St. Mark gets nothing at all within the Easter Octave — not even a bare commemoration — and surfaces on the 1st day of the Easter-octave backlog instead', () => {
// Corrected 2026-09-01: nothing is ever commemorated anywhere from // Corrected 2026-09-01: nothing is ever commemorated anywhere from
// Palm Sunday through Low Sunday, no exception for any rank (direct // Palm Sunday through Low Sunday, no exception for any rank (direct
// instruction; live-verified against the Annunciation on Holy // instruction; live-verified against the Annunciation on Holy
// Thursday and Chair of St. Peter at Antioch on Ash Wednesday, both // Thursday and Chair of St. Peter at Antioch on Ash Wednesday, both
// Duplex-majus+ and both transferring with zero commemoration). Mark // Duplex-majus+ and both transferring with zero commemoration). Per
// (Duplex II. classis) transfers off Apr 25, 2030 the same way; vu's // further direct instruction the same day, this doesn't cascade
// own dense calendar that week pushes his actual landing all the way // day-by-day looking for a free day: the Nth day after Low Sunday
// to May 5 (every day in between already spoken for). // celebrates the Nth candidate that cleared the backlog's own
// duplex-majus+ floor that year (native-date order), winning
// outright and demoting whatever would have natively been there to
// a commemoration — see calendar/index.ts's `applyEasterOctaveBacklog`.
// Mark (Duplex II. classis, well above the floor) is the only
// candidate in 2030's own backlog, so he lands the 1st day after Low
// Sunday (Apr 28 that year) outright.
const native = resolveDay('2030-04-25'); const native = resolveDay('2030-04-25');
expect(native.winner).toEqual({ kind: 'temporal', id: 'easter-sunday' }); expect(native.winner).toEqual({ kind: 'temporal', id: 'easter-sunday' });
expect(native.commemorations).toEqual([]); expect(native.commemorations).toEqual([]);
expect(native.transferredAway?.candidate.id).toBe('st-mark'); expect(native.transferredAway?.candidate.id).toBe('st-mark');
const landing = resolveDay('2030-05-05'); const landing = resolveDay('2030-04-29');
expect(landing.winner).toEqual({ expect(landing.winner).toEqual({
kind: 'sanctoral', kind: 'sanctoral',
id: 'st-mark', id: 'st-mark',
@@ -41,6 +47,9 @@ describe('April sanctoral pull (first pass)', () => {
rank: 'duplex-2-classis', rank: 'duplex-2-classis',
transferredFrom: '2030-04-25', transferredFrom: '2030-04-25',
}); });
// St. Robert, native to this day, is demoted to a commemoration
// rather than winning or being skipped past.
expect(landing.commemorations.some((c) => c.kind === 'sanctoral' && c.id === 'st-robert')).toBe(true);
}); });
it('St. Robert beats St. Catherine of Siena on their shared monastic-calendar date', () => { it('St. Robert beats St. Catherine of Siena on their shared monastic-calendar date', () => {
+62 -74
View File
@@ -108,96 +108,78 @@ describe('transfer mechanism (resolveDay integration)', () => {
}); });
}); });
// A forward transfer can chain through more than one impeded day in a // Anything impeded anywhere from Palm Sunday through Low Sunday
// row — the whole Holy Week/Easter Day/Easter Octave/Low Sunday span
// (2026-09-01: found real, live-verified precedence bugs in exactly this // (2026-09-01: found real, live-verified precedence bugs in exactly this
// area, then built the actual multi-hop mechanism per direct // area) doesn't cascade day-by-day looking for the next free day — per
// instruction). `resolveForwardTransferLanding` in calendar/index.ts // direct instruction, that's not this app's mechanism (it already has
// walks up to `MAX_FORWARD_TRANSFER_LOOKBACK_DAYS` days back to find // real precedent for stacking commemorations, and rejected both a
// where a still-unlanded candidate actually lands, in strict native-date // same-day batch and an open-ended search). Instead: only candidates at
// (FIFO) order against every other pending candidate. // or above `EASTER_OCTAVE_BACKLOG_FLOOR` (duplex-majus) are worth
describe('multi-hop forward transfer through Holy Week/the Easter Octave', () => { // carrying forward at all — anything weaker simply lapses for the year,
it('2033: St. Leo I (native Easter Monday, Duplex) chains all the way past the whole privileged span to land on Easter+8 — verified against the real reference engine', () => { // no trace, same as it already gets none within the span itself. Of
// Easter 2033 is Apr 17. Live-verified against the reference engine // those that clear the floor, the Nth day after Low Sunday celebrates
// (Monastic Tridentinum 1617, 2026-09-01): Leo transfers off Apr 11 // the Nth one (native-date order), winning outright — no rank fight —
// (Monday of Holy Week) with no commemoration anywhere in Holy Week, // and demoting whatever would have natively been there to a
// Easter Day, the Octave, or Low Sunday (Apr 24), landing outright on // commemoration. See calendar/index.ts's `applyEasterOctaveBacklog`.
// Apr 27 there (Apr 25/26 already spoken for by St. Mark and Ss. describe('the Easter-octave backlog (Palm Sunday through Low Sunday)', () => {
// Cletus & Marcellinus in the reference's own calendar). vu's own it('2033: nothing impeded that year clears the duplex-majus floor, so the backlog is empty and every impeded candidate simply lapses', () => {
// calendar differs on Apr 25-27 (different saints assigned), so this // Easter 2033 is Apr 17, Low Sunday Apr 24. Every candidate vu's own
// asserts the *mechanism* — Leo reaching a day past the whole span in // calendar puts inside that span this year (St. Leo I, Duplex;
// the right relative order — not the exact reference-engine landing // St. Hermenegild, Semiduplex; Ss. Tiburtius/Valerian/Maximus,
// day, which depends on which calendar is in play. // Simplex; St. Anselm, Duplex; Ss. Soter & Caius, Semiduplex;
// St. George, Semiduplex; St. Fidelis of Sigmaringen, Duplex) is
// below duplex-majus.
const holyMonday = resolveDay('2033-04-11'); const holyMonday = resolveDay('2033-04-11');
expect(holyMonday.winner).toEqual({ kind: 'temporal', id: 'palm-sunday' }); expect(holyMonday.winner).toEqual({ kind: 'temporal', id: 'palm-sunday' });
expect(holyMonday.commemorations).toEqual([]); expect(holyMonday.commemorations).toEqual([]);
expect(holyMonday.transferredAway?.candidate.id).toBe('st-leo-i'); expect(holyMonday.transferredAway?.candidate.id).toBe('st-leo-i');
for (const impededDate of ['2033-04-13', '2033-04-17', '2033-04-21', '2033-04-24']) { for (const afterLowSunday of ['2033-04-25', '2033-04-26', '2033-04-27', '2033-04-28', '2033-04-29']) {
const day = resolveDay(impededDate); const day = resolveDay(afterLowSunday);
expect(day.commemorations.some((c) => c.kind === 'sanctoral' && c.id === 'st-leo-i')).toBe(false); const ids = ['st-leo-i', 'st-hermenegild', 'ss-tiburtius-valerian-and-maximus', 'st-anselm', 'ss-soter-and-caius', 'st-george', 'st-fidelis-of-sigmaringen'];
expect(day.commemorations.some((c) => c.kind === 'sanctoral' && ids.includes(c.id))).toBe(false);
expect(day.winner.kind === 'sanctoral' ? ids.includes(day.winner.id) : false).toBe(false);
} }
// Lands the first day past the whole span not already spoken for by
// vu's own calendar (Apr 25 = St. Mark, native and unaffected).
const landing = resolveDay('2033-04-25');
expect(landing.winner).toEqual({
kind: 'sanctoral',
id: 'st-mark',
name: 'St. Mark, Evangelist',
nameLa: 'Sanctus Marcus, Evangelista',
rank: 'duplex-2-classis',
});
expect(landing.commemorations).toEqual([
{
kind: 'sanctoral',
id: 'st-leo-i',
name: 'St. Leo I, Pope, Confessor and Doctor of the Church',
nameLa: 'Sanctus Leo I, Papa, Confessor et Ecclesiæ Doctor',
rank: 'duplex',
transferredFrom: '2033-04-11',
},
]);
}); });
it('2033: three simultaneously-pending candidates (Leo, Hermenegild, Ss. Tiburtius/Valerian/Maximus) land in strict native-date order, none skipping ahead', () => { it('2035: three candidates clear the floor (St. Benedict, St. Gabriel the Archangel, the Annunciation) and land on the 1st/2nd/3rd day after Low Sunday, in native-date order, each demoting that day\'s own native winner to a commemoration', () => {
const leo = resolveDay('2033-04-25').commemorations.find((c) => c.kind === 'sanctoral' && c.id === 'st-leo-i'); // Easter 2035 is Mar 25 (the Annunciation's own fixed date), Low
const hermenegild = resolveDay('2033-04-26').commemorations.find((c) => c.kind === 'sanctoral' && c.id === 'st-hermenegild'); // Sunday Apr 1. St. Benedict (native Mar 21, Duplex I. classis),
const tiburtius = resolveDay('2033-04-27').commemorations.find( // St. Gabriel the Archangel (native Mar 24, Duplex majus), and the
(c) => c.kind === 'sanctoral' && c.id === 'ss-tiburtius-valerian-and-maximus', // Annunciation (native Mar 25, Duplex I. classis) are all impeded
); // and all clear the floor — live-verified 2026-09-01 that the
expect(leo?.kind === 'sanctoral' ? leo.transferredFrom : undefined).toBe('2033-04-11'); // Annunciation herself gets zero commemoration anywhere in the span.
expect(hermenegild?.kind === 'sanctoral' ? hermenegild.transferredFrom : undefined).toBe('2033-04-13');
expect(tiburtius?.kind === 'sanctoral' ? tiburtius.transferredFrom : undefined).toBe('2033-04-14');
});
it("2035: the Annunciation (native Easter Sunday itself, Duplex I. classis) transfers past the whole span with zero commemoration anywhere in it, queued behind two earlier-native-dated candidates also caught by the same span", () => {
// Easter 2035 is Mar 25 -- the Annunciation's own fixed date. Live-
// verified 2026-09-01: the real Monastic 1617 engine shows her
// transferring off Easter Day with no commemoration anywhere in Holy
// Week/the Octave/Low Sunday (Apr 1 that year) -- corrected the same
// day, direct instruction: nothing is ever commemorated anywhere in
// this span, at any rank, not even duplex-majus+ -- so a *native*
// occurrence there (e.g. St. Mark within some other year's Octave)
// gets exactly the same "transfers, no exception" treatment as a
// transferred-in candidate passing through, not the "still gets a
// nod" treatment this test used to assert existed.
const easterSunday = resolveDay('2035-03-25'); const easterSunday = resolveDay('2035-03-25');
expect(easterSunday.winner).toEqual({ kind: 'temporal', id: 'easter-sunday' }); expect(easterSunday.winner).toEqual({ kind: 'temporal', id: 'easter-sunday' });
expect(easterSunday.transferredAway?.candidate.id).toBe('annunciation'); expect(easterSunday.transferredAway?.candidate.id).toBe('annunciation');
for (const impededDate of ['2035-03-21', '2035-03-24', '2035-03-26', '2035-03-29', '2035-04-01']) {
for (const impededDate of ['2035-03-26', '2035-03-29', '2035-04-01']) {
const day = resolveDay(impededDate); const day = resolveDay(impededDate);
expect(day.commemorations.some((c) => c.kind === 'sanctoral' && c.id === 'annunciation')).toBe(false); expect(day.commemorations.some((c) => c.kind === 'sanctoral' && c.id === 'annunciation')).toBe(false);
} }
// vu's own calendar also has St. Benedict (native Mar 21, also const day1 = resolveDay('2035-04-02');
// Duplex I. classis, also impeded that year) and St. Gabriel the expect(day1.winner).toEqual({
// Archangel (native Mar 24, Duplex majus) queued *ahead* of the kind: 'sanctoral',
// Annunciation by native date -- they land Apr 4 and Apr 5 id: 'st-benedict',
// respectively, pushing her actual landing to Apr 6. name: 'St. Benedict, Abbot (Transitus)',
const landing = resolveDay('2035-04-06'); nameLa: 'Sanctus Benedictus, Abbas (Transitus)',
expect(landing.winner).toEqual({ rank: 'duplex-1-classis',
transferredFrom: '2035-03-21',
});
expect(day1.commemorations.some((c) => c.kind === 'sanctoral' && c.id === 'st-francis-of-paola')).toBe(true);
const day2 = resolveDay('2035-04-03');
expect(day2.winner).toEqual({
kind: 'sanctoral',
id: 'st-gabriel-archangel',
name: 'St. Gabriel the Archangel',
nameLa: 'Sanctus Gabriel Archangelus',
rank: 'duplex-majus',
transferredFrom: '2035-03-24',
});
const day3 = resolveDay('2035-04-04');
expect(day3.winner).toEqual({
kind: 'sanctoral', kind: 'sanctoral',
id: 'annunciation', id: 'annunciation',
name: 'The Annunciation of the Blessed Virgin Mary', name: 'The Annunciation of the Blessed Virgin Mary',
@@ -205,5 +187,11 @@ describe('multi-hop forward transfer through Holy Week/the Easter Octave', () =>
rank: 'duplex-1-classis', rank: 'duplex-1-classis',
transferredFrom: '2035-03-25', transferredFrom: '2035-03-25',
}); });
expect(day3.commemorations.some((c) => c.kind === 'sanctoral' && c.id === 'st-isidore-of-seville')).toBe(true);
// The backlog is exhausted after 3 days -- the 4th day after Low
// Sunday resolves with no override at all.
const day4 = resolveDay('2035-04-05');
expect(day4.winner.kind === 'sanctoral' ? day4.winner.transferredFrom : undefined).toBeUndefined();
}); });
}); });