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 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 07:39:58 -04:00
parent 9364185f70
commit 6f8f6618b9
8 changed files with 75 additions and 38 deletions
+16 -5
View File
@@ -115,11 +115,19 @@ export function strictestThreshold(octaves: ActiveOctave[]): FeastClass {
* *
* - Highest effective rank (`wins`, already elevated on either octave's * - Highest effective rank (`wins`, already elevated on either octave's
* own closing day) wins outright. * own closing day) wins outright.
* - Tied rank: the more recently *started* octave wins (smaller * - Tied rank, one of them on its own closing day: the closing day wins
* `dayNumber` today) — the reasoning given was that day 1 of a newly * — its "in Octava" day is the octave's own culminating day, so it
* started octave needs to be fully present, which is the whole point * takes precedence over an ordinary day of an equally-ranked octave
* of it starting; the older octave that's already been running is * even if that other octave started more recently (2026-08 user
* commemorated instead, same as any octave that loses this comparison. * 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 * Undefined when no octave is active at all. Every other active octave
* still gets commemorated regardless of which one wins here — this * still gets commemorated regardless of which one wins here — this
@@ -187,6 +195,9 @@ export function pickWinningOctave(active: ActiveOctave[]): ActiveOctave | undefi
if (rankCmp < 0) { if (rankCmp < 0) {
return best; return best;
} }
if (candidate.isClosingDay !== best.isClosingDay) {
return candidate.isClosingDay ? candidate : best;
}
return candidate.dayNumber < best.dayNumber ? candidate : best; return candidate.dayNumber < best.dayNumber ? candidate : best;
}, undefined); }, undefined);
} }
+11
View File
@@ -10,5 +10,16 @@ rank: duplex-1-classis
common: common-of-the-bvm common: common-of-the-bvm
propers: "assumption" propers: "assumption"
# Has a real octave (Aug 15-22) -- see calendar/types.ts's OctaveConfig. # 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: octave:
enabled: true enabled: true
wins: duplex
+8 -1
View File
@@ -4,9 +4,16 @@
# No [Ant 1] of his own -- benedictusCommon/minorHoursCommon fall back # No [Ant 1] of his own -- benedictusCommon/minorHoursCommon fall back
# to the plain Common of a Confessor. No Lectio7-9 in his own file -- # 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. # 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 id: st-john-eudes
name: "St. John Eudes, Confessor" name: "St. John Eudes, Confessor"
rank: duplex rank: semiduplex
common: common-of-a-confessor common: common-of-a-confessor
propers: "st-john-eudes" propers: "st-john-eudes"
benedictusCommon: common-of-a-confessor benedictusCommon: common-of-a-confessor
@@ -6,9 +6,15 @@
# `privileged-feria` model would have wrongly forbidden). Octave day # `privileged-feria` model would have wrongly forbidden). Octave day
# commemorated in return. Own proper collect, no unique antiphon of his # commemorated in return. Own proper collect, no unique antiphon of his
# own. # 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 id: st-thomas-becket
name: "St. Thomas of Canterbury, Bishop and Martyr" name: "St. Thomas of Canterbury, Bishop and Martyr"
rank: semiduplex rank: duplex
common: common-of-a-martyr-bishop common: common-of-a-martyr-bishop
propers: "st-thomas-becket" propers: "st-thomas-becket"
# P/T/S/N: no [Ant Prima]/etc of its own in the source (12-29o.txt) -- # P/T/S/N: no [Ant Prima]/etc of its own in the source (12-29o.txt) --
@@ -66,7 +66,7 @@ describe('applyChristmasOctaveSunday', () => {
expect(dec30.commemorations.some((c) => c.kind === 'octave' && c.id === 'holy-innocents')).toBe(false); 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'); const sunday = resolveDay('2024-12-29');
expect(sunday.winner).toEqual({ kind: 'temporal', id: 'christmas-octave-sunday' }); expect(sunday.winner).toEqual({ kind: 'temporal', id: 'christmas-octave-sunday' });
@@ -75,7 +75,7 @@ describe('applyChristmasOctaveSunday', () => {
kind: 'sanctoral', kind: 'sanctoral',
id: 'st-thomas-becket', id: 'st-thomas-becket',
name: 'St. Thomas of Canterbury, Bishop and Martyr', name: 'St. Thomas of Canterbury, Bishop and Martyr',
rank: 'semiduplex', rank: 'duplex',
}); });
}); });
@@ -105,7 +105,7 @@ describe('applyChristmasOctaveSunday', () => {
kind: 'sanctoral', kind: 'sanctoral',
id: 'st-thomas-becket', id: 'st-thomas-becket',
name: 'St. Thomas of Canterbury, Bishop and Martyr', name: 'St. Thomas of Canterbury, Bishop and Martyr',
rank: 'semiduplex', rank: 'duplex',
}); });
}); });
+24 -22
View File
@@ -222,7 +222,7 @@ describe('getDayLabel — active octave', () => {
expect(getDayLabel(day).en).toBe('Friday in Christmastide (Feria)'); 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 // Live-verified against the reference engine directly (Monastic
// Tridentinum 1617, command=prayMatutinum): Aug 17 is "In Octava S. // Tridentinum 1617, command=prayMatutinum): Aug 17 is "In Octava S.
// Laurentii Martyris ~ Duplex" -- Lawrence's own elevated closing day // 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), // closing day favors the octave (calendar/index.ts's applyOctaves),
// unlike an ordinary mid-octave day's tie, which still favors an // unlike an ordinary mid-octave day's tie, which still favors an
// occurring saint (see octaves.test.ts and the December/September // occurring saint (see octaves.test.ts and the December/September
// sanctoral tests for those counterexamples). The Assumption's own // sanctoral tests for those counterexamples). Octave-vs-octave, the
// day 3 (semiduplex, genuinely active but outranked by Lawrence's // same closing-day precedence applies (2026-08 user instruction,
// elevated duplex) still gets named, not dropped, alongside it -- with // octaves.ts's pickWinningOctave): now that Assumption's own octave
// its own full "Nth Day within the Octave of X" phrasing, not a bare // is strengthened to `wins: duplex`, its ordinary day 3 ties
// name: live-verified directly (Divino Afflatu 1954, where Aug 17's // Lawrence's elevated closing day in rank, and the closing day wins
// real winner is Hyacinth himself, not Lawrence's octave -- a // 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 // different rubric-track outcome, but the same commemoration phrasing
// question): "Commemoratio: Tertia die infra Octavam S. Assumptionis // question): "Commemoratio: Tertia die infra Octavam S. Assumptionis
// Beatæ Mariæ Virginis", not a bare "The Assumption of the Blessed // 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', '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( 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'); 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', () => { 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', () => {
// Live-verified against the reference engine (Divino Afflatu 1954, // 2026-08 user decision: St. John Eudes demoted from the reference
// prayMatutinum): Aug 19 is "S. Joannis Eudes Confessoris ~ Duplex" // engine's own Duplex to Semiduplex (see st-john-eudes.yml), and the
// with the commemoration read as plain "Quinta die infra Octavam S. // Assumption's octave strengthened from the shared default
// Assumptionis Beatæ Mariæ Virginis" -- no rank at all -- i.e. the // (`wins: semiduplex`) to `wins: duplex` (see assumption.yml, same
// octave's real DO title even when it lost outright, not a bare "The // pattern as pentecost-sunday.yml's own override) specifically so an
// Assumption of the Blessed Virgin Mary" (this app's previous // ordinary-day tie no longer favors the occurring saint here: Eudes's
// behavior, reusing `otherActiveOctaveNames`'s plain-name formatting // Semiduplex now loses outright to the octave's Duplex threshold,
// -- correct for a *secondary* octave alongside another *octave* that // rather than tying and winning. This deliberately diverges from the
// won, see the Aug 17/18 cases above, but wrong here where the octave // reference engine's own live behavior (which has Eudes winning
// itself is what's being commemorated against a winning saint) and // outright as Duplex) -- see CLAUDE.md's "not a reconstruction" note.
// also not `octaveLabel`'s own rank parenthetical (that belongs only
// to an octave day when it's the day's own primary winner).
expect(getDayLabel(resolveDay('2026-08-19')).en).toBe( 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',
); );
}); });
}); });
+4 -4
View File
@@ -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'); const day = resolveDay('2033-12-29');
expect(day.winner).toEqual({ expect(day.winner).toEqual({
kind: 'sanctoral', kind: 'sanctoral',
id: 'st-thomas-becket', id: 'st-thomas-becket',
name: 'St. Thomas of Canterbury, Bishop and Martyr', name: 'St. Thomas of Canterbury, Bishop and Martyr',
rank: 'semiduplex', rank: 'duplex',
}); });
// Thomas himself has no octave (user-confirmed, 2026-08), but by his // Thomas himself has no octave (user-confirmed, 2026-08), but by his
// own day, Christmas + Stephen + John + Holy Innocents' octaves have // 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', () => { 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 // rather than winning it -- per calendar/index.ts's
// applyChristmasOctaveSunday (see its own dedicated test file), which // applyChristmasOctaveSunday (see its own dedicated test file), which
// now unconditionally wins the Sunday over *any* rank within Dec // now unconditionally wins the Sunday over *any* rank within Dec
@@ -144,7 +144,7 @@ describe('December sanctoral pull (first pass, 12/12)', () => {
kind: 'sanctoral', kind: 'sanctoral',
id: 'st-thomas-becket', id: 'st-thomas-becket',
name: 'St. Thomas of Canterbury, Bishop and Martyr', name: 'St. Thomas of Canterbury, Bishop and Martyr',
rank: 'semiduplex', rank: 'duplex',
}); });
}); });
+2 -2
View File
@@ -23,7 +23,7 @@ describe('activeOctavesFor', () => {
{ {
id: 'assumption', id: 'assumption',
name: 'The Assumption of the Blessed Virgin Mary', name: 'The Assumption of the Blessed Virgin Mary',
wins: 'semiduplex', wins: 'duplex',
dayNumber: 3, dayNumber: 3,
isClosingDay: false, isClosingDay: false,
}, },
@@ -60,7 +60,7 @@ describe('resolveActiveOctave / pickWinningOctave -- overlapping-octave preceden
expect(resolveActiveOctave('2026-08-10')?.id).toBe('st-lawrence'); 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'); const winner = resolveActiveOctave('2026-08-17');
expect(winner?.id).toBe('st-lawrence'); expect(winner?.id).toBe('st-lawrence');
expect(winner?.wins).toBe('duplex'); expect(winner?.wins).toBe('duplex');