aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/calendar/calendar.go79
-rw-r--r--internal/calendar/transfer_plan_cache.go154
-rw-r--r--internal/calendar/transfer_plan_cache_test.go154
3 files changed, 294 insertions, 93 deletions
diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go
index 00048b7..a033c81 100644
--- a/internal/calendar/calendar.go
+++ b/internal/calendar/calendar.go
@@ -36,45 +36,22 @@ func computeEF(date time.Time, sel Selection, layers []Layer) LiturgicalDay {
td := temporalEF(date)
merged := mergeLayers(layers)
year := date.Year()
- // occupiedByRank reports whether some OTHER fixed-date sanctoral
- // celebration whose rank passes `allowed` resolves onto d this year.
- // transferIfImpededEF uses this at two different thresholds: class 1
- // only, to decide whether a candidate is impeded in the first place (a
- // class-2 occupant never impedes a class-1 feast -- class 1 always beats
- // class 2 outright, no tie exists); and class 1 OR 2, for RG 96's "next
- // day that is not I or II class" once a transfer is already under way
- // (e.g. the Visitation, 2 July, blocking the Precious Blood's transfer
- // off 1 July in 2011).
- occupiedByRank := func(d time.Time, exceptSlug string, allowed func(Rank) bool) bool {
- for slug2, rc2 := range merged {
- if slug2 == exceptSlug {
- continue
- }
- cel2 := buildCelebration(slug2, rc2)
- if !allowed(cel2.Rank) {
- continue
- }
- if when2, ok := celebrationDate(cel2, year, sel); ok && sameDay(when2, d) {
- return true
- }
- }
- return false
- }
- isClass1 := func(r Rank) bool { return r == RankClass1 }
- isClass1Or2 := func(r Rank) bool { return r == RankClass1 || r == RankClass2 }
- occ1 := func(d time.Time, except string) bool { return occupiedByRank(d, except, isClass1) }
- occ1Or2 := func(d time.Time, except string) bool { return occupiedByRank(d, except, isClass1Or2) }
// RG 97/98: the year's impeded I-class transfers are resolved as a set,
// not one at a time, so two feasts impeded by the same early Easter cannot
// both claim the same free day and lose one of themselves.
//
- // efTransferPlan is pure in (year, merged content, sel) and identical for
- // every day of the year it is asked about -- computeEF runs once PER DAY,
- // so a multi-day view (mobile.Days's week, a month view) was rebuilding
- // it from scratch on every single one. efTransferPlanCached memoises it;
- // see transfer_plan_cache.go for the cache key and why each of its three
- // parts is load-bearing.
- plan := efTransferPlanCached(year, merged, sel, occ1, occ1Or2)
+ // Both the plan and the occupancy index it is built from are pure in
+ // (year, merged content, sel) and identical for every day of the year
+ // they are asked about -- computeEF runs once PER DAY, so a multi-day
+ // view (mobile.Days's week, a month view) was rebuilding both from
+ // scratch on every single one. efTransferPlanCached memoises them
+ // together; see transfer_plan_cache.go for the cache key, why each of
+ // its three parts is load-bearing, and what the occupancy index is an
+ // index OF (every merged entry's own ORIGINAL, untransferred date --
+ // never a transfer TARGET, which is decided during planning and tracked
+ // separately, only within one planning pass, by efTransferPlan's own
+ // `claimed`).
+ plan, occ := efTransferPlanCached(year, merged, sel)
cands := []candidate{{Cel: td.Cel, Temporal: true, Season: td.Season, Sunday: td.Sunday}}
for slug, rc := range merged {
cel := buildCelebration(slug, rc)
@@ -90,9 +67,22 @@ func computeEF(date time.Time, sel Selection, layers []Layer) LiturgicalDay {
}
effective, planned := plan[cel.Slug]
if !planned {
+ // occ.occupied answers exactly what computeEF's own former
+ // occupiedByRank closure did -- "does some OTHER fixed-date
+ // sanctoral celebration whose rank passes `allowed` resolve onto
+ // d this year" -- at the same two thresholds transferIfImpededEF
+ // has always used: class 1 only, to decide whether a candidate is
+ // impeded in the first place (a class-2 occupant never impedes a
+ // class-1 feast -- class 1 always beats class 2 outright, no
+ // tie-break is even reached); and class 1 OR 2, for RG 96's "next
+ // day that is not I or II class" once a transfer is already under
+ // way (e.g. the Visitation, 2 July, blocking the Precious
+ // Blood's transfer off 1 July in 2011). It now reads a
+ // precomputed index instead of scanning merged afresh -- see
+ // transfer_plan_cache.go.
effective = transferIfImpededEF(cel, when,
- func(d time.Time) bool { return occ1(d, cel.Slug) },
- func(d time.Time) bool { return occ1Or2(d, cel.Slug) })
+ func(d time.Time) bool { return occ.occupied(d, cel.Slug, isClass1Rank) },
+ func(d time.Time) bool { return occ.occupied(d, cel.Slug, isClass1Or2Rank) })
}
if sameDay(effective, date) {
cands = append(cands, candidate{Cel: cel, Temporal: false, Season: td.Season})
@@ -248,6 +238,14 @@ func transferIfImpeded(cel Celebration, when time.Time, sel Selection) time.Time
return day
}
+// isClass1Rank and isClass1Or2Rank are the two occupancy thresholds
+// efTransferPlan and transferIfImpededEF (via computeEF's call site) test
+// against efOccupancyIndex -- see transfer_plan_cache.go's doc comment on
+// efOccupancyIndex for what "occupied" means and why it is safe to
+// precompute once per (year, merged content, sel).
+func isClass1Rank(r Rank) bool { return r == RankClass1 }
+func isClass1Or2Rank(r Rank) bool { return r == RankClass1 || r == RankClass2 }
+
// efTransferPlan resolves ALL of a year's impeded I-class transfers together,
// which RG 97/98 require and which resolving them one at a time cannot do.
//
@@ -268,8 +266,7 @@ func transferIfImpeded(cel Celebration, when time.Time, sel Selection) time.Time
// takes its proper seat on 2 April; St Joseph, impeded on the 19th, walks past
// Holy Week, the Easter octave and that claimed Monday to 3 April. Before this,
// St Joseph was observed on no day of 2008, 2035 or 2046 at all.
-func efTransferPlan(year int, merged map[string]RawCelebration, sel Selection,
- occupiedByClass1, occupiedByClass1Or2 func(time.Time, string) bool) map[string]time.Time {
+func efTransferPlan(year int, merged map[string]RawCelebration, sel Selection, occ efOccupancyIndex) map[string]time.Time {
type pending struct {
slug string
@@ -297,7 +294,7 @@ func efTransferPlan(year int, merged map[string]RawCelebration, sel Selection,
st := temporalEF(when)
tCand := candidate{Cel: st.Cel, Temporal: true, Season: st.Season, Sunday: st.Sunday}
sCand := candidate{Cel: cel, Temporal: false}
- if precedenceEF(tCand) >= precedenceEF(sCand) && !occupiedByClass1(when, cel.Slug) {
+ if precedenceEF(tCand) >= precedenceEF(sCand) && !occ.occupied(when, cel.Slug, isClass1Rank) {
continue // not impeded; stays put
}
// RG 96(a): a proper seat, claimed before anything queues. Guarded to
@@ -328,7 +325,7 @@ func efTransferPlan(year int, merged map[string]RawCelebration, sel Selection,
for i := 0; i < 60; i++ {
b := temporalEF(day)
isHighClass := b.Cel.Rank == RankClass1 || b.Cel.Rank == RankClass2
- if isHighClass || occupiedByClass1Or2(day, p.cel.Slug) || claimed[day.Format("2006-01-02")] {
+ if isHighClass || occ.occupied(day, p.cel.Slug, isClass1Or2Rank) || claimed[day.Format("2006-01-02")] {
day = day.AddDate(0, 0, 1)
continue
}
diff --git a/internal/calendar/transfer_plan_cache.go b/internal/calendar/transfer_plan_cache.go
index fdf62f7..f03e17b 100644
--- a/internal/calendar/transfer_plan_cache.go
+++ b/internal/calendar/transfer_plan_cache.go
@@ -18,9 +18,12 @@ import (
// view moving through nearby dates.
const efTransferPlanCacheCap = 64
-// efTransferPlanCacheKey is everything efTransferPlan's result actually
-// depends on, established by reading efTransferPlan, celebrationDate,
-// temporalEF and resolveDate rather than assumed:
+// efTransferPlanCacheKey is everything efTransferPlan's result -- and, since
+// ef_transfer_plan_cache.go v2, efOccupancyIndex's result -- actually depend
+// on, established by reading efTransferPlan, celebrationDate, temporalEF and
+// resolveDate rather than assumed. Both the plan and the occupancy index are
+// pure functions of exactly these three inputs (see efOccupancyIndex's own
+// doc comment for occupancy specifically), so one key correctly covers both:
//
// - year: efTransferPlan's own first argument (Easter, the transfer
// window, every celebrationDate call).
@@ -48,11 +51,14 @@ type efTransferPlanCacheKey struct {
hash [32]byte
}
-// efTransferPlanCacheEntry is one memoised plan, doubling as the LRU list's
-// payload so eviction and lookup share one map.
+// efTransferPlanCacheEntry is one memoised (plan, occupancy index) pair,
+// doubling as the LRU list's payload so eviction and lookup share one map.
+// The two are cached together because they are built from the same data in
+// one pass and share the same cache key -- see efTransferPlanCacheKey.
type efTransferPlanCacheEntry struct {
- key efTransferPlanCacheKey
- plan map[string]time.Time
+ key efTransferPlanCacheKey
+ plan map[string]time.Time
+ occupancy efOccupancyIndex
}
// efTransferPlanCache is a small, bounded, thread-safe LRU in front of
@@ -66,34 +72,109 @@ var efTransferPlanCache = struct {
order *list.List // front = most recently used
}{entries: map[efTransferPlanCacheKey]*list.Element{}, order: list.New()}
-// efTransferPlanCached is efTransferPlan, memoised by (year, sel, merged
-// content). It is safe and correct to share across every caller in the
-// process: two calls with the SAME key are, by construction of the key,
-// calls efTransferPlan itself would resolve identically (same year, same
-// Selection, same sanctoral content), so returning a cached result changes
-// nothing about what is computed -- only when. The returned map is a fresh
-// copy per call (clonePlan), never the cached instance itself, so no caller
-// can mutate shared cache state even though computeEF's own use of the
-// result is read-only today.
-func efTransferPlanCached(year int, merged map[string]RawCelebration, sel Selection,
- occupiedByClass1, occupiedByClass1Or2 func(time.Time, string) bool) map[string]time.Time {
+// efOccupancyEntry is one merged entry's slug and rank, indexed under its own
+// ORIGINAL (untransferred) date -- see efOccupancyIndex.
+type efOccupancyEntry struct {
+ slug string
+ rank Rank
+}
+
+// efOccupancyIndex answers exactly the question computeEF's old
+// occupiedByRank closure scanned all of merged for on every single call:
+// "does some entry other than exceptSlug, with a rank `allowed` accepts,
+// resolve to date d this year?" -- but as a precomputed lookup instead of a
+// fresh O(len(merged)) scan.
+//
+// BE PRECISE ABOUT WHAT "OCCUPIED" DEPENDS ON, because this is the question
+// that decides whether precomputing it is safe: an entry's ORIGINAL date
+// (buildCelebration + celebrationDate, exactly as this index computes it) is
+// a pure function of (merged content, year, sel) alone -- it never considers
+// whether that entry has since been TRANSFERRED (celebrationDate calls
+// resolveDate directly; nothing about transfer resolution feeds into it) and
+// never depends on `date`, the day computeEF happens to be resolving. That
+// is what makes one index, built once per (year, merged, sel) and reused for
+// every day and every candidate resolved against it, correct.
+//
+// This is a DIFFERENT question from efTransferPlan's own `claimed` map,
+// which tracks which TARGET days a transfer walk has already assigned
+// DURING one planning pass -- claimed genuinely mutates as planning
+// proceeds and has no meaning outside that single call, so it is (correctly)
+// still computed fresh inside efTransferPlan every time, never cached here.
+// Conflating the two -- treating "is some entry's ORIGINAL date d" as if it
+// captured "has some transfer already CLAIMED d" -- would be the exact
+// silent-wrong-answer failure mode this comment exists to rule out; they are
+// checked as three independent conditions everywhere they are used
+// together (see efTransferPlan's own forward-walk loop).
+type efOccupancyIndex map[string][]efOccupancyEntry // "2006-01-02" -> entries
+
+// buildEFOccupancyIndex computes the index in one O(len(merged)) pass.
+func buildEFOccupancyIndex(merged map[string]RawCelebration, year int, sel Selection) efOccupancyIndex {
+ idx := efOccupancyIndex{}
+ for slug, rc := range merged {
+ cel := buildCelebration(slug, rc)
+ when, ok := celebrationDate(cel, year, sel)
+ if !ok {
+ continue
+ }
+ key := when.Format("2006-01-02")
+ idx[key] = append(idx[key], efOccupancyEntry{slug: slug, rank: cel.Rank})
+ }
+ return idx
+}
+
+// occupied reports whether some entry other than exceptSlug at date d has a
+// rank allowed accepts -- computeEF's former occupiedByRank closure's exact
+// semantics, read from the precomputed index instead of a fresh scan. Safe
+// to call concurrently: idx is never mutated after buildEFOccupancyIndex
+// returns it (Go's map type permits unlimited concurrent reads with no
+// concurrent write).
+func (idx efOccupancyIndex) occupied(d time.Time, exceptSlug string, allowed func(Rank) bool) bool {
+ for _, e := range idx[d.Format("2006-01-02")] {
+ if e.slug == exceptSlug {
+ continue
+ }
+ if allowed(e.rank) {
+ return true
+ }
+ }
+ return false
+}
+
+// efTransferPlanCached is (efTransferPlan, buildEFOccupancyIndex), memoised
+// together by (year, sel, merged content). It is safe and correct to share
+// across every caller in the process: two calls with the SAME key are, by
+// construction of the key, calls efTransferPlan/buildEFOccupancyIndex
+// themselves would resolve identically (same year, same Selection, same
+// sanctoral content), so returning a cached result changes nothing about
+// what is computed -- only when. The returned plan is a fresh copy per call
+// (clonePlan), never the cached instance, so no caller can mutate shared
+// cache state even though computeEF's own use of it is read-only today. The
+// occupancy index is returned WITHOUT copying (unlike plan, it can hold one
+// entry per merged slug, up to ~330 on shipped data, so copying it on every
+// call -- most of them hits -- would give back a real slice of the win this
+// exists to capture); this is safe only because it is genuinely immutable
+// after construction (see efOccupancyIndex's own doc comment) and no code
+// anywhere writes to a returned index.
+func efTransferPlanCached(year int, merged map[string]RawCelebration, sel Selection) (map[string]time.Time, efOccupancyIndex) {
key := efTransferPlanCacheKey{year: year, sel: sel, hash: hashMergedForPlan(merged)}
efTransferPlanCache.mu.Lock()
if el, ok := efTransferPlanCache.entries[key]; ok {
efTransferPlanCache.order.MoveToFront(el)
- plan := el.Value.(*efTransferPlanCacheEntry).plan
+ entry := el.Value.(*efTransferPlanCacheEntry)
+ plan, occ := entry.plan, entry.occupancy
efTransferPlanCache.mu.Unlock()
- return clonePlan(plan)
+ return clonePlan(plan), occ
}
efTransferPlanCache.mu.Unlock()
- // Compute outside the lock: efTransferPlan is the expensive call this
- // cache exists to avoid repeating, and holding the mutex across it would
- // serialise every concurrent miss on DIFFERENT keys, not just protect
- // the shared map/list.
- plan := efTransferPlan(year, merged, sel, occupiedByClass1, occupiedByClass1Or2)
+ // Compute outside the lock: building the index and the plan is the
+ // expensive work this cache exists to avoid repeating, and holding the
+ // mutex across it would serialise every concurrent miss on DIFFERENT
+ // keys, not just protect the shared map/list.
+ occ := buildEFOccupancyIndex(merged, year, sel)
+ plan := efTransferPlan(year, merged, sel, occ)
efTransferPlanCache.mu.Lock()
defer efTransferPlanCache.mu.Unlock()
@@ -102,16 +183,17 @@ func efTransferPlanCached(year int, merged map[string]RawCelebration, sel Select
// were computing our own copy unlocked. Same key => same result by
// construction, so keep the existing entry and just bump recency.
efTransferPlanCache.order.MoveToFront(el)
- return clonePlan(el.Value.(*efTransferPlanCacheEntry).plan)
+ entry := el.Value.(*efTransferPlanCacheEntry)
+ return clonePlan(entry.plan), entry.occupancy
}
- el := efTransferPlanCache.order.PushFront(&efTransferPlanCacheEntry{key: key, plan: plan})
+ el := efTransferPlanCache.order.PushFront(&efTransferPlanCacheEntry{key: key, plan: plan, occupancy: occ})
efTransferPlanCache.entries[key] = el
if efTransferPlanCache.order.Len() > efTransferPlanCacheCap {
oldest := efTransferPlanCache.order.Back()
efTransferPlanCache.order.Remove(oldest)
delete(efTransferPlanCache.entries, oldest.Value.(*efTransferPlanCacheEntry).key)
}
- return clonePlan(plan)
+ return clonePlan(plan), occ
}
func clonePlan(plan map[string]time.Time) map[string]time.Time {
@@ -128,16 +210,16 @@ func clonePlan(plan map[string]time.Time) map[string]time.Time {
// logical content always hashes identically, regardless of which Layer
// stack produced it or what order ranging over it visits entries.
//
-// EVERY field of every entry is hashed, not just the ones efTransferPlan's
-// own read path happens to touch today (Rank, Date via celebrationDate).
-// occupiedByClass1/Or2 (closures over merged, passed in by computeEF) scan
-// ALL of merged looking for competing entries of any rank, and which
-// entries even qualify as class-1 in the first place is ITSELF computed
-// from this data (buildCelebration's "rank" field) -- so hashing only a
-// subset of fields would risk exactly the silent-stale-cache bug this
+// EVERY field of every entry is hashed, not just the ones the plan's or the
+// occupancy index's own read paths happen to touch today (Rank, Date via
+// celebrationDate). efOccupancyIndex indexes ALL of merged at every rank
+// (not just class-1/2 -- occ.occupied's `allowed` parameter is arbitrary),
+// and which entries even qualify as class-1 in the first place is ITSELF
+// computed from this data (buildCelebration's "rank" field) -- so hashing
+// only a subset of fields would risk exactly the silent-stale-cache bug this
// function exists to prevent: a user overlay that changes, say, a
// "suppress" or an unrelated feast's rank would not change the hash, and a
-// stale plan would keep being served.
+// stale plan or index would keep being served.
func hashMergedForPlan(merged map[string]RawCelebration) [32]byte {
slugs := make([]string, 0, len(merged))
for slug := range merged {
diff --git a/internal/calendar/transfer_plan_cache_test.go b/internal/calendar/transfer_plan_cache_test.go
index 6b3b48b..1592644 100644
--- a/internal/calendar/transfer_plan_cache_test.go
+++ b/internal/calendar/transfer_plan_cache_test.go
@@ -150,37 +150,69 @@ func TestEFTransferPlanCacheConcurrentUse(t *testing.T) {
}}
baseLayers := []calendar.Layer{base}
overlaidLayers := []calendar.Layer{base, overlay}
+ // A third, disjoint key exercising the occupancy-index collision path
+ // (TestEFOccupancyIndexDetectsSameDateClass1Collision) concurrently too
+ // -- the plan alone does not touch efOccupancyIndex.occupied unless a
+ // candidate's own precedence doesn't already decide it, which the
+ // Joseph/Annunciation scenario above never triggers (see that test's own
+ // doc comment).
+ collideLayers := []calendar.Layer{base, {ID: "user", Cels: map[string]calendar.RawCelebration{
+ "zz-test-alpha": {Fields: map[string]string{"rank": "class-1", "date": "07-06", "name.en": "ZZ Test Alpha"}, Variants: map[string]map[string]string{}},
+ "zz-test-beta": {Fields: map[string]string{"rank": "class-1", "date": "07-06", "name.en": "ZZ Test Beta"}, Variants: map[string]map[string]string{}},
+ }}}
years := []int{2008, 2011, 2035, 2046}
var wg sync.WaitGroup
- for g := 0; g < 12; g++ {
+ for g := 0; g < 15; g++ {
g := g
wg.Add(1)
go func() {
defer wg.Done()
- layers := baseLayers
- if g%2 == 0 {
- layers = overlaidLayers // different goroutines hammer different cache keys
- }
- for i := 0; i < 6; i++ {
- y := years[(g+i)%len(years)]
- for _, md := range []string{"03-19", "03-31", "04-01"} {
- d, err := time.Parse("2006-01-02", time.Date(y, 1, 1, 0, 0, 0, 0, time.UTC).Format("2006")+"-"+md)
- if err != nil {
- t.Errorf("bad date: %v", err)
- return
+ switch g % 3 {
+ case 0:
+ for i := 0; i < 6; i++ {
+ y := years[(g+i)%len(years)]
+ for _, md := range []string{"03-19", "03-31", "04-01"} {
+ d, err := time.Parse("2006-01-02", time.Date(y, 1, 1, 0, 0, 0, 0, time.UTC).Format("2006")+"-"+md)
+ if err != nil {
+ t.Errorf("bad date: %v", err)
+ return
+ }
+ _ = calendar.Compute(d.UTC(), sel, baseLayers).Observed.Slug
+ }
+ }
+ case 1:
+ for i := 0; i < 6; i++ {
+ y := years[(g+i)%len(years)]
+ for _, md := range []string{"03-19", "03-31", "04-01"} {
+ d, err := time.Parse("2006-01-02", time.Date(y, 1, 1, 0, 0, 0, 0, time.UTC).Format("2006")+"-"+md)
+ if err != nil {
+ t.Errorf("bad date: %v", err)
+ return
+ }
+ _ = calendar.Compute(d.UTC(), sel, overlaidLayers).Observed.Slug
+ }
+ }
+ default:
+ for i := 0; i < 6; i++ {
+ for _, ymd := range []string{"2026-07-06", "2026-07-07", "2026-07-08"} {
+ d, err := time.Parse("2006-01-02", ymd)
+ if err != nil {
+ t.Errorf("bad date: %v", err)
+ return
+ }
+ _ = calendar.Compute(d.UTC(), sel, collideLayers).Observed.Slug
}
- _ = calendar.Compute(d.UTC(), sel, layers).Observed.Slug
}
}
}()
}
wg.Wait()
- // After the concurrent hammering, correctness must still hold for both
- // keys -- the concurrency test is not a substitute for the correctness
- // test above, so re-assert both outcomes here too.
+ // After the concurrent hammering, correctness must still hold for all
+ // three keys -- the concurrency test is not a substitute for the
+ // correctness tests above, so re-assert their outcomes here too.
d, _ := time.Parse("2006-01-02", "2008-04-01")
if got := calendar.Compute(d.UTC(), sel, baseLayers).Observed.Slug; got != "joseph-spouse-of-the-bl-virgin-mary" {
t.Errorf("after concurrent use, base 2008-04-01 = %q, want joseph-spouse-of-the-bl-virgin-mary", got)
@@ -188,4 +220,94 @@ func TestEFTransferPlanCacheConcurrentUse(t *testing.T) {
if got := calendar.Compute(d.UTC(), sel, overlaidLayers).Observed.Slug; got == "joseph-spouse-of-the-bl-virgin-mary" {
t.Errorf("after concurrent use, overlaid 2008-04-01 = %q, want NOT joseph", got)
}
+ d2, _ := time.Parse("2006-01-02", "2026-07-06")
+ if got := calendar.Compute(d2.UTC(), sel, collideLayers).Observed.Slug; got != "ef-time-after-pentecost-6-monday" {
+ t.Errorf("after concurrent use, colliding 2026-07-06 = %q, want ef-time-after-pentecost-6-monday", got)
+ }
+}
+
+// TestEFOccupancyIndexDetectsSameDateClass1Collision covers a path the tests
+// above do not: efOccupancyIndex.occupied's use from computeEF's
+// transferIfImpededEF call site (the per-candidate fallback for a class-1
+// entry efTransferPlan judged NOT impeded by temporal precedence alone), and
+// efOccupancyIndex's own use inside efTransferPlan's initial impeded check
+// (occupiedByClass1(when, cel.Slug) -- "does some OTHER fixed-date class-1
+// SANCTORAL feast already sit on `when`", the doc comment's own example, RG
+// 97/98). Neither TestEFTwoTransfersDoNotCollide nor the overlay/year tests
+// above exercise it: St Joseph and the Annunciation are impeded by HOLY
+// WEEK'S OWN temporal precedence, on DIFFERENT original dates -- never by
+// colliding with each other's original date -- so no existing test had ever
+// driven two class-1 SANCTORAL entries onto the exact same calendar date.
+//
+// A synthetic overlay is used because no two class-1 feasts share a fixed
+// date in the shipped 1962 calendar (an ordinary Time-after-Pentecost
+// Monday, 6 July 2026, was checked empirically before writing this test:
+// alone, a single synthetic class-1 entry there is simply observed, since
+// nothing outranks or occupies it; the real collision case exists only by
+// construction).
+//
+// What is varied: whether a SECOND class-1 entry shares the first one's
+// date -- both entries otherwise identical (rank class-1, real content
+// unrelated to any liturgical rule under test). If efOccupancyIndex failed
+// to detect the collision (or wrongly matched exceptSlug against ITSELF,
+// the most dangerous failure shape -- see the mutation proof below), 6 July
+// would keep reporting the lone entry regardless.
+func TestEFOccupancyIndexDetectsSameDateClass1Collision(t *testing.T) {
+ sel := calendar.DefaultSelection()
+ sel.Form = "old"
+ base := caldata.Tridentine()
+
+ compute := func(layers []calendar.Layer, date string) string {
+ d, err := time.Parse("2006-01-02", date)
+ if err != nil {
+ t.Fatalf("bad test date %q: %v", date, err)
+ }
+ return calendar.Compute(d.UTC(), sel, layers).Observed.Slug
+ }
+
+ alpha := calendar.RawCelebration{
+ Fields: map[string]string{"rank": "class-1", "date": "07-06", "name.en": "ZZ Test Alpha"},
+ Variants: map[string]map[string]string{},
+ }
+ beta := calendar.RawCelebration{
+ Fields: map[string]string{"rank": "class-1", "date": "07-06", "name.en": "ZZ Test Beta"},
+ Variants: map[string]map[string]string{},
+ }
+
+ soloLayers := []calendar.Layer{base, {ID: "user", Cels: map[string]calendar.RawCelebration{
+ "zz-test-alpha": alpha,
+ }}}
+ collideLayers := []calendar.Layer{base, {ID: "user", Cels: map[string]calendar.RawCelebration{
+ "zz-test-alpha": alpha,
+ "zz-test-beta": beta,
+ }}}
+
+ // Alone, alpha is simply observed on its own date: nothing occupies 6
+ // July, so efTransferPlan judges it unimpeded and computeEF's fallback
+ // (occ.occupied via transferIfImpededEF) must agree and leave it there.
+ if got := compute(soloLayers, "2026-07-06"); got != "zz-test-alpha" {
+ t.Fatalf("alpha alone, 2026-07-06 = %q, want zz-test-alpha", got)
+ }
+
+ // Add beta on the SAME date. Both now occupy each other's date, so BOTH
+ // are impeded (RG 97/98) and transfer forward in table/impeded-first
+ // order (alpha to 7 July, beta to 8 -- empirically confirmed before
+ // writing this test); 6 July itself reverts to the ordinary temporal
+ // office, since NEITHER candidate keeps its place.
+ if got := compute(collideLayers, "2026-07-06"); got != "ef-time-after-pentecost-6-monday" {
+ t.Errorf("alpha+beta colliding, 2026-07-06 = %q, want ef-time-after-pentecost-6-monday (a stale/broken occupancy index would still show zz-test-alpha)", got)
+ }
+ if got := compute(collideLayers, "2026-07-07"); got != "zz-test-alpha" {
+ t.Errorf("alpha+beta colliding, 2026-07-07 = %q, want zz-test-alpha (transferred here)", got)
+ }
+ if got := compute(collideLayers, "2026-07-08"); got != "zz-test-beta" {
+ t.Errorf("alpha+beta colliding, 2026-07-08 = %q, want zz-test-beta (transferred here)", got)
+ }
+
+ // And alpha alone (no beta) must be unaffected by having since computed
+ // the colliding scenario -- the two overlays must not cross-contaminate
+ // the shared cache.
+ if got := compute(soloLayers, "2026-07-06"); got != "zz-test-alpha" {
+ t.Errorf("alpha alone after collision query, 2026-07-06 = %q, want zz-test-alpha (unaffected)", got)
+ }
}