updates on the ai agents and time series prediction and updates on the api to

This commit is contained in:
2026-10-09 13:50:30 +05:30
parent 29690c56f2
commit 153be40e5c
42 changed files with 4889 additions and 186 deletions

277
internal/prediction/eta.go Normal file
View 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)
}