From d6c8e8b7a8fb8e732afba59c45811cabce0d8cb1 Mon Sep 17 00:00:00 2001 From: Will Estes Date: Tue, 1 Sep 2026 12:25:21 -0400 Subject: [PATCH] Build the Easter octave transfer cascade Closes the tabled "Easter's own octave" mechanism gap. resolveDay only ever checked yesterday/tomorrow for an inbound transfer - a single hop - so anything impeded in Holy Week just vanished rather than reappearing after the Octave. Live-verified the real behavior first (2033 proof year): a candidate skips the whole privileged span and keeps walking forward, FIFO by native date, until it lands; a transferred-in candidate never gets the "still gets a nod" commemoration a native occurrence of the same rank would along the way (confirmed with the Annunciation, zero commemoration anywhere in the span). Also fixed Low Sunday itself, which needed the same privileged-sunday treatment as Easter Day. Built resolveForwardTransferLanding: a bounded queue simulation that reuses applyIncomingTransfer unchanged per hop - its existing privileged-feria-major no-op was already the right single-hop refusal, it just needed a real cascade around it instead of a dead end. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017XMSokiTD2Qc5vPP2YQu3Q --- TODO.md | 64 ++++++++++++++++- src/calendar/index.ts | 118 +++++++++++++++++++++++++++----- src/calendar/temporal.ts | 30 +++++--- src/calendar/types.ts | 16 +++-- tests/calendar/transfer.test.ts | 93 +++++++++++++++++++++++++ 5 files changed, 284 insertions(+), 37 deletions(-) diff --git a/TODO.md b/TODO.md index 5d200ce..0e63687 100644 --- a/TODO.md +++ b/TODO.md @@ -19,7 +19,12 @@ across every hour and content type in this app. 1. **Mechanism gaps** — a real design/code piece not built yet, blocking any content pass on it from starting: - - Easter's own octave — tabled, "a whole different discussion." + - ~~Easter's own octave~~ — DONE (2026-09-01): the transfer-cascade piece (a candidate + impeded within Holy Week/Easter Day/the Octave/Low Sunday now correctly skips the whole + span and keeps walking forward, FIFO, until it lands) is built and live-verified — see + the dated log's "Easter octave transfer cascade" entry. Content-wise, Easter Day/the + Octave's own full Matins proper (distinct psalms, not just antiphons) is still not + modeled — a separate, narrower remaining gap, not this one. - Date picker: `day-nav.ts` currently only steps a day at a time (prev/next); there's no way to jump to an arbitrary date without walking there one day at a time or editing the URL. Needs a calendar-grid picker UI — open, not started. @@ -4705,3 +4710,60 @@ exercised correctly across hundreds of ordinary weekdays in the existing test su `npm test` (820 passed — one net fewer than before since two tests merged into one clearer case), `tsc --noEmit`, and `npm run build` all pass. + +### Easter octave transfer cascade — mechanism built (2026-09-01) + +Closes the "Easter's own octave" item from this file's own Priority list. User asked for it +directly, then answered two design questions with real evidence rather than guesswork: +"what happens to the saint's own day" and "what are the gating rules" for something impeded +within Holy Week/the Easter Octave. + +**Root cause, found by reading the existing code rather than assuming**: `resolveDay` only ever +checked yesterday/tomorrow for an inbound transfer — a single hop. There was already an honest, +existing comment marking this exact gap (`applyIncomingTransfer`'s `privileged-feria-major` +branch: "a transfer chain — not modeled... simply not delivered here rather than guessed at"). +Anything impeded in Holy Week just vanished rather than reappearing. + +**Live-verified real behavior (2033: Palm Sunday Apr 10 → Low Sunday Apr 24, five real saints +falling inside that window) before building anything**: +- A candidate impeded anywhere in the whole span (Holy Week, Easter Day, the Octave, Low + Sunday) skips the *entire* span — not just the day it was impeded on — and keeps walking + forward day by day past it, same as any ordinary transfer, until it finds a day that isn't + already spoken for by a real native occurrence or an earlier-queued candidate ahead of it in + line. St. Leo I (native Apr 11, Duplex) landed Apr 27; Ss. Soter & Caius (native Apr 22) + landed Apr 28 (right after Leo); St. George (native Apr 23) had to wait until May 4, well + past several already-occupied days. +- **Multiple candidates can be pending at once** — a real FIFO queue ordered by native date, + not just "the one most recent candidate." All three above overlapped in their pending window. +- **A transferred-in candidate never gets the "duplex-majus+ still gets a nod" commemoration** + a *native* occurrence of that rank would on one of these days — confirmed with the + Annunciation (Duplex I. classis, native Easter Sunday itself in 2035): zero commemoration + anywhere across the whole span, landing outright on Apr 4 (past Low Sunday, Apr 1 that year). + This is genuinely different from a native duplex-majus+ candidate (e.g. St. Mark, native + within some other year's Octave, which *does* get commemorated in place) — the "never + displaced, but gets a nod" treatment is reserved for a day's own native candidate, not + anything merely passing through. +- **Low Sunday itself needed the same fix as Easter Day** — found while building this: it + carries the same formal "Duplex I. classis" rank as Easter Day in the source, but was + falling through to eastertide's plain `ordinary-sunday` default (today's earlier fix only + covered offset 0). Not independently collision-verified (no Monastic-track saint — a real + `SanctiM/MM-DD.txt` file — ever lands there across the real range of Easter dates), but + corroborated by its own rank label and by the complete absence of even a `Transfer:` note for + a same-dated non-Monastic-track saint (St. Isidore of Seville 2027, St. Vincent Ferrer 2043) + that a real Monastic day would show. Fixed in `calendar/temporal.ts`'s `resolveTemporalCategory` + alongside offset 0. + +**What got built**: a real `MAX_FORWARD_TRANSFER_LOOKBACK_DAYS`-bounded (60 days, generous +headroom over the worst observed real case) queue simulation, `resolveForwardTransferLanding` +in `calendar/index.ts` — walks chronologically from the lookback window up through the target +date, replaying each day's own native occurrence and, if a candidate is already queued, one +landing attempt per day (reusing `applyIncomingTransfer` completely unchanged — its existing +`privileged-feria-major` no-op turned out to already be exactly the right single-hop refusal; +the fix was purely wrapping it in an actual cascade instead of a dead end). Only forward- +direction transfers chain this way; a Vigil's backward transfer stays single-hop as before — +structurally different (displaces backward in time) and not observed to need it. + +New tests in `tests/calendar/transfer.test.ts` cover the full chain (Leo/Hermenegild/Tiburtius +landing in strict native-date order past the whole 2033 span) and the Annunciation's own +zero-commemoration case (2035). `npm test` (823 passed), `tsc --noEmit`, and `npm run build` +all pass. diff --git a/src/calendar/index.ts b/src/calendar/index.ts index 6b9f588..e1bd6cc 100644 --- a/src/calendar/index.ts +++ b/src/calendar/index.ts @@ -13,10 +13,13 @@ 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 *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. + * 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. */ function resolveNativeOccurrence(isoDate: string): { temporalCategory: TemporalCategory; result: OccurrenceResult } { const weekday = weekdayOf(isoDate); @@ -59,13 +62,22 @@ function applyIncomingTransfer( fromDate: string, ): DayWinner { if (temporalCategory === 'privileged-feria-major') { - // Deferred: a transfer landing on one of these (the concrete case is - // Holy Week, right after Palm Sunday) needs its own Easter-date-keyed - // lookup table, the same way the reference engine handles it — not - // modeled yet. The transfer is simply not delivered here rather than - // guessed at. 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, 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. return winner; } // Tags the returned winner with where it transferred from, but only when @@ -88,9 +100,9 @@ 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), that's a - // transfer chain — not modeled, same "not delivered rather than - // guessed at" stance as the privileged-feria-major case above. + // 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. return tagIfLanded(decided.winner); } const collision = resolveCollision(candidate, winner); @@ -98,6 +110,77 @@ 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; + +/** + * 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. + */ +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(); + } + } + 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 winner; + } + return applyIncomingTransfer(oldest.candidate, temporalCategory, winner, commemorations, oldest.fromDate); +} + /** * Resolves everything about a given day *except* hour content — weekday, * season, temporal precedence category, and the day's real winner plus @@ -112,11 +195,8 @@ export function resolveDay(isoDate: string): LiturgicalDay { let winner = result.winner; const commemorations = [...result.commemorations]; - const yesterdayIso = addDays(isoDate, -1); - const yesterday = resolveNativeOccurrence(yesterdayIso); - if (yesterday.result.transfer?.direction === 'forward') { - winner = applyIncomingTransfer(yesterday.result.transfer.candidate, temporalCategory, winner, commemorations, yesterdayIso); - } + winner = resolveForwardTransferLanding(isoDate, temporalCategory, winner, commemorations); + const tomorrowIso = addDays(isoDate, 1); const tomorrow = resolveNativeOccurrence(tomorrowIso); if (tomorrow.result.transfer?.direction === 'backward') { diff --git a/src/calendar/temporal.ts b/src/calendar/temporal.ts index b0c3786..9015830 100644 --- a/src/calendar/temporal.ts +++ b/src/calendar/temporal.ts @@ -202,21 +202,31 @@ export function adventEmberDayOffset(isoDate: string): number | undefined { * the Christmas vigil (Dec 24 can land on a Sunday) — and a Sunday's own * privileged/ordinary status should win in that case regardless, so * Sundays are resolved straight from `bySeason` without consulting the - * overrides at all. The one deliberate exception is Easter Sunday itself - * (offset 0): `eastertide`'s own `bySeason` default is `ordinary-sunday` - * (live-verified for the *ordinary* Sundays that follow it — see that - * entry's own comment), but Easter Day needs the stronger - * `privileged-sunday` tier instead (live-verified 2026-09-01: the - * Annunciation, Duplex I. classis, transfers off Easter Day itself in - * years they coincide, the same "transfers, no exception" behavior as - * every other privileged-sunday case) — checked directly here rather - * than via `offsets`, since offsets are never consulted for a Sunday. + * overrides at all. The deliberate exceptions are Easter Sunday itself + * (offset 0) and Low Sunday (offset 7, the Octave's own closing day): + * `eastertide`'s own `bySeason` default is `ordinary-sunday` (live- + * verified for the *ordinary* Sundays from the 2nd Sunday after Easter + * on — see that entry's own comment), but both bookend Sundays of the + * Easter Octave need the stronger `privileged-sunday` tier instead. + * Easter Day: live-verified 2026-09-01, the Annunciation (Duplex I. + * classis) transfers off it in years they coincide, the same "transfers, + * no exception" behavior as every other privileged-sunday case. Low + * Sunday: not independently collision-verified (no Monastic-track saint + * — `SanctiM/MM-DD.txt` — ever falls there across the real range of + * Easter dates, so no live test case exists), but its own formal rank in + * the reference source ("Dominica in Albis in Octava Paschæ ~ Duplex I. + * classis") matches Easter Day's own exactly, and no `Transfer:` note + * ever shows there even for a same-named non-Monastic-track saint + * (St. Isidore of Seville, 2027; St. Vincent Ferrer, 2043) that a real + * Monastic-track day would display — treated the same as Easter Day on + * that basis. Both checked directly here rather than via `offsets`, + * since offsets are never consulted for a Sunday. */ export function resolveTemporalCategory(isoDate: string, season: Season, weekday: Weekday): TemporalCategory { const bySeasonEntry = temporalCategories.bySeason[season]; const base = bySeasonEntry ? (weekday === 'sunday' ? bySeasonEntry.sunday : bySeasonEntry.feria) : 'ordinary-feria'; if (weekday === 'sunday') { - if (season === 'eastertide' && easterOffsetOf(isoDate) === 0) { + if (season === 'eastertide' && [0, 7].includes(easterOffsetOf(isoDate))) { return 'privileged-sunday'; } return base; diff --git a/src/calendar/types.ts b/src/calendar/types.ts index 30ebcea..d72f49f 100644 --- a/src/calendar/types.ts +++ b/src/calendar/types.ts @@ -216,13 +216,15 @@ export interface LiturgicalDay { /** Set when this date's own native sanctoral candidate couldn't be kept * here at all (calendar/commemorations.ts's `decideOccurrence` `transfer` * signal) and moved to a later/earlier date instead. No landing date is - * recorded here — `resolveDay` only ever checks the immediate adjacent - * date, but a real landing can chain further than that (an unmodeled - * case noted in `applyIncomingTransfer`, e.g. a transfer running into - * Holy Week), so naming a specific "to" date risked asserting a wrong - * one; this exists purely so the display can say "not an omission, this - * office moved elsewhere" without claiming to know where. Distinct from - * — and not implied by — `Commemoration`, since a transferred-away + * recorded here — this exists purely so the display can say "not an + * 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 * candidate gets no commemoration on its own native date at all. */ transferredAway?: { candidate: SanctoralIdentity }; } diff --git a/tests/calendar/transfer.test.ts b/tests/calendar/transfer.test.ts index 25801ae..8f70a73 100644 --- a/tests/calendar/transfer.test.ts +++ b/tests/calendar/transfer.test.ts @@ -107,3 +107,96 @@ 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 +// (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. + 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); + } + + // 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 — matching the real reference engine's own behavior, not just a native-date-order queue effect", () => { + // 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), unlike a same-ranked + // *native* occurrence there (e.g. St. Mark, Duplex II. classis, + // commemorated when native within some other year's Octave) -- + // confirming a transferred-in candidate never gets the "duplex-majus+ + // still gets a nod" treatment on these days, only a native one does. + 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']) { + const day = resolveDay(impededDate); + expect(day.commemorations.some((c) => c.kind === 'sanctoral' && c.id === 'annunciation')).toBe(false); + } + + const landing = resolveDay('2035-04-04'); + expect(landing.winner).toEqual({ + kind: 'sanctoral', + id: 'annunciation', + name: 'The Annunciation of the Blessed Virgin Mary', + nameLa: 'Annuntiatio Beatæ Mariæ Virginis', + rank: 'duplex-1-classis', + transferredFrom: '2035-03-25', + }); + }); +});