updates on the ai agents and time series prediction and updates on the api to
This commit is contained in:
277
internal/prediction/eta.go
Normal file
277
internal/prediction/eta.go
Normal file
@@ -0,0 +1,277 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user