Implement octave-vs-octave precedence, and fix a real day-label bug it surfaced
Deploy / deploy (push) Successful in 52s

When more than one octave is active on a day with no temporal standing of
its own (St. Lawrence's and the Assumption's genuinely overlap every Aug
16-17), which one governs the day's content/label is now decided by rank
comparison instead of "whichever started first" (an accident of
insertion order). Per direct instruction:

- Highest effective rank wins outright; every other active octave still
  gets commemorated, same as a single octave already outranking a weak
  rival saint.
- A rank tie goes to whichever octave started more recently -- day 1 of
  a new octave needs to be fully present, the whole point of it
  starting. No real tied-rank case exists yet to verify this
  empirically, unlike everything else here -- documented in TODO.md as
  a stated decision, not a live finding.

New OctaveConfig.closingDayRank (default duplex): an octave's own final
day ("in Octava") is elevated above its ordinary in-between rank --
live-verified as a real, general pattern (both St. Lawrence's Aug 17 and
the Assumption's Aug 22 show as Duplex, above their otherwise-Semiduplex
ordinary days), not a one-off. This elevation is *why* Lawrence's octave
beats the Assumption's on their one real overlap day despite the
Assumption being the far higher-ranked feast overall. Feeds both the
existing rival-saint threshold and the new octave-vs-octave comparison.

calendar/octaves.ts gains resolveActiveOctave (+ pickWinningOctave, the
comparison itself factored out for direct unit testing against synthetic
data, since no real tied-rank overlap exists to test against yet).
hours/resolve-common.ts's resolveOfficeWinner and calendar/day-label.ts
both now call it instead of each keeping their own "activeOctavesFor(...)
[0]" logic.

