223 lines
7.0 KiB
Go
223 lines
7.0 KiB
Go
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
|
|
actualComplete := true
|
|
for _, eventDay := range eventDays {
|
|
point, ok := byDate[dayKey(eventDay)]
|
|
if !ok {
|
|
actualComplete = false
|
|
break
|
|
}
|
|
effect.ActualSessionCount += point.SessionCount
|
|
effect.ActualPaidSessions += point.PaidSessionCount
|
|
effect.ActualAmountRial += point.PaidAmountRial
|
|
}
|
|
if !actualComplete {
|
|
effect.SuppressionReason = SuppressionIncompleteEventWindow
|
|
return effect
|
|
}
|
|
for week := 1; week <= 8; week++ {
|
|
var windowSessions, windowPaid, windowAmount int64
|
|
complete := true
|
|
for _, eventDay := range eventDays {
|
|
candidate := eventDay.AddDate(0, 0, -7*week)
|
|
if eventDates[dayKey(candidate)] {
|
|
complete = false
|
|
break
|
|
}
|
|
point, ok := byDate[dayKey(candidate)]
|
|
if !ok {
|
|
complete = false
|
|
break
|
|
}
|
|
windowSessions += point.SessionCount
|
|
windowPaid += point.PaidSessionCount
|
|
windowAmount += point.PaidAmountRial
|
|
}
|
|
if !complete {
|
|
continue
|
|
}
|
|
effect.ObservationCount++
|
|
baselineSessions += windowSessions
|
|
baselinePaid += windowPaid
|
|
baselineAmount += windowAmount
|
|
}
|
|
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, effect.BaselineSessionCount)
|
|
effect.PaidSessionUpliftBPS = changeBPS(effect.ActualPaidSessions, effect.BaselinePaidSessions)
|
|
effect.AmountUpliftBPS = changeBPS(effect.ActualAmountRial, effect.BaselineAmountRial)
|
|
effect.MaterialWin = effect.PaidSessionUpliftBPS >= MaterialChangeBPS || effect.AmountUpliftBPS >= MaterialChangeBPS
|
|
effect.MaterialDecline = effect.PaidSessionUpliftBPS <= -MaterialChangeBPS || effect.AmountUpliftBPS <= -MaterialChangeBPS
|
|
return effect
|
|
}
|
|
|
|
// MostRecentExactOccurrence returns only a completed occurrence with the same
|
|
// canonical occasion identity and variant. Similar titles, tags, and families
|
|
// are deliberately ignored so they cannot be represented as merchant history
|
|
// for the upcoming event.
|
|
func MostRecentExactOccurrence(upcoming Event, catalog []Event) (Event, bool) {
|
|
if strings.TrimSpace(upcoming.OccasionKey) == "" || strings.TrimSpace(upcoming.EventVariant) == "" {
|
|
return Event{}, false
|
|
}
|
|
var result Event
|
|
found := false
|
|
for _, candidate := range catalog {
|
|
if candidate.OccasionKey != upcoming.OccasionKey || candidate.EventVariant != upcoming.EventVariant || !dateBefore(candidate.EndDate, upcoming.StartDate) {
|
|
continue
|
|
}
|
|
if !found || dateAfter(candidate.EndDate, result.EndDate) || dayKey(candidate.EndDate) == dayKey(result.EndDate) && candidate.ID < result.ID {
|
|
result = candidate
|
|
found = true
|
|
}
|
|
}
|
|
return result, found
|
|
}
|
|
|
|
func RankEvent(event Event, input RankInput) EventRank {
|
|
rank := EventRank{Promotable: event.CommercialPosture != "NO_PROMOTION" && event.CommercialPosture != "CAUTION" && event.Tone != "MOURNING" && event.Tone != "MEMORIAL"}
|
|
rank.ScoreBPS = event.ImportanceBPS + event.ConfidenceBPS + event.RelevanceBPS
|
|
merchantTerms := normalizedSet(input.MerchantTerms)
|
|
for value := range normalizedSet(append(slices.Clone(event.Categories), event.Tags...)) {
|
|
if _, ok := merchantTerms[value]; ok {
|
|
rank.ScoreBPS += 2_000
|
|
rank.Factors = append(rank.Factors, value)
|
|
}
|
|
}
|
|
slices.Sort(rank.Factors)
|
|
if input.WithinPreparationWindow {
|
|
rank.ScoreBPS += 1_000
|
|
rank.Factors = append(rank.Factors, "PREPARATION_WINDOW")
|
|
}
|
|
if input.HistoricalEligible {
|
|
rank.ScoreBPS += 20_000 + abs64(input.HistoricalUpliftBPS)/10
|
|
rank.Factors = append(rank.Factors, "HISTORICAL_EVIDENCE")
|
|
}
|
|
return rank
|
|
}
|
|
|
|
func abs64(value int64) int64 {
|
|
if value < 0 {
|
|
return -value
|
|
}
|
|
return value
|
|
}
|
|
|
|
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 dateBefore(a, b time.Time) bool { return dayKey(a) < dayKey(b) }
|
|
func dateAfter(a, b time.Time) bool { return dayKey(a) > dayKey(b) }
|
|
|
|
func changeBPS(actual, baseline int64) int64 {
|
|
if baseline <= 0 {
|
|
return 0
|
|
}
|
|
return (actual - baseline) * 10_000 / baseline
|
|
}
|