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