From 6f8f6618b968af3d7fe1e3de4ca4ae9021e1c1fb Mon Sep 17 00:00:00 2001 From: Will Estes Date: Wed, 19 Aug 2026 07:39:58 -0400 Subject: [PATCH] Strengthen the Assumption's octave against a demoted St. John Eudes Demote St. John Eudes (Aug 19) from Duplex to Semiduplex -- too minor a confessor to keep outranking the Assumption's octave. To make that demotion actually cede the day, raise the Assumption's octave to `wins: duplex` (same pattern already used by Pentecost's octave), so an ordinary-day tie no longer automatically favors the occurring saint. Promote St. Thomas of Canterbury (Dec 29) to Duplex so he keeps winning against the Christmas octave stack now that ordinary-day ties are no longer a given. Also add a closing-day tie-break to octave-vs-octave precedence (pickWinningOctave): when two active octaves tie in rank, the one on its own closing day now wins the label contest, ahead of the existing "more recently started" tie-break. Needed because Assumption's day 3 and St. Lawrence's own closing day (Aug 17) now tie at Duplex, and the closing day should still govern that date's label. Co-Authored-By: Claude Sonnet 5 --- src/calendar/octaves.ts | 21 +++++++-- src/data/calendar/saints/assumption.yml | 11 +++++ src/data/calendar/saints/st-john-eudes.yml | 9 +++- src/data/calendar/saints/st-thomas-becket.yml | 8 +++- .../calendar/christmas-octave-sunday.test.ts | 6 +-- tests/calendar/day-label.test.ts | 46 ++++++++++--------- tests/calendar/december-sanctoral.test.ts | 8 ++-- tests/calendar/octaves.test.ts | 4 +- 8 files changed, 75 insertions(+), 38 deletions(-) diff --git a/src/calendar/octaves.ts b/src/calendar/octaves.ts index dbbed65..133360b 100644 --- a/src/calendar/octaves.ts +++ b/src/calendar/octaves.ts @@ -115,11 +115,19 @@ export function strictestThreshold(octaves: ActiveOctave[]): FeastClass { * * - Highest effective rank (`wins`, already elevated on either octave's * own closing day) wins outright. - * - Tied rank: the more recently *started* octave wins (smaller - * `dayNumber` today) — the reasoning given was that day 1 of a newly - * started octave needs to be fully present, which is the whole point - * of it starting; the older octave that's already been running is - * commemorated instead, same as any octave that loses this comparison. + * - Tied rank, one of them on its own closing day: the closing day wins + * — its "in Octava" day is the octave's own culminating day, so it + * takes precedence over an ordinary day of an equally-ranked octave + * even if that other octave started more recently (2026-08 user + * instruction, concrete case: Aug 17, St. Lawrence's own closing day, + * vs. the Assumption's ordinary day 3, both `duplex` once Assumption's + * octave was strengthened — Lawrence's closing day wins). + * - Tied rank, neither (or both) on their own closing day: the more + * recently *started* octave wins (smaller `dayNumber` today) — the + * reasoning given was that day 1 of a newly started octave needs to be + * fully present, which is the whole point of it starting; the older + * octave that's already been running is commemorated instead, same as + * any octave that loses this comparison. * * Undefined when no octave is active at all. Every other active octave * still gets commemorated regardless of which one wins here — this @@ -187,6 +195,9 @@ export function pickWinningOctave(active: ActiveOctave[]): ActiveOctave | undefi if (rankCmp < 0) { return best; } + if (candidate.isClosingDay !== best.isClosingDay) { + return candidate.isClosingDay ? candidate : best; + } return candidate.dayNumber < best.dayNumber ? candidate : best; }, undefined); } diff --git a/src/data/calendar/saints/assumption.yml b/src/data/calendar/saints/assumption.yml index 1d12548..4ccb2e3 100644 --- a/src/data/calendar/saints/assumption.yml +++ b/src/data/calendar/saints/assumption.yml @@ -10,5 +10,16 @@ rank: duplex-1-classis common: common-of-the-bvm propers: "assumption" # Has a real octave (Aug 15-22) -- see calendar/types.ts's OctaveConfig. +# Strengthened to `wins: duplex` (2026-08 user decision), same pattern +# as pentecost-sunday.yml's own override -- a Semiduplex saint no +# longer just ties (and, by the ordinary-day tie rule, wins) an +# ordinary octave day here; it loses outright to the octave instead. +# Concrete motivation: st-john-eudes.yml's demotion to Semiduplex only +# actually cedes Aug 19 to the octave because of this. Side effect +# accepted by the user: st-damasus.yml (Dec 11, Immaculate Conception's +# octave) and st-nicholas-of-tolentino.yml (Sep 10, Nativity of the +# BVM's octave) are unrelated octaves not touched by this change, so +# they're unaffected -- this only strengthens Assumption's own octave. octave: enabled: true + wins: duplex diff --git a/src/data/calendar/saints/st-john-eudes.yml b/src/data/calendar/saints/st-john-eudes.yml index e402b82..189dd2d 100644 --- a/src/data/calendar/saints/st-john-eudes.yml +++ b/src/data/calendar/saints/st-john-eudes.yml @@ -4,9 +4,16 @@ # No [Ant 1] of his own -- benedictusCommon/minorHoursCommon fall back # to the plain Common of a Confessor. No Lectio7-9 in his own file -- # defers to the Common's own Nocturn 3, not transcribed here. +# +# Deliberately demoted from the reference engine's own Duplex to +# Semiduplex (2026-08 user decision) -- a minor confessor of no +# particular standing shouldn't outrank the Assumption's own octave, +# now strengthened to `wins: duplex` (see assumption.yml) specifically +# so this demotion actually takes effect on his day (Aug 19, the +# octave's 5th day) instead of merely tying and still winning. id: st-john-eudes name: "St. John Eudes, Confessor" -rank: duplex +rank: semiduplex common: common-of-a-confessor propers: "st-john-eudes" benedictusCommon: common-of-a-confessor diff --git a/src/data/calendar/saints/st-thomas-becket.yml b/src/data/calendar/saints/st-thomas-becket.yml index 7f72391..09daaef 100644 --- a/src/data/calendar/saints/st-thomas-becket.yml +++ b/src/data/calendar/saints/st-thomas-becket.yml @@ -6,9 +6,15 @@ # `privileged-feria` model would have wrongly forbidden). Octave day # commemorated in return. Own proper collect, no unique antiphon of his # own. +# +# Deliberately promoted from the reference engine's own Semiduplex to +# Duplex (2026-08 user decision, made alongside assumption.yml's octave +# strengthening to `wins: duplex`) -- purely to keep him winning Dec 29 +# outright once the common octave-vs-saint default is no longer +# uniformly Semiduplex everywhere; not a reference-engine finding. id: st-thomas-becket name: "St. Thomas of Canterbury, Bishop and Martyr" -rank: semiduplex +rank: duplex common: common-of-a-martyr-bishop propers: "st-thomas-becket" # P/T/S/N: no [Ant Prima]/etc of its own in the source (12-29o.txt) -- diff --git a/tests/calendar/christmas-octave-sunday.test.ts b/tests/calendar/christmas-octave-sunday.test.ts index eac5c61..5e2384f 100644 --- a/tests/calendar/christmas-octave-sunday.test.ts +++ b/tests/calendar/christmas-octave-sunday.test.ts @@ -66,7 +66,7 @@ describe('applyChristmasOctaveSunday', () => { expect(dec30.commemorations.some((c) => c.kind === 'octave' && c.id === 'holy-innocents')).toBe(false); }); - it('Sunday on Dec 29 (St. Thomas Becket, only Semiduplex): still fully displaced to Dec 30, same as the Duplex-II-classis saints -- rank never matters for this rule', () => { + it('Sunday on Dec 29 (St. Thomas Becket): still fully displaced to Dec 30, same as the Duplex-II-classis saints -- rank never matters for this rule', () => { const sunday = resolveDay('2024-12-29'); expect(sunday.winner).toEqual({ kind: 'temporal', id: 'christmas-octave-sunday' }); @@ -75,7 +75,7 @@ describe('applyChristmasOctaveSunday', () => { kind: 'sanctoral', id: 'st-thomas-becket', name: 'St. Thomas of Canterbury, Bishop and Martyr', - rank: 'semiduplex', + rank: 'duplex', }); }); @@ -105,7 +105,7 @@ describe('applyChristmasOctaveSunday', () => { kind: 'sanctoral', id: 'st-thomas-becket', name: 'St. Thomas of Canterbury, Bishop and Martyr', - rank: 'semiduplex', + rank: 'duplex', }); }); diff --git a/tests/calendar/day-label.test.ts b/tests/calendar/day-label.test.ts index 4b30aca..69d9175 100644 --- a/tests/calendar/day-label.test.ts +++ b/tests/calendar/day-label.test.ts @@ -222,7 +222,7 @@ describe('getDayLabel — active octave', () => { expect(getDayLabel(day).en).toBe('Friday in Christmastide (Feria)'); }); - it("picks the higher-ranked of two genuinely overlapping octaves for the label -- St. Lawrence's own elevated closing day (Aug 17) over the Assumption's ordinary day 3, even though the Assumption's octave started later and the Assumption is the higher-ranked feast overall -- with St. Hyacinth (Duplex, tied with Lawrence's own elevated rank) commemorated rather than winning outright", () => { + it("picks St. Lawrence's own elevated closing day (Aug 17) over the Assumption's ordinary day 3, both duplex now that Assumption's octave is strengthened, via the closing-day tie-break -- with St. Hyacinth (Duplex, tied with Lawrence's own elevated rank) commemorated rather than winning outright", () => { // Live-verified against the reference engine directly (Monastic // Tridentinum 1617, command=prayMatutinum): Aug 17 is "In Octava S. // Laurentii Martyris ~ Duplex" -- Lawrence's own elevated closing day @@ -232,12 +232,16 @@ describe('getDayLabel — active octave', () => { // closing day favors the octave (calendar/index.ts's applyOctaves), // unlike an ordinary mid-octave day's tie, which still favors an // occurring saint (see octaves.test.ts and the December/September - // sanctoral tests for those counterexamples). The Assumption's own - // day 3 (semiduplex, genuinely active but outranked by Lawrence's - // elevated duplex) still gets named, not dropped, alongside it -- with - // its own full "Nth Day within the Octave of X" phrasing, not a bare - // name: live-verified directly (Divino Afflatu 1954, where Aug 17's - // real winner is Hyacinth himself, not Lawrence's octave -- a + // sanctoral tests for those counterexamples). Octave-vs-octave, the + // same closing-day precedence applies (2026-08 user instruction, + // octaves.ts's pickWinningOctave): now that Assumption's own octave + // is strengthened to `wins: duplex`, its ordinary day 3 ties + // Lawrence's elevated closing day in rank, and the closing day wins + // that tie regardless of which octave started more recently. The + // Assumption's own day 3 still gets named, not dropped, alongside + // it -- with its own full "Nth Day within the Octave of X" phrasing, + // not a bare name: live-verified directly (Divino Afflatu 1954, where + // Aug 17's real winner is Hyacinth himself, not Lawrence's octave -- a // different rubric-track outcome, but the same commemoration phrasing // question): "Commemoratio: Tertia die infra Octavam S. Assumptionis // Beatæ Mariæ Virginis", not a bare "The Assumption of the Blessed @@ -246,7 +250,7 @@ describe('getDayLabel — active octave', () => { 'Octave of St. Lawrence, Martyr (Duplex) — 3rd Day within the Octave of The Assumption of the Blessed Virgin Mary — St. Hyacinth, Confessor', ); expect(getDayLabel(resolveDay('2026-08-18')).en).toBe( - '4th Day within the Octave of The Assumption of the Blessed Virgin Mary (Semiduplex)', + '4th Day within the Octave of The Assumption of the Blessed Virgin Mary (Duplex)', ); }); @@ -304,21 +308,19 @@ describe('getDayLabel — active octave', () => { expect(getDayLabel(day).en).not.toContain('Octave'); }); - it('a sanctoral winner that outright displaces the only active octave still shows that octave with its full "Nth Day within the Octave" phrasing, not a bare feast name -- and without a rank, since the octave is riding along under the winning duplex rather than asserting its own rank', () => { - // Live-verified against the reference engine (Divino Afflatu 1954, - // prayMatutinum): Aug 19 is "S. Joannis Eudes Confessoris ~ Duplex" - // with the commemoration read as plain "Quinta die infra Octavam S. - // Assumptionis Beatæ Mariæ Virginis" -- no rank at all -- i.e. the - // octave's real DO title even when it lost outright, not a bare "The - // Assumption of the Blessed Virgin Mary" (this app's previous - // behavior, reusing `otherActiveOctaveNames`'s plain-name formatting - // -- correct for a *secondary* octave alongside another *octave* that - // won, see the Aug 17/18 cases above, but wrong here where the octave - // itself is what's being commemorated against a winning saint) and - // also not `octaveLabel`'s own rank parenthetical (that belongs only - // to an octave day when it's the day's own primary winner). + it('the Assumption octave, strengthened to `wins: duplex`, now outright displaces St. John Eudes (demoted to Semiduplex) on its own ordinary day 5 -- he is merely commemorated, not won as the day\'s own winner', () => { + // 2026-08 user decision: St. John Eudes demoted from the reference + // engine's own Duplex to Semiduplex (see st-john-eudes.yml), and the + // Assumption's octave strengthened from the shared default + // (`wins: semiduplex`) to `wins: duplex` (see assumption.yml, same + // pattern as pentecost-sunday.yml's own override) specifically so an + // ordinary-day tie no longer favors the occurring saint here: Eudes's + // Semiduplex now loses outright to the octave's Duplex threshold, + // rather than tying and winning. This deliberately diverges from the + // reference engine's own live behavior (which has Eudes winning + // outright as Duplex) -- see CLAUDE.md's "not a reconstruction" note. expect(getDayLabel(resolveDay('2026-08-19')).en).toBe( - 'St. John Eudes, Confessor (Duplex) — 5th Day within the Octave of The Assumption of the Blessed Virgin Mary', + '5th Day within the Octave of The Assumption of the Blessed Virgin Mary (Duplex) — St. John Eudes, Confessor', ); }); }); diff --git a/tests/calendar/december-sanctoral.test.ts b/tests/calendar/december-sanctoral.test.ts index db90c98..89fa9f0 100644 --- a/tests/calendar/december-sanctoral.test.ts +++ b/tests/calendar/december-sanctoral.test.ts @@ -88,13 +88,13 @@ describe('December sanctoral pull (first pass, 12/12)', () => { ]); }); - it('St. Thomas of Canterbury, only Semiduplex, still wins outright within the Christmas Octave -- the concrete case behind this month\'s temporal-category fix', () => { + it('St. Thomas of Canterbury still wins outright within the Christmas Octave -- the concrete case behind this month\'s temporal-category fix', () => { const day = resolveDay('2033-12-29'); expect(day.winner).toEqual({ kind: 'sanctoral', id: 'st-thomas-becket', name: 'St. Thomas of Canterbury, Bishop and Martyr', - rank: 'semiduplex', + rank: 'duplex', }); // Thomas himself has no octave (user-confirmed, 2026-08), but by his // own day, Christmas + Stephen + John + Holy Innocents' octaves have @@ -128,7 +128,7 @@ describe('December sanctoral pull (first pass, 12/12)', () => { }); it('the Christmas Octave Sunday still keeps a lesser feast from winning, now via applyChristmasOctaveSunday specifically', () => { - // St. Thomas Becket (Semiduplex, Dec 29) transfers off the Sunday + // St. Thomas Becket (Dec 29) transfers off the Sunday // rather than winning it -- per calendar/index.ts's // applyChristmasOctaveSunday (see its own dedicated test file), which // now unconditionally wins the Sunday over *any* rank within Dec @@ -144,7 +144,7 @@ describe('December sanctoral pull (first pass, 12/12)', () => { kind: 'sanctoral', id: 'st-thomas-becket', name: 'St. Thomas of Canterbury, Bishop and Martyr', - rank: 'semiduplex', + rank: 'duplex', }); }); diff --git a/tests/calendar/octaves.test.ts b/tests/calendar/octaves.test.ts index 2c66efb..495ddf6 100644 --- a/tests/calendar/octaves.test.ts +++ b/tests/calendar/octaves.test.ts @@ -23,7 +23,7 @@ describe('activeOctavesFor', () => { { id: 'assumption', name: 'The Assumption of the Blessed Virgin Mary', - wins: 'semiduplex', + wins: 'duplex', dayNumber: 3, isClosingDay: false, }, @@ -60,7 +60,7 @@ describe('resolveActiveOctave / pickWinningOctave -- overlapping-octave preceden expect(resolveActiveOctave('2026-08-10')?.id).toBe('st-lawrence'); }); - it("St. Lawrence's own elevated closing day (duplex) outranks the Assumption's ordinary day 3 (semiduplex) on their one real overlap, Aug 17", () => { + it("St. Lawrence's own elevated closing day (duplex) wins the closing-day tie-break over the Assumption's ordinary day 3 (also duplex) on their one real overlap, Aug 17", () => { const winner = resolveActiveOctave('2026-08-17'); expect(winner?.id).toBe('st-lawrence'); expect(winner?.wins).toBe('duplex');