adaptive_loop.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  1. """Adaptive Simulation Loop (P3-M5).
  2. Integrates all P3 components into a complete end-to-end adaptive
  3. simulation closed loop:
  4. 1. AI plan generation (natural language -> structured plan)
  5. 2. L0 pre-screening (exclude infeasible regions)
  6. 3. Feasibility-first search (active learning + trust region)
  7. 4. Simulation execution (external Motor-CAD/Maxwell)
  8. 5. AI result analysis (trends, anomalies, optimization suggestions)
  9. 6. Multi-fidelity calibration + confidence grading
  10. 7. Experience library enhancement (extract design knowledge)
  11. 8. Adaptive next-batch selection (loop back to step 3)
  12. """
  13. import json
  14. from typing import Dict, List, Optional, Any, Callable
  15. from datetime import datetime
  16. from enum import Enum
  17. from ..services.l0_prescreening import L0PreScreeningEngine
  18. from ..services.feasibility_search import FeasibilityFirstSearch, ParameterRange
  19. from ..services.plan_generator import AIPlanGenerator
  20. from ..services.result_analyst import AIResultAnalyst
  21. from ..services.experience_enhancer import ExperienceEnhancer
  22. from ..services.task_manager import get_task_manager
  23. class LoopPhase(str, Enum):
  24. """Phases of the adaptive simulation loop."""
  25. INIT = "init"
  26. PLAN_GENERATED = "plan_generated"
  27. L0_SCREENED = "l0_screened"
  28. SEARCH_INITIALIZED = "search_initialized"
  29. BATCH_SELECTED = "batch_selected"
  30. SIMULATION_RUNNING = "simulation_running"
  31. RESULTS_ANALYZED = "results_analyzed"
  32. EXPERIENCE_UPDATED = "experience_updated"
  33. CONVERGED = "converged"
  34. BUDGET_EXHAUSTED = "budget_exhausted"
  35. COMPLETED = "completed"
  36. class AdaptiveLoop:
  37. """Complete adaptive simulation closed loop.
  38. This orchestrator ties together all P3-M1 through P3-M5 components
  39. into a single, stateful simulation optimization loop.
  40. """
  41. def __init__(
  42. self,
  43. loop_id: Optional[str] = None,
  44. user_requirement: Optional[str] = None,
  45. total_budget: int = 80,
  46. batch_size: int = 4,
  47. ):
  48. self.loop_id = loop_id or f"loop_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
  49. self.user_requirement = user_requirement
  50. self.total_budget = total_budget
  51. self.batch_size = batch_size
  52. self.phase = LoopPhase.INIT
  53. # Components
  54. self.l0_engine = L0PreScreeningEngine()
  55. self.plan_generator = AIPlanGenerator(l0_engine=self.l0_engine)
  56. self.result_analyst = AIResultAnalyst()
  57. self.experience_enhancer = ExperienceEnhancer()
  58. self.search: Optional[FeasibilityFirstSearch] = None
  59. # State
  60. self.plan: Optional[Dict[str, Any]] = None
  61. self.plan_validation: Optional[Dict[str, Any]] = None
  62. self.all_results: List[Dict[str, Any]] = []
  63. self.latest_analysis: Optional[Dict[str, Any]] = None
  64. self.latest_insights: Optional[Dict[str, Any]] = None
  65. self.history: List[Dict[str, Any]] = []
  66. self.created_at = datetime.now().isoformat()
  67. self.updated_at = datetime.now().isoformat()
  68. def generate_plan(self, user_requirement: Optional[str] = None) -> Dict[str, Any]:
  69. """Step 1: Generate simulation plan from natural language.
  70. Args:
  71. user_requirement: Natural language requirement (uses stored if None)
  72. Returns:
  73. Generated plan with validation
  74. """
  75. if user_requirement:
  76. self.user_requirement = user_requirement
  77. if not self.user_requirement:
  78. raise ValueError("No user requirement provided")
  79. result = self.plan_generator.generate(self.user_requirement)
  80. self.plan = result.get("plan", {})
  81. # The unified plan uses "variables" (converted from the AI's
  82. # "scan_variables"); downstream steps read scan_variables. Normalize
  83. # once here so both names resolve.
  84. if not self.plan.get("scan_variables") and self.plan.get("variables"):
  85. self.plan["scan_variables"] = self.plan["variables"]
  86. # Topology + default model: loops carry no project context, so fill
  87. # from the registry; otherwise simulation would start with no model.
  88. from src.afmcore.topology import is_supported, default_model_for
  89. topo = (self.plan.get("topology") or "").strip().upper()
  90. if not is_supported(topo):
  91. topo = "SSSR"
  92. self.plan["topology"] = topo
  93. if not (self.plan.get("model_path") or "").strip():
  94. self.plan["model_path"] = default_model_for(topo)
  95. self.plan_validation = result.get("validation", {})
  96. self.phase = LoopPhase.PLAN_GENERATED
  97. self._record_history("plan_generated", {"plan_name": self.plan.get("plan_name", "")})
  98. return {
  99. "loop_id": self.loop_id,
  100. "phase": self.phase.value,
  101. "plan": self.plan,
  102. "validation": self.plan_validation,
  103. }
  104. def initialize_search(self) -> Dict[str, Any]:
  105. """Step 2-3: Initialize feasibility-first search from plan.
  106. Converts plan scan variables to search parameters and
  107. generates initial LHS batch with L0 pre-screening.
  108. """
  109. if not self.plan:
  110. raise RuntimeError("Plan not generated. Call generate_plan() first.")
  111. # Convert scan variables to ParameterRange. Accept both the unified
  112. # "variables" list (start/stop/step or explicit values) and the raw
  113. # "scan_variables" name; the AI may use min_value/max_value instead.
  114. parameters = []
  115. scan_vars = self.plan.get("scan_variables") or self.plan.get("variables") or []
  116. for var in scan_vars:
  117. if not isinstance(var, dict) or "name" not in var:
  118. continue
  119. lo = var.get("min_value", var.get("start"))
  120. hi = var.get("max_value", var.get("stop"))
  121. if lo is not None and hi is not None:
  122. parameters.append(ParameterRange(
  123. name=var["name"],
  124. min_value=float(lo),
  125. max_value=float(hi),
  126. step=var.get("step"),
  127. unit=var.get("unit", ""),
  128. description=var.get("description", ""),
  129. ))
  130. elif isinstance(var.get("values"), list) and len(var["values"]) >= 2:
  131. vals = [float(x) for x in var["values"]]
  132. parameters.append(ParameterRange(
  133. name=var["name"],
  134. min_value=min(vals),
  135. max_value=max(vals),
  136. step=None,
  137. unit=var.get("unit", ""),
  138. description=var.get("description", ""),
  139. ))
  140. if not parameters:
  141. raise RuntimeError("No valid scan variables in plan")
  142. # Get search strategy from plan
  143. search_strategy = self.plan.get("search_strategy", {})
  144. acceptance = self.plan.get("acceptance_criteria", {})
  145. self.search = FeasibilityFirstSearch(
  146. parameters=parameters,
  147. l0_engine=self.l0_engine,
  148. total_budget=search_strategy.get("max_solver_calls", self.total_budget),
  149. batch_size=search_strategy.get("batch_size", self.batch_size),
  150. initial_samples=search_strategy.get("initial_samples", 16),
  151. objective_metric=acceptance.get("objective_metric", "tavg_nm"),
  152. objective_direction=acceptance.get("objective_direction", "maximize"),
  153. )
  154. # Generate initial batch
  155. initial_batch = self.search.generate_initial_batch()
  156. self.phase = LoopPhase.SEARCH_INITIALIZED
  157. self._record_history("search_initialized", {
  158. "n_parameters": len(parameters),
  159. "initial_batch_size": len(initial_batch),
  160. })
  161. return {
  162. "loop_id": self.loop_id,
  163. "phase": self.phase.value,
  164. "search_id": self.search.state.run_id,
  165. "initial_batch": [
  166. {"id": p.id, "params": p.params, "status": p.status}
  167. for p in initial_batch
  168. ],
  169. "state": self.search.get_state_summary(),
  170. }
  171. def get_next_batch(self) -> Dict[str, Any]:
  172. """Step 3 (loop): Select next batch of points to simulate.
  173. Uses active learning with trust region refinement.
  174. """
  175. if not self.search:
  176. raise RuntimeError("Search not initialized. Call initialize_search() first.")
  177. batch = self.search.select_next_batch()
  178. if not batch:
  179. if self.search.state.convergence_status == "converged":
  180. self.phase = LoopPhase.CONVERGED
  181. else:
  182. self.phase = LoopPhase.BUDGET_EXHAUSTED
  183. return {
  184. "loop_id": self.loop_id,
  185. "phase": self.phase.value,
  186. "batch": [],
  187. "message": f"Search {self.search.state.convergence_status}",
  188. }
  189. self.phase = LoopPhase.BATCH_SELECTED
  190. self._record_history("batch_selected", {
  191. "batch_id": self.search.state.current_batch - 1,
  192. "batch_size": len(batch),
  193. })
  194. return {
  195. "loop_id": self.loop_id,
  196. "phase": self.phase.value,
  197. "batch_id": self.search.state.current_batch - 1,
  198. "points": [
  199. {"id": p.id, "params": p.params, "status": p.status}
  200. for p in batch
  201. ],
  202. "state": self.search.get_state_summary(),
  203. }
  204. def report_results(self, point_results: List[Dict[str, Any]]) -> Dict[str, Any]:
  205. """Step 4-5: Report simulation results and trigger analysis.
  206. Args:
  207. point_results: List of {point_id, metrics, status} dicts
  208. Returns:
  209. Analysis results and next-step recommendations
  210. """
  211. if not self.search:
  212. raise RuntimeError("Search not initialized")
  213. # Report each result to search
  214. for pr in point_results:
  215. point_id = pr.get("point_id")
  216. metrics = pr.get("metrics", {})
  217. status = pr.get("status", "ok")
  218. if point_id is not None:
  219. self.search.report_result(point_id, metrics, status)
  220. # Add to all results, INCLUDING the point's input params:
  221. # experience extraction needs inputs+outputs together to do
  222. # parameter-sensitivity analysis (metrics-only entries left the
  223. # AI with "no input parameters provided").
  224. point_params = {}
  225. for sp in self.search.state.points:
  226. if sp.id == point_id:
  227. point_params = dict(sp.params or {})
  228. break
  229. result_entry = {"point_id": point_id, **point_params, **metrics}
  230. self.all_results.append(result_entry)
  231. # Run AI analysis on accumulated results
  232. targets = self.plan.get("acceptance_criteria", {}).get("hard_constraints", {})
  233. target_dict = {}
  234. if isinstance(targets, list):
  235. for constraint in targets:
  236. # Parse simple constraints like "efficiency_pct >= 92"
  237. if ">=" in constraint:
  238. parts = constraint.split(">=")
  239. target_dict[parts[0].strip()] = float(parts[1].strip())
  240. elif "<=" in constraint:
  241. parts = constraint.split("<=")
  242. target_dict[parts[0].strip()] = float(parts[1].strip())
  243. self.latest_analysis = self.result_analyst.analyze(
  244. results=self.all_results,
  245. targets=target_dict if target_dict else None,
  246. fidelity="L3",
  247. scan_parameters=[p.name for p in self.search.parameters] if self.search else None,
  248. )
  249. self.phase = LoopPhase.RESULTS_ANALYZED
  250. self._record_history("results_analyzed", {
  251. "n_results": len(self.all_results),
  252. "confidence": self.latest_analysis.get("confidence", {}).get("grade", "?"),
  253. })
  254. return {
  255. "loop_id": self.loop_id,
  256. "phase": self.phase.value,
  257. "analysis": self.latest_analysis,
  258. "search_state": self.search.get_state_summary(),
  259. "recommendation": self._get_recommendation(),
  260. }
  261. def update_experience(self) -> Dict[str, Any]:
  262. """Step 6: Extract insights and update experience library.
  263. Returns:
  264. Extracted insights and experience entry
  265. """
  266. if len(self.all_results) < 5:
  267. return {"message": "Insufficient results for experience extraction (need >= 5)"}
  268. self.latest_insights = self.experience_enhancer.extract_insights(
  269. results=self.all_results,
  270. project_context={
  271. "topology": self.plan.get("topology", ""),
  272. "plan_name": self.plan.get("plan_name", ""),
  273. },
  274. )
  275. # Generate experience entry
  276. experience_entry = self.experience_enhancer.generate_experience_entry(
  277. insights=self.latest_insights,
  278. project_name=self.plan.get("plan_name", "unknown"),
  279. topology=self.plan.get("topology", "unknown"),
  280. )
  281. # Persist into the experience library so later AI plan generation can
  282. # reuse the knowledge. Previously the entry was only returned in the
  283. # HTTP response and lost on restart - the loop's knowledge never
  284. # actually closed into the library.
  285. case_id = self._persist_experience_case(experience_entry)
  286. self.phase = LoopPhase.EXPERIENCE_UPDATED
  287. self._record_history("experience_updated", {
  288. "n_rules": len(experience_entry.get("design_rules", [])),
  289. "case_id": case_id,
  290. })
  291. return {
  292. "loop_id": self.loop_id,
  293. "phase": self.phase.value,
  294. "insights": self.latest_insights,
  295. "experience_entry": experience_entry,
  296. "experience_case_id": case_id,
  297. }
  298. def _persist_experience_case(self, entry: Dict[str, Any]) -> Optional[int]:
  299. """Store an extracted experience entry as an ExperienceCase row.
  300. Maps the AI-insight structure onto the case model: the best feasible
  301. point carries params/metrics, the summary + design rules form the
  302. conclusion. Returns the new case id, or None on failure (persistence
  303. must never break the loop).
  304. """
  305. try:
  306. from ..database import SessionLocal
  307. from ..models.experience_case import ExperienceCase
  308. best = None
  309. if self.search:
  310. best = self.search.state.best_feasible_point
  311. rules = entry.get("design_rules") or []
  312. rule_texts = [
  313. (r.get("rule") if isinstance(r, dict) else str(r)) for r in rules
  314. ]
  315. conclusion = (entry.get("summary") or "") + "\n\n" + "\n".join(
  316. f"- {t}" for t in rule_texts if t
  317. )
  318. tags = ",".join(entry.get("tags") or [])
  319. with SessionLocal() as db:
  320. case = ExperienceCase(
  321. source_plan_id=self.loop_id,
  322. topology=entry.get("topology", ""),
  323. model_path=(self.plan or {}).get("model_path", ""),
  324. params_json=json.dumps((best.params if best else {}) or {}, ensure_ascii=False),
  325. metrics_json=json.dumps((best.metrics if best else {}) or {}, ensure_ascii=False),
  326. boundary_json=json.dumps(
  327. (self.plan or {}).get("boundary_conditions", {}) or {},
  328. ensure_ascii=False,
  329. ),
  330. conclusion=conclusion.strip(),
  331. tags=tags,
  332. rating=0,
  333. )
  334. db.add(case)
  335. db.commit()
  336. db.refresh(case)
  337. return case.id
  338. except Exception:
  339. return None
  340. def check_completion(self) -> Dict[str, Any]:
  341. """Check if loop should terminate.
  342. Returns:
  343. Completion status and reason
  344. """
  345. if not self.search:
  346. return {"completed": False, "reason": "search_not_initialized"}
  347. state = self.search.get_state_summary()
  348. if state["convergence_status"] == "converged":
  349. self.phase = LoopPhase.CONVERGED
  350. return {"completed": True, "reason": "converged", "state": state}
  351. if state["remaining_budget"] <= 0:
  352. self.phase = LoopPhase.BUDGET_EXHAUSTED
  353. return {"completed": True, "reason": "budget_exhausted", "state": state}
  354. return {"completed": False, "reason": "continue", "state": state}
  355. def submit_batch_to_executor(self, task_manager=None) -> Dict[str, Any]:
  356. """Submit the current pending batch to the local executor.
  357. Bridges the web-side adaptive loop to the local Motor-CAD executor
  358. through the task system: the batch points (returned by the last
  359. generate_initial_batch / get_next_batch call and held in the search
  360. pending list) are wrapped into a single adaptive_batch task. The
  361. local executor claims it, runs each point, and reports results back
  362. through /report-results, which feeds the search and continues the loop.
  363. Returns:
  364. {"task_id", "n_points", "batch_id"}
  365. """
  366. if not self.search:
  367. raise RuntimeError("Search not initialized. Call initialize_search() first.")
  368. # Submit exactly the current batch (points marked dispatched by
  369. # select_next_batch), not every pending point - otherwise the whole
  370. # initial LHS pool would be submitted at once, defeating batching.
  371. current = [p for p in self.search.state.points if p.status == "dispatched"]
  372. if not current:
  373. return {"task_id": None, "n_points": 0, "batch_id": None,
  374. "message": "no dispatched batch to submit (call next-batch first)"}
  375. tm = task_manager or get_task_manager()
  376. # Merge the plan's writable fixed params into every point: the executor
  377. # runs the flat parameter dict as-is and does NOT apply plan_data
  378. # fixed_params itself. Params with motorcad_var=None are not writable
  379. # on this model and must be skipped (writing them fails the point).
  380. fixed: Dict[str, Any] = {}
  381. for fp in (self.plan or {}).get("fixed_params") or []:
  382. if isinstance(fp, dict) and fp.get("motorcad_var"):
  383. fixed[fp["motorcad_var"]] = fp.get("value")
  384. point_ids = []
  385. parameters = []
  386. for p in current:
  387. point_ids.append(p.id)
  388. item = dict(fixed)
  389. item.update(p.params or {})
  390. item["point_id"] = p.id
  391. parameters.append(item)
  392. task = tm.create_task(
  393. plan_id=None,
  394. plan_data=self.plan or {},
  395. parameters=parameters,
  396. task_name="adaptive-%s-b%s" % (self.loop_id, self.search.state.current_batch),
  397. task_type="adaptive_batch",
  398. loop_id=self.loop_id,
  399. batch_id=self.search.state.current_batch,
  400. point_ids=point_ids,
  401. dynamic=True,
  402. )
  403. self.phase = LoopPhase.SIMULATION_RUNNING
  404. self._record_history("batch_submitted", {
  405. "task_id": task.get("task_id"),
  406. "batch_id": self.search.state.current_batch,
  407. "n_points": len(point_ids),
  408. })
  409. return {
  410. "task_id": task.get("task_id"),
  411. "n_points": len(point_ids),
  412. "batch_id": self.search.state.current_batch,
  413. "message": "Batch submitted. Local executor will claim and run it.",
  414. }
  415. def export_state(self) -> Dict[str, Any]:
  416. """Serialize the loop (plan + results + search) for checkpointing.
  417. The search is exported through FeasibilityFirstSearch.export_state();
  418. combined with restore_state() this is the checkpoint/resume path.
  419. """
  420. return {
  421. "loop_id": self.loop_id,
  422. "user_requirement": self.user_requirement,
  423. "total_budget": self.total_budget,
  424. "batch_size": self.batch_size,
  425. "phase": self.phase.value,
  426. "plan": self.plan,
  427. "plan_validation": self.plan_validation,
  428. "all_results": list(self.all_results),
  429. "latest_analysis": self.latest_analysis,
  430. "latest_insights": self.latest_insights,
  431. "history": list(self.history),
  432. "created_at": self.created_at,
  433. "updated_at": self.updated_at,
  434. "search": self.search.export_state() if self.search else None,
  435. }
  436. @classmethod
  437. def restore_state(cls, payload: Dict[str, Any], l0_engine=None) -> "AdaptiveLoop":
  438. """Rebuild a loop from export_state() output (checkpoint resume).
  439. Args:
  440. payload: dict returned by export_state().
  441. l0_engine: optional pre-screening engine for the rebuilt search.
  442. Returns:
  443. A new AdaptiveLoop with plan/phase/results/history/search restored
  444. and registered in the in-memory loop registry.
  445. """
  446. loop = cls(
  447. loop_id=payload.get("loop_id"),
  448. user_requirement=payload.get("user_requirement"),
  449. total_budget=payload.get("total_budget", 80),
  450. batch_size=payload.get("batch_size", 4),
  451. )
  452. try:
  453. if payload.get("phase"):
  454. loop.phase = LoopPhase(payload["phase"])
  455. except ValueError:
  456. pass # unknown phase -> keep init
  457. loop.plan = payload.get("plan")
  458. loop.plan_validation = payload.get("plan_validation")
  459. loop.all_results = list(payload.get("all_results", []))
  460. loop.latest_analysis = payload.get("latest_analysis")
  461. loop.latest_insights = payload.get("latest_insights")
  462. loop.history = list(payload.get("history", []))
  463. if payload.get("created_at"):
  464. loop.created_at = payload["created_at"]
  465. if payload.get("updated_at"):
  466. loop.updated_at = payload["updated_at"]
  467. if payload.get("search"):
  468. loop.search = FeasibilityFirstSearch.import_state(
  469. payload["search"], l0_engine=l0_engine or loop.l0_engine
  470. )
  471. _loops[loop.loop_id] = loop
  472. return loop
  473. def _get_recommendation(self) -> str:
  474. """Generate next-step recommendation based on current state."""
  475. if not self.search:
  476. return "Initialize search first"
  477. state = self.search.get_state_summary()
  478. analysis = self.latest_analysis or {}
  479. confidence = analysis.get("confidence", {}).get("grade", "?")
  480. if state["convergence_status"] == "converged":
  481. return f"Search converged. Best: {state['best_objective_value']}. Confidence: {confidence}."
  482. if state["remaining_budget"] <= 0:
  483. return "Budget exhausted."
  484. if confidence in ("D",):
  485. return f"Low confidence ({confidence}). Consider increasing sample count or fidelity level."
  486. if state["trust_region_active"]:
  487. return f"Trust region active (radius={state['trust_region_radius']:.3f}). Continue exploitation. Best: {state['best_objective_value']}"
  488. return f"Exploration phase. {state['feasible_points']} feasible points found. Continue sampling."
  489. def _record_history(self, event: str, data: Dict[str, Any]):
  490. """Record a history event."""
  491. self.history.append({
  492. "event": event,
  493. "timestamp": datetime.now().isoformat(),
  494. "phase": self.phase.value,
  495. "data": data,
  496. })
  497. self.updated_at = datetime.now().isoformat()
  498. def get_state(self) -> Dict[str, Any]:
  499. """Get complete loop state."""
  500. return {
  501. "loop_id": self.loop_id,
  502. "phase": self.phase.value,
  503. "user_requirement": self.user_requirement,
  504. "total_budget": self.total_budget,
  505. "batch_size": self.batch_size,
  506. "plan_name": self.plan.get("plan_name", "") if self.plan else None,
  507. "n_results": len(self.all_results),
  508. "search_state": self.search.get_state_summary() if self.search else None,
  509. "latest_confidence": self.latest_analysis.get("confidence", {}).get("grade") if self.latest_analysis else None,
  510. "history": self.history,
  511. "created_at": self.created_at,
  512. "updated_at": self.updated_at,
  513. }
  514. # Global loop registry (in-memory, P3-M5 prototype)
  515. _loops: Dict[str, AdaptiveLoop] = {}
  516. def get_loop(loop_id: str) -> Optional[AdaptiveLoop]:
  517. """Get an existing adaptive loop by ID."""
  518. return _loops.get(loop_id)
  519. def create_loop(user_requirement: str, total_budget: int = 80, batch_size: int = 4) -> AdaptiveLoop:
  520. """Create a new adaptive loop."""
  521. loop = AdaptiveLoop(
  522. user_requirement=user_requirement,
  523. total_budget=total_budget,
  524. batch_size=batch_size,
  525. )
  526. _loops[loop.loop_id] = loop
  527. return loop
  528. def list_loops() -> List[Dict[str, Any]]:
  529. """List all active loops."""
  530. return [
  531. {"loop_id": lid, "phase": loop.phase.value, "n_results": len(loop.all_results)}
  532. for lid, loop in _loops.items()
  533. ]