feat: ground competitive insights in peer metrics
This commit is contained in:
139
internal/peercomparison/calculator.go
Normal file
139
internal/peercomparison/calculator.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// Package peercomparison contains the privacy-bounded, merchant-weighted
|
||||
// comparison calculation shared by analytics and page insights.
|
||||
package peercomparison
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
const (
|
||||
MinimumPeerCount = 10
|
||||
MinimumPeerSessions = 1000
|
||||
)
|
||||
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateReady State = "READY"
|
||||
StateSuppressed State = "SUPPRESSED"
|
||||
)
|
||||
|
||||
type MetricDefinition struct {
|
||||
ID string
|
||||
LowerIsBetter bool
|
||||
}
|
||||
|
||||
var metricDefinitions = []MetricDefinition{
|
||||
{ID: "payment_success_rate"},
|
||||
{ID: "verification_completion_rate"},
|
||||
{ID: "retry_recovery_rate"},
|
||||
{ID: "init_time_ms_p50", LowerIsBetter: true},
|
||||
{ID: "init_time_ms_p95", LowerIsBetter: true},
|
||||
{ID: "verify_time_ms_p50", LowerIsBetter: true},
|
||||
{ID: "verify_time_ms_p95", LowerIsBetter: true},
|
||||
{ID: "paid_fee_rate", LowerIsBetter: true},
|
||||
}
|
||||
|
||||
func MetricDefinitions() []MetricDefinition {
|
||||
return append([]MetricDefinition(nil), metricDefinitions...)
|
||||
}
|
||||
|
||||
// MerchantValues contains one aggregate value per merchant. It intentionally
|
||||
// carries no merchant data other than the internal id used to exclude the
|
||||
// active merchant and the session count used for suppression.
|
||||
type MerchantValues struct {
|
||||
MerchantID int64
|
||||
SessionCount int64
|
||||
Values [8]*float64
|
||||
}
|
||||
|
||||
type Metric struct {
|
||||
MetricID string `json:"metricId"`
|
||||
CohortMedian string `json:"cohortMedian"`
|
||||
MerchantPerformancePercentile string `json:"merchantPerformancePercentile"`
|
||||
Direction string `json:"direction"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
State State `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
EligiblePeerCount int `json:"eligiblePeerCount"`
|
||||
CombinedPeerSessions int64 `json:"combinedPeerSessionCount"`
|
||||
Metrics []Metric `json:"metrics,omitempty"`
|
||||
}
|
||||
|
||||
func Calculate(activeMerchantID int64, terminalScoped bool, merchants []MerchantValues) Result {
|
||||
result := Result{State: StateSuppressed}
|
||||
if terminalScoped {
|
||||
result.Reason = "TERMINAL_SCOPE_UNSUPPORTED"
|
||||
return result
|
||||
}
|
||||
var active *MerchantValues
|
||||
peers := make([]MerchantValues, 0, len(merchants))
|
||||
for index := range merchants {
|
||||
merchant := merchants[index]
|
||||
if merchant.MerchantID == activeMerchantID {
|
||||
active = &merchants[index]
|
||||
continue
|
||||
}
|
||||
peers = append(peers, merchant)
|
||||
result.CombinedPeerSessions += merchant.SessionCount
|
||||
}
|
||||
result.EligiblePeerCount = len(peers)
|
||||
if len(peers) < MinimumPeerCount || result.CombinedPeerSessions < MinimumPeerSessions {
|
||||
result.Reason = "INSUFFICIENT_COHORT"
|
||||
return result
|
||||
}
|
||||
if active == nil {
|
||||
result.Reason = "INSUFFICIENT_COHORT"
|
||||
return result
|
||||
}
|
||||
|
||||
result.State = StateReady
|
||||
result.Metrics = make([]Metric, 0, len(metricDefinitions))
|
||||
for index, definition := range metricDefinitions {
|
||||
activeValue := active.Values[index]
|
||||
values := make([]float64, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
if peer.Values[index] != nil {
|
||||
values = append(values, *peer.Values[index])
|
||||
}
|
||||
}
|
||||
// Keep the public shape stable while making unavailable metric input
|
||||
// explicit through the existing zero representation.
|
||||
if len(values) == 0 || activeValue == nil {
|
||||
result.Metrics = append(result.Metrics, Metric{MetricID: definition.ID, CohortMedian: "0.00", MerchantPerformancePercentile: "0.00", Direction: direction(definition)})
|
||||
continue
|
||||
}
|
||||
sort.Float64s(values)
|
||||
median := values[len(values)/2]
|
||||
if len(values)%2 == 0 {
|
||||
median = (values[len(values)/2-1] + median) / 2
|
||||
}
|
||||
below, equal := 0, 0
|
||||
for _, value := range values {
|
||||
if definition.LowerIsBetter {
|
||||
if *activeValue < value {
|
||||
below++
|
||||
} else if *activeValue == value {
|
||||
equal++
|
||||
}
|
||||
} else if *activeValue > value {
|
||||
below++
|
||||
} else if *activeValue == value {
|
||||
equal++
|
||||
}
|
||||
}
|
||||
percentileBPS := (2*below + equal) * 5000 / len(values)
|
||||
result.Metrics = append(result.Metrics, Metric{MetricID: definition.ID, CohortMedian: fmt.Sprintf("%.2f", median), MerchantPerformancePercentile: fmt.Sprintf("%.2f", float64(percentileBPS)/100), Direction: direction(definition)})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func direction(definition MetricDefinition) string {
|
||||
if definition.LowerIsBetter {
|
||||
return "LOWER_IS_BETTER"
|
||||
}
|
||||
return "HIGHER_IS_BETTER"
|
||||
}
|
||||
76
internal/peercomparison/calculator_test.go
Normal file
76
internal/peercomparison/calculator_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package peercomparison
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func pointer(value float64) *float64 { return &value }
|
||||
|
||||
func TestCalculateAppliesPrivacyThresholdsAndExcludesActiveMerchant(t *testing.T) {
|
||||
merchants := make([]MerchantValues, 0, 11)
|
||||
merchants = append(merchants, MerchantValues{MerchantID: 70, SessionCount: 900, Values: [8]*float64{pointer(0)}})
|
||||
for i := int64(1); i <= 10; i++ {
|
||||
merchants = append(merchants, MerchantValues{MerchantID: i, SessionCount: 100, Values: [8]*float64{pointer(float64(i))}})
|
||||
}
|
||||
result := Calculate(70, false, merchants)
|
||||
if result.State != StateReady || result.EligiblePeerCount != 10 || result.CombinedPeerSessions != 1000 {
|
||||
t.Fatalf("unexpected ready result: %+v", result)
|
||||
}
|
||||
if result.Metrics[0].MerchantPerformancePercentile != "0.00" {
|
||||
t.Fatalf("active merchant must be ranked below all peers: %+v", result.Metrics[0])
|
||||
}
|
||||
suppressed := Calculate(70, true, merchants)
|
||||
if suppressed.State != StateSuppressed || suppressed.Reason != "TERMINAL_SCOPE_UNSUPPORTED" {
|
||||
t.Fatalf("unexpected terminal result: %+v", suppressed)
|
||||
}
|
||||
insufficient := Calculate(70, false, merchants[:10])
|
||||
if insufficient.State != StateSuppressed || insufficient.Reason != "INSUFFICIENT_COHORT" {
|
||||
t.Fatalf("unexpected insufficient result: %+v", insufficient)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateUsesDirectionAwareMidRankAndStableMetricOrder(t *testing.T) {
|
||||
merchants := make([]MerchantValues, 0, 11)
|
||||
merchants = append(merchants, MerchantValues{MerchantID: 70, SessionCount: 1, Values: [8]*float64{pointer(5), pointer(5), pointer(5), pointer(5), pointer(5), pointer(5), pointer(5), pointer(5)}})
|
||||
for i := int64(1); i <= 10; i++ {
|
||||
value := float64(i)
|
||||
merchants = append(merchants, MerchantValues{MerchantID: i, SessionCount: 100, Values: [8]*float64{pointer(value), pointer(value), pointer(value), pointer(value), pointer(value), pointer(value), pointer(value), pointer(value)}})
|
||||
}
|
||||
result := Calculate(70, false, merchants)
|
||||
if len(result.Metrics) != 8 || result.Metrics[0].MetricID != "payment_success_rate" || result.Metrics[7].MetricID != "paid_fee_rate" {
|
||||
t.Fatalf("unexpected metric order: %+v", result.Metrics)
|
||||
}
|
||||
if result.Metrics[0].MerchantPerformancePercentile != "45.00" {
|
||||
t.Fatalf("higher-is-better midrank = %s, want 45.00", result.Metrics[0].MerchantPerformancePercentile)
|
||||
}
|
||||
if result.Metrics[3].MerchantPerformancePercentile != "55.00" || result.Metrics[3].Direction != "LOWER_IS_BETTER" {
|
||||
t.Fatalf("lower-is-better direction/percentile = %+v", result.Metrics[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateKeepsPeerIdentitiesOutOfSerializedResult(t *testing.T) {
|
||||
merchants := []MerchantValues{{MerchantID: 987654321, Values: [8]*float64{pointer(5)}}}
|
||||
for i := int64(1); i <= 10; i++ {
|
||||
merchants = append(merchants, MerchantValues{
|
||||
MerchantID: 1000 + i,
|
||||
SessionCount: 100,
|
||||
Values: [8]*float64{pointer(5)},
|
||||
})
|
||||
}
|
||||
result := Calculate(987654321, false, merchants)
|
||||
encoded, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal result: %v", err)
|
||||
}
|
||||
text := string(encoded)
|
||||
for _, forbidden := range []string{"merchantId", "987654321", "1001"} {
|
||||
if strings.Contains(text, forbidden) {
|
||||
t.Fatalf("serialized peer result exposed %q: %s", forbidden, text)
|
||||
}
|
||||
}
|
||||
if result.Metrics[0].MerchantPerformancePercentile != "50.00" {
|
||||
t.Fatalf("all-tie percentile = %s, want 50.00", result.Metrics[0].MerchantPerformancePercentile)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user