Files
routesapi/app/config/dynamic_config.py
Suriya 9ef2f61870 sync: capture the production server's code, which was never committed
/root/Routes-api on 31.97.228.132 is not a git repository. Work had been
done directly on the box and existed nowhere else -- a single rm -rf from
being lost, and impossible to review or roll back.

Deploying the previous HEAD over it would have silently reverted all of
this. Most visibly the Valhalla road-backend probe in main.py, whose own
comment explains why it exists: road sequencing degrades to aerial
silently by design, so an unreachable backend stays invisible, "which is
exactly how the expired Google key went unnoticed". Overwriting it would
have reintroduced precisely the failure it was written to catch, and the
service would have kept answering 200 throughout.

The server had also moved from Google Maps to Valhalla for road distance
(VALHALLA_URL, road_backend_status, +190 lines in route_optimizer),
extended docker-compose from 44 to 95 lines, and changed rider fetching,
health, dynamic config and the cache layer.

Only 10 files differ in substance. The other 27 that appeared to differ
were CRLF-vs-LF noise -- the server writes CRLF -- and are normalised to
LF here rather than committed as spurious whole-file rewrites.

Committed as-is, before any change of mine, so the diff that follows is
reviewable against what is actually running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 15:56:32 +05:30

259 lines
11 KiB
Python

