fix(growth): harden deterministic report evidence

This commit is contained in:
2026-08-21 00:32:21 +03:30
parent 1d8d4edf92
commit 6c80f71847
11 changed files with 369 additions and 103 deletions

View File

@@ -0,0 +1,43 @@
package growth
import (
"slices"
"time"
)
type Session struct {
CreatedAt time.Time `json:"createdAt"`
Status string `json:"status"`
AmountRial int64 `json:"amountRial"`
}
// AggregateDaily groups already-authorized, scope-filtered sessions by Tehran
// local day. Paid and Verified are the only successful bank-payment statuses.
func AggregateDaily(sessions []Session, catalog []Event) []DailyPoint {
byDate := make(map[string]DailyPoint)
for _, session := range sessions {
local := session.CreatedAt.In(tehranLocation())
date := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, tehranLocation())
key := dayKey(date)
point := byDate[key]
point.Date = date
point.SessionCount++
if session.Status == "Paid" || session.Status == "Verified" {
point.PaidSessionCount++
point.PaidAmountRial += session.AmountRial
}
byDate[key] = point
}
points := make([]DailyPoint, 0, len(byDate))
for _, point := range byDate {
for _, event := range catalog {
if !dateBefore(point.Date, event.StartDate) && !dateAfter(point.Date, event.EndDate) {
point.EventIDs = append(point.EventIDs, event.ID)
}
}
slices.Sort(point.EventIDs)
points = append(points, point)
}
slices.SortFunc(points, func(a, b DailyPoint) int { return a.Date.Compare(b.Date) })
return points
}

View File

@@ -0,0 +1,29 @@
package growth
import (
"testing"
"time"
)
func TestAggregateDailyUsesTehranDayAndSessionGrain(t *testing.T) {
sessions := []Session{
{CreatedAt: time.Date(2026, 1, 1, 20, 29, 0, 0, time.UTC), Status: "Paid", AmountRial: 100},
{CreatedAt: time.Date(2026, 1, 1, 20, 31, 0, 0, time.UTC), Status: "Verified", AmountRial: 200},
{CreatedAt: time.Date(2026, 1, 1, 21, 0, 0, 0, time.UTC), Status: "Failed", AmountRial: 900},
}
points := AggregateDaily(sessions, nil)
if len(points) != 2 || dayKey(points[0].Date) != "2026-01-01" || points[0].PaidAmountRial != 100 {
t.Fatalf("unexpected first day: %+v", points)
}
if dayKey(points[1].Date) != "2026-01-02" || points[1].SessionCount != 2 || points[1].PaidSessionCount != 1 || points[1].PaidAmountRial != 200 {
t.Fatalf("unexpected second day: %+v", points[1])
}
}
func TestAggregateDailyAnnotatesCatalogEvents(t *testing.T) {
events := []Event{{ID: "b", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}, {ID: "a", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}}
points := AggregateDaily([]Session{{CreatedAt: time.Date(2026, 5, 2, 8, 0, 0, 0, tehranLocation()), Status: "Paid", AmountRial: 10}}, events)
if len(points) != 1 || len(points[0].EventIDs) != 2 || points[0].EventIDs[0] != "a" || points[0].EventIDs[1] != "b" {
t.Fatalf("unexpected event annotation: %+v", points)
}
}

View File

