Files

140 lines
4.1 KiB
Go

// 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"
}