feat(growth): add deterministic forecasting engine
This commit is contained in:
148
internal/growth/events.go
Normal file
148
internal/growth/events.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package growth
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func AnalyzeEventEffect(history []DailyPoint, event Event, catalog []Event) EventEffect {
|
||||
effect := EventEffect{EvidenceID: "event:" + event.ID, EventID: event.ID}
|
||||
eventDates := catalogDates(append(slices.Clone(catalog), event))
|
||||
byDate := pointsByDate(history)
|
||||
eventDays := daysInclusive(event.StartDate, event.EndDate)
|
||||
var baselineSessions, baselinePaid, baselineAmount int64
|
||||
for _, eventDay := range eventDays {
|
||||
if point, ok := byDate[dayKey(eventDay)]; ok {
|
||||
effect.ActualSessionCount += point.SessionCount
|
||||
effect.ActualPaidSessions += point.PaidSessionCount
|
||||
effect.ActualAmountRial += point.PaidAmountRial
|
||||
}
|
||||
for week := 1; week <= 8; week++ {
|
||||
candidate := eventDay.AddDate(0, 0, -7*week)
|
||||
if eventDates[dayKey(candidate)] {
|
||||
continue
|
||||
}
|
||||
point, ok := byDate[dayKey(candidate)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
effect.ObservationCount++
|
||||
baselineSessions += point.SessionCount
|
||||
baselinePaid += point.PaidSessionCount
|
||||
baselineAmount += point.PaidAmountRial
|
||||
}
|
||||
}
|
||||
if effect.ObservationCount < MinBaselineObservations {
|
||||
effect.SuppressionReason = SuppressionInsufficientObservations
|
||||
return effect
|
||||
}
|
||||
effect.BaselineSessionCount = baselineSessions / int64(effect.ObservationCount)
|
||||
effect.BaselinePaidSessions = baselinePaid / int64(effect.ObservationCount)
|
||||
effect.BaselineAmountRial = baselineAmount / int64(effect.ObservationCount)
|
||||
if baselineSessions < MinBaselineSessions {
|
||||
effect.SuppressionReason = SuppressionInsufficientBaselineSessions
|
||||
return effect
|
||||
}
|
||||
effect.Eligible = true
|
||||
effect.SessionUpliftBPS = changeBPS(effect.ActualSessionCount/int64(len(eventDays)), effect.BaselineSessionCount)
|
||||
effect.PaidSessionUpliftBPS = changeBPS(effect.ActualPaidSessions/int64(len(eventDays)), effect.BaselinePaidSessions)
|
||||
effect.AmountUpliftBPS = changeBPS(effect.ActualAmountRial/int64(len(eventDays)), effect.BaselineAmountRial)
|
||||
effect.MaterialWin = effect.PaidSessionUpliftBPS >= MaterialChangeBPS || effect.AmountUpliftBPS >= MaterialChangeBPS
|
||||
effect.MaterialDecline = effect.PaidSessionUpliftBPS <= -MaterialChangeBPS || effect.AmountUpliftBPS <= -MaterialChangeBPS
|
||||
return effect
|
||||
}
|
||||
|
||||
func MatchScoreBPS(historical, upcoming Event) (int64, string) {
|
||||
if historical.Family != "" && strings.EqualFold(strings.TrimSpace(historical.Family), strings.TrimSpace(upcoming.Family)) {
|
||||
return 10_000, "FAMILY"
|
||||
}
|
||||
a := normalizedSet(historical.Tags)
|
||||
b := normalizedSet(upcoming.Tags)
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return 0, "TAGS"
|
||||
}
|
||||
intersection := 0
|
||||
union := make(map[string]struct{}, len(a)+len(b))
|
||||
for tag := range a {
|
||||
union[tag] = struct{}{}
|
||||
if _, ok := b[tag]; ok {
|
||||
intersection++
|
||||
}
|
||||
}
|
||||
for tag := range b {
|
||||
union[tag] = struct{}{}
|
||||
}
|
||||
return int64(intersection * 10_000 / len(union)), "TAGS"
|
||||
}
|
||||
|
||||
func MatchUpcomingEvents(historical Event, upcoming []Event) []EventMatch {
|
||||
matches := make([]EventMatch, 0, len(upcoming))
|
||||
for _, candidate := range upcoming {
|
||||
score, basis := MatchScoreBPS(historical, candidate)
|
||||
if score >= MinSimilarityBPS {
|
||||
matches = append(matches, EventMatch{Event: candidate, ScoreBPS: score, MatchBasis: basis})
|
||||
}
|
||||
}
|
||||
slices.SortFunc(matches, func(a, b EventMatch) int {
|
||||
if a.ScoreBPS != b.ScoreBPS {
|
||||
return int(b.ScoreBPS - a.ScoreBPS)
|
||||
}
|
||||
return strings.Compare(a.Event.ID, b.Event.ID)
|
||||
})
|
||||
return matches
|
||||
}
|
||||
|
||||
func pointsByDate(points []DailyPoint) map[string]DailyPoint {
|
||||
result := make(map[string]DailyPoint, len(points))
|
||||
for _, point := range points {
|
||||
result[dayKey(point.Date)] = point
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func catalogDates(events []Event) map[string]bool {
|
||||
result := make(map[string]bool)
|
||||
for _, event := range events {
|
||||
for _, day := range daysInclusive(event.StartDate, event.EndDate) {
|
||||
result[dayKey(day)] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func daysInclusive(start, end time.Time) []time.Time {
|
||||
if end.Before(start) {
|
||||
return nil
|
||||
}
|
||||
days := make([]time.Time, 0, int(end.Sub(start).Hours()/24)+1)
|
||||
for day := normalizeDate(start); !day.After(normalizeDate(end)); day = day.AddDate(0, 0, 1) {
|
||||
days = append(days, day)
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
func normalizedSet(values []string) map[string]struct{} {
|
||||
result := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value != "" {
|
||||
result[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeDate(value time.Time) time.Time {
|
||||
year, month, day := value.Date()
|
||||
return time.Date(year, month, day, 0, 0, 0, 0, value.Location())
|
||||
}
|
||||
|
||||
func dayKey(value time.Time) string { return normalizeDate(value).Format(time.DateOnly) }
|
||||
|
||||
func changeBPS(actual, baseline int64) int64 {
|
||||
if baseline <= 0 {
|
||||
return 0
|
||||
}
|
||||
return (actual - baseline) * 10_000 / baseline
|
||||
}
|
||||
Reference in New Issue
Block a user