feat: rank growth events with grounded evidence
This commit is contained in:
@@ -33,6 +33,22 @@ func TestAnalyzeEventEffectSuppressesSmallBaseline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMostRecentExactOccurrenceRequiresOccasionAndVariant(t *testing.T) {
|
||||
t.Parallel()
|
||||
upcoming := Event{ID: "black-friday-2026", OccasionKey: "black-friday", EventVariant: "CAMPAIGN_WINDOW", StartDate: time.Date(2026, 11, 27, 0, 0, 0, 0, time.UTC)}
|
||||
catalog := []Event{
|
||||
{ID: "wrong-variant", OccasionKey: "black-friday", EventVariant: "PRIMARY", StartDate: time.Date(2025, 11, 28, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2025, 11, 28, 0, 0, 0, 0, time.UTC)},
|
||||
{ID: "old", OccasionKey: "black-friday", EventVariant: "CAMPAIGN_WINDOW", StartDate: time.Date(2024, 11, 29, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2024, 11, 29, 0, 0, 0, 0, time.UTC)},
|
||||
{ID: "recent", OccasionKey: "black-friday", EventVariant: "CAMPAIGN_WINDOW", StartDate: time.Date(2025, 11, 28, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2025, 11, 28, 0, 0, 0, 0, time.UTC)},
|
||||
{ID: "wrong-key", OccasionKey: "cyber-monday", EventVariant: "CAMPAIGN_WINDOW", StartDate: time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC)},
|
||||
}
|
||||
|
||||
got, ok := MostRecentExactOccurrence(upcoming, catalog)
|
||||
if !ok || got.ID != "recent" {
|
||||
t.Fatalf("MostRecentExactOccurrence() = (%+v, %v), want recent exact occurrence", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchUpcomingEventsPrefersFamilyThenTags(t *testing.T) {
|
||||
historical := Event{ID: "teacher", Family: "appreciation", Tags: []string{"gift", "education"}}
|
||||
upcoming := []Event{
|
||||
|
||||
@@ -73,6 +73,57 @@ func AnalyzeEventEffect(history []DailyPoint, event Event, catalog []Event) Even
|
||||
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"
|
||||
|
||||
@@ -3,8 +3,8 @@ package growth
|
||||
import "time"
|
||||
|
||||
const (
|
||||
AnalyticsVersion = "1.3.0"
|
||||
DefaultModelConfigVersion = "avalai-responses-v1-tools4-turns2-output1600"
|
||||
AnalyticsVersion = "1.4.0"
|
||||
DefaultModelConfigVersion = "avalai-responses-v1-ranked-events-tools4-turns2-output1600"
|
||||
|
||||
MinBaselineObservations = 4
|
||||
MinBaselineSessions = 100
|
||||
@@ -48,18 +48,39 @@ const (
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
TitleFA string `json:"titleFa"`
|
||||
StartDate time.Time `json:"startDate"`
|
||||
EndDate time.Time `json:"endDate"`
|
||||
Family string `json:"family"`
|
||||
Tags []string `json:"tags"`
|
||||
Source string `json:"source"`
|
||||
SourceVersion string `json:"sourceVersion"`
|
||||
Categories []string `json:"categories"`
|
||||
Kind EventKind `json:"kind"`
|
||||
OfficialHoliday bool `json:"officialHoliday"`
|
||||
Universal bool `json:"universal"`
|
||||
ID string `json:"id"`
|
||||
OccasionKey string `json:"occasionKey"`
|
||||
EventVariant string `json:"eventVariant"`
|
||||
TitleFA string `json:"titleFa"`
|
||||
StartDate time.Time `json:"startDate"`
|
||||
EndDate time.Time `json:"endDate"`
|
||||
Family string `json:"family"`
|
||||
Tags []string `json:"tags"`
|
||||
Source string `json:"source"`
|
||||
SourceVersion string `json:"sourceVersion"`
|
||||
Categories []string `json:"categories"`
|
||||
Kind EventKind `json:"kind"`
|
||||
OfficialHoliday bool `json:"officialHoliday"`
|
||||
Universal bool `json:"universal"`
|
||||
Tone string `json:"tone,omitempty"`
|
||||
CommercialPosture string `json:"commercialPosture,omitempty"`
|
||||
LeadDays int `json:"leadDays,omitempty"`
|
||||
RelevanceBPS int64 `json:"relevanceBps,omitempty"`
|
||||
ConfidenceBPS int64 `json:"confidenceBps,omitempty"`
|
||||
ImportanceBPS int64 `json:"importanceBps,omitempty"`
|
||||
}
|
||||
|
||||
type RankInput struct {
|
||||
MerchantTerms []string
|
||||
WithinPreparationWindow bool
|
||||
HistoricalUpliftBPS int64
|
||||
HistoricalEligible bool
|
||||
}
|
||||
|
||||
type EventRank struct {
|
||||
ScoreBPS int64
|
||||
Factors []string
|
||||
Promotable bool
|
||||
}
|
||||
|
||||
type EventEffect struct {
|
||||
|
||||
Reference in New Issue
Block a user