@@ -74,13 +74,25 @@ func TestDetectRisksRequiresTwentyPercentDropAndBaselineSize(t *testing.T) {
}
}
func TestDetectRisksCoalescesAdjacentDaysAndRanksDeepestFirst(t *testing.T) {
forecast := Forecast{Confidence: ConfidenceHigh, Points: []ForecastPoint{
{Date: date("2026-07-01"), BaselineSessions: 200, BaselineAmountRial: 1_000, PaidSessions: Range{P50: 150}, PaidAmountRial: Range{P50: 750}},
{Date: date("2026-07-02"), BaselineSessions: 200, BaselineAmountRial: 1_000, PaidSessions: Range{P50: 100}, PaidAmountRial: Range{P50: 500}},
{Date: date("2026-07-04"), BaselineSessions: 200, BaselineAmountRial: 1_000, PaidSessions: Range{P50: 120}, PaidAmountRial: Range{P50: 600}},
}}
risks := DetectRisks(forecast, 3)
if len(risks) != 2 || risks[0].Date != date("2026-07-01") || risks[0].EndDate != date("2026-07-02") || risks[0].SessionDeclineBPS != 5_000 {
t.Fatalf("unexpected ranked/coalesced risks: %+v", risks)
}
}
func TestEvaluatePeerAppliesPrivacyThresholdAndEqualMerchantWeight(t *testing.T) {
peers := make([]PeerMerchantValue, 10)
for i := range peers {
peers[i] = PeerMerchantValue{PaidSessions: int64(i + 1), SessionCount: 100}
}
result := EvaluatePeer(PeerInput{MerchantPaidSessions: 8, Peers: peers})
if result.State != PeerReady || result.MedianPaidSessions != 5 || result.PercentileBPS != 8_000 {
if result.State != PeerReady || result.MedianPaidSessions != 5 || result.PercentileBPS != 7_500 {
t.Fatalf("unexpected peer result: %+v", result)
}
suppressed := EvaluatePeer(PeerInput{TerminalScoped: true, Peers: peers})
@@ -90,7 +102,7 @@ func TestEvaluatePeerAppliesPrivacyThresholdAndEqualMerchantWeight(t *testing.T)
}
func TestFingerprintIsStableAndVersionSensitive(t *testing.T) {
input := FingerprintInput{MerchantID: 7, DatasetWatermark: "run-1", ForecastStart: date("2026-07-01"), HorizonDays: 90, CatalogVersion: "1", AnalyticsVersion: "1", PromptVersion: "1", SchemaVersion: "1", Model: "mini"}
input := FingerprintInput{MerchantID: 7, DatasetWatermark: "run-1", ForecastStart: date("2026-07-01"), HorizonDays: 90, CatalogVersion: "1", AnalyticsVersion: "1", PromptVersion: "1", SchemaVersion: "1", Model: "mini", ModelConfigVersion: "cfg-1"}
a := Fingerprint(input)
b := Fingerprint(input)
input.Model = "other"
@@ -99,6 +111,26 @@ func TestFingerprintIsStableAndVersionSensitive(t *testing.T) {
}
}
func TestPeerPercentileUsesMidRankForTies(t *testing.T) {
peers := make([]PeerMerchantValue, 10)
for index := range peers {
peers[index] = PeerMerchantValue{PaidSessions: 50, SessionCount: 100}
}
result := EvaluatePeer(PeerInput{MerchantPaidSessions: 50, Peers: peers})
if result.PercentileBPS != 5_000 {
t.Fatalf("all-tie percentile = %d, want 5000", result.PercentileBPS)
}
}
func TestFingerprintIncludesModelConfigurationPolicy(t *testing.T) {
input := FingerprintInput{MerchantID: 7, ForecastStart: date("2026-07-01"), HorizonDays: 90, Model: "mini", ModelConfigVersion: "cfg-1"}
first := Fingerprint(input)
input.ModelConfigVersion = "cfg-2"
if first == Fingerprint(input) {
t.Fatal("model configuration policy must invalidate fingerprint")
}
}
func TestTehranRefreshBoundary(t *testing.T) {
last := time.Date(2026, 8, 20, 20, 31, 0, 0, time.UTC) // 00:01 Tehran on Aug 21.
next := NextRefreshAt(last)

View File

@@ -9,6 +9,7 @@ import (
const officialCalendarSource = "https://calendar.ut.ac.ir/"
const curatedCommercialSource = "CURATED_COMMERCIAL_1405_V1"
const officialCalendarVersion = "IR_OFFICIAL_1404_1405_REVIEWED_2026-08-21"
var importedMerchantCategories = []string{
"ارائه دهنده خدمات اینترنت",
@@ -23,12 +24,29 @@ var importedMerchantCategories = []string{
// the first 90 forecast days after 2026-06-30.
func Catalog1405() []Event {
events := []Event{
catalogEvent("nowruz-1405", "نوروز", "2026-03-21", "2026-03-24", "new-year", EventOfficial, []string{"هدیه", "خرید", "سال-نو"}),
catalogEvent("nature-day-1405", "روز طبیعت", "2026-04-02", "2026-04-02", "leisure", EventOfficial, []string{"تفریح", "خرید"}),
catalogEvent("teachers-day-1405", "روز معلم", "2026-05-02", "2026-05-02", "appreciation", EventOfficial, []string{"هدیه", "آموزش", "قدردانی"}),
catalogEvent("khordad-15-1405", "قیام پانزده خرداد", "2026-06-05", "2026-06-05", "official-holiday", EventOfficial, []string{"تعطیل"}),
catalogEvent("doctors-day-1405", "روز پزشک", "2026-08-23", "2026-08-23", "appreciation", EventOfficial, []string{"هدیه", "قدردانی", "حرفه‌ای"}),
catalogEvent("back-to-school-1405", "آمادگی بازگشایی مدارس", "2026-09-10", "2026-09-22", "education-season", EventCommercial, []string{"آموزش", "خرید", "مدرسه"}),
officialEvent("fathers-day-1404", "روز پدر", "2026-01-03", "2026-01-03", "appreciation", true, []string{"هدیه", "قدردانی"}),
officialEvent("mabath-1404", "مبعث پیامبر اکرم", "2026-01-17", "2026-01-17", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
officialEvent("imam-mahdi-birthday-1404", "نیمه شعبان", "2026-02-04", "2026-02-04", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
officialEvent("revolution-day-1404", "پیروزی انقلاب اسلامی", "2026-02-11", "2026-02-11", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}),
officialEvent("imam-ali-martyrdom-1404", "شهادت امام علی", "2026-03-11", "2026-03-11", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
officialEvent("oil-nationalization-1404", "ملی شدن صنعت نفت", "2026-03-19", "2026-03-19", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}),
officialEvent("nowruz-1405", "نوروز", "2026-03-21", "2026-03-24", "new-year", true, []string{"هدیه", "خرید", "سال-نو", "تعطیل"}),
officialEvent("islamic-republic-day-1405", "روز جمهوری اسلامی", "2026-04-01", "2026-04-01", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}),
officialEvent("nature-day-1405", "روز طبیعت", "2026-04-02", "2026-04-02", "leisure", true, []string{"تفریح", "خرید", "تعطیل"}),
officialEvent("imam-sadegh-martyrdom-1405", "شهادت امام جعفر صادق", "2026-04-14", "2026-04-14", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
officialEvent("teachers-day-1405", "روز معلم", "2026-05-02", "2026-05-02", "appreciation", false, []string{"هدیه", "آموزش", "قدردانی"}),
officialEvent("eid-al-adha-1405", "عید قربان", "2026-05-27", "2026-05-27", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
officialEvent("khomeini-ghadir-1405", "رحلت امام خمینی و عید غدیر", "2026-06-04", "2026-06-04", "official-holiday", true, []string{"تعطیل", "مناسبت-ملی", "مناسبت-مذهبی"}),
officialEvent("khordad-15-1405", "قیام پانزده خرداد", "2026-06-05", "2026-06-05", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}),
officialEvent("tasua-1405", "تاسوعای حسینی", "2026-06-24", "2026-06-24", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
officialEvent("ashura-1405", "عاشورای حسینی", "2026-06-25", "2026-06-25", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
commercialEvent("summer-midseason-1405", "میانه فصل تابستان", "2026-07-15", "2026-07-17", "seasonal-shopping", []string{"خرید", "تابستان", "پیشنهاد-فصلی"}, importedMerchantCategories),
officialEvent("arbaeen-1405", "اربعین حسینی", "2026-08-04", "2026-08-04", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
officialEvent("prophet-demise-1405", "رحلت پیامبر اکرم و شهادت امام حسن", "2026-08-12", "2026-08-12", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
officialEvent("doctors-day-1405", "روز پزشک", "2026-08-23", "2026-08-23", "appreciation", false, []string{"هدیه", "قدردانی", "حرفه‌ای"}),
officialEvent("prophet-birthday-1405", "میلاد پیامبر اکرم و امام جعفر صادق", "2026-08-30", "2026-08-30", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
commercialEvent("back-to-school-1405", "آمادگی بازگشایی مدارس", "2026-09-10", "2026-09-22", "education-season", []string{"آموزش", "خرید", "مدرسه"}, []string{"مراکز آموزشی مجازی", "کیف و کفش فروشی"}),
commercialEvent("autumn-opening-1405", "آغاز فصل پاییز", "2026-09-23", "2026-09-28", "seasonal-shopping", []string{"خرید", "پاییز", "پیشنهاد-فصلی"}, importedMerchantCategories),
}
result := make([]Event, len(events))
for index, event := range events {
@@ -39,18 +57,18 @@ func Catalog1405() []Event {
return result
}
func catalogEvent(id, title, start, end, family string, kind EventKind, tags []string) Event {
source := officialCalendarSource
if kind == EventCommercial {
source = curatedCommercialSource
}
return Event{ID: id, TitleFA: title, StartDate: mustCatalogDate(start), EndDate: mustCatalogDate(end), Family: family, Tags: slices.Clone(tags), Source: source, Categories: slices.Clone(importedMerchantCategories), Kind: kind}
func officialEvent(id, title, start, end, family string, holiday bool, tags []string) Event {
return Event{ID: id, TitleFA: title, StartDate: mustCatalogDate(start), EndDate: mustCatalogDate(end), Family: family, Tags: slices.Clone(tags), Source: officialCalendarSource, SourceVersion: officialCalendarVersion, Kind: EventOfficial, OfficialHoliday: holiday, Universal: true}
}
func commercialEvent(id, title, start, end, family string, tags, categories []string) Event {
return Event{ID: id, TitleFA: title, StartDate: mustCatalogDate(start), EndDate: mustCatalogDate(end), Family: family, Tags: slices.Clone(tags), Source: curatedCommercialSource, SourceVersion: CatalogVersion, Categories: slices.Clone(categories), Kind: EventCommercial, Universal: false}
}
func ValidateCatalog(events []Event) error {
seen := make(map[string]struct{}, len(events))
for index, event := range events {
if strings.TrimSpace(event.ID) == "" || strings.TrimSpace(event.TitleFA) == "" || strings.TrimSpace(event.Family) == "" || strings.TrimSpace(event.Source) == "" {
if strings.TrimSpace(event.ID) == "" || strings.TrimSpace(event.TitleFA) == "" || strings.TrimSpace(event.Family) == "" || strings.TrimSpace(event.Source) == "" || strings.TrimSpace(event.SourceVersion) == "" {
return fmt.Errorf("growth catalog event %d is incomplete", index)
}
if _, exists := seen[event.ID]; exists {
@@ -63,8 +81,14 @@ func ValidateCatalog(events []Event) error {
if event.Kind != EventOfficial && event.Kind != EventCommercial {
return fmt.Errorf("growth catalog event %q has invalid kind", event.ID)
}
if len(normalizedSet(event.Tags)) == 0 || len(event.Categories) == 0 {
return fmt.Errorf("growth catalog event %q lacks tags or supported categories", event.ID)
if len(normalizedSet(event.Tags)) == 0 {
return fmt.Errorf("growth catalog event %q lacks tags", event.ID)
}
if event.Universal && len(event.Categories) != 0 {
return fmt.Errorf("growth catalog universal event %q must not contain categories", event.ID)
}
if !event.Universal && len(event.Categories) == 0 {
return fmt.Errorf("growth catalog scoped event %q lacks categories", event.ID)
}
}
return nil

View File

@@ -15,7 +15,7 @@ func TestCatalog1405IsValidAndDefensivelyCopied(t *testing.T) {
}
func TestValidateCatalogRejectsDuplicateAndIncompleteEvents(t *testing.T) {
event := catalogEvent("duplicate", "رویداد", "2026-01-01", "2026-01-01", "family", EventOfficial, []string{"tag"})
event := officialEvent("duplicate", "رویداد", "2026-01-01", "2026-01-01", "family", false, []string{"tag"})
if err := ValidateCatalog([]Event{event, event}); err == nil {
t.Fatal("expected duplicate id error")
}
@@ -25,6 +25,22 @@ func TestValidateCatalogRejectsDuplicateAndIncompleteEvents(t *testing.T) {
}
}
func TestCatalogCoversImportedHistoryAndForecastHorizon(t *testing.T) {
catalog := Catalog1405()
if catalog[0].StartDate.After(date("2026-01-03")) || catalog[len(catalog)-1].EndDate.Before(date("2026-09-28")) {
t.Fatalf("catalog coverage is incomplete: %s..%s", catalog[0].StartDate, catalog[len(catalog)-1].EndDate)
}
var scoped bool
for _, event := range catalog {
if event.ID == "back-to-school-1405" {
scoped = !event.Universal && len(event.Categories) == 2
}
}
if !scoped {
t.Fatal("back-to-school event must preserve explicit category scope")
}
}
func TestAnalyzeEventEffectExcludesCatalogEventsFromBaseline(t *testing.T) {
target := Event{ID: "target", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}
excluded := Event{ID: "other", StartDate: target.StartDate.AddDate(0, 0, -7), EndDate: target.StartDate.AddDate(0, 0, -7)}
@@ -43,10 +59,31 @@ func TestAnalyzeEventEffectExcludesCatalogEventsFromBaseline(t *testing.T) {
}
}
func TestAnalyzeEventEffectUsesCompleteComparableWindows(t *testing.T) {
target := Event{ID: "two-day", StartDate: date("2026-05-02"), EndDate: date("2026-05-03")}
history := []DailyPoint{
{Date: date("2026-04-25"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
{Date: date("2026-04-26"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
{Date: date("2026-04-18"), SessionCount: 1_000, PaidSessionCount: 1_000, PaidAmountRial: 100_000}, // fragmented week, next day absent
{Date: date("2026-04-11"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
{Date: date("2026-04-12"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
{Date: date("2026-04-04"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
{Date: date("2026-04-05"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
{Date: date("2026-03-28"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
{Date: date("2026-03-29"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
{Date: target.StartDate, SessionCount: 80, PaidSessionCount: 75, PaidAmountRial: 7_500},
{Date: target.EndDate, SessionCount: 80, PaidSessionCount: 75, PaidAmountRial: 7_500},
}
effect := AnalyzeEventEffect(history, target, nil)
if effect.ObservationCount != 4 || effect.BaselineSessionCount != 120 || effect.ActualSessionCount != 160 || effect.PaidSessionUpliftBPS != 5_000 {
t.Fatalf("unexpected complete-window effect: %+v", effect)
}
}
func TestForecastSuppressesPoorBacktest(t *testing.T) {
start := date("2026-07-01")
history := make([]DailyPoint, 0, 90)
for i := 90; i > 0; i-- {
history := make([]DailyPoint, 0, 140)
for i := 140; i > 0; i-- {
value := int64(100)
if i%2 == 0 {
value = 1_000
@@ -54,7 +91,21 @@ func TestForecastSuppressesPoorBacktest(t *testing.T) {
history = append(history, DailyPoint{Date: start.AddDate(0, 0, -i), SessionCount: value, PaidSessionCount: value, PaidAmountRial: value * 1_000})
}
forecast := BuildForecast(history, nil, start, 90)
if forecast.Confidence != ConfidenceInsufficientData || len(forecast.Points) != 0 {
if forecast.Backtest.Origins < 4 || forecast.Confidence != ConfidenceInsufficientData || len(forecast.Points) != 0 {
t.Fatalf("poor model should be suppressed: %+v", forecast)
}
}
func TestBacktestUsesProductionTrendPredictorAcrossFourteenDayHorizons(t *testing.T) {
start := date("2026-07-01")
history := make([]DailyPoint, 0, 140)
for index := 0; index < 140; index++ {
block := index / 28
value := int64(100 + block*20)
history = append(history, DailyPoint{Date: start.AddDate(0, 0, index-140), SessionCount: value, PaidSessionCount: value, PaidAmountRial: value * 1_000})
}
forecast := BuildForecast(history, nil, start, 90)
if forecast.Backtest.Origins < 4 || forecast.Confidence == ConfidenceInsufficientData {
t.Fatalf("trend-aware rolling backtest should be usable: %+v", forecast.Backtest)
}
}

View File

@@ -12,26 +12,46 @@ func AnalyzeEventEffect(history []DailyPoint, event Event, catalog []Event) Even
byDate := pointsByDate(history)
eventDays := daysInclusive(event.StartDate, event.EndDate)
var baselineSessions, baselinePaid, baselineAmount int64
actualComplete := true
for _, eventDay := range eventDays {
if point, ok := byDate[dayKey(eventDay)]; ok {
effect.ActualSessionCount += point.SessionCount
effect.ActualPaidSessions += point.PaidSessionCount
effect.ActualAmountRial += point.PaidAmountRial
point, ok := byDate[dayKey(eventDay)]
if !ok {
actualComplete = false
break
}
for week := 1; week <= 8; week++ {
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)] {
continue
complete = false
break
}
point, ok := byDate[dayKey(candidate)]
if !ok {
continue
complete = false
break
}
effect.ObservationCount++
baselineSessions += point.SessionCount
baselinePaid += point.PaidSessionCount
baselineAmount += point.PaidAmountRial
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
@@ -45,9 +65,9 @@ func AnalyzeEventEffect(history []DailyPoint, event Event, catalog []Event) Even
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.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
@@ -140,6 +160,9 @@ func normalizeDate(value time.Time) time.Time {
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

View File

@@ -20,7 +20,7 @@ func BuildForecast(history []DailyPoint, catalog []Event, start time.Time, horiz
if len(ordered) < minimumHistoryDays {
return result
}
result.Backtest = backtest(ordered)
result.Backtest = backtest(ordered, catalog)
result.Confidence = classifyConfidence(result.Backtest)
if result.Confidence == ConfidenceInsufficientData {
return result
@@ -31,28 +31,11 @@ func BuildForecast(history []DailyPoint, catalog []Event, start time.Time, horiz
}
func forecastPoints(history []DailyPoint, catalog []Event, start time.Time, horizon int) []ForecastPoint {
sessionResiduals, amountResiduals := residuals(history)
trendSessions := trendBPS(history, func(p DailyPoint) int64 { return p.PaidSessionCount })
trendAmount := trendBPS(history, func(p DailyPoint) int64 { return p.PaidAmountRial })
familyEffects := eventFamilyEffects(history, catalog)
sessionResiduals, amountResiduals := modelResiduals(history, catalog)
points := make([]ForecastPoint, 0, horizon)
for index := 0; index < horizon; index++ {
day := start.AddDate(0, 0, index)
baselineSessions := weekdayMedian(history, day.Weekday(), func(p DailyPoint) int64 { return p.PaidSessionCount })
baselineAmount := weekdayMedian(history, day.Weekday(), func(p DailyPoint) int64 { return p.PaidAmountRial })
predictedSessions := applyBPS(baselineSessions, trendSessions)
predictedAmount := applyBPS(baselineAmount, trendAmount)
eventIDs := make([]string, 0, 1)
for _, event := range catalog {
if day.Before(normalizeDate(event.StartDate)) || day.After(normalizeDate(event.EndDate)) {
continue
}
eventIDs = append(eventIDs, event.ID)
if uplift, ok := familyEffects[event.Family]; ok {
predictedSessions = applyBPS(predictedSessions, uplift.sessions)
predictedAmount = applyBPS(predictedAmount, uplift.amount)
}
}
baselineSessions, baselineAmount, predictedSessions, predictedAmount, eventIDs := predictCenter(history, catalog, day)
points = append(points, ForecastPoint{
Date: day, BaselineSessions: baselineSessions, BaselineAmountRial: baselineAmount,
PaidSessions: rangeFromResiduals(predictedSessions, sessionResiduals),
@@ -65,7 +48,7 @@ func forecastPoints(history []DailyPoint, catalog []Event, start time.Time, hori
func normalizedHistory(history []DailyPoint, before time.Time) []DailyPoint {
ordered := make([]DailyPoint, 0, len(history))
for _, point := range history {
if point.Date.Before(before) {
if dateBefore(point.Date, before) {
point.Date = normalizeDate(point.Date)
ordered = append(ordered, point)
}
@@ -97,13 +80,14 @@ func median(values []int64) int64 {
return (copyOfValues[middle-1] + copyOfValues[middle]) / 2
}
func residuals(history []DailyPoint) ([]int64, []int64) {
sessions := make([]int64, 0, len(history)-28)
amounts := make([]int64, 0, len(history)-28)
for index := 28; index < len(history); index++ {
func modelResiduals(history []DailyPoint, catalog []Event) ([]int64, []int64) {
sessions := make([]int64, 0, maxInt(0, len(history)-minimumHistoryDays))
amounts := make([]int64, 0, maxInt(0, len(history)-minimumHistoryDays))
for index := minimumHistoryDays; index < len(history); index++ {
prior := history[:index]
sessions = append(sessions, history[index].PaidSessionCount-weekdayMedian(prior, history[index].Date.Weekday(), func(p DailyPoint) int64 { return p.PaidSessionCount }))
amounts = append(amounts, history[index].PaidAmountRial-weekdayMedian(prior, history[index].Date.Weekday(), func(p DailyPoint) int64 { return p.PaidAmountRial }))
_, _, predictedSessions, predictedAmount, _ := predictCenter(prior, catalog, history[index].Date)
sessions = append(sessions, history[index].PaidSessionCount-predictedSessions)
amounts = append(amounts, history[index].PaidAmountRial-predictedAmount)
}
return sessions, amounts
}
@@ -147,22 +131,23 @@ func trendBPS(history []DailyPoint, value func(DailyPoint) int64) int64 {
return trend
}
func backtest(history []DailyPoint) BacktestResult {
func backtest(history []DailyPoint, catalog []Event) BacktestResult {
result := BacktestResult{}
var sessionError, amountError, covered int64
var evaluated int64
first := len(history) - 28
for origin := first; origin < len(history); origin += 7 {
first := len(history) - 56
for origin := first; origin < len(history); origin += 14 {
if origin < minimumHistoryDays {
continue
}
result.Origins++
training := history[:origin]
sessionResiduals, amountResiduals := residuals(training)
for index := origin; index < minInt(origin+7, len(history)); index++ {
sessionResiduals, amountResiduals := modelResiduals(training, catalog)
for index := origin; index < minInt(origin+14, len(history)); index++ {
actual := history[index]
sessionRange := rangeFromResiduals(weekdayMedian(training, actual.Date.Weekday(), func(p DailyPoint) int64 { return p.PaidSessionCount }), sessionResiduals)
amountRange := rangeFromResiduals(weekdayMedian(training, actual.Date.Weekday(), func(p DailyPoint) int64 { return p.PaidAmountRial }), amountResiduals)
_, _, sessionCenter, amountCenter, _ := predictCenter(training, catalog, actual.Date)
sessionRange := rangeFromResiduals(sessionCenter, sessionResiduals)
amountRange := rangeFromResiduals(amountCenter, amountResiduals)
sessionError += absolutePercentageErrorBPS(actual.PaidSessionCount, sessionRange.P50)
amountError += absolutePercentageErrorBPS(actual.PaidAmountRial, amountRange.P50)
if actual.PaidSessionCount >= sessionRange.P10 && actual.PaidSessionCount <= sessionRange.P90 && actual.PaidAmountRial >= amountRange.P10 && actual.PaidAmountRial <= amountRange.P90 {
@@ -179,6 +164,26 @@ func backtest(history []DailyPoint) BacktestResult {
return result
}
func predictCenter(history []DailyPoint, catalog []Event, day time.Time) (int64, int64, int64, int64, []string) {
baselineSessions := weekdayMedian(history, day.Weekday(), func(p DailyPoint) int64 { return p.PaidSessionCount })
baselineAmount := weekdayMedian(history, day.Weekday(), func(p DailyPoint) int64 { return p.PaidAmountRial })
predictedSessions := applyBPS(baselineSessions, trendBPS(history, func(p DailyPoint) int64 { return p.PaidSessionCount }))
predictedAmount := applyBPS(baselineAmount, trendBPS(history, func(p DailyPoint) int64 { return p.PaidAmountRial }))
eventIDs := make([]string, 0, 1)
familyEffects := eventFamilyEffects(history, catalog)
for _, event := range catalog {
if dateBefore(day, event.StartDate) || dateAfter(day, event.EndDate) {
continue
}
eventIDs = append(eventIDs, event.ID)
if uplift, ok := familyEffects[event.Family]; ok {
predictedSessions = applyBPS(predictedSessions, uplift.sessions)
predictedAmount = applyBPS(predictedAmount, uplift.amount)
}
}
return baselineSessions, baselineAmount, predictedSessions, predictedAmount, eventIDs
}
func classifyConfidence(backtest BacktestResult) Confidence {
if backtest.Origins < 4 {
return ConfidenceInsufficientData
@@ -199,7 +204,7 @@ func eventFamilyEffects(history []DailyPoint, catalog []Event) map[string]family
result := make(map[string]familyUplift)
counts := make(map[string]int64)
for _, event := range catalog {
if event.Family == "" || event.StartDate.After(history[len(history)-1].Date) {
if event.Family == "" || dateAfter(event.StartDate, history[len(history)-1].Date) {
continue
}
effect := AnalyzeEventEffect(history, event, catalog)
@@ -249,11 +254,18 @@ func minInt(a, b int) int {
return b
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func DetectRisks(forecast Forecast, limit int) []Risk {
if forecast.Confidence == ConfidenceInsufficientData || limit <= 0 {
return nil
}
risks := make([]Risk, 0, limit)
candidates := make([]Risk, 0, len(forecast.Points))
for _, point := range forecast.Points {
if point.BaselineSessions < MinBaselineSessions {
continue
@@ -263,10 +275,49 @@ func DetectRisks(forecast Forecast, limit int) []Risk {
if sessionDecline < MaterialChangeBPS && amountDecline < MaterialChangeBPS {
continue
}
risks = append(risks, Risk{EvidenceID: "risk:" + dayKey(point.Date), Date: point.Date, SessionDeclineBPS: maxInt64(0, sessionDecline), AmountDeclineBPS: maxInt64(0, amountDecline), ExpectedSessions: point.PaidSessions, ExpectedAmountRial: point.PaidAmountRial})
if len(risks) == limit {
break
candidates = append(candidates, Risk{Date: point.Date, EndDate: point.Date, SessionDeclineBPS: maxInt64(0, sessionDecline), AmountDeclineBPS: maxInt64(0, amountDecline), ExpectedSessions: point.PaidSessions, ExpectedAmountRial: point.PaidAmountRial})
}
coalesced := coalesceRisks(candidates)
slices.SortStableFunc(coalesced, func(a, b Risk) int {
aSeverity := maxInt64(a.SessionDeclineBPS, a.AmountDeclineBPS)
bSeverity := maxInt64(b.SessionDeclineBPS, b.AmountDeclineBPS)
if aSeverity > bSeverity {
return -1
}
if aSeverity < bSeverity {
return 1
}
return a.Date.Compare(b.Date)
})
if len(coalesced) > limit {
coalesced = coalesced[:limit]
}
return coalesced
}
func coalesceRisks(candidates []Risk) []Risk {
if len(candidates) == 0 {
return nil
}
result := make([]Risk, 0, len(candidates))
current := candidates[0]
for _, candidate := range candidates[1:] {
if dayKey(candidate.Date) != dayKey(current.EndDate.AddDate(0, 0, 1)) {
current.EvidenceID = EvidenceID("risk", dayKey(current.Date)+":"+dayKey(current.EndDate))
result = append(result, current)
current = candidate
continue
}
current.EndDate = candidate.EndDate
if candidate.SessionDeclineBPS > current.SessionDeclineBPS {
current.SessionDeclineBPS = candidate.SessionDeclineBPS
current.ExpectedSessions = candidate.ExpectedSessions
}
if candidate.AmountDeclineBPS > current.AmountDeclineBPS {
current.AmountDeclineBPS = candidate.AmountDeclineBPS
current.ExpectedAmountRial = candidate.ExpectedAmountRial
}
}
return risks
current.EvidenceID = EvidenceID("risk", dayKey(current.Date)+":"+dayKey(current.EndDate))
return append(result, current)
}

View File

@@ -9,7 +9,7 @@ import (
func historicalWins(history []DailyPoint, catalog []Event, before time.Time) []EventEffect {
wins := make([]EventEffect, 0)
for _, event := range catalog {
if !event.EndDate.Before(before) {
if !dateBefore(event.EndDate, before) {
continue
}
effect := AnalyzeEventEffect(history, event, catalog)
@@ -48,7 +48,7 @@ func buildOpportunities(wins []EventEffect, catalog []Event, forecast Forecast,
historical := eventByID[win.EventID]
upcoming := make([]Event, 0)
for _, event := range catalog {
if !event.EndDate.Before(start) {
if !dateBefore(event.EndDate, start) {
upcoming = append(upcoming, event)
}
}
@@ -93,7 +93,7 @@ func buildOpportunities(wins []EventEffect, catalog []Event, forecast Forecast,
func filterCatalog(catalog []Event, category string) []Event {
filtered := make([]Event, 0, len(catalog))
for _, event := range catalog {
if category == "" || len(event.Categories) == 0 || slices.Contains(event.Categories, category) {
if category == "" || event.Universal || slices.Contains(event.Categories, category) {
filtered = append(filtered, event)
}
}

View File

@@ -47,12 +47,14 @@ func EvaluatePeer(input PeerInput) PeerResult {
slices.Sort(values)
result.State = PeerReady
result.MedianPaidSessions = median(values)
var atOrBelow int64
var below, equal int64
for _, value := range values {
if input.MerchantPaidSessions >= value {
atOrBelow++
if input.MerchantPaidSessions > value {
below++
} else if input.MerchantPaidSessions == value {
equal++
}
}
result.PercentileBPS = atOrBelow * 10_000 / int64(len(values))
result.PercentileBPS = (2*below + equal) * 5_000 / int64(len(values))
return result
}

View File

@@ -9,16 +9,17 @@ import (
)
type FingerprintInput struct {
MerchantID int64 `json:"merchantId"`
TerminalID string `json:"terminalId,omitempty"`
DatasetWatermark string `json:"datasetWatermark"`
ForecastStart time.Time `json:"forecastStart"`
HorizonDays int `json:"horizonDays"`
CatalogVersion string `json:"catalogVersion"`
AnalyticsVersion string `json:"analyticsVersion"`
PromptVersion string `json:"promptVersion"`
SchemaVersion string `json:"schemaVersion"`
Model string `json:"model"`
MerchantID int64 `json:"merchantId"`
TerminalID string `json:"terminalId,omitempty"`
DatasetWatermark string `json:"datasetWatermark"`
ForecastStart time.Time `json:"forecastStart"`
HorizonDays int `json:"horizonDays"`
CatalogVersion string `json:"catalogVersion"`
AnalyticsVersion string `json:"analyticsVersion"`
PromptVersion string `json:"promptVersion"`
SchemaVersion string `json:"schemaVersion"`
Model string `json:"model"`
ModelConfigVersion string `json:"modelConfigVersion"`
}
func Fingerprint(input FingerprintInput) string {
@@ -30,6 +31,7 @@ func Fingerprint(input FingerprintInput) string {
DatasetWatermark: input.DatasetWatermark, HorizonDays: input.HorizonDays,
CatalogVersion: input.CatalogVersion, AnalyticsVersion: input.AnalyticsVersion,
PromptVersion: input.PromptVersion, SchemaVersion: input.SchemaVersion, Model: input.Model,
ModelConfigVersion: input.ModelConfigVersion,
}, ForecastDate: input.ForecastStart.In(tehranLocation()).Format(time.DateOnly)})
if err != nil {
return ""
@@ -95,6 +97,7 @@ func BuildFallback(bundle EvidenceBundle) []CampaignBrief {
HeadlineFA: "یک فرصت مناسب در راه است: " + opportunity.EventTitleFA,
GuidanceFA: "داده‌های قبلی شما نشان می‌دهد این مناسبت می‌تواند برای برنامه‌ریزی فروش ارزشمند باشد.",
OfferFA: "پیشنهاد خود را متناسب با نیاز مشتریان این مناسبت آماده کنید.",
MessageFA: "برای این مناسبت یک پیشنهاد ویژه برای شما آماده کرده‌ایم.",
PreparationFA: "موجودی، پیام کمپین و مسیر پرداخت را پیش از شروع مناسبت بررسی کنید.",
LimitationFA: "این پیشنهاد بر پایه الگوهای گذشته است و نتیجه قطعی نیست.",
EvidenceIDs: []string{opportunity.EvidenceID},
@@ -105,6 +108,7 @@ func BuildFallback(bundle EvidenceBundle) []CampaignBrief {
HeadlineFA: "برای یک بازه آرام‌تر آماده باشید",
GuidanceFA: "الگوی گذشته احتمال کاهش فعالیت را در این بازه نشان می‌دهد.",
OfferFA: "یک پیشنهاد سبک برای حفظ ارتباط با مشتریان در نظر بگیرید.",
MessageFA: "این روزها هم کنار شما هستیم؛ پیشنهادهای تازه را ببینید.",
PreparationFA: "پیام یادآوری و تجربه پرداخت را پیش از این بازه بازبینی کنید.",
LimitationFA: "این هشدار بر پایه الگوهای گذشته است و نتیجه قطعی نیست.",
EvidenceIDs: []string{risk.EvidenceID},

View File

@@ -3,8 +3,9 @@ package growth
import "time"
const (
CatalogVersion = "1405.1"
AnalyticsVersion = "1.0.0"
CatalogVersion = "1405.2"
AnalyticsVersion = "1.1.0"
DefaultModelConfigVersion = "avalai-responses-v1-tools4-turns2-output1600"
MinBaselineObservations = 4
MinBaselineSessions = 100
@@ -26,6 +27,7 @@ const (
SuppressionInsufficientObservations SuppressionReason = "INSUFFICIENT_BASELINE_OBSERVATIONS"
SuppressionInsufficientBaselineSessions SuppressionReason = "INSUFFICIENT_BASELINE_SESSIONS"
SuppressionInsufficientHistory SuppressionReason = "INSUFFICIENT_HISTORY"
SuppressionIncompleteEventWindow SuppressionReason = "INCOMPLETE_EVENT_WINDOW"
SuppressionInsufficientCohort SuppressionReason = "INSUFFICIENT_COHORT"
SuppressionTerminalScope SuppressionReason = "TERMINAL_SCOPE_UNSUPPORTED"
)
@@ -46,15 +48,18 @@ 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"`
Categories []string `json:"categories"`
Kind EventKind `json:"kind"`
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"`
}
type EventEffect struct {
@@ -127,6 +132,7 @@ type Opportunity struct {
type Risk struct {
EvidenceID string `json:"evidenceId"`
Date time.Time `json:"date"`
EndDate time.Time `json:"endDate"`
SessionDeclineBPS int64 `json:"sessionDeclineBps"`
AmountDeclineBPS int64 `json:"amountDeclineBps"`
ExpectedSessions Range `json:"expectedSessions"`
@@ -142,6 +148,7 @@ type CampaignBrief struct {
HeadlineFA string `json:"headlineFa"`
GuidanceFA string `json:"guidanceFa"`
OfferFA string `json:"offerFa"`
MessageFA string `json:"messageFa"`
PreparationFA string `json:"preparationFa"`
LimitationFA string `json:"limitationFa"`
EvidenceIDs []string `json:"evidenceIds"`