"""
Dynamic Configuration - rider-api
Replaces all hardcoded hyperparameters with DB-backed values. The autonomous
agents (ETA sync, road-sequencing, rider affinity) and the ml_admin API write
tuned values here; services read from here.
Fallback: If DB is unavailable or no tuned values exist, defaults are used.
This means zero risk - the system works day 1 with no data.
"""
import json
import logging
import os
import sqlite3
from datetime import datetime
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
# --- DB Path ------------------------------------------------------------------
_DB_PATH = os.getenv("ML_DB_PATH", "ml_data/ml_store.db")
# --- Hard Defaults (What the system used before ML) ---------------------------
DEFAULTS: Dict[str, Any] = {
# AssignmentService
"max_pickup_distance_km": 10.0,
"max_kitchen_distance_km": 3.0,
"max_orders_per_rider": 12,
"ideal_load": 6,
"workload_balance_threshold": 0.7,
"workload_penalty_weight": 100.0,
"distance_penalty_weight": 2.0,
"preference_bonus": -15.0,
"home_zone_bonus_4km": -3.0,
"home_zone_bonus_2km": -5.0,
"emergency_load_penalty": 3.0, # km penalty per order in emergency assign
# RouteOptimizer
"search_time_limit_seconds": 5,
# Active-rider roster cache. The upstream getriderlogs call measured ~6s on
# live traffic. Short TTL so on/off-duty changes surface quickly; 0 = off.
"rider_roster_cache_ttl_seconds": 30,
"avg_speed_kmh": 18.0,
"road_factor": 1.3,
# ClusteringService
"cluster_radius_km": 3.0,
# RealisticETACalculator
"eta_pickup_time_min": 3.0,
"eta_delivery_time_min": 4.0,
"eta_navigation_buffer_min": 1.5,
"eta_short_trip_factor": 0.8, # speed multiplier for dist < 2km
"eta_long_trip_factor": 1.1, # speed multiplier for dist > 8km
# EmpiricalETACalculator (learned ETAs from actual delivery times)
"eta_empirical_enabled": True, # False -> instantly revert to the formula
"eta_min_samples": 10, # min history samples a key needs before trust
# (backtest on live 14d data: 10 -> MAE 4.85 vs
# formula 5.73; 20 -> 5.64. 10 wins on held-out.)
"eta_history_days": 14, # rolling window pulled from nearledb
"eta_stat": "median", # "median" or "p75" (p75 = more conservative)
"eta_sync_interval_hours": 6, # autonomous background sync cadence
# Road-aware sequencing (Phase 2). Backed by self-hosted Valhalla, so there is
# no per-request cost — only local latency. Still OFF by default and left to
# the agent to enable on measured gain. Only the *visiting order* changes;
# step/ETA metrics stay aerial-based.
"routing_use_road_distance": False, # AGENT-MANAGED (see routing_auto_manage)
"routing_road_cache_ttl_seconds": 86400, # road geometry is stable; cache 24h
"routing_road_max_stops": 25, # beyond this the solver time outweighs
# the ordering gain (not a backend cap)
# Valhalla matrix backend. "motorcycle" models the lane access and one-way
# behaviour our riders actually have; "auto" would overstate their travel time.
"routing_valhalla_costing": "motorcycle",
"routing_matrix_timeout_seconds": 15.0,
"routing_matrix_max_unroutable_pct": 20.0, # above this -> tiles likely missing
# this region, fall back to aerial
# Autonomous road-sequencing decision agent: measures road-vs-aerial travel
# time on real batches and flips routing_use_road_distance on its own.
"routing_auto_manage": True, # False -> humans own the flag
"routing_auto_enable_gain_pct": 3.0, # enable when mean gain >= this
"routing_auto_disable_gain_pct": 1.0, # disable when mean gain < this (hysteresis)
"routing_eval_sample_batches": 8, # batches measured per cycle (cost bound)
"routing_eval_min_batches": 3, # need >= this evaluated to decide
"routing_eval_interval_hours": 24, # decision cadence
"routing_eval_days": 14, # window sampled from the local mirror
# Learned rider->kitchen affinity (soft steering only; union with curated config).
"rider_affinity_enabled": True, # False -> pure curated config
"rider_affinity_min_deliveries": 10, # learned owner needs >= this many deliveries
"rider_affinity_refresh_hours": 6, # recompute cadence (piggybacks the agent)
# Phase-0 kitchen+zone pattern store (delivery_history_store.py): which rider
# historically owns a kitchen->drop-zone pair, used to pre-assign orders.
# "csv" -> legacy: built from delivery_details.csv, only refreshed when a
# human re-exports it and calls POST /ml/reload-history.
# "db" -> built from the already-synced nearledb mirror (delivery_raw),
# rebuilt automatically every eta_sync_interval_hours — no manual
# step, no extra DB load (reuses rows the ETA sync already pulled).
"pattern_source": "csv",
"pattern_history_days": 30, # retention floor for delivery_raw kept for pattern-
# matching volume; independent of eta_history_days
# so the (already-validated) empirical ETA window
# is untouched.
}
class DynamicConfig:
"""
Thread-safe, DB-backed configuration store.
Usage:
cfg = DynamicConfig()
max_dist = cfg.get("max_pickup_distance_km")
all_params = cfg.get_all()
"""
_instance: Optional["DynamicConfig"] = None
def __new__(cls) -> "DynamicConfig":
"""Singleton - one config per process."""
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self._initialized = True
self._cache: Dict[str, Any] = {}
self._last_loaded: Optional[datetime] = None
self._ensure_db()
self._load()
# --------------------------------------------------------------------------
# Public API
# --------------------------------------------------------------------------
def get(self, key: str, default: Any = None) -> Any:
"""Get a config value. Returns ML-tuned value if available, else default."""
self._maybe_reload()
val = self._cache.get(key)
if val is not None:
return val
fallback = default if default is not None else DEFAULTS.get(key)
return fallback
def get_all(self) -> Dict[str, Any]:
"""Return all current config values (ML-tuned + defaults for missing keys)."""
self._maybe_reload()
result = dict(DEFAULTS)
result.update(self._cache)
return result
def set(self, key: str, value: Any, source: str = "manual") -> None:
"""Write a config value to DB (used by the ml_admin API and agents)."""
try:
os.makedirs(os.path.dirname(_DB_PATH) or ".", exist_ok=True)
conn = sqlite3.connect(_DB_PATH)
conn.execute(
"""
INSERT INTO dynamic_config (key, value, source, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value=excluded.value,
source=excluded.source,
updated_at=excluded.updated_at
""",
(key, json.dumps(value), source, datetime.utcnow().isoformat()),
)
conn.commit()
conn.close()
self._cache[key] = value
logger.info(f"[DynamicConfig] Set {key}={value} (source={source})")
except Exception as e:
logger.error(f"[DynamicConfig] Failed to set {key}: {e}")
def set_bulk(self, params: Dict[str, Any], source: str = "manual") -> None:
"""Write multiple config values at once."""
for key, value in params.items():
self.set(key, value, source=source)
logger.info(f"[DynamicConfig] Bulk update: {len(params)} params from {source}")
def reset_to_defaults(self) -> None:
"""Wipe all ML-tuned values, revert to hardcoded defaults."""
try:
conn = sqlite3.connect(_DB_PATH)
conn.execute("DELETE FROM dynamic_config")
conn.commit()
conn.close()
self._cache.clear()
logger.warning("[DynamicConfig] Reset to factory defaults.")
except Exception as e:
logger.error(f"[DynamicConfig] Reset failed: {e}")
# --------------------------------------------------------------------------
# Internal
# --------------------------------------------------------------------------
def _ensure_db(self) -> None:
try:
os.makedirs(os.path.dirname(_DB_PATH) or ".", exist_ok=True)
conn = sqlite3.connect(_DB_PATH)
conn.execute("""
CREATE TABLE IF NOT EXISTS dynamic_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
source TEXT DEFAULT 'manual',
updated_at TEXT
)
""")
conn.commit()
conn.close()
except Exception as e:
logger.error(f"[DynamicConfig] DB init failed: {e}")
def _load(self) -> None:
try:
conn = sqlite3.connect(_DB_PATH)
rows = conn.execute("SELECT key, value FROM dynamic_config").fetchall()
conn.close()
self._cache = {}
for key, raw in rows:
try:
self._cache[key] = json.loads(raw)
except Exception:
self._cache[key] = raw
self._last_loaded = datetime.utcnow()
if self._cache:
logger.info(
f"[DynamicConfig] Loaded {len(self._cache)} ML-tuned params from DB"
)
except Exception as e:
logger.warning(
f"[DynamicConfig] Could not load from DB (using defaults): {e}"
)
self._cache = {}
def _maybe_reload(self, interval_seconds: int = 300) -> None:
"""Reload from DB every 5 minutes - picks up new tuned params without restart."""
if self._last_loaded is None:
self._load()
return
delta = (datetime.utcnow() - self._last_loaded).total_seconds()
if delta > interval_seconds:
self._load()
# --- Module-level convenience singleton ---------------------------------------
_cfg = DynamicConfig()
def get_config() -> DynamicConfig:
"""Get the global DynamicConfig singleton."""
return _cfg
__all__ = [
"DynamicConfig",
"get_config",
]