That consolidation surfaced a real, independent bug: getDayLabel never
checked temporalCategory at all before choosing an octave name, unlike
resolveOfficeWinner -- found while testing the real Aug 16 overlap (a
Sunday that year, where the temporal Sunday has standing and should win
outright). Live-verified counterexample: the Christmas Octave's own
stack (Dec 30) was wrongly labeled "3rd Day within the Octave of The Holy
Innocents" instead of the correct plain temporal label -- the real title
never names any of the four stacked octaves there. Fixed by sharing the
exact same ordinary-feria gate resolveOfficeWinner already had, so the
two can no longer disagree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 09:38:06 -04:00
parent 5f4cf33776
commit 397f31f4fc
8 changed files with 207 additions and 51 deletions
+45 -31
View File
@@ -67,40 +67,54 @@ for Terce/Sext/None, antiphon only for Prime (its chapter needed no new
file — see above). The same 31-saint backlog above applies here too, one
id at a time, as each gets a Lauds override.
### Overlapping octaves — real gap found, not yet fixed
### Overlapping octaves — done (2026-08)
`resolveOfficeWinner` (and `getDayLabel`) currently pick whichever active
octave started *first* (`activeOctavesFor`'s own ordering) whenever more
than one is active on a day with no temporal standing of its own — purely
an accident of insertion order, not a real precedence rule. St. Lawrence's
octave (Aug 10-17) and the Assumption's octave (Aug 15-22) genuinely
overlap on Aug 16-17 every year, so this isn't a hypothetical.
Was: `resolveOfficeWinner`/`getDayLabel` picked whichever active octave
started *first* whenever more than one was active — an accident of
insertion order, not a real precedence rule. St. Lawrence's octave (Aug
10-17) and the Assumption's (Aug 15-22) genuinely overlap on Aug 16-17
every year, so this wasn't hypothetical.
Live-checked both overlap days (Monastic Tridentinum 1617, 2026): Aug 16
that year happens to be a Sunday, so the *temporal* Sunday wins outright
and both octaves are merely commemorated (already correctly handled by
the existing `ordinary-feria` gate — not actually a two-octave contest at
all). Aug 17 is the real case: "**In Octava S. Laurentii Martyris ~
Duplex**" wins outright, with "*Tertia die infra Octavam S. Assumptionis*"
merely commemorated — Lawrence's octave beats the Assumption's despite the
Assumption being the far higher-ranked feast overall (Duplex I. classis vs.
Lawrence's Duplex II. classis). Why: Aug 17 is specifically Lawrence's own
**final octave day** ("in Octava," the 8th and closing day), which real
practice elevates to its own higher rank (Duplex here, above the ordinary
Semiduplex of the days in between) — a concept this app doesn't model at
all yet (`OctaveConfig` has no notion of an elevated final day, every day
of an octave is currently treated as uniform strength).
Design (per direct instruction, not independently re-derived from a
primary source the way most of this project is): when more than one
octave is active on a day with no temporal standing of its own to yield
to first, compare them by rank — highest wins outright, exactly like a
single octave already out-ranks a weak rival saint; every other active
octave still gets commemorated, never silently dropped. A rank tie goes
to whichever octave started more *recently* — reasoning given: day 1 of
a newly started octave needs to be fully present, which is the whole
point of an octave starting, so the older one yields instead. No real
tied-rank case exists yet in this app's calendar to verify that
empirically, unlike almost everything else in this project — flagged as
a stated, unverified-by-evidence design decision, not a live finding.
Proposed design (not yet built, needs a decision): when multiple octaves
are active with no temporal standing to yield to, compare them by rank
the same way a single octave already compares against a rival saint —
highest rank wins outright, the rest are commemorated instead of silently
dropped to "whichever came first." Needs `OctaveConfig` to carry an
elevated final-day rank (distinct from the ordinary in-between-days rank)
to get Aug 17 right for a real reason rather than by lucky insertion
order. Low urgency in practice (currently only this one real overlap
pair), but worth fixing before adding more octaves that could collide the
same way.
Also needed `OctaveConfig.closingDayRank` (default `duplex`): an
octave's own *closing* day ("in Octava," the 8th/final day) is elevated
above its ordinary in-between-days rank — live-verified as a real,
general pattern (not a St. Lawrence quirk): both his own Aug 17 and the
Assumption's own Aug 22 show as Duplex, above their otherwise-Semiduplex
ordinary days. This is *why* Lawrence's octave beats the Assumption's on
their one real overlap day (Aug 17) despite the Assumption being the far
higher-ranked feast overall (Duplex I. classis vs. Lawrence's Duplex II.
classis) — his closing-day elevation (Duplex) beats her ordinary day-3
strength (Semiduplex). Feeds both `strictestThreshold` (a rival saint
now needs Duplex+, not just Semiduplex+, to beat an octave specifically
on its own closing day) and the new octave-vs-octave comparison
(`calendar/octaves.ts`'s `resolveActiveOctave`/`pickWinningOctave`) —
same underlying "how strong is this octave today" question, two
different comparison partners.
`getDayLabel` was carrying its own separate, never-actually-correct
octave-selection logic that additionally never checked
`temporalCategory` at all — found while testing the real Aug 16 overlap
(a Sunday that year): it was showing an octave name even though the
*temporal* Sunday has real standing and wins outright, live-verified
counterexample being the Christmas Octave's own stack (Dec 30), which
was wrongly showing "3rd Day within the Octave of The Holy Innocents"
instead of the correct plain temporal label. Fixed by having
`getDayLabel` share the exact same `ordinary-feria` gate and
`resolveActiveOctave` call as `resolveOfficeWinner`, so the two can never
disagree about which identity governs a day.
### Benedictus antiphons for temporal (non-sanctoral) days — done
+21 -11
View File
@@ -14,7 +14,7 @@ import { easterSunday } from './easter';
import { adventStart, firstSundayStrictlyAfter, sundayOnOrBefore } from './temporal';
import { addDays, daysBetween, toIsoDate } from './date-math';
import { getTemporalFeastRecord } from './temporal-feasts';
import { activeOctavesFor, type ActiveOctave } from './octaves';
import { resolveActiveOctave, type ActiveOctave } from './octaves';
function capitalize(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
@@ -186,16 +186,26 @@ export function getDayLabel(day: LiturgicalDay): string {
return commemoratedSaint ? `${commemoratedSaint.name}${anchorName}` : anchorName;
}
// An active octave (St. Lawrence's, Christmas's own stack, ...) is this
// day's real primary identity in the live engine, not a footnote — e.g.
// "Tertia die infra Octavam S. Laurentii Martyris", not "Wednesday in
// the 11th week after Trinity". Picks the oldest-started octave when
// several are stacked (activeOctavesFor's own ordering) as the headline;
// the others still ride along as ordinary octave commemorations, just
// not separately named here.
const octaves = activeOctavesFor(day.date);
if (octaves.length > 0) {
const primary = octaveLabel(octaves[0]!);
// An active octave (St. Lawrence's, ...) is this day's real primary
// identity in the live engine, not a footnote — e.g. "Tertia die infra
// Octavam S. Laurentii Martyris", not "Wednesday in the 11th week after
// Trinity" — but *only* when the temporal day itself has no standing of
// its own (`ordinary-feria`), same gate as hours/resolve-common.ts's
// resolveOfficeWinner and for the same reason: live-verified
// counterexample is the Christmas Octave's own stack (Dec 30, e.g.,
// `privileged-feria-minor`), where the real title stays the temporal
// Sunday's own ("De Dominica Infra Octavam Nativitatis") with no octave
// name in it at all — this label agreeing with resolveOfficeWinner
// about which one wins is what makes "the office is Lawrence's" and
// "the label says Lawrence" consistent instead of two independent
// guesses that can disagree. When more than one octave is active at
// once (resolveActiveOctave), the highest-ranked wins the headline
// (ties broken by whichever started more recently) — the others still
// ride along as ordinary octave commemorations, just not separately
// named here.
const activeOctave = day.temporalCategory === 'ordinary-feria' ? resolveActiveOctave(day.date) : undefined;
if (activeOctave) {
const primary = octaveLabel(activeOctave);
return commemoratedSaint ? `${primary}${commemoratedSaint.name}` : primary;
}
+1 -1
View File
@@ -239,7 +239,7 @@ export function isSundayOrFeast(day: LiturgicalDay): boolean {
export { compareFeastClass, isAtLeast, decideOccurrence } from './commemorations';
export { resolveCollision } from './collision';
export { resolveTemporalId } from './temporal-id';
export { activeOctavesFor, strictestThreshold } from './octaves';
export { activeOctavesFor, strictestThreshold, resolveActiveOctave } from './octaves';
export type { ActiveOctave } from './octaves';
export type {
LiturgicalDay,
+58 -2
View File
@@ -17,7 +17,13 @@ import { compareFeastClass } from './commemorations';
export interface ActiveOctave {
id: string;
name: string;
/** Rank threshold below which the occurring saint loses the day to this octave. */
/** This octave's own effective rank *today* — the ordinary `wins`
* threshold on every day except its own closing day, where it's
* `closingDayRank` instead (see calendar/types.ts's OctaveConfig).
* Doubles as both "how strong a rival saint must be to displace this
* octave" and "this octave's own strength when compared against
* another simultaneously-active octave" (resolveActiveOctave) — same
* underlying question, two different comparison partners. */
wins: FeastClass;
/** 1 on the feast's own day, counting up from there. */
dayNumber: number;
@@ -25,6 +31,7 @@ export interface ActiveOctave {
const DEFAULT_DAYS = 8;
const DEFAULT_WINS: FeastClass = 'semiduplex';
const DEFAULT_CLOSING_DAY_RANK: FeastClass = 'duplex';
/** How far back to look for an octave's own start date — must cover the
* longest configured `days` a caller might use; 7 covers the standard
* 8-day octave (day 1 = the start itself, day 8 = 7 days later). */
@@ -48,7 +55,10 @@ function considerCandidate(
return;
}
seen.add(id);
active.push({ id, name, wins: octave.wins ?? DEFAULT_WINS, dayNumber: offset + 1 });
const dayNumber = offset + 1;
const isClosingDay = dayNumber === days;
const wins = isClosingDay ? (octave.closingDayRank ?? DEFAULT_CLOSING_DAY_RANK) : (octave.wins ?? DEFAULT_WINS);
active.push({ id, name, wins, dayNumber });
}
/** Every octave (sanctoral or temporal) whose window covers `isoDate`,
@@ -89,3 +99,49 @@ export function strictestThreshold(octaves: ActiveOctave[]): FeastClass {
octaves[0]?.wins ?? DEFAULT_WINS,
);
}
/**
* Which single active octave actually governs a day's own content/label
* when more than one is active at once (this app's first real case: St.
* Lawrence's, Aug 10-17, and the Assumption's, Aug 15-22, genuinely
* overlap every year) and no rival saint has already displaced all of
* them outright (that's calendar/index.ts's applyOctaves — this only
* runs octave-vs-octave). Per direct instruction:
*
* - 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.
*
* Undefined when no octave is active at all. Every other active octave
* still gets commemorated regardless of which one wins here — this
* function only decides whose *content* (and day-label name) governs,
* not who gets left out of the commemoration list entirely (see
* calendar/index.ts's applyOctaves, unchanged by this).
*/
export function resolveActiveOctave(isoDate: string): ActiveOctave | undefined {
return pickWinningOctave(activeOctavesFor(isoDate));
}
/** The comparison itself, factored out from resolveActiveOctave so the
* precedence rule (rank, then recency) is directly unit-testable against
* synthetic ActiveOctave data — no real equal-rank overlap exists yet in
* this app's own calendar to exercise the tie-break against. */
export function pickWinningOctave(active: ActiveOctave[]): ActiveOctave | undefined {
return active.reduce<ActiveOctave | undefined>((best, candidate) => {
if (!best) {
return candidate;
}
const rankCmp = compareFeastClass(candidate.wins, best.wins);
if (rankCmp > 0) {
return candidate;
}
if (rankCmp < 0) {
return best;
}
return candidate.dayNumber < best.dayNumber ? candidate : best;
}, undefined);
}
+9
View File
@@ -137,6 +137,15 @@ export interface OctaveConfig {
* data/calendar/temporal-feasts/pentecost-sunday.yml), which is
* stricter (`duplex`). */
wins?: FeastClass;
/** The octave's own effective rank on its *last* day specifically (the
* "in Octava" closing day) instead of `wins` — live-verified as a real,
* general pattern rather than a per-saint quirk: both St. Lawrence's
* own Aug 17 and the Assumption's own Aug 22 show as Duplex, elevated
* above their otherwise-Semiduplex ordinary in-between days. Default
* 'duplex'. Feeds both the rival-saint threshold (calendar/octaves.ts's
* `strictestThreshold`) and octave-vs-octave precedence
* (`resolveActiveOctave`) on that one day. */
closingDayRank?: FeastClass;
}
export type DayWinner =
+11 -3
View File
@@ -3,7 +3,7 @@ import type { Commemoration, DayWinner, LiturgicalDay } from '../calendar/types'
import type { ProperText } from '../propers';
import { getCommonProper, getTemporalProper } from '../propers';
import { getSaintRecord } from '../calendar/feasts';
import { activeOctavesFor, isAtLeast } from '../calendar';
import { resolveActiveOctave, isAtLeast } from '../calendar';
import { splitAntiphon } from './antiphon';
function toResolvedText(proper: ProperText): ResolvedText {
@@ -60,8 +60,16 @@ export function resolveOfficeWinner(day: LiturgicalDay): DayWinner {
if (day.temporalCategory !== 'ordinary-feria') {
return day.winner;
}
for (const octave of activeOctavesFor(day.date)) {
const saint = getSaintRecord(octave.id);
// When more than one octave is active at once (St. Lawrence's and the
// Assumption's genuinely overlap every Aug 16-17), resolveActiveOctave
// picks the one that actually governs the day — see its own doc
// comment for the precedence rule. Falls through to the plain temporal
// default if that octave turns out to be a temporal-only one with no
// saint record (e.g. Christmas's or Pentecost's own octave id) — no
// sanctoral content to synthesize from those.
const activeOctave = resolveActiveOctave(day.date);
if (activeOctave) {
const saint = getSaintRecord(activeOctave.id);
if (saint) {
return { kind: 'sanctoral', id: saint.id, name: saint.name, rank: saint.rank };
}
+20
View File
@@ -62,6 +62,26 @@ describe('getDayLabel — active octave', () => {
it("a season's own named anchor day outranks an active octave, even though Trinity Sunday is technically also day 8 of Pentecost's own octave", () => {
expect(getDayLabel(resolveDay('2026-05-31'))).toBe('Trinity Sunday');
});
it("a temporal day with real standing of its own (not ordinary-feria) never shows an octave name at all, even with one active -- live-verified counterexample: the Christmas Octave's own stack (Dec 30) titles itself off the Sunday, never any of the four octaves layered on top of it", () => {
const day = resolveDay('2033-12-30');
expect(day.temporalCategory).toBe('privileged-feria-minor');
expect(getDayLabel(day)).not.toContain('Octave');
expect(getDayLabel(day)).toBe('Friday in Christmastide');
});
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", () => {
expect(getDayLabel(resolveDay('2026-08-17'))).toBe('8th Day within the Octave of St. Lawrence, Martyr');
expect(getDayLabel(resolveDay('2026-08-18'))).toBe('4th Day within the Octave of The Assumption of the Blessed Virgin Mary');
});
it('a real Sunday with standing of its own wins over both octaves entirely, showing the plain ordinal Sunday label -- not an octave name at all', () => {
// 2026-08-16 is a Sunday genuinely within both St. Lawrence's and the
// Assumption's overlapping octave windows.
const day = resolveDay('2026-08-16');
expect(day.weekday).toBe('sunday');
expect(getDayLabel(day)).not.toContain('Octave');
});
});
describe('getDayLabel — feast name combination', () => {
+42 -3
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { activeOctavesFor, strictestThreshold } from '../../src/calendar/octaves';
import { activeOctavesFor, strictestThreshold, resolveActiveOctave, pickWinningOctave } from '../../src/calendar/octaves';
import type { ActiveOctave } from '../../src/calendar/octaves';
describe('activeOctavesFor', () => {
it('finds nothing outside any octave window', () => {
@@ -11,10 +12,12 @@ describe('activeOctavesFor', () => {
expect(day1).toEqual([{ id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'semiduplex', dayNumber: 1 }]);
// Aug 17 is also day 3 of the Assumption's own octave (started Aug 15) --
// the two run concurrently this time of year.
// the two run concurrently this time of year. Day 8 is Lawrence's own
// closing day ("in Octava"), elevated to duplex -- live-verified real
// pattern, not a per-saint quirk (see calendar/types.ts's OctaveConfig).
const day8 = activeOctavesFor('2026-08-17');
expect(day8).toEqual([
{ id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'semiduplex', dayNumber: 8 },
{ id: 'st-lawrence', name: 'St. Lawrence, Martyr', wins: 'duplex', dayNumber: 8 },
{ id: 'assumption', name: 'The Assumption of the Blessed Virgin Mary', wins: 'semiduplex', dayNumber: 3 },
]);
@@ -40,3 +43,39 @@ describe('activeOctavesFor', () => {
expect(octaves.map((o) => o.dayNumber)).toEqual([5, 4, 3, 2]);
});
});
describe('resolveActiveOctave / pickWinningOctave -- overlapping-octave precedence', () => {
it('returns undefined when no octave is active, and the sole octave when only one is', () => {
expect(resolveActiveOctave('2026-07-15')).toBeUndefined();
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", () => {
const winner = resolveActiveOctave('2026-08-17');
expect(winner?.id).toBe('st-lawrence');
expect(winner?.wins).toBe('duplex');
});
it("gives way to the Assumption once Lawrence's own octave has ended (Aug 18-22)", () => {
expect(resolveActiveOctave('2026-08-18')?.id).toBe('assumption');
});
// pickWinningOctave directly, since no real equal-rank overlap exists yet
// in this app's own calendar to exercise the tie-break against live data.
const higher = (dayNumber: number): ActiveOctave => ({ id: 'higher', name: 'Higher', wins: 'duplex', dayNumber });
const lower = (dayNumber: number): ActiveOctave => ({ id: 'lower', name: 'Lower', wins: 'semiduplex', dayNumber });
const equalA = (dayNumber: number): ActiveOctave => ({ id: 'equal-a', name: 'Equal A', wins: 'duplex', dayNumber });
const equalB = (dayNumber: number): ActiveOctave => ({ id: 'equal-b', name: 'Equal B', wins: 'duplex', dayNumber });
it('picks the higher-ranked octave regardless of order or start date', () => {
expect(pickWinningOctave([lower(2), higher(6)])?.id).toBe('higher');
expect(pickWinningOctave([higher(6), lower(2)])?.id).toBe('higher');
});
it('on a tied rank, picks the more recently started octave (smaller dayNumber) -- per direct instruction: day 1 of a newly started octave needs to be fully present, the whole point of it starting; the older, tied-rank octave loses and is commemorated instead', () => {
// equalB started later (day 2, vs equalA's day 5 -- fewer days have
// passed since it began) and should win the tie.
expect(pickWinningOctave([equalA(5), equalB(2)])?.id).toBe('equal-b');
expect(pickWinningOctave([equalB(2), equalA(5)])?.id).toBe('equal-b');
});
});