From 5b3dc2e9d58dfcd4823248d572ebb55dea6ea2ef Mon Sep 17 00:00:00 2001 From: Will Estes Date: Tue, 1 Sep 2026 15:06:55 -0400 Subject: [PATCH] Redesign the Easter-octave backlog: floor-gated, one per day 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 Claude-Session: https://claude.ai/code/session_017XMSokiTD2Qc5vPP2YQu3Q --- TODO.md | 42 +++++ src/calendar/index.ts | 198 +++++++++++++----------- src/calendar/types.ts | 12 +- src/data/calendar/saints/st-patrick.yml | 12 +- tests/calendar/april-sanctoral.test.ts | 21 ++- tests/calendar/transfer.test.ts | 136 ++++++++-------- 6 files changed, 241 insertions(+), 180 deletions(-) diff --git a/TODO.md b/TODO.md index 0345b40..7dace36 100644 --- a/TODO.md +++ b/TODO.md @@ -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. `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. diff --git a/src/calendar/index.ts b/src/calendar/index.ts index e1bd6cc..9cae713 100644 --- a/src/calendar/index.ts +++ b/src/calendar/index.ts @@ -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 { resolveSeason, resolveTemporalCategory } from './temporal'; +import { resolveSeason, resolveTemporalCategory, easterOffsetOf } from './temporal'; import { resolveTemporalId } from './temporal-id'; import { getSanctoralCandidatesFor } from './feasts'; -import { decideOccurrence, compareFeastClass, type OccurrenceResult } from './commemorations'; +import { decideOccurrence, compareFeastClass, isAtLeast, type OccurrenceResult } from './commemorations'; import { resolveCollision } from './collision'; import { addDays, toIsoDate } from './date-math'; 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 * adjacent day might be trying to transfer in. `resolveDay` calls this - * for the date itself, for tomorrow (a backward-direction Vigil transfer, - * single-hop), and — via `resolveForwardTransferLanding` — for up to - * `MAX_FORWARD_TRANSFER_LOOKBACK_DAYS` days *before* the date (a forward - * transfer can chain through more than one impeded day) — without ever - * recursing into any of those days' own adjacent-day checks in turn — - * that's what keeps this from being mutually/infinitely recursive. + * for the date itself and for yesterday/tomorrow (to check for an + * inbound transfer) without ever recursing into their own adjacent-day + * checks — that's what keeps this from being mutually recursive. The one + * exception, `easterOctaveBacklogFor`, deliberately calls this for up to + * 15 fixed days (Palm Sunday through Low Sunday) regardless of `isoDate` + * — see its own doc comment. */ function resolveNativeOccurrence(isoDate: string): { temporalCategory: TemporalCategory; result: OccurrenceResult } { const weekday = weekdayOf(isoDate); @@ -62,22 +62,17 @@ function applyIncomingTransfer( fromDate: string, ): DayWinner { if (temporalCategory === 'privileged-feria-major') { - // Never a landing spot, no matter the candidate's rank or the day's - // own winner — unlike every other category, a transferred-in - // candidate doesn't even get the bare "duplex-majus+ still gets a - // nod" commemoration a *native* occurrence of that rank would (live- - // verified 2026-09-01: the Annunciation, Duplex I. classis, passes - // through the entire Easter Octave with zero commemoration anywhere - // in it, unlike a same-ranked native occurrence there — e.g. St. - // Mark, Duplex II. classis, native within the Octave in some years, - // which *does* get commemorated). This single-hop refusal is the - // building block `resolveForwardTransferLanding` below chains - // through the whole privileged span (Holy Week + Easter Day + the - // 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. + // Never a landing spot for any rank, and never even a bare + // commemoration (commemorations.ts's own case already guarantees + // this — a `winner.kind === 'sanctoral'` day never actually happens + // in this category, so `decideOccurrence` alone would already + // produce this same no-op; kept explicit as a documented short- + // circuit). A candidate impeded here — Holy Week, the Easter Octave, + // Low Sunday — doesn't chain forward day-by-day the way an ordinary + // transfer does; see `applyEasterOctaveBacklog` in `resolveDay` for + // its own dedicated mechanism instead. The lesser privileged-feria + // tier doesn't need any of this — ordinary collision/transfer logic + // is fine there, same as any ordinary-sunday landing. return winner; } // 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. const decided = decideOccurrence(temporalCategory, winner.id, candidate); commemorations.push(...tagCommemorations(decided.commemorations)); - // If the candidate fails here too (decided.transfer set), the caller - // (resolveForwardTransferLanding, for a forward-direction candidate) - // keeps it queued and tries the next day — that's the real chain. + // If the candidate fails here too (decided.transfer set), it's simply + // not delivered — every category reaching this branch only ever + // needs a single hop (see `applyEasterOctaveBacklog` for the one + // multi-day case, handled separately, not through here). return tagIfLanded(decided.winner); } const collision = resolveCollision(candidate, winner); @@ -110,75 +106,72 @@ function applyIncomingTransfer( return tagIfLanded(collision.winner); } -// How far forward a still-unlanded candidate is allowed to keep looking -// for a day — generous headroom over the worst real case found so far -// (St. George, 2033: native Apr 23, impeded through the rest of Holy -// Week + Easter Day + the Octave + Low Sunday, then further blocked by -// 5 more already-occupied days in a row, landing May 4 — an 11-day -// chain). Not expected to ever bind; exists so a pathological/malformed -// sanctoral-calendar entry fails a candidate quietly (never lands, same -// "not delivered rather than guessed at" stance as everywhere else in -// this file) instead of the lookup silently walking forever. -const MAX_FORWARD_TRANSFER_LOOKBACK_DAYS = 60; +// The minimum rank worth carrying out of the Palm-Sunday-through-Low- +// Sunday span at all (see `applyEasterOctaveBacklog` below) — anything +// weaker than this just lapses for the year, same as it already gets no +// trace *within* the span itself (commemorations.ts's `privileged-feria- +// major`/`privileged-sunday` cases). Per direct instruction (2026-09-01): +// this is the same threshold the sibling in-place-commemoration rule +// used before it was found wrong — that number wasn't the mistake, only +// which question it was answering. +const EASTER_OCTAVE_BACKLOG_FLOOR: FeastClass = 'duplex-majus'; /** - * A forward-direction transfer (the vigil/backward case stays single-hop, - * see `resolveDay` below) can chain through more than one impeded day in - * a row — the concrete, and so far only observed, case is anything - * impeded within Holy Week/Easter Day/the Easter Octave/Low Sunday (all - * `privileged-feria-major` or `privileged-sunday`, none of which ever - * accept a landing, per `applyIncomingTransfer`'s own single-hop refusal - * 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. + * Every candidate impeded anywhere from Palm Sunday through Low Sunday + * inclusive (`easterOffsetOf` -7 through +7) that clears + * `EASTER_OCTAVE_BACKLOG_FLOOR`, in native-date order — the backlog + * `applyEasterOctaveBacklog` draws from. Judged only by each day's own + * `resolveNativeOccurrence`, never a fully `resolveDay`-resolved one + * (same convention `applyIncomingTransfer` already uses). */ -function resolveForwardTransferLanding( - isoDate: string, - temporalCategory: TemporalCategory, - winner: DayWinner, - commemorations: Commemoration[], -): DayWinner { - const queue: { candidate: SanctoralIdentity; fromDate: string }[] = []; - let cursor = addDays(isoDate, -MAX_FORWARD_TRANSFER_LOOKBACK_DAYS); - 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(); - } +function easterOctaveBacklogFor(year: number): { candidate: SanctoralIdentity; fromDate: string }[] { + const easterIso = toIsoDate(easterSunday(year)); + const backlog: { candidate: SanctoralIdentity; fromDate: string }[] = []; + for (let offset = -7; offset <= 7; offset++) { + const date = addDays(easterIso, offset); + const candidate = resolveNativeOccurrence(date).result.transfer?.candidate; + if (candidate && isAtLeast(candidate.rank, EASTER_OCTAVE_BACKLOG_FLOOR)) { + backlog.push({ candidate, fromDate: date }); } - if (native.result.transfer?.direction === 'forward') { - queue.push({ candidate: native.result.transfer.candidate, fromDate: cursor }); - } - cursor = addDays(cursor, 1); } - const oldest = queue[0]; - if (!oldest) { + return backlog; +} + +// 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 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; 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, Septuagesima–Passiontide) 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 tomorrow = resolveNativeOccurrence(tomorrowIso); if (tomorrow.result.transfer?.direction === 'backward') { 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 = applyEmberDay(isoDate, winner, commemorations); diff --git a/src/calendar/types.ts b/src/calendar/types.ts index bcd0b4a..a740f0c 100644 --- a/src/calendar/types.ts +++ b/src/calendar/types.ts @@ -224,11 +224,13 @@ export interface LiturgicalDay { * omission, this office moved elsewhere" without needing the caller to * separately go find where; the actual landing date is discoverable * from the landing day's own winner/commemoration, tagged with - * `transferredFrom` (see calendar/index.ts's `resolveForwardTransferLanding`, - * which does chain forward through more than one impeded day when - * needed — e.g. anything impeded within Holy Week/the Easter Octave — - * up to its own `MAX_FORWARD_TRANSFER_LOOKBACK_DAYS` bound). Distinct - * from — and not implied by — `Commemoration`, since a transferred-away + * `transferredFrom`. An ordinary transfer (calendar/index.ts's + * `applyIncomingTransfer`, called from `resolveDay`) only ever needs + * the immediately adjacent day; anything impeded within Holy Week/the + * Easter Octave/Low Sunday goes through the separate, dedicated + * `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. */ transferredAway?: { candidate: SanctoralIdentity }; } diff --git a/src/data/calendar/saints/st-patrick.yml b/src/data/calendar/saints/st-patrick.yml index 8d72f31..2c94104 100644 --- a/src/data/calendar/saints/st-patrick.yml +++ b/src/data/calendar/saints/st-patrick.yml @@ -1,12 +1,18 @@ -# Read directly from the reference engine: untagged/default [Rank] -# block of `web/www/horas/Latin/Sancti/03-17.txt` -- "vide C4;;Duplex;;3;;". +# Reference source: untagged/default [Rank] block of +# `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 # falls back to Common of a Confessor Bishop (C4), matching # st-andrew-corsini.yml. No Lectio7-9 in the source (6-lesson office). id: st-patrick name: "St. Patrick, Bishop and Confessor" nameLa: "Sanctus Patricius, Episcopus et Confessor" -rank: duplex +rank: duplex-majus common: common-of-a-confessor-bishop propers: "st-patrick" minorHoursCommon: common-of-a-confessor-bishop diff --git a/tests/calendar/april-sanctoral.test.ts b/tests/calendar/april-sanctoral.test.ts index ab262d7..9d5099a 100644 --- a/tests/calendar/april-sanctoral.test.ts +++ b/tests/calendar/april-sanctoral.test.ts @@ -18,21 +18,27 @@ describe('April sanctoral pull (first pass)', () => { 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 // Palm Sunday through Low Sunday, no exception for any rank (direct // instruction; live-verified against the Annunciation on Holy // Thursday and Chair of St. Peter at Antioch on Ash Wednesday, both - // Duplex-majus+ and both transferring with zero commemoration). Mark - // (Duplex II. classis) transfers off Apr 25, 2030 the same way; vu's - // own dense calendar that week pushes his actual landing all the way - // to May 5 (every day in between already spoken for). + // Duplex-majus+ and both transferring with zero commemoration). Per + // further direct instruction the same day, this doesn't cascade + // day-by-day looking for a free day: the Nth day after Low Sunday + // 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'); expect(native.winner).toEqual({ kind: 'temporal', id: 'easter-sunday' }); expect(native.commemorations).toEqual([]); expect(native.transferredAway?.candidate.id).toBe('st-mark'); - const landing = resolveDay('2030-05-05'); + const landing = resolveDay('2030-04-29'); expect(landing.winner).toEqual({ kind: 'sanctoral', id: 'st-mark', @@ -41,6 +47,9 @@ describe('April sanctoral pull (first pass)', () => { rank: 'duplex-2-classis', 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', () => { diff --git a/tests/calendar/transfer.test.ts b/tests/calendar/transfer.test.ts index 7cd903e..664ef2b 100644 --- a/tests/calendar/transfer.test.ts +++ b/tests/calendar/transfer.test.ts @@ -108,96 +108,78 @@ describe('transfer mechanism (resolveDay integration)', () => { }); }); -// A forward transfer can chain through more than one impeded day in a -// row — the whole Holy Week/Easter Day/Easter Octave/Low Sunday span +// Anything impeded anywhere from Palm Sunday through Low Sunday // (2026-09-01: found real, live-verified precedence bugs in exactly this -// area, then built the actual multi-hop mechanism per direct -// instruction). `resolveForwardTransferLanding` in calendar/index.ts -// walks up to `MAX_FORWARD_TRANSFER_LOOKBACK_DAYS` days back to find -// where a still-unlanded candidate actually lands, in strict native-date -// (FIFO) order against every other pending candidate. -describe('multi-hop forward transfer through Holy Week/the Easter Octave', () => { - 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', () => { - // Easter 2033 is Apr 17. Live-verified against the reference engine - // (Monastic Tridentinum 1617, 2026-09-01): Leo transfers off Apr 11 - // (Monday of Holy Week) with no commemoration anywhere in Holy Week, - // Easter Day, the Octave, or Low Sunday (Apr 24), landing outright on - // Apr 27 there (Apr 25/26 already spoken for by St. Mark and Ss. - // Cletus & Marcellinus in the reference's own calendar). vu's own - // calendar differs on Apr 25-27 (different saints assigned), so this - // asserts the *mechanism* — Leo reaching a day past the whole span in - // the right relative order — not the exact reference-engine landing - // day, which depends on which calendar is in play. +// area) doesn't cascade day-by-day looking for the next free day — per +// direct instruction, that's not this app's mechanism (it already has +// real precedent for stacking commemorations, and rejected both a +// same-day batch and an open-ended search). Instead: only candidates at +// or above `EASTER_OCTAVE_BACKLOG_FLOOR` (duplex-majus) are worth +// carrying forward at all — anything weaker simply lapses for the year, +// no trace, same as it already gets none within the span itself. Of +// those that clear the floor, the Nth day after Low Sunday celebrates +// the Nth one (native-date order), winning outright — no rank fight — +// and demoting whatever would have natively been there to a +// commemoration. See calendar/index.ts's `applyEasterOctaveBacklog`. +describe('the Easter-octave backlog (Palm Sunday through Low Sunday)', () => { + it('2033: nothing impeded that year clears the duplex-majus floor, so the backlog is empty and every impeded candidate simply lapses', () => { + // Easter 2033 is Apr 17, Low Sunday Apr 24. Every candidate vu's own + // calendar puts inside that span this year (St. Leo I, Duplex; + // St. Hermenegild, Semiduplex; Ss. Tiburtius/Valerian/Maximus, + // 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'); expect(holyMonday.winner).toEqual({ kind: 'temporal', id: 'palm-sunday' }); expect(holyMonday.commemorations).toEqual([]); 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']) { - const day = resolveDay(impededDate); - expect(day.commemorations.some((c) => c.kind === 'sanctoral' && c.id === 'st-leo-i')).toBe(false); + for (const afterLowSunday of ['2033-04-25', '2033-04-26', '2033-04-27', '2033-04-28', '2033-04-29']) { + const day = resolveDay(afterLowSunday); + 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', () => { - const leo = resolveDay('2033-04-25').commemorations.find((c) => c.kind === 'sanctoral' && c.id === 'st-leo-i'); - const hermenegild = resolveDay('2033-04-26').commemorations.find((c) => c.kind === 'sanctoral' && c.id === 'st-hermenegild'); - const tiburtius = resolveDay('2033-04-27').commemorations.find( - (c) => c.kind === 'sanctoral' && c.id === 'ss-tiburtius-valerian-and-maximus', - ); - expect(leo?.kind === 'sanctoral' ? leo.transferredFrom : undefined).toBe('2033-04-11'); - 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. + 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', () => { + // Easter 2035 is Mar 25 (the Annunciation's own fixed date), Low + // Sunday Apr 1. St. Benedict (native Mar 21, Duplex I. classis), + // St. Gabriel the Archangel (native Mar 24, Duplex majus), and the + // Annunciation (native Mar 25, Duplex I. classis) are all impeded + // and all clear the floor — live-verified 2026-09-01 that the + // Annunciation herself gets zero commemoration anywhere in the span. const easterSunday = resolveDay('2035-03-25'); expect(easterSunday.winner).toEqual({ kind: 'temporal', id: 'easter-sunday' }); expect(easterSunday.transferredAway?.candidate.id).toBe('annunciation'); - - for (const impededDate of ['2035-03-26', '2035-03-29', '2035-04-01']) { + for (const impededDate of ['2035-03-21', '2035-03-24', '2035-03-26', '2035-03-29', '2035-04-01']) { const day = resolveDay(impededDate); 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 - // Duplex I. classis, also impeded that year) and St. Gabriel the - // Archangel (native Mar 24, Duplex majus) queued *ahead* of the - // Annunciation by native date -- they land Apr 4 and Apr 5 - // respectively, pushing her actual landing to Apr 6. - const landing = resolveDay('2035-04-06'); - expect(landing.winner).toEqual({ + const day1 = resolveDay('2035-04-02'); + expect(day1.winner).toEqual({ + kind: 'sanctoral', + id: 'st-benedict', + name: 'St. Benedict, Abbot (Transitus)', + nameLa: 'Sanctus Benedictus, Abbas (Transitus)', + 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', id: 'annunciation', 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', 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(); }); });