updates on the ai agents and time series prediction and updates on the api to
This commit is contained in:
272
internal/prediction/calibration.go
Normal file
272
internal/prediction/calibration.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package prediction
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The calibration refresh.
|
||||
//
|
||||
// Every delivered consignment whose rider was sequenced carries two numbers:
|
||||
// what the Route Optimization API predicted (bookingassignments.etaminutes,
|
||||
// written by internal/routing) and what actually happened (the Delivered row in
|
||||
// consignmenthistory). The ratio between them, grouped and taken at p80, is the
|
||||
// factor ETAMinutes multiplies by.
|
||||
//
|
||||
// Why p80 and not the mean: docs/prediction-plan.md §8 decision 3. An ETA is a
|
||||
// promise, and a mean is late half the time.
|
||||
//
|
||||
// Why a view and not a join here: consignments has no bookingid column (hazard
|
||||
// H4 in the plan). The consignment_booking view resolves the two paths —
|
||||
// bookingdestinations for multi-destination pickups, the legacy
|
||||
// pickupbookings.consignmentid for console and pre-fan-out rows — once, in
|
||||
// migrate.go. Joining through consignments directly attaches features to the
|
||||
// wrong parcel for every multi-destination booking, silently.
|
||||
|
||||
// ETACalibration is one calibration cell as stored. Written only by Refresh,
|
||||
// read at boot and after each refresh.
|
||||
type ETACalibration struct {
|
||||
Calibrationid int `gorm:"primaryKey;column:calibrationid;autoIncrement"`
|
||||
Scope string `gorm:"column:scope;size:24;not null;index:idx_etacalibration_lookup,priority:1"`
|
||||
Zone string `gorm:"column:zone;size:8;index:idx_etacalibration_lookup,priority:2"`
|
||||
Hourbucket *int `gorm:"column:hourbucket;index:idx_etacalibration_lookup,priority:3"`
|
||||
Weekday *int `gorm:"column:weekday;index:idx_etacalibration_lookup,priority:4"`
|
||||
Factor float64 `gorm:"column:factor;not null"`
|
||||
Handling float64 `gorm:"column:handling;not null;default:0"`
|
||||
Samples int `gorm:"column:samples;not null"`
|
||||
Refreshedat time.Time `gorm:"column:refreshedat;not null"`
|
||||
}
|
||||
|
||||
func (ETACalibration) TableName() string { return "etacalibration" }
|
||||
|
||||
// row is one group from the refresh query.
|
||||
type row struct {
|
||||
Scope string
|
||||
Zone string
|
||||
Hourbucket *int
|
||||
Weekday *int
|
||||
Factor float64
|
||||
Samples int
|
||||
}
|
||||
|
||||
// Observed durations below this are almost certainly data errors — a Delivered
|
||||
// event written in the same second as the consignment, or a backfill. Including
|
||||
// them drags every factor toward zero.
|
||||
const minObservedMinutes = 5
|
||||
|
||||
// And above this, the parcel sat for days for a reason that has nothing to do
|
||||
// with road duration (held at hub, re-attempts, disputes). Calibrating a
|
||||
// routed-duration multiplier on those teaches it the wrong thing.
|
||||
const maxObservedMinutes = 48 * 60
|
||||
|
||||
// refreshSQL computes the p80 ratio of actual to routed duration, at three
|
||||
// grains plus a global row, in one pass.
|
||||
//
|
||||
// The `actual` term uses consignmenthistory.createdat - consignments.createdat.
|
||||
// Both come from CURRENT_TIMESTAMP defaults, so they share a tagging and their
|
||||
// difference is correct regardless of hazard H1 — which is why this does not
|
||||
// go near estimateddeliveryat, whose writes are not uniform
|
||||
// (adminController.go:2738 uses time.Now(), elsewhere it is CURRENT_TIMESTAMP).
|
||||
//
|
||||
// EXTRACT(ISODOW) is 1..7 Monday-first, matching weekdayOf in eta.go. The hour
|
||||
// bucket divides by 3, matching hourBucketSize. If either changes, both change.
|
||||
// A booking can hold SEVERAL bookingassignments rows — Assigned, Rejected,
|
||||
// Reassigned, Cancelled are all statuses it passes through. Joining them all
|
||||
// would multiply one delivered parcel by its whole assignment history and pull
|
||||
// the p80 toward whatever got reassigned most, so `assigned` picks exactly one
|
||||
// row per booking: the most recently sequenced one, which is the routed ETA
|
||||
// that was actually in force when the parcel was delivered.
|
||||
//
|
||||
// NULL::int / NULL::text are explicit because a UNION ALL resolves column types
|
||||
// across its branches; an untyped NULL can be inferred as text and fail against
|
||||
// the integer from the first branch — at runtime, against the real database,
|
||||
// which is exactly where it is most expensive to discover.
|
||||
const refreshSQL = `
|
||||
WITH assigned AS (
|
||||
SELECT DISTINCT ON (ba.bookingid)
|
||||
ba.bookingid,
|
||||
ba.etaminutes
|
||||
FROM bookingassignments ba
|
||||
WHERE ba.etaminutes > 0
|
||||
ORDER BY ba.bookingid, ba.sequencedat DESC NULLS LAST, ba.bookingassignmentid DESC
|
||||
),
|
||||
observed AS (
|
||||
SELECT left(c.deliverypincode, 3) AS zone,
|
||||
(EXTRACT(HOUR FROM c.createdat)::int / ?) AS hourbucket,
|
||||
EXTRACT(ISODOW FROM c.createdat)::int AS weekday,
|
||||
a.etaminutes::double precision AS routed,
|
||||
EXTRACT(EPOCH FROM (h.createdat - c.createdat)) / 60.0 AS actual
|
||||
FROM consignments c
|
||||
JOIN consignmenthistory h
|
||||
ON h.consignmentid = c.consignmentid
|
||||
AND h.eventstatus = ?
|
||||
JOIN consignment_booking cb
|
||||
ON cb.consignmentid = c.consignmentid
|
||||
JOIN assigned a
|
||||
ON a.bookingid = cb.bookingid
|
||||
WHERE c.deliverypincode IS NOT NULL
|
||||
AND length(c.deliverypincode) >= 3
|
||||
),
|
||||
clean AS (
|
||||
SELECT * FROM observed
|
||||
WHERE actual BETWEEN ? AND ?
|
||||
AND routed > 0
|
||||
)
|
||||
SELECT 'zone_hour_weekday'::text AS scope, zone, hourbucket, weekday,
|
||||
percentile_cont(0.8) WITHIN GROUP (ORDER BY actual / routed) AS factor,
|
||||
count(*)::int AS samples
|
||||
FROM clean GROUP BY zone, hourbucket, weekday
|
||||
UNION ALL
|
||||
SELECT 'zone_weekday'::text, zone, NULL::int, weekday,
|
||||
percentile_cont(0.8) WITHIN GROUP (ORDER BY actual / routed), count(*)::int
|
||||
FROM clean GROUP BY zone, weekday
|
||||
UNION ALL
|
||||
SELECT 'zone'::text, zone, NULL::int, NULL::int,
|
||||
percentile_cont(0.8) WITHIN GROUP (ORDER BY actual / routed), count(*)::int
|
||||
FROM clean GROUP BY zone
|
||||
UNION ALL
|
||||
SELECT 'global'::text, ''::text, NULL::int, NULL::int,
|
||||
percentile_cont(0.8) WITHIN GROUP (ORDER BY actual / routed), count(*)::int
|
||||
FROM clean
|
||||
`
|
||||
|
||||
// Refresh recomputes the calibration from history, replaces the stored table,
|
||||
// and swaps the in-memory snapshot.
|
||||
//
|
||||
// A failure leaves the previous calibration in place — a refresh that cannot
|
||||
// run is not a reason to stop answering with the last good factors. If they go
|
||||
// stale past staleAfter, ETAMinutes stops trusting them on its own.
|
||||
func Refresh(gdb *gorm.DB) error {
|
||||
if gdb == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var rows []row
|
||||
if err := gdb.Raw(refreshSQL,
|
||||
hourBucketSize, constants.ConsignmentDelivered, minObservedMinutes, maxObservedMinutes,
|
||||
).Scan(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
kept := make([]ETACalibration, 0, len(rows))
|
||||
now := utils.DBNow()
|
||||
for _, r := range rows {
|
||||
if r.Samples < minSamples {
|
||||
continue // a p80 over fewer than minSamples is noise
|
||||
}
|
||||
if r.Factor < minFactor || r.Factor > maxFactor {
|
||||
// Out of bounds is a signal about the data, not a usable factor.
|
||||
// Log it once here rather than discovering it per request.
|
||||
utils.Warn("prediction: discarding out-of-bounds calibration factor",
|
||||
"scope", r.Scope, "zone", r.Zone, "factor", r.Factor, "samples", r.Samples)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, ETACalibration{
|
||||
Scope: r.Scope,
|
||||
Zone: r.Zone,
|
||||
Hourbucket: r.Hourbucket,
|
||||
Weekday: r.Weekday,
|
||||
Factor: r.Factor,
|
||||
Samples: r.Samples,
|
||||
Refreshedat: now,
|
||||
})
|
||||
}
|
||||
|
||||
if len(kept) == 0 {
|
||||
// No cell cleared the floor. Expected until routing is on and history
|
||||
// accumulates; ETAMinutes keeps returning false and the promise tables
|
||||
// keep answering.
|
||||
utils.Info("prediction: no calibration cells met the sample floor",
|
||||
"groups_considered", len(rows), "min_samples", minSamples)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Replace wholesale in one transaction: a partial table would serve a mix
|
||||
// of old and new factors for the same zone.
|
||||
if err := gdb.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`DELETE FROM etacalibration`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.CreateInBatches(&kept, 200).Error
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Load(kept)
|
||||
utils.Info("prediction: calibration refreshed", "cells", len(kept))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load swaps the in-memory snapshot. Exported so boot can populate from the
|
||||
// stored table without recomputing, and so tests can install a known
|
||||
// calibration without a database.
|
||||
//
|
||||
// builtAt is the NEWEST Refreshedat among the rows, not time.Now(): a restart
|
||||
// that loads month-old rows from the store must still look month-old to the
|
||||
// staleness guard in ETAMinutes. Taking the load time here would silently
|
||||
// re-arm a stale calibration on every deploy. A row with no Refreshedat (a test
|
||||
// fixture) is treated as fresh.
|
||||
// Refreshedat is stored through utils.DBNow, which writes IST wall-clock digits
|
||||
// labelled UTC. Reading it back as an instant is off by 5h30m, so it goes
|
||||
// through utils.IST first — the same correction utils/epoch.go exists for.
|
||||
// Without it a fresh calibration reads as 5.5 hours old, which is the very
|
||||
// hazard docs/prediction-plan.md calls H1.
|
||||
func Load(rows []ETACalibration) {
|
||||
t := &table{cells: make(map[string]cell, len(rows))}
|
||||
for _, r := range rows {
|
||||
if r.Refreshedat.IsZero() {
|
||||
continue
|
||||
}
|
||||
if at := utils.IST(r.Refreshedat); at.After(t.builtAt) {
|
||||
t.builtAt = at
|
||||
}
|
||||
}
|
||||
if t.builtAt.IsZero() {
|
||||
t.builtAt = time.Now() // a fixture with no Refreshedat counts as fresh
|
||||
}
|
||||
for _, r := range rows {
|
||||
c := cell{factor: r.Factor, handling: r.Handling, samples: r.Samples}
|
||||
switch r.Scope {
|
||||
case "global":
|
||||
t.global, t.hasGlobal = c, true
|
||||
case "zone":
|
||||
t.cells[keyZone(r.Zone)] = c
|
||||
case "zone_weekday":
|
||||
if r.Weekday != nil {
|
||||
t.cells[keyZoneWeekday(r.Zone, *r.Weekday)] = c
|
||||
}
|
||||
case "zone_hour_weekday":
|
||||
if r.Weekday != nil && r.Hourbucket != nil {
|
||||
t.cells[keyZoneHourWeekday(r.Zone, *r.Hourbucket, *r.Weekday)] = c
|
||||
}
|
||||
}
|
||||
}
|
||||
current.store(t)
|
||||
}
|
||||
|
||||
// Reset clears the in-memory calibration. Tests only — it makes ETAMinutes
|
||||
// return false, which is the state a fresh process is in before boot loads.
|
||||
func Reset() { current.store(nil) }
|
||||
|
||||
// LoadFromDB populates the snapshot from the stored table at boot, so a restart
|
||||
// does not wait for the next refresh to start answering.
|
||||
func LoadFromDB() {
|
||||
if db.DB == nil {
|
||||
return
|
||||
}
|
||||
var rows []ETACalibration
|
||||
if err := db.DB.Find(&rows).Error; err != nil {
|
||||
utils.Warn("prediction: could not load stored calibration", "error", err)
|
||||
return
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
Load(rows)
|
||||
utils.Info("prediction: calibration loaded from store", "cells", len(rows))
|
||||
}
|
||||
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)
|
||||
}
|
||||
284
internal/prediction/eta_test.go
Normal file
284
internal/prediction/eta_test.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package prediction
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"doormile/utils"
|
||||
)
|
||||
|
||||
func ptr(n int) *int { return &n }
|
||||
|
||||
// A calibration that is deliberately coarse-to-fine, so the fallback ladder is
|
||||
// observable: the zone_hour_weekday cell has a different factor from the
|
||||
// zone_weekday one, which differs from zone, which differs from global.
|
||||
func fixture() []ETACalibration {
|
||||
return []ETACalibration{
|
||||
{Scope: "zone_hour_weekday", Zone: "641", Hourbucket: ptr(3), Weekday: ptr(1), Factor: 1.5, Samples: 100},
|
||||
{Scope: "zone_weekday", Zone: "641", Weekday: ptr(1), Factor: 2.0, Samples: 100},
|
||||
{Scope: "zone", Zone: "641", Factor: 2.5, Samples: 100},
|
||||
{Scope: "global", Factor: 3.0, Samples: 100},
|
||||
}
|
||||
}
|
||||
|
||||
// 2026-10-05 is a Monday. 10:00 IST is hour bucket 3 (10/3). The database
|
||||
// stores IST digits labelled UTC, so the fixture time is built that way on
|
||||
// purpose — it is the shape a real column read produces.
|
||||
func mondayTenAM() time.Time {
|
||||
return time.Date(2026, 10, 5, 10, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// ─── The floor rule: these are the cases that must return false ────────────
|
||||
|
||||
func TestNoRoutedDurationFallsBack(t *testing.T) {
|
||||
Load(fixture())
|
||||
defer Reset()
|
||||
|
||||
// The dominant case in production today: ROUTE_OPTIMIZER_URL is unset, so
|
||||
// internal/routing never runs and etaminutes is 0.
|
||||
if _, ok := ETAMinutes(Input{RoutedMinutes: 0, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
|
||||
t.Fatal("ETAMinutes answered with no routed duration; the promise table must keep answering")
|
||||
}
|
||||
if _, ok := ETAMinutes(Input{RoutedMinutes: -5, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
|
||||
t.Fatal("ETAMinutes answered on a negative routed duration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoCalibrationFallsBack(t *testing.T) {
|
||||
Reset()
|
||||
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
|
||||
t.Fatal("ETAMinutes answered with no calibration loaded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBelowSampleFloorFallsBack(t *testing.T) {
|
||||
Load([]ETACalibration{
|
||||
{Scope: "zone", Zone: "641", Factor: 2.0, Samples: minSamples - 1},
|
||||
})
|
||||
defer Reset()
|
||||
|
||||
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
|
||||
t.Fatalf("ETAMinutes trusted a cell with %d samples (floor is %d)", minSamples-1, minSamples)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutOfBoundsFactorFallsBack(t *testing.T) {
|
||||
for _, f := range []float64{minFactor - 0.1, maxFactor + 0.1, 0, -1} {
|
||||
Load([]ETACalibration{{Scope: "zone", Zone: "641", Factor: f, Samples: 100}})
|
||||
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
|
||||
t.Errorf("ETAMinutes served an out-of-bounds factor %v; an absurd ETA is worse than a flat constant", f)
|
||||
}
|
||||
Reset()
|
||||
}
|
||||
}
|
||||
|
||||
// This is also the H1 regression test, verified by mutation: Refreshedat is
|
||||
// written through utils.DBNow (IST wall-clock digits labelled UTC), so reading
|
||||
// it as a raw instant makes it appear 5h30m NEWER than it is. A calibration
|
||||
// 73h old then measures as 67.5h and slips under the 72h staleAfter bound.
|
||||
// Removing the utils.IST call in Load fails exactly this test.
|
||||
//
|
||||
// The margin matters: with staleAfter at 72h, the 5.5h error is a 7.6% window
|
||||
// in which a stale calibration keeps answering. Narrow staleAfter and the bug
|
||||
// gets proportionally worse, which is why the correction belongs in Load rather
|
||||
// than in a wider bound here.
|
||||
func TestStaleCalibrationFallsBack(t *testing.T) {
|
||||
old := utils.DBNow().Add(-(staleAfter + time.Hour))
|
||||
Load([]ETACalibration{
|
||||
{Scope: "zone", Zone: "641", Factor: 2.0, Samples: 100, Refreshedat: old},
|
||||
})
|
||||
defer Reset()
|
||||
|
||||
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
|
||||
t.Fatal("ETAMinutes trusted a calibration older than staleAfter; refreshes have been failing")
|
||||
}
|
||||
}
|
||||
|
||||
// The companion case: a just-refreshed calibration must answer. Note this one
|
||||
// passes with or without the IST correction (the raw read errs toward "newer",
|
||||
// not "older"), so it documents intent rather than guarding the bug — the guard
|
||||
// above is what fails on mutation.
|
||||
func TestFreshCalibrationIsNotMisreadAsStale(t *testing.T) {
|
||||
Load([]ETACalibration{
|
||||
{Scope: "zone", Zone: "641", Factor: 2.0, Samples: 100, Refreshedat: utils.DBNow()},
|
||||
})
|
||||
defer Reset()
|
||||
|
||||
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); !ok {
|
||||
t.Fatal("a just-refreshed calibration was treated as stale — the DBNow/IST correction in Load is wrong")
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: loading month-old rows from the store at boot must stay stale.
|
||||
// Stamping builtAt with time.Now() here would re-arm a dead calibration on
|
||||
// every deploy.
|
||||
func TestBootLoadDoesNotRearmStaleRows(t *testing.T) {
|
||||
old := utils.DBNow().Add(-(staleAfter + 24*time.Hour))
|
||||
Load([]ETACalibration{
|
||||
{Scope: "zone", Zone: "641", Factor: 2.0, Samples: 100, Refreshedat: old},
|
||||
{Scope: "global", Factor: 2.2, Samples: 100, Refreshedat: old},
|
||||
})
|
||||
defer Reset()
|
||||
|
||||
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
|
||||
t.Fatal("a restart re-armed a stale stored calibration")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── The ladder: most specific cell wins, then progressively coarser ───────
|
||||
|
||||
func TestMostSpecificCellWins(t *testing.T) {
|
||||
Load(fixture())
|
||||
defer Reset()
|
||||
|
||||
r, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()})
|
||||
if !ok {
|
||||
t.Fatal("expected an estimate")
|
||||
}
|
||||
if r.Source != "zone_hour_weekday" {
|
||||
t.Errorf("source = %q, want zone_hour_weekday", r.Source)
|
||||
}
|
||||
if r.Minutes != 60 { // 40 × 1.5
|
||||
t.Errorf("minutes = %d, want 60 (40 × 1.5)", r.Minutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallsToZoneWeekdayThenZoneThenGlobal(t *testing.T) {
|
||||
full := fixture()
|
||||
defer Reset()
|
||||
|
||||
// Drop the finest cell: 13:00 IST is bucket 4, for which there is no
|
||||
// zone_hour_weekday row, so zone_weekday answers.
|
||||
Load(full)
|
||||
r, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001",
|
||||
At: time.Date(2026, 10, 5, 13, 0, 0, 0, time.UTC)})
|
||||
if !ok || r.Source != "zone_weekday" || r.Minutes != 80 { // 40 × 2.0
|
||||
t.Errorf("got %+v ok=%v, want zone_weekday 80", r, ok)
|
||||
}
|
||||
|
||||
// A Tuesday has no weekday cell for this zone, so zone answers.
|
||||
r, ok = ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001",
|
||||
At: time.Date(2026, 10, 6, 13, 0, 0, 0, time.UTC)})
|
||||
if !ok || r.Source != "zone" || r.Minutes != 100 { // 40 × 2.5
|
||||
t.Errorf("got %+v ok=%v, want zone 100", r, ok)
|
||||
}
|
||||
|
||||
// An unknown zone falls all the way to global.
|
||||
r, ok = ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "500081", At: mondayTenAM()})
|
||||
if !ok || r.Source != "global" || r.Minutes != 120 { // 40 × 3.0
|
||||
t.Errorf("got %+v ok=%v, want global 120", r, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortOrEmptyPincodeUsesGlobalOnly(t *testing.T) {
|
||||
Load(fixture())
|
||||
defer Reset()
|
||||
|
||||
for _, p := range []string{"", "6", "64", " "} {
|
||||
r, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: p, At: mondayTenAM()})
|
||||
if !ok || r.Source != "global" {
|
||||
t.Errorf("pincode %q: got %+v ok=%v, want global", p, r, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bucket arithmetic must match the refresh SQL, or the lookup misses ────
|
||||
|
||||
func TestHourBucketMatchesRefreshSQL(t *testing.T) {
|
||||
// refreshSQL does EXTRACT(HOUR FROM createdat)::int / hourBucketSize.
|
||||
// hourBucket must agree exactly, or every finest-grain lookup misses.
|
||||
for hour := 0; hour < 24; hour++ {
|
||||
got := hourBucket(time.Date(2026, 10, 5, hour, 30, 0, 0, time.UTC))
|
||||
if want := hour / hourBucketSize; got != want {
|
||||
t.Errorf("hour %d: bucket = %d, want %d", hour, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeekdayMatchesPostgresISODOW(t *testing.T) {
|
||||
// EXTRACT(ISODOW) is Monday=1 … Sunday=7. Go's time.Weekday is Sunday=0.
|
||||
// 2026-10-05 is a Monday.
|
||||
want := map[int]int{5: 1, 6: 2, 7: 3, 8: 4, 9: 5, 10: 6, 11: 7}
|
||||
for day, w := range want {
|
||||
got := weekdayOf(time.Date(2026, 10, day, 12, 0, 0, 0, time.UTC))
|
||||
if got != w {
|
||||
t.Errorf("2026-10-%02d: weekday = %d, want %d (ISODOW)", day, got, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZoneIsPincodePrefix(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"641001": "641", "641 ": "641", "500081": "500",
|
||||
"": "", "64": "", " ": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := zoneOf(in); got != want {
|
||||
t.Errorf("zoneOf(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ETAAt and handling term ───────────────────────────────────────────────
|
||||
|
||||
func TestETAAtAddsToTheGivenTime(t *testing.T) {
|
||||
Load(fixture())
|
||||
defer Reset()
|
||||
|
||||
from := mondayTenAM()
|
||||
at, r, ok := ETAAt(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: from}, from)
|
||||
if !ok {
|
||||
t.Fatal("expected an estimate")
|
||||
}
|
||||
if want := from.Add(60 * time.Minute); !at.Equal(want) {
|
||||
t.Errorf("ETAAt = %v, want %v", at, want)
|
||||
}
|
||||
// The returned time keeps the caller's location, so it round-trips into the
|
||||
// database in the same shape it came out.
|
||||
if at.Location() != from.Location() {
|
||||
t.Errorf("ETAAt changed the location from %v to %v", from.Location(), at.Location())
|
||||
}
|
||||
if r.Minutes != 60 {
|
||||
t.Errorf("result minutes = %d, want 60", r.Minutes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestETAAtFallsBackWithoutCalibration(t *testing.T) {
|
||||
Reset()
|
||||
from := mondayTenAM()
|
||||
if _, _, ok := ETAAt(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: from}, from); ok {
|
||||
t.Fatal("ETAAt answered with no calibration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlingTermIsAddedAndBounded(t *testing.T) {
|
||||
Load([]ETACalibration{
|
||||
{Scope: "zone", Zone: "641", Factor: 2.0, Handling: 15, Samples: 100},
|
||||
})
|
||||
r, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()})
|
||||
if !ok || r.Minutes != 95 { // 40 × 2.0 + 15
|
||||
t.Errorf("got %+v ok=%v, want 95", r, ok)
|
||||
}
|
||||
Reset()
|
||||
|
||||
Load([]ETACalibration{
|
||||
{Scope: "zone", Zone: "641", Factor: 2.0, Handling: maxHandlingMinutes + 1, Samples: 100},
|
||||
})
|
||||
if _, ok := ETAMinutes(Input{RoutedMinutes: 40, DeliveryPincode: "641001", At: mondayTenAM()}); ok {
|
||||
t.Error("served a handling term above the cap")
|
||||
}
|
||||
Reset()
|
||||
}
|
||||
|
||||
func TestLoadedReportsState(t *testing.T) {
|
||||
Reset()
|
||||
if ok, _, _ := Loaded(); ok {
|
||||
t.Error("Loaded() true after Reset")
|
||||
}
|
||||
Load(fixture())
|
||||
defer Reset()
|
||||
ok, _, cells := Loaded()
|
||||
if !ok || cells != 3 { // 3 keyed cells; global is held separately
|
||||
t.Errorf("Loaded() = %v, cells %d; want true, 3", ok, cells)
|
||||
}
|
||||
}
|
||||
185
internal/prediction/refine.go
Normal file
185
internal/prediction/refine.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package prediction
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Refining a promise after the customer has already been given one.
|
||||
//
|
||||
// ─── Why this cannot happen at booking-create time ─────────────────────────
|
||||
//
|
||||
// The obvious place to use a calibrated ETA is where the promise is first
|
||||
// written — controllers/adminController.go:2738 and
|
||||
// controllers/cxPickupFanout.go:224. It does not work there, and not because
|
||||
// the calibration is cold: at that moment there is structurally no routed
|
||||
// duration to calibrate. The order of events is
|
||||
//
|
||||
// booking created -> estimateddeliveryat written from the promise table
|
||||
// -> rider assigned
|
||||
// -> internal/routing sequences -> etaminutes written
|
||||
//
|
||||
// so RoutedMinutes is 0 on every create and ETAMinutes would return false
|
||||
// forever. Wiring it there would be dead code.
|
||||
//
|
||||
// The routed duration first exists at sequencing. So refining the promise means
|
||||
// revising a number the customer may already have seen.
|
||||
//
|
||||
// ─── What this does and deliberately does not do ───────────────────────────
|
||||
//
|
||||
// It updates consignments.estimateddeliveryat — the "arrives by" the customer
|
||||
// is shown — and never touches sladueat. That split is the whole design:
|
||||
//
|
||||
// estimateddeliveryat our best current belief. Allowed to improve.
|
||||
// sladueat the commitment made at booking. Must not move, or it
|
||||
// stops being a commitment and every breach can be
|
||||
// explained away by moving the target.
|
||||
//
|
||||
// It also only ever moves the estimate when the calibration is trustworthy
|
||||
// (ETAMinutes true), so with ROUTE_OPTIMIZER_URL unset — the state today —
|
||||
// this function reads some rows and writes nothing.
|
||||
//
|
||||
// A customer watching the tracking page will see the time change. Whether that
|
||||
// should also emit a stage event is an open product question
|
||||
// (docs/prediction-plan.md §3a); this writes the column and does not notify,
|
||||
// because inventing a notification is the more consequential of the two
|
||||
// choices to get wrong.
|
||||
|
||||
// RefinedETA is one estimate this pass revised, for logging.
|
||||
type RefinedETA struct {
|
||||
Consignmentid int
|
||||
Was *time.Time
|
||||
Now time.Time
|
||||
Source string
|
||||
}
|
||||
|
||||
// refineRow is a consignment awaiting delivery, joined to the routed duration
|
||||
// of its booking's latest sequenced assignment.
|
||||
//
|
||||
// The join goes through consignment_booking because consignments has no
|
||||
// bookingid column, and the legacy pickupbookings.consignmentid link names only
|
||||
// the FIRST order of a multi-destination pickup — hazard H4. DISTINCT ON picks
|
||||
// one assignment per booking: a booking passes through several
|
||||
// (Assigned/Rejected/Reassigned) and the newest sequenced one is the routed ETA
|
||||
// actually in force.
|
||||
type refineRow struct {
|
||||
Consignmentid int
|
||||
Deliverypincode string
|
||||
Createdat time.Time
|
||||
Estimateddeliveryat *time.Time
|
||||
Etaminutes int
|
||||
Sequencedat *time.Time
|
||||
}
|
||||
|
||||
const refineSQL = `
|
||||
WITH assigned AS (
|
||||
SELECT DISTINCT ON (ba.bookingid)
|
||||
ba.bookingid, ba.etaminutes, ba.sequencedat
|
||||
FROM bookingassignments ba
|
||||
WHERE ba.etaminutes > 0
|
||||
ORDER BY ba.bookingid, ba.sequencedat DESC NULLS LAST, ba.bookingassignmentid DESC
|
||||
)
|
||||
SELECT c.consignmentid,
|
||||
COALESCE(c.deliverypincode, '') AS deliverypincode,
|
||||
c.createdat,
|
||||
c.estimateddeliveryat,
|
||||
a.etaminutes,
|
||||
a.sequencedat
|
||||
FROM consignments c
|
||||
JOIN consignment_booking cb ON cb.consignmentid = c.consignmentid
|
||||
JOIN assigned a ON a.bookingid = cb.bookingid
|
||||
WHERE c.status NOT IN ?
|
||||
AND c.deletedat IS NULL
|
||||
ORDER BY a.sequencedat DESC NULLS LAST
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
// refineBatch caps one pass. The sweeper runs often enough that a backlog
|
||||
// clears over a few ticks, and an unbounded UPDATE loop on a hot table is the
|
||||
// kind of thing that shows up as a latency spike somewhere unrelated.
|
||||
const refineBatch = 300
|
||||
|
||||
// terminal statuses — a parcel that has arrived, come back, or been written off
|
||||
// has nothing left to estimate.
|
||||
func terminalStatuses() []string {
|
||||
return []string{
|
||||
constants.ConsignmentDelivered,
|
||||
"Returned_to_Sender",
|
||||
"Missing",
|
||||
"Damaged",
|
||||
}
|
||||
}
|
||||
|
||||
// RefineInFlight recomputes estimateddeliveryat for consignments that are still
|
||||
// moving and whose rider has a routed duration. Returns what it changed.
|
||||
//
|
||||
// Safe to call when nothing is calibrated: ETAMinutes returns false for every
|
||||
// row and this writes nothing.
|
||||
func RefineInFlight(gdb *gorm.DB, now time.Time) ([]RefinedETA, error) {
|
||||
if gdb == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if ok, _, _ := Loaded(); !ok {
|
||||
return nil, nil // nothing to refine with; the promise tables stand
|
||||
}
|
||||
|
||||
var rows []refineRow
|
||||
if err := gdb.Raw(refineSQL, terminalStatuses(), refineBatch).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
changed := make([]RefinedETA, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
// The estimate is anchored on when sequencing happened, not on the
|
||||
// consignment's creation: the routed duration describes the journey
|
||||
// from the rider's current position onward.
|
||||
anchor := r.Createdat
|
||||
if r.Sequencedat != nil {
|
||||
anchor = *r.Sequencedat
|
||||
}
|
||||
|
||||
eta, res, ok := ETAAt(Input{
|
||||
RoutedMinutes: r.Etaminutes,
|
||||
DeliveryPincode: r.Deliverypincode,
|
||||
At: anchor,
|
||||
}, anchor)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip a no-op write. Without this every pass rewrites every in-flight
|
||||
// row with the same value, which churns WAL and makes the log useless
|
||||
// for seeing what actually moved.
|
||||
if r.Estimateddeliveryat != nil && withinAMinute(*r.Estimateddeliveryat, eta) {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := gdb.Model(&models.Consignment{}).
|
||||
Where("consignmentid = ?", r.Consignmentid).
|
||||
Update("estimateddeliveryat", eta).Error; err != nil {
|
||||
utils.Warn("prediction: could not refine ETA",
|
||||
"consignmentid", r.Consignmentid, "error", err)
|
||||
continue
|
||||
}
|
||||
changed = append(changed, RefinedETA{
|
||||
Consignmentid: r.Consignmentid,
|
||||
Was: r.Estimateddeliveryat,
|
||||
Now: eta,
|
||||
Source: res.Source,
|
||||
})
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func withinAMinute(a, b time.Time) bool {
|
||||
d := a.Sub(b)
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
return d < time.Minute
|
||||
}
|
||||
130
internal/prediction/sweeper.go
Normal file
130
internal/prediction/sweeper.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package prediction
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/db"
|
||||
"doormile/utils"
|
||||
)
|
||||
|
||||
// The calibration sweeper.
|
||||
//
|
||||
// Deliberately the same shape as internal/assignment/sweeper.go: a ticker, a
|
||||
// recover() per tick so one bad run cannot take the process down, and a Redis
|
||||
// lock that expires before the next tick so only one replica of three does the
|
||||
// work. Without Redis every replica refreshes, which is wasteful but correct —
|
||||
// Refresh replaces the table in one transaction, so concurrent runs converge
|
||||
// rather than interleave.
|
||||
//
|
||||
// This is a nightly job by default. The calibration is a p80 over weeks of
|
||||
// deliveries; recomputing it more often costs a scan and changes nothing.
|
||||
|
||||
const (
|
||||
defaultCalibrationSweepSeconds = 6 * 60 * 60 // 6h
|
||||
calibrationLockKey = "prediction:calibration:lock"
|
||||
|
||||
// Below this a "sweep" is a scan loop against the whole delivery history.
|
||||
minCalibrationSweepSeconds = 600
|
||||
)
|
||||
|
||||
// calibrationInterval: PREDICTION_CALIBRATION_SECONDS, default 6h; 0 turns the
|
||||
// sweeper off and leaves whatever is in the store. Read once at start, matching
|
||||
// how assignment's sweepInterval behaves.
|
||||
func calibrationInterval() time.Duration {
|
||||
if v := strings.TrimSpace(os.Getenv("PREDICTION_CALIBRATION_SECONDS")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
if n > 0 && n < minCalibrationSweepSeconds {
|
||||
n = minCalibrationSweepSeconds
|
||||
}
|
||||
return time.Duration(n) * time.Second
|
||||
}
|
||||
utils.Warn("PREDICTION_CALIBRATION_SECONDS is not a non-negative integer, using the default",
|
||||
"value", v, "default_seconds", defaultCalibrationSweepSeconds)
|
||||
}
|
||||
return defaultCalibrationSweepSeconds * time.Second
|
||||
}
|
||||
|
||||
// StartCalibrationSweeper loads whatever calibration is stored, then refreshes
|
||||
// it on a timer. Call once at boot, in a goroutine.
|
||||
//
|
||||
// The load happens even when the sweeper is disabled: a stored calibration from
|
||||
// a previous deploy is still the best available answer, and ETAMinutes will
|
||||
// stop trusting it on its own once it passes staleAfter.
|
||||
func StartCalibrationSweeper() {
|
||||
LoadFromDB()
|
||||
|
||||
interval := calibrationInterval()
|
||||
if interval == 0 {
|
||||
utils.Info("CalibrationSweeper: disabled (PREDICTION_CALIBRATION_SECONDS=0)")
|
||||
return
|
||||
}
|
||||
utils.Info("CalibrationSweeper: started", "interval", interval.String())
|
||||
|
||||
// One refresh shortly after boot rather than waiting a full interval, so a
|
||||
// newly deployed replica is not serving a day-old calibration for six
|
||||
// hours. Offset so three replicas do not all wake together.
|
||||
time.Sleep(90 * time.Second)
|
||||
refreshOnce(interval)
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
refreshOnce(interval)
|
||||
}
|
||||
}
|
||||
|
||||
// refreshOnce makes one refresh attempt. Only one replica at a time (a Redis
|
||||
// lock that expires before the next tick).
|
||||
func refreshOnce(interval time.Duration) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
utils.Error("CalibrationSweeper: panic recovered", "error", r)
|
||||
}
|
||||
}()
|
||||
if db.DB == nil {
|
||||
return
|
||||
}
|
||||
if db.Rdb != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
ttl := interval - 60*time.Second
|
||||
if ttl <= 0 {
|
||||
ttl = interval / 2
|
||||
}
|
||||
got, err := db.Rdb.SetNX(ctx, calibrationLockKey, "1", ttl).Result()
|
||||
cancel()
|
||||
if err == nil && !got {
|
||||
return // another replica has this refresh
|
||||
}
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
if err := Refresh(db.DB); err != nil {
|
||||
// A failed refresh leaves the previous calibration serving. That is the
|
||||
// intended behaviour: the last good factors beat falling back to flat
|
||||
// constants, and staleAfter puts a bound on how long that can last.
|
||||
utils.Error("CalibrationSweeper: refresh failed", "error", err,
|
||||
"took_ms", time.Since(started).Milliseconds())
|
||||
return
|
||||
}
|
||||
ok, builtAt, cells := Loaded()
|
||||
utils.Info("CalibrationSweeper: refresh complete",
|
||||
"loaded", ok, "cells", cells, "built_at", builtAt,
|
||||
"took_ms", time.Since(started).Milliseconds())
|
||||
|
||||
// Apply the fresh calibration to parcels still in flight. Writes
|
||||
// estimateddeliveryat only — never sladueat, which is the commitment made
|
||||
// at booking (see internal/prediction/refine.go). A no-op while nothing is
|
||||
// calibrated.
|
||||
refined, err := RefineInFlight(db.DB, time.Now())
|
||||
if err != nil {
|
||||
utils.Error("CalibrationSweeper: ETA refine failed", "error", err)
|
||||
return
|
||||
}
|
||||
if len(refined) > 0 {
|
||||
utils.Info("CalibrationSweeper: refined in-flight ETAs", "count", len(refined))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user