278 lines
9.4 KiB
Go
278 lines
9.4 KiB
Go
// Package prediction turns Doormile's own past predictions into better ones.
|
||
//
|
||
// Doormile already promises a delivery time, in two places and both of them
|
||
// fixed tables: controllers/adminController.go uses service-type constants
|
||
// (Normal 24h, Fast 12h, Superfast 6h) and controllers/cxPickupFanout.go uses
|
||
// the destination district's `promise` column (same-day/next-day/2-day/3-day).
|
||
// Neither looks at a single delivery that actually happened.
|
||
//
|
||
// This package does. The Route Optimization API already returns a road-network
|
||
// duration per stop and internal/routing already stores it on
|
||
// bookingassignments.etaminutes. Pairing that stored prediction with the
|
||
// Delivered event in consignmenthistory gives a predicted-vs-actual series the
|
||
// system produced itself, and a grouped p80 over it is a calibration factor:
|
||
//
|
||
// eta = routed_minutes × factor[zone, hour_bucket, weekday] + handling[zone]
|
||
//
|
||
// That is a median, not a model. No training, no inference server, no new
|
||
// runtime — a table refreshed nightly and a map lookup on the booking path.
|
||
//
|
||
// ─── The floor rule ────────────────────────────────────────────────────────
|
||
//
|
||
// ETAMinutes returns (0, false) whenever it lacks the evidence to do better,
|
||
// and every caller keeps its existing rule for that case. So:
|
||
//
|
||
// - no routed duration (ROUTE_OPTIMIZER_URL unset, or a single-stop rider) → false
|
||
// - no calibration cell with enough samples → false
|
||
// - calibration never refreshed, or the refresh failed → false
|
||
// - a factor outside sane bounds → false
|
||
//
|
||
// Today, in the cluster, ROUTE_OPTIMIZER_URL is not set (see Phase 7 Track A1),
|
||
// so this returns false for every booking and the promise tables answer exactly
|
||
// as they do now. Switch routing on, let a few weeks of deliveries land, and it
|
||
// starts answering — with no code change and no deploy. Today's behaviour is
|
||
// the floor; this can only raise it.
|
||
//
|
||
// ─── p80, not the mean ─────────────────────────────────────────────────────
|
||
//
|
||
// The calibration is the 80th percentile of observed overrun, not the average.
|
||
// An ETA shown to a customer is a promise — "arrives by" — and a mean is late
|
||
// half the time. docs/prediction-plan.md §8 decision 3.
|
||
package prediction
|
||
|
||
import (
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"doormile/utils"
|
||
)
|
||
|
||
const (
|
||
// minSamples is the floor for trusting one calibration cell. Below it the
|
||
// p80 is noise and the lookup falls back to a coarser key.
|
||
minSamples = 20
|
||
|
||
// A factor outside these bounds means the calibration is wrong, not that
|
||
// deliveries are 10× their routed duration. Refuse rather than serve it:
|
||
// an absurd ETA is worse than today's flat constant.
|
||
minFactor = 0.5
|
||
maxFactor = 5.0
|
||
|
||
// maxHandlingMinutes caps the additive hub term for the same reason.
|
||
maxHandlingMinutes = 240
|
||
|
||
// staleAfter is how long a calibration stays usable without a refresh. The
|
||
// sweeper runs far more often than this; exceeding it means the refresh has
|
||
// been failing silently, and a month-old factor should not keep answering.
|
||
staleAfter = 72 * time.Hour
|
||
|
||
// hourBucketSize groups the day into 8 three-hour buckets. Finer buckets
|
||
// split the samples too thin to clear minSamples on real volume.
|
||
hourBucketSize = 3
|
||
)
|
||
|
||
// Input is everything the estimate needs. Built by the caller from the booking
|
||
// it already has in hand; this package never queries on the request path.
|
||
type Input struct {
|
||
// RoutedMinutes is bookingassignments.etaminutes — the Route Optimization
|
||
// API's road-network duration. Zero means unknown, which is the common case
|
||
// today and the whole reason for the floor rule.
|
||
RoutedMinutes int
|
||
|
||
// DeliveryPincode keys the zone. Only its first three digits are used, the
|
||
// same grain the hub console filters on (pickuppincode LIKE '641%').
|
||
DeliveryPincode string
|
||
|
||
// At is when the estimate is being made. Interpreted through utils.IST,
|
||
// because this database stores IST wall-clock digits and a raw .Hour() on a
|
||
// value tagged UTC is off by 5h30m — the defect utils/epoch.go documents.
|
||
At time.Time
|
||
}
|
||
|
||
// Result is a calibrated estimate and the cell that produced it. Source is for
|
||
// logging and for the console to say where a number came from; nothing branches
|
||
// on it.
|
||
type Result struct {
|
||
Minutes int
|
||
Source string // "zone_hour_weekday", "zone_weekday", "zone", "global"
|
||
Samples int
|
||
}
|
||
|
||
// cell is one calibration row, in memory.
|
||
type cell struct {
|
||
factor float64
|
||
handling float64
|
||
samples int
|
||
}
|
||
|
||
// table is an immutable calibration snapshot. Replaced wholesale by the
|
||
// refresh; readers never see a half-updated map.
|
||
type table struct {
|
||
cells map[string]cell
|
||
global cell
|
||
hasGlobal bool
|
||
builtAt time.Time
|
||
}
|
||
|
||
var (
|
||
current atomic[*table]
|
||
)
|
||
|
||
// atomic is a tiny generic holder. sync/atomic.Pointer would do, but this keeps
|
||
// the zero value useful (an unset calibration reads as nil, which ETAMinutes
|
||
// treats as "no evidence") without an init func.
|
||
type atomic[T any] struct {
|
||
mu sync.RWMutex
|
||
v T
|
||
}
|
||
|
||
func (a *atomic[T]) load() T {
|
||
a.mu.RLock()
|
||
defer a.mu.RUnlock()
|
||
return a.v
|
||
}
|
||
|
||
func (a *atomic[T]) store(v T) {
|
||
a.mu.Lock()
|
||
a.v = v
|
||
a.mu.Unlock()
|
||
}
|
||
|
||
// zoneOf is the first three digits of a pincode — the same grain the hub
|
||
// console scopes on. An empty or short pincode has no zone, which the lookup
|
||
// treats as "global only".
|
||
func zoneOf(pincode string) string {
|
||
p := strings.TrimSpace(pincode)
|
||
if len(p) < 3 {
|
||
return ""
|
||
}
|
||
return p[:3]
|
||
}
|
||
|
||
// hourBucket is the 3-hour block of the IST day, 0..7.
|
||
func hourBucket(t time.Time) int {
|
||
return utils.IST(t).Hour() / hourBucketSize
|
||
}
|
||
|
||
// weekdayOf is the ISO weekday in IST, 1 (Monday) to 7 (Sunday) — matching
|
||
// Postgres's EXTRACT(ISODOW) so the Go lookup and the refresh SQL agree.
|
||
func weekdayOf(t time.Time) int {
|
||
d := int(utils.IST(t).Weekday())
|
||
if d == 0 {
|
||
return 7 // Go's Sunday is 0; ISO's is 7
|
||
}
|
||
return d
|
||
}
|
||
|
||
// keyZoneHourWeekday, keyZoneWeekday and keyZone are the three progressively
|
||
// coarser lookups. Distinct prefixes so a zone can never collide with a
|
||
// weekday-qualified key.
|
||
func keyZoneHourWeekday(zone string, bucket, weekday int) string {
|
||
return "zhw:" + zone + ":" + strconv.Itoa(bucket) + ":" + strconv.Itoa(weekday)
|
||
}
|
||
|
||
func keyZoneWeekday(zone string, weekday int) string {
|
||
return "zw:" + zone + ":" + strconv.Itoa(weekday)
|
||
}
|
||
|
||
func keyZone(zone string) string {
|
||
return "z:" + zone
|
||
}
|
||
|
||
// ETAMinutes returns a calibrated door-to-door estimate in minutes, and whether
|
||
// it is trustworthy.
|
||
//
|
||
// False means the caller must use whatever it does today — its service-type
|
||
// constants or the district promise table. A false is not an error and is not
|
||
// logged per call: it is the expected answer until routing is switched on and
|
||
// history accumulates.
|
||
func ETAMinutes(in Input) (Result, bool) {
|
||
if in.RoutedMinutes <= 0 {
|
||
// No road-network duration to calibrate against. The dominant case
|
||
// today: ROUTE_OPTIMIZER_URL is unset in the cluster, and even with it
|
||
// set, internal/routing skips riders with fewer than two stops.
|
||
return Result{}, false
|
||
}
|
||
|
||
t := current.load()
|
||
if t == nil || len(t.cells) == 0 && !t.hasGlobal {
|
||
return Result{}, false
|
||
}
|
||
if !t.builtAt.IsZero() && time.Since(t.builtAt) > staleAfter {
|
||
// A refresh has been failing for days. Fall back rather than serve a
|
||
// factor that predates whatever changed.
|
||
return Result{}, false
|
||
}
|
||
|
||
zone := zoneOf(in.DeliveryPincode)
|
||
bucket, weekday := hourBucket(in.At), weekdayOf(in.At)
|
||
|
||
type candidate struct {
|
||
key string
|
||
source string
|
||
}
|
||
candidates := []candidate{}
|
||
if zone != "" {
|
||
candidates = append(candidates,
|
||
candidate{keyZoneHourWeekday(zone, bucket, weekday), "zone_hour_weekday"},
|
||
candidate{keyZoneWeekday(zone, weekday), "zone_weekday"},
|
||
candidate{keyZone(zone), "zone"},
|
||
)
|
||
}
|
||
|
||
for _, c := range candidates {
|
||
if cl, ok := t.cells[c.key]; ok && cl.samples >= minSamples {
|
||
if m, ok := apply(in.RoutedMinutes, cl); ok {
|
||
return Result{Minutes: m, Source: c.source, Samples: cl.samples}, true
|
||
}
|
||
}
|
||
}
|
||
|
||
if t.hasGlobal && t.global.samples >= minSamples {
|
||
if m, ok := apply(in.RoutedMinutes, t.global); ok {
|
||
return Result{Minutes: m, Source: "global", Samples: t.global.samples}, true
|
||
}
|
||
}
|
||
|
||
return Result{}, false
|
||
}
|
||
|
||
// apply is the estimate itself, with the bounds check that keeps a bad
|
||
// calibration from producing an absurd promise.
|
||
func apply(routedMinutes int, c cell) (int, bool) {
|
||
if c.factor < minFactor || c.factor > maxFactor {
|
||
return 0, false
|
||
}
|
||
if c.handling < 0 || c.handling > maxHandlingMinutes {
|
||
return 0, false
|
||
}
|
||
m := float64(routedMinutes)*c.factor + c.handling
|
||
if m <= 0 {
|
||
return 0, false
|
||
}
|
||
return int(m + 0.5), true
|
||
}
|
||
|
||
// ETAAt is ETAMinutes as an absolute time, for the callers that store a
|
||
// timestamp rather than a duration. The returned time is in the same shape the
|
||
// caller's `from` was, so it round-trips into the database unchanged.
|
||
func ETAAt(in Input, from time.Time) (time.Time, Result, bool) {
|
||
r, ok := ETAMinutes(in)
|
||
if !ok {
|
||
return time.Time{}, Result{}, false
|
||
}
|
||
return from.Add(time.Duration(r.Minutes) * time.Minute), r, true
|
||
}
|
||
|
||
// Loaded reports whether a usable calibration is in memory. For
|
||
// GET /admin/ai/status and the readiness note; not used on the booking path.
|
||
func Loaded() (bool, time.Time, int) {
|
||
t := current.load()
|
||
if t == nil {
|
||
return false, time.Time{}, 0
|
||
}
|
||
return len(t.cells) > 0 || t.hasGlobal, t.builtAt, len(t.cells)
|
||
}
|