diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-25 10:45:28 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-08-25 10:45:28 +0200 |
| commit | f110c338718a6eea3693fb6afb4f7fcc5b77003b (patch) | |
| tree | 52b6adb382445a0762f63fb862361d7fbd8637f2 /internal/calendar/calendar.go | |
| parent | fd3eda0da228d2417b8b7f9007533e214d7a5e80 (diff) | |
| parent | b24702cc9b8d7f2e408bb92032e8f7fa2aaa836b (diff) | |
| download | lectio-f110c338718a6eea3693fb6afb4f7fcc5b77003b.tar.gz lectio-f110c338718a6eea3693fb6afb4f7fcc5b77003b.zip | |
merge: make the calendar engine stop repeating whole-year work per day
A seven-day view resolved the year seven times. Each readings.Load stacked
the calendar data layers and re-read the Bible book table -- both
date-independent -- and the EF path rebuilt the year's entire transfer plan
and its occupancy scan on every single day.
Three logic-neutral fixes: hoist the date-independent setup out of the Days
loop; memoise the EF transfer plan on (year, Selection, content hash of the
merged sanctoral); and build the occupancy index once alongside it rather
than scanning per candidate.
EF's seven-day view goes 74ms to 16ms, its multiple over OF from ~13x to
~3x. OF is unchanged, as it never touched the EF paths.
Output is byte-identical throughout, proven by SHA-256 over a 492-case
sweep spanning both forms, both languages, four corpora, the Triduum, a
Requiem day and the three Joseph/Annunciation collision years. The caches
are keyed on content, not identity, so a user overlay invalidates them --
asserted by a test that varies the overlay and requires the answer to
change, itself mutation-proved by dropping the content hash and watching it
fail.
Diffstat (limited to 'internal/calendar/calendar.go')
| -rw-r--r-- | internal/calendar/calendar.go | 74 |
1 files changed, 39 insertions, 35 deletions
diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index d918547..a033c81 100644 --- a/internal/calendar/calendar.go +++ b/internal/calendar/calendar.go @@ -36,38 +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. - plan := efTransferPlan(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) @@ -83,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}) @@ -241,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. // @@ -261,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 @@ -290,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 @@ -321,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 } |
