Redesign the Easter-octave backlog: floor-gated, one per day
Deploy / deploy (push) Successful in 1m28s
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:
@@ -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
@@ -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, 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 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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user