updates on the ai agents and time series prediction and updates on the api to
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user