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 }