strategy_orchestrator.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. """Adaptive simulation loop orchestrator (P3-M2).
  2. Bridges the web-side feasibility-first search (FeasibilityFirstSearch) to the
  3. local executor through the task system.
  4. Lifecycle:
  5. start_loop() build search from plan parameters -> create the initial
  6. adaptive_batch task (task_type="adaptive_batch")
  7. advance_loop() when the current batch task is completed, read its results
  8. (point_id -> params -> metrics), feed them back into the
  9. search via report_result(), run AI analysis, then either
  10. converge or create the next batch task.
  11. The orchestrator is pull-driven (advance_loop is invoked by a caller / route
  12. / scheduler), matching the existing poll-based executor model and keeping the
  13. task system free of new completion hooks.
  14. Loop state is persisted as JSON under output/adaptive_loops/ so a restart can
  15. at least recover loop metadata and the current batch task.
  16. All source is ASCII only.
  17. """
  18. import json
  19. import os
  20. from datetime import datetime
  21. from typing import Any, Dict, List, Optional
  22. from ..services.feasibility_search import FeasibilityFirstSearch, ParameterRange
  23. from ..services.l0_prescreening import L0PreScreeningEngine
  24. from ..services.task_manager import get_task_manager, TaskManager
  25. from ..services.result_analyst import AIResultAnalyst
  26. from ..config import KIMI_API_KEY
  27. # Valid loop phases (mirror the executor/task vocabulary).
  28. LOOP_PHASE_INIT = "initializing"
  29. LOOP_PHASE_RUNNING = "running"
  30. LOOP_PHASE_CONVERGED = "converged"
  31. LOOP_PHASE_BUDGET_EXHAUSTED = "budget_exhausted"
  32. LOOP_PHASE_FAILED = "failed"
  33. def _loop_state_path(state_dir: str, loop_id: str) -> str:
  34. return os.path.join(state_dir, "%s_loop.json" % loop_id)
  35. class AdaptiveOrchestrator:
  36. """Coordinates adaptive search <-> task system <-> local executor."""
  37. def __init__(
  38. self,
  39. task_manager: Optional[TaskManager] = None,
  40. state_dir: Optional[str] = None,
  41. l0_engine: Optional[L0PreScreeningEngine] = None,
  42. ):
  43. self.tm = task_manager or get_task_manager()
  44. self.l0_engine = l0_engine or L0PreScreeningEngine()
  45. self.state_dir = state_dir or os.path.join(
  46. os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))),
  47. "output", "adaptive_loops",
  48. )
  49. os.makedirs(self.state_dir, exist_ok=True)
  50. self._analyst = AIResultAnalyst()
  51. # loop_id -> in-memory search + runtime metadata
  52. self._searches: Dict[str, FeasibilityFirstSearch] = {}
  53. self._loops: Dict[str, Dict[str, Any]] = {}
  54. self._load_state()
  55. # ------------------------------------------------------------------
  56. # Public API
  57. # ------------------------------------------------------------------
  58. def start_loop(
  59. self,
  60. loop_id: str,
  61. parameters: List[Dict[str, Any]],
  62. plan_id: Optional[int] = None,
  63. plan_data: Optional[Dict[str, Any]] = None,
  64. objective_metric: str = "tavg_nm",
  65. objective_direction: str = "maximize",
  66. total_budget: int = 80,
  67. batch_size: int = 4,
  68. initial_samples: int = 16,
  69. seed: int = 42,
  70. ) -> Dict[str, Any]:
  71. """Start an adaptive loop from explicit search parameters.
  72. parameters: [{"name", "min_value", "max_value", "step"?, "unit"?}]
  73. Returns loop status including the initial batch task_id.
  74. """
  75. if loop_id in self._loops:
  76. raise ValueError("Loop %s already exists" % loop_id)
  77. search = self._build_search(
  78. parameters, objective_metric, objective_direction,
  79. total_budget, batch_size, initial_samples, seed,
  80. )
  81. self._searches[loop_id] = search
  82. meta = {
  83. "loop_id": loop_id,
  84. "phase": LOOP_PHASE_INIT,
  85. "plan_id": plan_id,
  86. "plan_data": plan_data or {},
  87. "parameters": parameters,
  88. "objective_metric": objective_metric,
  89. "objective_direction": objective_direction,
  90. "total_budget": total_budget,
  91. "batch_size": batch_size,
  92. "initial_samples": initial_samples,
  93. "current_batch": 0,
  94. "current_task_id": None,
  95. "n_results": 0,
  96. "created_at": datetime.now().isoformat(),
  97. "updated_at": datetime.now().isoformat(),
  98. }
  99. self._loops[loop_id] = meta
  100. # First batch
  101. batch = search.generate_initial_batch()
  102. task = self._create_batch_task(loop_id, search, batch, batch_id=0)
  103. meta["current_batch"] = 0
  104. meta["current_task_id"] = task.get("task_id")
  105. meta["phase"] = LOOP_PHASE_RUNNING
  106. self._save_state(loop_id)
  107. return self._loop_view(loop_id, include_task=task)
  108. def advance_loop(self, loop_id: str) -> Dict[str, Any]:
  109. """Advance one adaptive step: if the current batch task completed,
  110. feed results back, analyze, then converge or create next batch."""
  111. meta = self._loops.get(loop_id)
  112. if meta is None:
  113. raise ValueError("Loop %s not found" % loop_id)
  114. search = self._searches.get(loop_id)
  115. if search is None:
  116. raise ValueError("Loop %s has no search (process restarted?)" % loop_id)
  117. if meta["phase"] in (LOOP_PHASE_CONVERGED, LOOP_PHASE_BUDGET_EXHAUSTED, LOOP_PHASE_FAILED):
  118. return self._loop_view(loop_id)
  119. task_id = meta.get("current_task_id")
  120. if not task_id:
  121. return self._loop_view(loop_id, message="no current task")
  122. task = self.tm.get_task(task_id)
  123. if task is None:
  124. raise ValueError("Loop %s current task %s missing" % (loop_id, task_id))
  125. if task.get("status") not in ("completed", "failed", "cancelled"):
  126. # still running - nothing to do yet
  127. return self._loop_view(loop_id, message="batch still running")
  128. # ---- batch finished: pull results ----
  129. results = self.tm.get_task_results(task_id)
  130. point_results = (results or {}).get("results", [])
  131. if task.get("status") == "failed":
  132. meta["phase"] = LOOP_PHASE_FAILED
  133. meta["updated_at"] = datetime.now().isoformat()
  134. self._save_state(loop_id)
  135. return self._loop_view(loop_id, message="batch task failed")
  136. # Feed results back into the search by point_id
  137. n_fed = 0
  138. for r in point_results:
  139. pid = r.get("point_id")
  140. if pid is None:
  141. continue
  142. metrics = r.get("metrics") or {}
  143. if not metrics:
  144. # metrics may be flattened on the result top level
  145. for k, v in r.items():
  146. if k not in ("point_id", "params", "status", "error", "point_index", "solve_time_s"):
  147. if isinstance(v, (int, float)):
  148. metrics[k] = float(v)
  149. status = "ok" if r.get("status") == "OK" else "failed"
  150. search.report_result(int(pid), metrics, status)
  151. n_fed += 1
  152. meta["n_results"] += n_fed
  153. # Optional AI analysis on accumulated results (best-effort; only
  154. # when the AI backend is configured, else keep quantitative only).
  155. try:
  156. analysis = None
  157. if KIMI_API_KEY:
  158. analysis = self._analyst.analyze(
  159. results=self._collect_results(search),
  160. targets=None,
  161. fidelity="L3",
  162. scan_parameters=[p.name for p in search.parameters],
  163. )
  164. meta["latest_analysis"] = analysis
  165. except Exception as exc: # noqa: BLE001 - analysis is best-effort
  166. meta["latest_analysis_error"] = str(exc)
  167. # ---- decide next step ----
  168. state = search.get_state_summary()
  169. if state.get("convergence_status") == "converged":
  170. meta["phase"] = LOOP_PHASE_CONVERGED
  171. elif state.get("remaining_budget", 0) <= 0:
  172. meta["phase"] = LOOP_PHASE_BUDGET_EXHAUSTED
  173. else:
  174. nxt = search.select_next_batch()
  175. if not nxt:
  176. meta["phase"] = LOOP_PHASE_CONVERGED
  177. else:
  178. next_batch_id = int(meta.get("current_batch", 0)) + 1
  179. task = self._create_batch_task(loop_id, search, nxt, batch_id=next_batch_id)
  180. meta["current_batch"] = next_batch_id
  181. meta["current_task_id"] = task.get("task_id")
  182. meta["phase"] = LOOP_PHASE_RUNNING
  183. meta["updated_at"] = datetime.now().isoformat()
  184. self._save_state(loop_id)
  185. return self._loop_view(loop_id)
  186. def get_loop_status(self, loop_id: str) -> Dict[str, Any]:
  187. if loop_id not in self._loops:
  188. raise ValueError("Loop %s not found" % loop_id)
  189. return self._loop_view(loop_id)
  190. def list_loops(self) -> List[Dict[str, Any]]:
  191. return [
  192. {
  193. "loop_id": m["loop_id"],
  194. "phase": m["phase"],
  195. "current_batch": m.get("current_batch"),
  196. "n_results": m.get("n_results"),
  197. "updated_at": m.get("updated_at"),
  198. }
  199. for m in self._loops.values()
  200. ]
  201. # ------------------------------------------------------------------
  202. # Internals
  203. # ------------------------------------------------------------------
  204. def _build_search(
  205. self, parameters, objective_metric, objective_direction,
  206. total_budget, batch_size, initial_samples, seed,
  207. ) -> FeasibilityFirstSearch:
  208. ranges = []
  209. for p in parameters:
  210. ranges.append(ParameterRange(
  211. name=p["name"],
  212. min_value=float(p["min_value"]),
  213. max_value=float(p["max_value"]),
  214. step=float(p["step"]) if p.get("step") else None,
  215. unit=p.get("unit", ""),
  216. description=p.get("description", ""),
  217. ))
  218. if not ranges:
  219. raise ValueError("at least one search parameter required")
  220. return FeasibilityFirstSearch(
  221. parameters=ranges,
  222. l0_engine=self.l0_engine,
  223. total_budget=int(total_budget),
  224. batch_size=int(batch_size),
  225. initial_samples=int(initial_samples),
  226. objective_metric=objective_metric,
  227. objective_direction=objective_direction,
  228. seed=int(seed),
  229. )
  230. def _create_batch_task(self, loop_id, search, batch, batch_id: int) -> Dict[str, Any]:
  231. meta = self._loops[loop_id]
  232. parameters = []
  233. point_ids = []
  234. for p in batch:
  235. pid = getattr(p, "id", None)
  236. params = getattr(p, "params", None)
  237. if params is None and isinstance(p, dict):
  238. params = p.get("params")
  239. pid = p.get("point_id", p.get("id"))
  240. point_ids.append(pid)
  241. item = dict(params or {})
  242. item["point_id"] = pid
  243. parameters.append(item)
  244. task = self.tm.create_task(
  245. plan_id=meta.get("plan_id"),
  246. plan_data=meta.get("plan_data") or {},
  247. parameters=parameters,
  248. task_name="adaptive-%s-b%s" % (loop_id, batch_id),
  249. task_type="adaptive_batch",
  250. loop_id=loop_id,
  251. batch_id=batch_id,
  252. point_ids=point_ids,
  253. dynamic=True,
  254. )
  255. return task
  256. def _collect_results(self, search) -> List[Dict[str, Any]]:
  257. """Flatten search completed points to [{point_id, **metrics}]."""
  258. out = []
  259. for p in search.state.get_completed_points():
  260. entry = {"point_id": p.id, "status": p.status}
  261. entry.update(p.metrics)
  262. out.append(entry)
  263. return out
  264. def _loop_view(self, loop_id, include_task=None, message=None) -> Dict[str, Any]:
  265. meta = self._loops[loop_id]
  266. search = self._searches.get(loop_id)
  267. view = {
  268. "loop_id": loop_id,
  269. "phase": meta["phase"],
  270. "plan_id": meta.get("plan_id"),
  271. "current_batch": meta.get("current_batch"),
  272. "current_task_id": meta.get("current_task_id"),
  273. "n_results": meta.get("n_results", 0),
  274. "updated_at": meta.get("updated_at"),
  275. "created_at": meta.get("created_at"),
  276. "search_state": search.get_state_summary() if search else None,
  277. }
  278. if include_task:
  279. view["batch_task"] = include_task
  280. if message:
  281. view["message"] = message
  282. if meta.get("latest_analysis"):
  283. view["latest_analysis"] = meta["latest_analysis"]
  284. return view
  285. # ------------------------------------------------------------------
  286. # Persistence (best-effort)
  287. # ------------------------------------------------------------------
  288. def _save_state(self, loop_id: str) -> None:
  289. meta = self._loops.get(loop_id)
  290. if meta is None:
  291. return
  292. search = self._searches.get(loop_id)
  293. payload = dict(meta)
  294. try:
  295. if search is not None and hasattr(search, "export_state"):
  296. payload["search_export"] = search.export_state()
  297. except Exception:
  298. pass
  299. path = _loop_state_path(self.state_dir, loop_id)
  300. with open(path, "w", encoding="utf-8") as f:
  301. json.dump(payload, f, ensure_ascii=False, indent=2)
  302. def _load_state(self) -> None:
  303. if not os.path.isdir(self.state_dir):
  304. return
  305. for fname in os.listdir(self.state_dir):
  306. if not fname.endswith("_loop.json"):
  307. continue
  308. path = os.path.join(self.state_dir, fname)
  309. try:
  310. with open(path, "r", encoding="utf-8") as f:
  311. meta = json.load(f)
  312. if isinstance(meta, dict) and meta.get("loop_id"):
  313. # metadata recovered; search object is rebuilt on demand
  314. self._loops[meta["loop_id"]] = meta
  315. except Exception:
  316. continue
  317. # Module-level singleton (mirrors get_task_manager pattern).
  318. _orchestrator: Optional[AdaptiveOrchestrator] = None
  319. def get_orchestrator() -> AdaptiveOrchestrator:
  320. global _orchestrator
  321. if _orchestrator is None:
  322. _orchestrator = AdaptiveOrchestrator()
  323. return _orchestrator