Files
routesapi/app/services/routing/road_sequencing_agent.py
2026-06-22 17:40:08 +05:30

234 lines
9.6 KiB
Python

"""
Road-Sequencing Decision Agent
==============================
Autonomous controller for the `routing_use_road_distance` feature. Instead of a
human flipping a config flag, this agent periodically *measures* whether road-aware
sequencing (road travel-time matrix + OR-Tools open-TSP) actually beats the default
straight-line ordering on REAL recent batches, and turns the feature on or off by
itself — re-checking every cycle so it self-corrects if the gain ever disappears.
Decision (with hysteresis, so it doesn't flap):
mean travel-time gain >= routing_auto_enable_gain_pct -> enable
mean travel-time gain < routing_auto_disable_gain_pct -> disable
in between -> leave as-is
Every decision is stored (auditable) in DynamicConfig under `routing_road_eval`
and exposed via GET /api/v1/ml/road-eval. Cost is bounded: it evaluates only
`routing_eval_sample_batches` batches once per `routing_eval_interval_hours`.
"""
import asyncio
import json
import logging
import threading
import time
from datetime import datetime
from typing import Any, Dict, List, Optional
import numpy as np
logger = logging.getLogger(__name__)
def _total(order_idx: List[int], matrix: List[List[float]]) -> float:
"""Total open-route cost: origin(0) -> drops in `order_idx` (1-based into matrix)."""
seq = [0] + [i + 1 for i in order_idx]
return sum(matrix[seq[k]][seq[k + 1]] for k in range(len(seq) - 1))
class RoadSequencingAgent:
"""Measures road-vs-aerial sequencing on real batches and auto-toggles the flag."""
def __init__(self):
self._opt = None # lazy RouteOptimizer
self._scheduler_started = False
self._lock = threading.Lock()
self.last_decision: Dict[str, Any] = {}
def _optimizer(self):
if self._opt is None:
from app.services.routing.route_optimizer import RouteOptimizer
self._opt = RouteOptimizer()
return self._opt
async def evaluate(self, sample_batches: int = 8, days: int = 14) -> Dict[str, Any]:
"""
Compare aerial-order vs road-order travel time on sampled real batches.
Returns mean gain % and per-batch detail. No flag changes here.
"""
from app.services.routing.delivery_history_service import get_delivery_history_service
from app.core.arrow_utils import calculate_haversine_matrix_vectorized
opt = self._optimizer()
if not opt.use_google_maps:
return {"evaluated": 0, "reason": "no_google_key", "mean_gain_pct": 0.0}
batches = get_delivery_history_service().sample_batches(
days=days, limit=sample_batches
)
if not batches:
return {"evaluated": 0, "reason": "no_local_batches", "mean_gain_pct": 0.0}
per_batch: List[Dict[str, Any]] = []
for b in batches:
origin, drops = b["origin"], b["drops"]
locs = [origin] + drops
matrix = await opt._road_duration_matrix(locs)
if matrix is None:
continue
# aerial order (current production behaviour)
lats = np.array([p[0] for p in locs])
lons = np.array([p[1] for p in locs])
aerial = calculate_haversine_matrix_vectorized(lats, lons)
aer = [i - 1 for i in opt._two_opt_improve(opt._solve_greedy(locs, aerial), aerial) if i != 0]
# road order (OR-Tools open-TSP on the road travel-time matrix)
road = [i - 1 for i in opt._solve_tsp_ortools(locs, matrix) if i != 0]
# Human's ACTUAL route: drops are already in delivered order
# (_load_raw_rows is ORDER BY deliverytime), so identity = what the
# rider really drove. This is the "do we beat the humans?" baseline.
human = list(range(len(drops)))
t_aer = _total(aer, matrix)
t_road = _total(road, matrix)
t_human = _total(human, matrix)
gain = (100.0 * (t_aer - t_road) / t_aer) if t_aer > 0 else 0.0
gain_vs_human = (100.0 * (t_human - t_road) / t_human) if t_human > 0 else 0.0
per_batch.append({
"rider": b["rider"], "day": b["day"], "drops": len(drops),
"aerial_min": round(t_aer, 1), "road_min": round(t_road, 1),
"human_min": round(t_human, 1),
"gain_pct": round(gain, 1),
"gain_vs_human_pct": round(gain_vs_human, 1),
"beat_human": t_road <= t_human + 1e-9,
})
if not per_batch:
return {"evaluated": 0, "reason": "matrix_unavailable", "mean_gain_pct": 0.0}
n = len(per_batch)
mean_gain = sum(x["gain_pct"] for x in per_batch) / n
mean_vs_human = sum(x["gain_vs_human_pct"] for x in per_batch) / n
beats = sum(1 for x in per_batch if x["beat_human"])
return {
"evaluated": n,
"mean_gain_pct": round(mean_gain, 2),
"median_gain_pct": round(sorted(x["gain_pct"] for x in per_batch)[n // 2], 2),
"human_beat_rate_pct": round(100.0 * beats / n, 1),
"human_beat_count": f"{beats}/{n}",
"mean_gain_vs_human_pct": round(mean_vs_human, 2),
"per_batch": per_batch,
}
def decide_and_apply(self) -> Dict[str, Any]:
"""Run an evaluation and autonomously enable/disable road sequencing."""
from app.config.dynamic_config import get_config
cfg = get_config()
sample = int(cfg.get("routing_eval_sample_batches", 8))
days = int(cfg.get("routing_eval_days", 14))
enable_thr = float(cfg.get("routing_auto_enable_gain_pct", 3.0))
disable_thr = float(cfg.get("routing_auto_disable_gain_pct", 1.0))
min_batches = int(cfg.get("routing_eval_min_batches", 3))
auto_manage = bool(cfg.get("routing_auto_manage", True))
try:
result = asyncio.run(self.evaluate(sample_batches=sample, days=days))
except RuntimeError:
# An event loop is already running in this thread — use a fresh one.
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(self.evaluate(sample_batches=sample, days=days))
finally:
loop.close()
current = bool(cfg.get("routing_use_road_distance", False))
evaluated = result.get("evaluated", 0)
gain = result.get("mean_gain_pct", 0.0)
action = "kept"
new_state = current
if not auto_manage:
action = "auto_manage_off"
elif evaluated < min_batches:
action = "insufficient_data"
else:
if gain >= enable_thr and not current:
new_state = True
cfg.set("routing_use_road_distance", True, source="road_agent")
action = "enabled"
elif gain < disable_thr and current:
new_state = False
cfg.set("routing_use_road_distance", False, source="road_agent")
action = "disabled"
decision = {
"decided_at": datetime.utcnow().isoformat(),
"action": action,
"flag_before": current,
"flag_after": new_state,
"mean_gain_pct": gain,
"enable_threshold_pct": enable_thr,
"disable_threshold_pct": disable_thr,
"evaluation": result,
}
self.last_decision = decision
try:
# Persist a compact copy for audit (without the full per-batch list).
compact = {k: v for k, v in decision.items() if k != "evaluation"}
compact["evaluated"] = evaluated
compact["human_beat_rate_pct"] = result.get("human_beat_rate_pct")
compact["mean_gain_vs_human_pct"] = result.get("mean_gain_vs_human_pct")
cfg.set("routing_road_eval", compact, source="road_agent")
except Exception:
pass
logger.info(
f"[RoadAgent] action={action} gain_vs_aerial={gain}% evaluated={evaluated} "
f"flag {current}->{new_state}"
)
if evaluated:
logger.info(
f"[RoadAgent] beat humans on {result.get('human_beat_count', '?')} batches "
f"(avg {result.get('mean_gain_vs_human_pct', 0)}% faster than actual delivered order)"
)
return decision
def ensure_background_agent(self, interval_hours: int = 24,
warmup_seconds: int = 120) -> bool:
"""Start the autonomous decision loop once (daemon thread)."""
with self._lock:
if self._scheduler_started:
return False
self._scheduler_started = True
def _loop():
# Let the ETA sync agent populate the local mirror first.
time.sleep(max(0, warmup_seconds))
logger.info(f"[RoadAgent] autonomous road-sequencing decision loop started "
f"(interval={interval_hours}h)")
while True:
try:
self.decide_and_apply()
except Exception as e:
logger.warning(f"[RoadAgent] decision cycle failed (will retry): {e}")
from app.config.dynamic_config import get_config
hrs = int(get_config().get("routing_eval_interval_hours", interval_hours))
time.sleep(max(1, hrs) * 3600)
threading.Thread(target=_loop, daemon=True, name="road-seq-agent").start()
return True
_agent: Optional[RoadSequencingAgent] = None
_agent_lock = threading.Lock()
def get_road_agent() -> RoadSequencingAgent:
global _agent
with _agent_lock:
if _agent is None:
_agent = RoadSequencingAgent()
return _agent