fix(growth): harden deterministic report evidence
This commit is contained in:
43
internal/growth/aggregate.go
Normal file
43
internal/growth/aggregate.go
Normal 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
|
||||||
|
}
|
||||||
29
internal/growth/aggregate_test.go
Normal file
29
internal/growth/aggregate_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
func TestEvaluatePeerAppliesPrivacyThresholdAndEqualMerchantWeight(t *testing.T) {
|
||||||
peers := make([]PeerMerchantValue, 10)
|
peers := make([]PeerMerchantValue, 10)
|
||||||
for i := range peers {
|
for i := range peers {
|
||||||
peers[i] = PeerMerchantValue{PaidSessions: int64(i + 1), SessionCount: 100}
|
peers[i] = PeerMerchantValue{PaidSessions: int64(i + 1), SessionCount: 100}
|
||||||
}
|
}
|
||||||
result := EvaluatePeer(PeerInput{MerchantPaidSessions: 8, Peers: peers})
|
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)
|
t.Fatalf("unexpected peer result: %+v", result)
|
||||||
}
|
}
|
||||||
suppressed := EvaluatePeer(PeerInput{TerminalScoped: true, Peers: peers})
|
suppressed := EvaluatePeer(PeerInput{TerminalScoped: true, Peers: peers})
|
||||||
@@ -90,7 +102,7 @@ func TestEvaluatePeerAppliesPrivacyThresholdAndEqualMerchantWeight(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestFingerprintIsStableAndVersionSensitive(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)
|
a := Fingerprint(input)
|
||||||
b := Fingerprint(input)
|
b := Fingerprint(input)
|
||||||
input.Model = "other"
|
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) {
|
func TestTehranRefreshBoundary(t *testing.T) {
|
||||||
last := time.Date(2026, 8, 20, 20, 31, 0, 0, time.UTC) // 00:01 Tehran on Aug 21.
|
last := time.Date(2026, 8, 20, 20, 31, 0, 0, time.UTC) // 00:01 Tehran on Aug 21.
|
||||||
next := NextRefreshAt(last)
|
next := NextRefreshAt(last)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
const officialCalendarSource = "https://calendar.ut.ac.ir/"
|
const officialCalendarSource = "https://calendar.ut.ac.ir/"
|
||||||
const curatedCommercialSource = "CURATED_COMMERCIAL_1405_V1"
|
const curatedCommercialSource = "CURATED_COMMERCIAL_1405_V1"
|
||||||
|
const officialCalendarVersion = "IR_OFFICIAL_1404_1405_REVIEWED_2026-08-21"
|
||||||
|
|
||||||
var importedMerchantCategories = []string{
|
var importedMerchantCategories = []string{
|
||||||
"ارائه دهنده خدمات اینترنت",
|
"ارائه دهنده خدمات اینترنت",
|
||||||
@@ -23,12 +24,29 @@ var importedMerchantCategories = []string{
|
|||||||
// the first 90 forecast days after 2026-06-30.
|
// the first 90 forecast days after 2026-06-30.
|
||||||
func Catalog1405() []Event {
|
func Catalog1405() []Event {
|
||||||
events := []Event{
|
events := []Event{
|
||||||
catalogEvent("nowruz-1405", "نوروز", "2026-03-21", "2026-03-24", "new-year", EventOfficial, []string{"هدیه", "خرید", "سال-نو"}),
|
officialEvent("fathers-day-1404", "روز پدر", "2026-01-03", "2026-01-03", "appreciation", true, []string{"هدیه", "قدردانی"}),
|
||||||
catalogEvent("nature-day-1405", "روز طبیعت", "2026-04-02", "2026-04-02", "leisure", EventOfficial, []string{"تفریح", "خرید"}),
|
officialEvent("mabath-1404", "مبعث پیامبر اکرم", "2026-01-17", "2026-01-17", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||||
catalogEvent("teachers-day-1405", "روز معلم", "2026-05-02", "2026-05-02", "appreciation", EventOfficial, []string{"هدیه", "آموزش", "قدردانی"}),
|
officialEvent("imam-mahdi-birthday-1404", "نیمه شعبان", "2026-02-04", "2026-02-04", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||||
catalogEvent("khordad-15-1405", "قیام پانزده خرداد", "2026-06-05", "2026-06-05", "official-holiday", EventOfficial, []string{"تعطیل"}),
|
officialEvent("revolution-day-1404", "پیروزی انقلاب اسلامی", "2026-02-11", "2026-02-11", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}),
|
||||||
catalogEvent("doctors-day-1405", "روز پزشک", "2026-08-23", "2026-08-23", "appreciation", EventOfficial, []string{"هدیه", "قدردانی", "حرفهای"}),
|
officialEvent("imam-ali-martyrdom-1404", "شهادت امام علی", "2026-03-11", "2026-03-11", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||||
catalogEvent("back-to-school-1405", "آمادگی بازگشایی مدارس", "2026-09-10", "2026-09-22", "education-season", EventCommercial, []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))
|
result := make([]Event, len(events))
|
||||||
for index, event := range events {
|
for index, event := range events {
|
||||||
@@ -39,18 +57,18 @@ func Catalog1405() []Event {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func catalogEvent(id, title, start, end, family string, kind EventKind, tags []string) Event {
|
func officialEvent(id, title, start, end, family string, holiday bool, tags []string) Event {
|
||||||
source := officialCalendarSource
|
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}
|
||||||
if kind == EventCommercial {
|
}
|
||||||
source = curatedCommercialSource
|
|
||||||
}
|
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: source, Categories: slices.Clone(importedMerchantCategories), Kind: kind}
|
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 {
|
func ValidateCatalog(events []Event) error {
|
||||||
seen := make(map[string]struct{}, len(events))
|
seen := make(map[string]struct{}, len(events))
|
||||||
for index, event := range 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)
|
return fmt.Errorf("growth catalog event %d is incomplete", index)
|
||||||
}
|
}
|
||||||
if _, exists := seen[event.ID]; exists {
|
if _, exists := seen[event.ID]; exists {
|
||||||
@@ -63,8 +81,14 @@ func ValidateCatalog(events []Event) error {
|
|||||||
if event.Kind != EventOfficial && event.Kind != EventCommercial {
|
if event.Kind != EventOfficial && event.Kind != EventCommercial {
|
||||||
return fmt.Errorf("growth catalog event %q has invalid kind", event.ID)
|
return fmt.Errorf("growth catalog event %q has invalid kind", event.ID)
|
||||||
}
|
}
|
||||||
if len(normalizedSet(event.Tags)) == 0 || len(event.Categories) == 0 {
|
if len(normalizedSet(event.Tags)) == 0 {
|
||||||
return fmt.Errorf("growth catalog event %q lacks tags or supported categories", event.ID)
|
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
|
return nil
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func TestCatalog1405IsValidAndDefensivelyCopied(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateCatalogRejectsDuplicateAndIncompleteEvents(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 {
|
if err := ValidateCatalog([]Event{event, event}); err == nil {
|
||||||
t.Fatal("expected duplicate id error")
|
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) {
|
func TestAnalyzeEventEffectExcludesCatalogEventsFromBaseline(t *testing.T) {
|
||||||
target := Event{ID: "target", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}
|
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)}
|
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) {
|
func TestForecastSuppressesPoorBacktest(t *testing.T) {
|
||||||
start := date("2026-07-01")
|
start := date("2026-07-01")
|
||||||
history := make([]DailyPoint, 0, 90)
|
history := make([]DailyPoint, 0, 140)
|
||||||
for i := 90; i > 0; i-- {
|
for i := 140; i > 0; i-- {
|
||||||
value := int64(100)
|
value := int64(100)
|
||||||
if i%2 == 0 {
|
if i%2 == 0 {
|
||||||
value = 1_000
|
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})
|
history = append(history, DailyPoint{Date: start.AddDate(0, 0, -i), SessionCount: value, PaidSessionCount: value, PaidAmountRial: value * 1_000})
|
||||||
}
|
}
|
||||||
forecast := BuildForecast(history, nil, start, 90)
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,26 +12,46 @@ func AnalyzeEventEffect(history []DailyPoint, event Event, catalog []Event) Even
|
|||||||
byDate := pointsByDate(history)
|
byDate := pointsByDate(history)
|
||||||
eventDays := daysInclusive(event.StartDate, event.EndDate)
|
eventDays := daysInclusive(event.StartDate, event.EndDate)
|
||||||
var baselineSessions, baselinePaid, baselineAmount int64
|
var baselineSessions, baselinePaid, baselineAmount int64
|
||||||
|
actualComplete := true
|
||||||
for _, eventDay := range eventDays {
|
for _, eventDay := range eventDays {
|
||||||
if point, ok := byDate[dayKey(eventDay)]; ok {
|
point, ok := byDate[dayKey(eventDay)]
|
||||||
effect.ActualSessionCount += point.SessionCount
|
if !ok {
|
||||||
effect.ActualPaidSessions += point.PaidSessionCount
|
actualComplete = false
|
||||||
effect.ActualAmountRial += point.PaidAmountRial
|
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)
|
candidate := eventDay.AddDate(0, 0, -7*week)
|
||||||
if eventDates[dayKey(candidate)] {
|
if eventDates[dayKey(candidate)] {
|
||||||
continue
|
complete = false
|
||||||
|
break
|
||||||
}
|
}
|
||||||
point, ok := byDate[dayKey(candidate)]
|
point, ok := byDate[dayKey(candidate)]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
complete = false
|
||||||
|
break
|
||||||
}
|
}
|
||||||
effect.ObservationCount++
|
windowSessions += point.SessionCount
|
||||||
baselineSessions += point.SessionCount
|
windowPaid += point.PaidSessionCount
|
||||||
baselinePaid += point.PaidSessionCount
|
windowAmount += point.PaidAmountRial
|
||||||
baselineAmount += point.PaidAmountRial
|
|
||||||
}
|
}
|
||||||
|
if !complete {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
effect.ObservationCount++
|
||||||
|
baselineSessions += windowSessions
|
||||||
|
baselinePaid += windowPaid
|
||||||
|
baselineAmount += windowAmount
|
||||||
}
|
}
|
||||||
if effect.ObservationCount < MinBaselineObservations {
|
if effect.ObservationCount < MinBaselineObservations {
|
||||||
effect.SuppressionReason = SuppressionInsufficientObservations
|
effect.SuppressionReason = SuppressionInsufficientObservations
|
||||||
@@ -45,9 +65,9 @@ func AnalyzeEventEffect(history []DailyPoint, event Event, catalog []Event) Even
|
|||||||
return effect
|
return effect
|
||||||
}
|
}
|
||||||
effect.Eligible = true
|
effect.Eligible = true
|
||||||
effect.SessionUpliftBPS = changeBPS(effect.ActualSessionCount/int64(len(eventDays)), effect.BaselineSessionCount)
|
effect.SessionUpliftBPS = changeBPS(effect.ActualSessionCount, effect.BaselineSessionCount)
|
||||||
effect.PaidSessionUpliftBPS = changeBPS(effect.ActualPaidSessions/int64(len(eventDays)), effect.BaselinePaidSessions)
|
effect.PaidSessionUpliftBPS = changeBPS(effect.ActualPaidSessions, effect.BaselinePaidSessions)
|
||||||
effect.AmountUpliftBPS = changeBPS(effect.ActualAmountRial/int64(len(eventDays)), effect.BaselineAmountRial)
|
effect.AmountUpliftBPS = changeBPS(effect.ActualAmountRial, effect.BaselineAmountRial)
|
||||||
effect.MaterialWin = effect.PaidSessionUpliftBPS >= MaterialChangeBPS || effect.AmountUpliftBPS >= MaterialChangeBPS
|
effect.MaterialWin = effect.PaidSessionUpliftBPS >= MaterialChangeBPS || effect.AmountUpliftBPS >= MaterialChangeBPS
|
||||||
effect.MaterialDecline = effect.PaidSessionUpliftBPS <= -MaterialChangeBPS || effect.AmountUpliftBPS <= -MaterialChangeBPS
|
effect.MaterialDecline = effect.PaidSessionUpliftBPS <= -MaterialChangeBPS || effect.AmountUpliftBPS <= -MaterialChangeBPS
|
||||||
return effect
|
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 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 {
|
func changeBPS(actual, baseline int64) int64 {
|
||||||
if baseline <= 0 {
|
if baseline <= 0 {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func BuildForecast(history []DailyPoint, catalog []Event, start time.Time, horiz
|
|||||||
if len(ordered) < minimumHistoryDays {
|
if len(ordered) < minimumHistoryDays {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
result.Backtest = backtest(ordered)
|
result.Backtest = backtest(ordered, catalog)
|
||||||
result.Confidence = classifyConfidence(result.Backtest)
|
result.Confidence = classifyConfidence(result.Backtest)
|
||||||
if result.Confidence == ConfidenceInsufficientData {
|
if result.Confidence == ConfidenceInsufficientData {
|
||||||
return result
|
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 {
|
func forecastPoints(history []DailyPoint, catalog []Event, start time.Time, horizon int) []ForecastPoint {
|
||||||
sessionResiduals, amountResiduals := residuals(history)
|
sessionResiduals, amountResiduals := modelResiduals(history, catalog)
|
||||||
trendSessions := trendBPS(history, func(p DailyPoint) int64 { return p.PaidSessionCount })
|
|
||||||
trendAmount := trendBPS(history, func(p DailyPoint) int64 { return p.PaidAmountRial })
|
|
||||||
familyEffects := eventFamilyEffects(history, catalog)
|
|
||||||
points := make([]ForecastPoint, 0, horizon)
|
points := make([]ForecastPoint, 0, horizon)
|
||||||
for index := 0; index < horizon; index++ {
|
for index := 0; index < horizon; index++ {
|
||||||
day := start.AddDate(0, 0, index)
|
day := start.AddDate(0, 0, index)
|
||||||
baselineSessions := weekdayMedian(history, day.Weekday(), func(p DailyPoint) int64 { return p.PaidSessionCount })
|
baselineSessions, baselineAmount, predictedSessions, predictedAmount, eventIDs := predictCenter(history, catalog, day)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
points = append(points, ForecastPoint{
|
points = append(points, ForecastPoint{
|
||||||
Date: day, BaselineSessions: baselineSessions, BaselineAmountRial: baselineAmount,
|
Date: day, BaselineSessions: baselineSessions, BaselineAmountRial: baselineAmount,
|
||||||
PaidSessions: rangeFromResiduals(predictedSessions, sessionResiduals),
|
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 {
|
func normalizedHistory(history []DailyPoint, before time.Time) []DailyPoint {
|
||||||
ordered := make([]DailyPoint, 0, len(history))
|
ordered := make([]DailyPoint, 0, len(history))
|
||||||
for _, point := range history {
|
for _, point := range history {
|
||||||
if point.Date.Before(before) {
|
if dateBefore(point.Date, before) {
|
||||||
point.Date = normalizeDate(point.Date)
|
point.Date = normalizeDate(point.Date)
|
||||||
ordered = append(ordered, point)
|
ordered = append(ordered, point)
|
||||||
}
|
}
|
||||||
@@ -97,13 +80,14 @@ func median(values []int64) int64 {
|
|||||||
return (copyOfValues[middle-1] + copyOfValues[middle]) / 2
|
return (copyOfValues[middle-1] + copyOfValues[middle]) / 2
|
||||||
}
|
}
|
||||||
|
|
||||||
func residuals(history []DailyPoint) ([]int64, []int64) {
|
func modelResiduals(history []DailyPoint, catalog []Event) ([]int64, []int64) {
|
||||||
sessions := make([]int64, 0, len(history)-28)
|
sessions := make([]int64, 0, maxInt(0, len(history)-minimumHistoryDays))
|
||||||
amounts := make([]int64, 0, len(history)-28)
|
amounts := make([]int64, 0, maxInt(0, len(history)-minimumHistoryDays))
|
||||||
for index := 28; index < len(history); index++ {
|
for index := minimumHistoryDays; index < len(history); index++ {
|
||||||
prior := history[:index]
|
prior := history[:index]
|
||||||
sessions = append(sessions, history[index].PaidSessionCount-weekdayMedian(prior, history[index].Date.Weekday(), func(p DailyPoint) int64 { return p.PaidSessionCount }))
|
_, _, predictedSessions, predictedAmount, _ := predictCenter(prior, catalog, history[index].Date)
|
||||||
amounts = append(amounts, history[index].PaidAmountRial-weekdayMedian(prior, history[index].Date.Weekday(), func(p DailyPoint) int64 { return p.PaidAmountRial }))
|
sessions = append(sessions, history[index].PaidSessionCount-predictedSessions)
|
||||||
|
amounts = append(amounts, history[index].PaidAmountRial-predictedAmount)
|
||||||
}
|
}
|
||||||
return sessions, amounts
|
return sessions, amounts
|
||||||
}
|
}
|
||||||
@@ -147,22 +131,23 @@ func trendBPS(history []DailyPoint, value func(DailyPoint) int64) int64 {
|
|||||||
return trend
|
return trend
|
||||||
}
|
}
|
||||||
|
|
||||||
func backtest(history []DailyPoint) BacktestResult {
|
func backtest(history []DailyPoint, catalog []Event) BacktestResult {
|
||||||
result := BacktestResult{}
|
result := BacktestResult{}
|
||||||
var sessionError, amountError, covered int64
|
var sessionError, amountError, covered int64
|
||||||
var evaluated int64
|
var evaluated int64
|
||||||
first := len(history) - 28
|
first := len(history) - 56
|
||||||
for origin := first; origin < len(history); origin += 7 {
|
for origin := first; origin < len(history); origin += 14 {
|
||||||
if origin < minimumHistoryDays {
|
if origin < minimumHistoryDays {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
result.Origins++
|
result.Origins++
|
||||||
training := history[:origin]
|
training := history[:origin]
|
||||||
sessionResiduals, amountResiduals := residuals(training)
|
sessionResiduals, amountResiduals := modelResiduals(training, catalog)
|
||||||
for index := origin; index < minInt(origin+7, len(history)); index++ {
|
for index := origin; index < minInt(origin+14, len(history)); index++ {
|
||||||
actual := history[index]
|
actual := history[index]
|
||||||
sessionRange := rangeFromResiduals(weekdayMedian(training, actual.Date.Weekday(), func(p DailyPoint) int64 { return p.PaidSessionCount }), sessionResiduals)
|
_, _, sessionCenter, amountCenter, _ := predictCenter(training, catalog, actual.Date)
|
||||||
amountRange := rangeFromResiduals(weekdayMedian(training, actual.Date.Weekday(), func(p DailyPoint) int64 { return p.PaidAmountRial }), amountResiduals)
|
sessionRange := rangeFromResiduals(sessionCenter, sessionResiduals)
|
||||||
|
amountRange := rangeFromResiduals(amountCenter, amountResiduals)
|
||||||
sessionError += absolutePercentageErrorBPS(actual.PaidSessionCount, sessionRange.P50)
|
sessionError += absolutePercentageErrorBPS(actual.PaidSessionCount, sessionRange.P50)
|
||||||
amountError += absolutePercentageErrorBPS(actual.PaidAmountRial, amountRange.P50)
|
amountError += absolutePercentageErrorBPS(actual.PaidAmountRial, amountRange.P50)
|
||||||
if actual.PaidSessionCount >= sessionRange.P10 && actual.PaidSessionCount <= sessionRange.P90 && actual.PaidAmountRial >= amountRange.P10 && actual.PaidAmountRial <= amountRange.P90 {
|
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
|
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 {
|
func classifyConfidence(backtest BacktestResult) Confidence {
|
||||||
if backtest.Origins < 4 {
|
if backtest.Origins < 4 {
|
||||||
return ConfidenceInsufficientData
|
return ConfidenceInsufficientData
|
||||||
@@ -199,7 +204,7 @@ func eventFamilyEffects(history []DailyPoint, catalog []Event) map[string]family
|
|||||||
result := make(map[string]familyUplift)
|
result := make(map[string]familyUplift)
|
||||||
counts := make(map[string]int64)
|
counts := make(map[string]int64)
|
||||||
for _, event := range catalog {
|
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
|
continue
|
||||||
}
|
}
|
||||||
effect := AnalyzeEventEffect(history, event, catalog)
|
effect := AnalyzeEventEffect(history, event, catalog)
|
||||||
@@ -249,11 +254,18 @@ func minInt(a, b int) int {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func maxInt(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
func DetectRisks(forecast Forecast, limit int) []Risk {
|
func DetectRisks(forecast Forecast, limit int) []Risk {
|
||||||
if forecast.Confidence == ConfidenceInsufficientData || limit <= 0 {
|
if forecast.Confidence == ConfidenceInsufficientData || limit <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
risks := make([]Risk, 0, limit)
|
candidates := make([]Risk, 0, len(forecast.Points))
|
||||||
for _, point := range forecast.Points {
|
for _, point := range forecast.Points {
|
||||||
if point.BaselineSessions < MinBaselineSessions {
|
if point.BaselineSessions < MinBaselineSessions {
|
||||||
continue
|
continue
|
||||||
@@ -263,10 +275,49 @@ func DetectRisks(forecast Forecast, limit int) []Risk {
|
|||||||
if sessionDecline < MaterialChangeBPS && amountDecline < MaterialChangeBPS {
|
if sessionDecline < MaterialChangeBPS && amountDecline < MaterialChangeBPS {
|
||||||
continue
|
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})
|
candidates = append(candidates, Risk{Date: point.Date, EndDate: point.Date, SessionDeclineBPS: maxInt64(0, sessionDecline), AmountDeclineBPS: maxInt64(0, amountDecline), ExpectedSessions: point.PaidSessions, ExpectedAmountRial: point.PaidAmountRial})
|
||||||
if len(risks) == limit {
|
}
|
||||||
break
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
func historicalWins(history []DailyPoint, catalog []Event, before time.Time) []EventEffect {
|
func historicalWins(history []DailyPoint, catalog []Event, before time.Time) []EventEffect {
|
||||||
wins := make([]EventEffect, 0)
|
wins := make([]EventEffect, 0)
|
||||||
for _, event := range catalog {
|
for _, event := range catalog {
|
||||||
if !event.EndDate.Before(before) {
|
if !dateBefore(event.EndDate, before) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
effect := AnalyzeEventEffect(history, event, catalog)
|
effect := AnalyzeEventEffect(history, event, catalog)
|
||||||
@@ -48,7 +48,7 @@ func buildOpportunities(wins []EventEffect, catalog []Event, forecast Forecast,
|
|||||||
historical := eventByID[win.EventID]
|
historical := eventByID[win.EventID]
|
||||||
upcoming := make([]Event, 0)
|
upcoming := make([]Event, 0)
|
||||||
for _, event := range catalog {
|
for _, event := range catalog {
|
||||||
if !event.EndDate.Before(start) {
|
if !dateBefore(event.EndDate, start) {
|
||||||
upcoming = append(upcoming, event)
|
upcoming = append(upcoming, event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +93,7 @@ func buildOpportunities(wins []EventEffect, catalog []Event, forecast Forecast,
|
|||||||
func filterCatalog(catalog []Event, category string) []Event {
|
func filterCatalog(catalog []Event, category string) []Event {
|
||||||
filtered := make([]Event, 0, len(catalog))
|
filtered := make([]Event, 0, len(catalog))
|
||||||
for _, event := range 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)
|
filtered = append(filtered, event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,12 +47,14 @@ func EvaluatePeer(input PeerInput) PeerResult {
|
|||||||
slices.Sort(values)
|
slices.Sort(values)
|
||||||
result.State = PeerReady
|
result.State = PeerReady
|
||||||
result.MedianPaidSessions = median(values)
|
result.MedianPaidSessions = median(values)
|
||||||
var atOrBelow int64
|
var below, equal int64
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if input.MerchantPaidSessions >= value {
|
if input.MerchantPaidSessions > value {
|
||||||
atOrBelow++
|
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
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type FingerprintInput struct {
|
type FingerprintInput struct {
|
||||||
MerchantID int64 `json:"merchantId"`
|
MerchantID int64 `json:"merchantId"`
|
||||||
TerminalID string `json:"terminalId,omitempty"`
|
TerminalID string `json:"terminalId,omitempty"`
|
||||||
DatasetWatermark string `json:"datasetWatermark"`
|
DatasetWatermark string `json:"datasetWatermark"`
|
||||||
ForecastStart time.Time `json:"forecastStart"`
|
ForecastStart time.Time `json:"forecastStart"`
|
||||||
HorizonDays int `json:"horizonDays"`
|
HorizonDays int `json:"horizonDays"`
|
||||||
CatalogVersion string `json:"catalogVersion"`
|
CatalogVersion string `json:"catalogVersion"`
|
||||||
AnalyticsVersion string `json:"analyticsVersion"`
|
AnalyticsVersion string `json:"analyticsVersion"`
|
||||||
PromptVersion string `json:"promptVersion"`
|
PromptVersion string `json:"promptVersion"`
|
||||||
SchemaVersion string `json:"schemaVersion"`
|
SchemaVersion string `json:"schemaVersion"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
|
ModelConfigVersion string `json:"modelConfigVersion"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Fingerprint(input FingerprintInput) string {
|
func Fingerprint(input FingerprintInput) string {
|
||||||
@@ -30,6 +31,7 @@ func Fingerprint(input FingerprintInput) string {
|
|||||||
DatasetWatermark: input.DatasetWatermark, HorizonDays: input.HorizonDays,
|
DatasetWatermark: input.DatasetWatermark, HorizonDays: input.HorizonDays,
|
||||||
CatalogVersion: input.CatalogVersion, AnalyticsVersion: input.AnalyticsVersion,
|
CatalogVersion: input.CatalogVersion, AnalyticsVersion: input.AnalyticsVersion,
|
||||||
PromptVersion: input.PromptVersion, SchemaVersion: input.SchemaVersion, Model: input.Model,
|
PromptVersion: input.PromptVersion, SchemaVersion: input.SchemaVersion, Model: input.Model,
|
||||||
|
ModelConfigVersion: input.ModelConfigVersion,
|
||||||
}, ForecastDate: input.ForecastStart.In(tehranLocation()).Format(time.DateOnly)})
|
}, ForecastDate: input.ForecastStart.In(tehranLocation()).Format(time.DateOnly)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -95,6 +97,7 @@ func BuildFallback(bundle EvidenceBundle) []CampaignBrief {
|
|||||||
HeadlineFA: "یک فرصت مناسب در راه است: " + opportunity.EventTitleFA,
|
HeadlineFA: "یک فرصت مناسب در راه است: " + opportunity.EventTitleFA,
|
||||||
GuidanceFA: "دادههای قبلی شما نشان میدهد این مناسبت میتواند برای برنامهریزی فروش ارزشمند باشد.",
|
GuidanceFA: "دادههای قبلی شما نشان میدهد این مناسبت میتواند برای برنامهریزی فروش ارزشمند باشد.",
|
||||||
OfferFA: "پیشنهاد خود را متناسب با نیاز مشتریان این مناسبت آماده کنید.",
|
OfferFA: "پیشنهاد خود را متناسب با نیاز مشتریان این مناسبت آماده کنید.",
|
||||||
|
MessageFA: "برای این مناسبت یک پیشنهاد ویژه برای شما آماده کردهایم.",
|
||||||
PreparationFA: "موجودی، پیام کمپین و مسیر پرداخت را پیش از شروع مناسبت بررسی کنید.",
|
PreparationFA: "موجودی، پیام کمپین و مسیر پرداخت را پیش از شروع مناسبت بررسی کنید.",
|
||||||
LimitationFA: "این پیشنهاد بر پایه الگوهای گذشته است و نتیجه قطعی نیست.",
|
LimitationFA: "این پیشنهاد بر پایه الگوهای گذشته است و نتیجه قطعی نیست.",
|
||||||
EvidenceIDs: []string{opportunity.EvidenceID},
|
EvidenceIDs: []string{opportunity.EvidenceID},
|
||||||
@@ -105,6 +108,7 @@ func BuildFallback(bundle EvidenceBundle) []CampaignBrief {
|
|||||||
HeadlineFA: "برای یک بازه آرامتر آماده باشید",
|
HeadlineFA: "برای یک بازه آرامتر آماده باشید",
|
||||||
GuidanceFA: "الگوی گذشته احتمال کاهش فعالیت را در این بازه نشان میدهد.",
|
GuidanceFA: "الگوی گذشته احتمال کاهش فعالیت را در این بازه نشان میدهد.",
|
||||||
OfferFA: "یک پیشنهاد سبک برای حفظ ارتباط با مشتریان در نظر بگیرید.",
|
OfferFA: "یک پیشنهاد سبک برای حفظ ارتباط با مشتریان در نظر بگیرید.",
|
||||||
|
MessageFA: "این روزها هم کنار شما هستیم؛ پیشنهادهای تازه را ببینید.",
|
||||||
PreparationFA: "پیام یادآوری و تجربه پرداخت را پیش از این بازه بازبینی کنید.",
|
PreparationFA: "پیام یادآوری و تجربه پرداخت را پیش از این بازه بازبینی کنید.",
|
||||||
LimitationFA: "این هشدار بر پایه الگوهای گذشته است و نتیجه قطعی نیست.",
|
LimitationFA: "این هشدار بر پایه الگوهای گذشته است و نتیجه قطعی نیست.",
|
||||||
EvidenceIDs: []string{risk.EvidenceID},
|
EvidenceIDs: []string{risk.EvidenceID},
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ package growth
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CatalogVersion = "1405.1"
|
CatalogVersion = "1405.2"
|
||||||
AnalyticsVersion = "1.0.0"
|
AnalyticsVersion = "1.1.0"
|
||||||
|
DefaultModelConfigVersion = "avalai-responses-v1-tools4-turns2-output1600"
|
||||||
|
|
||||||
MinBaselineObservations = 4
|
MinBaselineObservations = 4
|
||||||
MinBaselineSessions = 100
|
MinBaselineSessions = 100
|
||||||
@@ -26,6 +27,7 @@ const (
|
|||||||
SuppressionInsufficientObservations SuppressionReason = "INSUFFICIENT_BASELINE_OBSERVATIONS"
|
SuppressionInsufficientObservations SuppressionReason = "INSUFFICIENT_BASELINE_OBSERVATIONS"
|
||||||
SuppressionInsufficientBaselineSessions SuppressionReason = "INSUFFICIENT_BASELINE_SESSIONS"
|
SuppressionInsufficientBaselineSessions SuppressionReason = "INSUFFICIENT_BASELINE_SESSIONS"
|
||||||
SuppressionInsufficientHistory SuppressionReason = "INSUFFICIENT_HISTORY"
|
SuppressionInsufficientHistory SuppressionReason = "INSUFFICIENT_HISTORY"
|
||||||
|
SuppressionIncompleteEventWindow SuppressionReason = "INCOMPLETE_EVENT_WINDOW"
|
||||||
SuppressionInsufficientCohort SuppressionReason = "INSUFFICIENT_COHORT"
|
SuppressionInsufficientCohort SuppressionReason = "INSUFFICIENT_COHORT"
|
||||||
SuppressionTerminalScope SuppressionReason = "TERMINAL_SCOPE_UNSUPPORTED"
|
SuppressionTerminalScope SuppressionReason = "TERMINAL_SCOPE_UNSUPPORTED"
|
||||||
)
|
)
|
||||||
@@ -46,15 +48,18 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Event struct {
|
type Event struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TitleFA string `json:"titleFa"`
|
TitleFA string `json:"titleFa"`
|
||||||
StartDate time.Time `json:"startDate"`
|
StartDate time.Time `json:"startDate"`
|
||||||
EndDate time.Time `json:"endDate"`
|
EndDate time.Time `json:"endDate"`
|
||||||
Family string `json:"family"`
|
Family string `json:"family"`
|
||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
Categories []string `json:"categories"`
|
SourceVersion string `json:"sourceVersion"`
|
||||||
Kind EventKind `json:"kind"`
|
Categories []string `json:"categories"`
|
||||||
|
Kind EventKind `json:"kind"`
|
||||||
|
OfficialHoliday bool `json:"officialHoliday"`
|
||||||
|
Universal bool `json:"universal"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventEffect struct {
|
type EventEffect struct {
|
||||||
@@ -127,6 +132,7 @@ type Opportunity struct {
|
|||||||
type Risk struct {
|
type Risk struct {
|
||||||
EvidenceID string `json:"evidenceId"`
|
EvidenceID string `json:"evidenceId"`
|
||||||
Date time.Time `json:"date"`
|
Date time.Time `json:"date"`
|
||||||
|
EndDate time.Time `json:"endDate"`
|
||||||
SessionDeclineBPS int64 `json:"sessionDeclineBps"`
|
SessionDeclineBPS int64 `json:"sessionDeclineBps"`
|
||||||
AmountDeclineBPS int64 `json:"amountDeclineBps"`
|
AmountDeclineBPS int64 `json:"amountDeclineBps"`
|
||||||
ExpectedSessions Range `json:"expectedSessions"`
|
ExpectedSessions Range `json:"expectedSessions"`
|
||||||
@@ -142,6 +148,7 @@ type CampaignBrief struct {
|
|||||||
HeadlineFA string `json:"headlineFa"`
|
HeadlineFA string `json:"headlineFa"`
|
||||||
GuidanceFA string `json:"guidanceFa"`
|
GuidanceFA string `json:"guidanceFa"`
|
||||||
OfferFA string `json:"offerFa"`
|
OfferFA string `json:"offerFa"`
|
||||||
|
MessageFA string `json:"messageFa"`
|
||||||
PreparationFA string `json:"preparationFa"`
|
PreparationFA string `json:"preparationFa"`
|
||||||
LimitationFA string `json:"limitationFa"`
|
LimitationFA string `json:"limitationFa"`
|
||||||
EvidenceIDs []string `json:"evidenceIds"`
|
EvidenceIDs []string `json:"evidenceIds"`
|
||||||
|
|||||||
Reference in New Issue
Block a user