feasibility_search.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690
  1. """Feasibility-First Search Framework (P3-M2).
  2. Pure Python implementation (no numpy/scipy/sklearn dependency).
  3. Implements:
  4. - LHS (Latin Hypercube Sampling) for initial space coverage
  5. - Active learning batch selection (distance + uncertainty heuristic)
  6. - Constrained feasibility prioritization
  7. - Local trust region refinement
  8. - Search state management with checkpoint support
  9. Per third-party review: default mode is feasibility-first
  10. (constrained Bayesian / active learning), not fixed full-factorial DoE.
  11. """
  12. import math
  13. import random
  14. import hashlib
  15. import json
  16. from dataclasses import dataclass, field
  17. from typing import Dict, List, Optional, Tuple, Any, Callable
  18. from datetime import datetime
  19. from .l0_prescreening import L0PreScreeningEngine, FeasibilityReport
  20. @dataclass
  21. class ParameterRange:
  22. """Definition of a scan parameter range."""
  23. name: str
  24. min_value: float
  25. max_value: float
  26. step: Optional[float] = None # if set, values are quantized
  27. unit: str = ""
  28. description: str = ""
  29. def normalize(self, value: float) -> float:
  30. """Normalize value to [0, 1] range."""
  31. if self.max_value == self.min_value:
  32. return 0.5
  33. return (value - self.min_value) / (self.max_value - self.min_value)
  34. def denormalize(self, norm: float) -> float:
  35. """Denormalize from [0, 1] to actual range."""
  36. value = self.min_value + norm * (self.max_value - self.min_value)
  37. if self.step is not None and self.step > 0:
  38. value = round(value / self.step) * self.step
  39. value = max(self.min_value, min(self.max_value, value))
  40. return value
  41. def random_value(self, rng: random.Random) -> float:
  42. """Generate a random value within range."""
  43. return self.denormalize(rng.random())
  44. @dataclass
  45. class SearchPoint:
  46. """A single point in the search space."""
  47. id: int
  48. params: Dict[str, float]
  49. status: str = "pending" # pending / running / ok / failed / infeasible
  50. metrics: Dict[str, float] = field(default_factory=dict)
  51. feasibility_report: Optional[Dict[str, Any]] = None
  52. surrogate_prediction: Optional[float] = None
  53. surrogate_uncertainty: Optional[float] = None
  54. batch_id: int = 0
  55. created_at: str = ""
  56. def param_hash(self) -> str:
  57. """Generate a hash of parameter values for caching/dedup."""
  58. sorted_params = sorted(self.params.items())
  59. param_str = json.dumps(sorted_params, sort_keys=True)
  60. return hashlib.md5(param_str.encode()).hexdigest()[:12]
  61. @dataclass
  62. class SearchState:
  63. """Complete state of an adaptive search run."""
  64. run_id: str
  65. parameters: List[ParameterRange]
  66. points: List[SearchPoint] = field(default_factory=list)
  67. current_batch: int = 0
  68. total_budget: int = 80
  69. used_budget: int = 0
  70. batch_size: int = 4
  71. search_method: str = "active_learning"
  72. trust_region_active: bool = False
  73. trust_region_center: Optional[Dict[str, float]] = None
  74. trust_region_radius: float = 0.3 # fraction of normalized range
  75. best_feasible_point: Optional[SearchPoint] = None
  76. best_objective_value: float = float('inf')
  77. objective_metric: str = "tavg_nm" # metric to optimize
  78. objective_direction: str = "maximize" # maximize / minimize
  79. convergence_status: str = "searching" # searching / converged / stalled / budget_exhausted
  80. history: List[Dict[str, Any]] = field(default_factory=list)
  81. created_at: str = ""
  82. updated_at: str = ""
  83. def get_param_names(self) -> List[str]:
  84. return [p.name for p in self.parameters]
  85. def get_param_by_name(self, name: str) -> Optional[ParameterRange]:
  86. for p in self.parameters:
  87. if p.name == name:
  88. return p
  89. return None
  90. def get_pending_points(self) -> List[SearchPoint]:
  91. return [p for p in self.points if p.status == "pending"]
  92. def get_completed_points(self) -> List[SearchPoint]:
  93. return [p for p in self.points if p.status in ("ok", "failed", "infeasible")]
  94. def get_feasible_points(self) -> List[SearchPoint]:
  95. return [p for p in self.points if p.status == "ok" and p.feasibility_report and p.feasibility_report.get("feasible", False)]
  96. def remaining_budget(self) -> int:
  97. return self.total_budget - self.used_budget
  98. class FeasibilityFirstSearch:
  99. """Feasibility-first adaptive search engine.
  100. Implements the recommended default path from third-party review:
  101. L0 pre-screening + initial samples + active learning batch selection
  102. + local trust region refinement.
  103. """
  104. def __init__(
  105. self,
  106. parameters: List[ParameterRange],
  107. l0_engine: Optional[L0PreScreeningEngine] = None,
  108. total_budget: int = 80,
  109. batch_size: int = 4,
  110. initial_samples: int = 16,
  111. objective_metric: str = "tavg_nm",
  112. objective_direction: str = "maximize",
  113. seed: int = 42,
  114. ):
  115. self.rng = random.Random(seed)
  116. self.parameters = parameters
  117. self.l0_engine = l0_engine or L0PreScreeningEngine()
  118. self.total_budget = total_budget
  119. self.batch_size = batch_size
  120. self.initial_samples = initial_samples
  121. self.objective_metric = objective_metric
  122. self.objective_direction = objective_direction
  123. self.state = SearchState(
  124. run_id=f"search_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
  125. parameters=parameters,
  126. total_budget=total_budget,
  127. batch_size=batch_size,
  128. objective_metric=objective_metric,
  129. objective_direction=objective_direction,
  130. created_at=datetime.now().isoformat(),
  131. )
  132. def latin_hypercube_sample(self, n_samples: int) -> List[Dict[str, float]]:
  133. """Generate Latin Hypercube Samples in normalized space.
  134. Pure Python implementation (no numpy dependency).
  135. """
  136. n_params = len(self.parameters)
  137. # Create n_samples intervals per dimension
  138. intervals = []
  139. for _ in range(n_params):
  140. # Shuffle interval assignments
  141. assignments = list(range(n_samples))
  142. self.rng.shuffle(assignments)
  143. intervals.append(assignments)
  144. samples = []
  145. for i in range(n_samples):
  146. sample = {}
  147. for j, param in enumerate(self.parameters):
  148. # Random point within assigned interval
  149. interval_idx = intervals[j][i]
  150. norm = (interval_idx + self.rng.random()) / n_samples
  151. sample[param.name] = param.denormalize(norm)
  152. samples.append(sample)
  153. return samples
  154. def _min_distance_to_existing(self, params: Dict[str, float], existing: List[Dict[str, float]]) -> float:
  155. """Calculate minimum normalized Euclidean distance to existing points."""
  156. if not existing:
  157. return float('inf')
  158. min_dist = float('inf')
  159. for ex in existing:
  160. dist_sq = 0.0
  161. for param in self.parameters:
  162. v1 = param.normalize(params.get(param.name, param.min_value))
  163. v2 = param.normalize(ex.get(param.name, param.min_value))
  164. dist_sq += (v1 - v2) ** 2
  165. dist = math.sqrt(dist_sq)
  166. min_dist = min(min_dist, dist)
  167. return min_dist
  168. def _is_duplicate(self, params: Dict[str, float], existing_points: List[SearchPoint]) -> bool:
  169. """Check if parameter set is a duplicate of an existing point."""
  170. param_hash = hashlib.md5(
  171. json.dumps(sorted(params.items()), sort_keys=True).encode()
  172. ).hexdigest()[:12]
  173. return any(p.param_hash() == param_hash for p in existing_points)
  174. def generate_initial_batch(self) -> List[SearchPoint]:
  175. """Generate initial LHS samples, filtered by L0 feasibility.
  176. Prioritizes feasible points but includes some infeasible for
  177. boundary learning (active learning needs both classes).
  178. """
  179. # Generate more samples than needed, then select diverse subset
  180. n_candidates = max(self.initial_samples * 3, 20)
  181. candidates = self.latin_hypercube_sample(n_candidates)
  182. # L0 pre-screen all candidates
  183. scored = []
  184. for params in candidates:
  185. report = self.l0_engine.evaluate(params)
  186. scored.append((params, report))
  187. # Separate feasible and infeasible
  188. feasible = [(p, r) for p, r in scored if r.feasible]
  189. infeasible = [(p, r) for p, r in scored if not r.feasible]
  190. # Select diverse subset: mostly feasible, some infeasible for boundary
  191. n_feasible = min(len(feasible), int(self.initial_samples * 0.75))
  192. n_infeasible = min(len(infeasible), self.initial_samples - n_feasible)
  193. selected = []
  194. selected_params = []
  195. # Greedy max-distance selection for feasible points
  196. feasible.sort(key=lambda x: x[1].pass_rate, reverse=True)
  197. for params, report in feasible:
  198. if len(selected) >= n_feasible:
  199. break
  200. # Check duplicate against already selected params
  201. is_dup = any(
  202. hashlib.md5(json.dumps(sorted(p.items()), sort_keys=True).encode()).hexdigest()[:12] ==
  203. hashlib.md5(json.dumps(sorted(params.items()), sort_keys=True).encode()).hexdigest()[:12]
  204. for p, _ in selected
  205. )
  206. if not is_dup:
  207. dist = self._min_distance_to_existing(params, selected_params)
  208. if dist > 0.1 or len(selected) < 3:
  209. selected.append((params, report))
  210. selected_params.append(params)
  211. # Add infeasible points for boundary learning
  212. for params, report in infeasible:
  213. if len(selected) >= self.initial_samples:
  214. break
  215. is_dup = any(
  216. hashlib.md5(json.dumps(sorted(p.items()), sort_keys=True).encode()).hexdigest()[:12] ==
  217. hashlib.md5(json.dumps(sorted(params.items()), sort_keys=True).encode()).hexdigest()[:12]
  218. for p, _ in selected
  219. )
  220. if not is_dup:
  221. selected.append((params, report))
  222. selected_params.append(params)
  223. # Create SearchPoint objects
  224. points = []
  225. for i, (params, report) in enumerate(selected):
  226. point = SearchPoint(
  227. id=len(self.state.points) + i,
  228. params=params,
  229. status="infeasible" if not report.feasible else "pending",
  230. feasibility_report=report.to_dict(),
  231. batch_id=0,
  232. created_at=datetime.now().isoformat(),
  233. )
  234. if not report.feasible:
  235. # Mark as completed (L0 rejected) without using solver budget
  236. point.status = "infeasible"
  237. points.append(point)
  238. self.state.points.extend(points)
  239. self.state.current_batch = 1
  240. self.state.used_budget += sum(1 for p in points if p.status == "pending")
  241. self.state.updated_at = datetime.now().isoformat()
  242. return [p for p in points if p.status == "pending"]
  243. def select_next_batch(self) -> List[SearchPoint]:
  244. """Select next batch of points using active learning.
  245. Strategy:
  246. 1. If feasible points found, activate trust region around best
  247. 2. Select points balancing:
  248. - Exploration: high distance from existing points
  249. - Exploitation: near best feasible point (trust region)
  250. - Uncertainty: points near feasibility boundary
  251. 3. L0 pre-screen all candidates, reject obviously infeasible
  252. """
  253. if self.state.remaining_budget() <= 0:
  254. self.state.convergence_status = "budget_exhausted"
  255. return []
  256. # Consume the pending initial LHS batch first: points generated by
  257. # generate_initial_batch must be simulated before active learning has
  258. # any observations to learn from. Mark them dispatched so they are not
  259. # re-selected and so submit_batch targets exactly this batch.
  260. pending = self.state.get_pending_points()
  261. if pending:
  262. batch = pending[: self.batch_size]
  263. for p in batch:
  264. p.status = "dispatched"
  265. self.state.updated_at = datetime.now().isoformat()
  266. return batch
  267. feasible_points = self.state.get_feasible_points()
  268. completed_params = [p.params for p in self.state.get_completed_points()]
  269. # Activate trust region if we have enough feasible points
  270. if len(feasible_points) >= 3 and not self.state.trust_region_active:
  271. # C4 fix: select anchor by optimization direction
  272. if self.objective_direction == "minimize":
  273. best = min(feasible_points, key=lambda p: p.metrics.get(self.objective_metric, float("inf")))
  274. else:
  275. best = max(feasible_points, key=lambda p: p.metrics.get(self.objective_metric, 0))
  276. self.state.trust_region_active = True
  277. self.state.trust_region_center = best.params
  278. self.state.best_feasible_point = best
  279. self.state.history.append({
  280. "event": "trust_region_activated",
  281. "center": best.params,
  282. "batch": self.state.current_batch,
  283. })
  284. # Generate candidates
  285. n_candidates = 50
  286. candidates = []
  287. if self.state.trust_region_active and self.state.trust_region_center:
  288. # 70% from trust region, 30% from global exploration
  289. n_trust = int(n_candidates * 0.7)
  290. n_global = n_candidates - n_trust
  291. # Trust region samples (Gaussian-like around center)
  292. for _ in range(n_trust):
  293. params = {}
  294. for param in self.parameters:
  295. center_norm = param.normalize(self.state.trust_region_center[param.name])
  296. # Sample with decreasing radius as search progresses
  297. radius = self.state.trust_region_radius * max(0.3, 1.0 - self.state.used_budget / self.total_budget)
  298. sample_norm = center_norm + self.rng.gauss(0, radius * 0.3)
  299. sample_norm = max(0.0, min(1.0, sample_norm))
  300. params[param.name] = param.denormalize(sample_norm)
  301. candidates.append(params)
  302. # Global exploration samples
  303. candidates.extend(self.latin_hypercube_sample(n_global))
  304. else:
  305. # No trust region yet: full LHS exploration
  306. candidates = self.latin_hypercube_sample(n_candidates)
  307. # Score and select candidates
  308. scored_candidates = []
  309. for params in candidates:
  310. if self._is_duplicate(params, self.state.points):
  311. continue
  312. # L0 pre-screen
  313. report = self.l0_engine.evaluate(params)
  314. if not report.feasible:
  315. continue # Skip obviously infeasible
  316. # Distance score (exploration)
  317. dist = self._min_distance_to_existing(params, completed_params)
  318. dist_score = min(dist / math.sqrt(len(self.parameters)), 1.0)
  319. # Trust region proximity score (exploitation)
  320. trust_score = 0.0
  321. if self.state.trust_region_center:
  322. dist_to_center_sq = 0.0
  323. for param in self.parameters:
  324. v1 = param.normalize(params[param.name])
  325. v2 = param.normalize(self.state.trust_region_center[param.name])
  326. dist_to_center_sq += (v1 - v2) ** 2
  327. dist_to_center = math.sqrt(dist_to_center_sq)
  328. trust_score = max(0, 1.0 - dist_to_center / self.state.trust_region_radius)
  329. # Combined score (balance exploration and exploitation)
  330. if self.state.trust_region_active:
  331. score = 0.4 * dist_score + 0.6 * trust_score
  332. else:
  333. score = dist_score
  334. scored_candidates.append((params, report, score))
  335. # Sort by score and select top batch_size
  336. scored_candidates.sort(key=lambda x: x[2], reverse=True)
  337. batch_size = min(self.batch_size, self.state.remaining_budget(), len(scored_candidates))
  338. selected = []
  339. for i in range(batch_size):
  340. params, report, score = scored_candidates[i]
  341. point = SearchPoint(
  342. id=len(self.state.points) + i,
  343. params=params,
  344. status="dispatched",
  345. feasibility_report=report.to_dict(),
  346. batch_id=self.state.current_batch,
  347. created_at=datetime.now().isoformat(),
  348. )
  349. selected.append(point)
  350. self.state.points.extend(selected)
  351. self.state.used_budget += len(selected)
  352. self.state.current_batch += 1
  353. self.state.updated_at = datetime.now().isoformat()
  354. return selected
  355. def report_result(self, point_id: int, metrics: Dict[str, float], status: str = "ok") -> None:
  356. """Report simulation result for a point.
  357. Args:
  358. point_id: ID of the search point
  359. metrics: Dictionary of metric values
  360. status: ok / failed
  361. """
  362. for point in self.state.points:
  363. if point.id == point_id:
  364. point.metrics = metrics
  365. point.status = status
  366. # Update best feasible point
  367. if status == "ok" and self.objective_metric in metrics:
  368. value = metrics[self.objective_metric]
  369. is_better = (
  370. (self.objective_direction == "maximize" and value > self.state.best_objective_value) or
  371. (self.objective_direction == "minimize" and value < self.state.best_objective_value)
  372. )
  373. if is_better or self.state.best_feasible_point is None:
  374. self.state.best_objective_value = value
  375. self.state.best_feasible_point = point
  376. # C5 fix: migrate trust region center to new best point
  377. if self.state.trust_region_active:
  378. self.state.trust_region_center = point.params
  379. # Check convergence
  380. self._check_convergence()
  381. break
  382. self.state.updated_at = datetime.now().isoformat()
  383. def _check_convergence(self) -> None:
  384. """Check if search has converged or stalled."""
  385. feasible = self.state.get_feasible_points()
  386. if len(feasible) < 5:
  387. return
  388. # Check if objective has improved in last N points
  389. recent = feasible[-10:] if len(feasible) >= 10 else feasible
  390. if len(recent) >= 5:
  391. values = [p.metrics.get(self.objective_metric, 0) for p in recent]
  392. if self.objective_direction == "maximize":
  393. improvement = max(values) - max(values[:-3]) if len(values) > 3 else 0
  394. else:
  395. improvement = min(values[:-3]) - min(values) if len(values) > 3 else 0
  396. if improvement < 0.001 and self.state.trust_region_active:
  397. # Shrink trust region
  398. self.state.trust_region_radius *= 0.7
  399. if self.state.trust_region_radius < 0.05:
  400. self.state.convergence_status = "converged"
  401. self.state.history.append({
  402. "event": "converged",
  403. "reason": "trust_region_shrunk_below_threshold",
  404. "batch": self.state.current_batch,
  405. })
  406. def get_state_summary(self) -> Dict[str, Any]:
  407. """Get a summary of current search state."""
  408. return {
  409. "run_id": self.state.run_id,
  410. "search_method": self.state.search_method,
  411. "convergence_status": self.state.convergence_status,
  412. "total_budget": self.state.total_budget,
  413. "used_budget": self.state.used_budget,
  414. "remaining_budget": self.state.remaining_budget(),
  415. "current_batch": self.state.current_batch,
  416. "total_points": len(self.state.points),
  417. "pending_points": len(self.state.get_pending_points()),
  418. "completed_points": len(self.state.get_completed_points()),
  419. "feasible_points": len(self.state.get_feasible_points()),
  420. "trust_region_active": self.state.trust_region_active,
  421. "trust_region_radius": round(self.state.trust_region_radius, 4),
  422. "best_objective_value": self.state.best_objective_value if self.state.best_objective_value != float('inf') else None,
  423. "best_point_params": self.state.best_feasible_point.params if self.state.best_feasible_point else None,
  424. "points_history": [
  425. {
  426. "id": _p.id,
  427. "batch_id": _p.batch_id,
  428. "params": _p.params,
  429. "objective": _p.metrics.get(self.state.objective_metric),
  430. "feasible": _p.status == "ok",
  431. "status": _p.status,
  432. }
  433. for _p in self.state.points
  434. if _p.status in ("ok", "failed", "infeasible")
  435. and _p.metrics.get(self.state.objective_metric) is not None
  436. ],
  437. "infeasible_points": self._count_by_status("infeasible"),
  438. "failed_points": self._count_by_status("failed"),
  439. "batch_summary": self._build_batch_summary(),
  440. "l0_summary": self._build_l0_summary(),
  441. }
  442. def _count_by_status(self, status: str) -> int:
  443. """Count points with the given status."""
  444. return sum(1 for _p in self.state.points if _p.status == status)
  445. def _build_batch_summary(self) -> list:
  446. """Aggregate per-batch point status distribution.
  447. Returns a list sorted by batch_id, each entry containing
  448. batch_id, total, pending/ok/infeasible/failed counts and
  449. best_objective (respecting objective_direction).
  450. """
  451. batches = {}
  452. direction = self.state.objective_direction
  453. metric = self.state.objective_metric
  454. for _p in self.state.points:
  455. b = batches.setdefault(_p.batch_id, {
  456. "batch_id": _p.batch_id, "total": 0,
  457. "pending": 0, "dispatched": 0, "ok": 0, "infeasible": 0, "failed": 0,
  458. "best_objective": None,
  459. })
  460. b["total"] += 1
  461. if _p.status in ("pending", "dispatched", "ok", "infeasible", "failed"):
  462. b[_p.status] += 1
  463. if _p.status == "ok":
  464. obj = _p.metrics.get(metric)
  465. if obj is not None:
  466. if b["best_objective"] is None:
  467. b["best_objective"] = obj
  468. elif direction == "maximize":
  469. b["best_objective"] = max(b["best_objective"], obj)
  470. else:
  471. b["best_objective"] = min(b["best_objective"], obj)
  472. return sorted(batches.values(), key=lambda x: x["batch_id"])
  473. def _build_l0_summary(self) -> dict:
  474. """Aggregate L0 pre-screening statistics across all points.
  475. Returns sampled/feasible/infeasible counts, pass_rate and
  476. top_infeasible_reasons (name/count/category) from failed
  477. constraint checks in infeasible points.
  478. """
  479. points = self.state.points
  480. total = len(points)
  481. infeasible_pts = [p for p in points if p.status == "infeasible"]
  482. feasible_count = total - len(infeasible_pts)
  483. reason_counts = {}
  484. for p in infeasible_pts:
  485. report = p.feasibility_report or {}
  486. for r in report.get("results", []):
  487. if not r.get("passed"):
  488. name = r.get("name", "unknown")
  489. entry = reason_counts.setdefault(name, {
  490. "name": name, "count": 0,
  491. "category": r.get("category", ""),
  492. })
  493. entry["count"] += 1
  494. top_reasons = sorted(
  495. reason_counts.values(), key=lambda x: -x["count"]
  496. )[:5]
  497. return {
  498. "sampled": total,
  499. "feasible": feasible_count,
  500. "infeasible": len(infeasible_pts),
  501. "pass_rate": round(feasible_count / total, 4) if total else 0.0,
  502. "top_infeasible_reasons": top_reasons,
  503. }
  504. def export_state(self) -> Dict[str, Any]:
  505. """Export full search state for checkpointing."""
  506. return {
  507. "state": {
  508. "run_id": self.state.run_id,
  509. "current_batch": self.state.current_batch,
  510. "total_budget": self.state.total_budget,
  511. "used_budget": self.state.used_budget,
  512. "convergence_status": self.state.convergence_status,
  513. "trust_region_active": self.state.trust_region_active,
  514. "trust_region_center": self.state.trust_region_center,
  515. "trust_region_radius": self.state.trust_region_radius,
  516. # inf (initial sentinel before any result) is not JSON-compliant;
  517. # export as None, matching get_state_summary().
  518. "best_objective_value": (
  519. self.state.best_objective_value
  520. if self.state.best_objective_value not in (float("inf"), float("-inf"))
  521. else None
  522. ),
  523. "batch_size": self.state.batch_size,
  524. "objective_metric": self.state.objective_metric,
  525. "objective_direction": self.state.objective_direction,
  526. "search_method": self.state.search_method,
  527. },
  528. "parameters": [
  529. {"name": p.name, "min": p.min_value, "max": p.max_value, "step": p.step, "unit": p.unit}
  530. for p in self.parameters
  531. ],
  532. "points": [
  533. {
  534. "id": p.id,
  535. "params": p.params,
  536. "status": p.status,
  537. "metrics": p.metrics,
  538. "batch_id": p.batch_id,
  539. "feasible": p.feasibility_report.get("feasible") if p.feasibility_report else None,
  540. }
  541. for p in self.state.points
  542. ],
  543. }
  544. @classmethod
  545. def import_state(cls, payload: Dict[str, Any], l0_engine=None) -> "FeasibilityFirstSearch":
  546. """Rebuild a search from export_state() output (checkpoint resume).
  547. Args:
  548. payload: dict returned by export_state().
  549. l0_engine: optional pre-screening engine (reused if omitted).
  550. Returns:
  551. A new FeasibilityFirstSearch with the same parameters and
  552. replayed points (status/metrics/batch preserved).
  553. """
  554. params_data = payload.get("parameters", [])
  555. parameters = [
  556. ParameterRange(
  557. name=p["name"],
  558. min_value=p["min"],
  559. max_value=p["max"],
  560. step=p.get("step"),
  561. unit=p.get("unit", ""),
  562. description=p.get("description", ""),
  563. )
  564. for p in params_data
  565. ]
  566. st = payload.get("state", {})
  567. search = cls(
  568. parameters=parameters,
  569. l0_engine=l0_engine,
  570. total_budget=st.get("total_budget", 80),
  571. batch_size=st.get("batch_size", 4),
  572. objective_metric=st.get("objective_metric", "tavg_nm"),
  573. objective_direction=st.get("objective_direction", "maximize"),
  574. )
  575. s = search.state
  576. if st.get("run_id"):
  577. s.run_id = st["run_id"]
  578. if "current_batch" in st:
  579. s.current_batch = int(st["current_batch"])
  580. if "used_budget" in st:
  581. s.used_budget = int(st["used_budget"])
  582. if st.get("convergence_status"):
  583. s.convergence_status = st["convergence_status"]
  584. if "trust_region_active" in st:
  585. s.trust_region_active = bool(st["trust_region_active"])
  586. if st.get("trust_region_center") is not None:
  587. s.trust_region_center = st["trust_region_center"]
  588. if "trust_region_radius" in st:
  589. s.trust_region_radius = float(st["trust_region_radius"])
  590. # export_state serializes the inf sentinel as None (JSON compliance);
  591. # restore it to the dataclass default rather than assigning None
  592. # (None would break the `value > best_objective_value` comparison).
  593. if "best_objective_value" in st and st["best_objective_value"] is not None:
  594. s.best_objective_value = st["best_objective_value"]
  595. if st.get("search_method"):
  596. s.search_method = st["search_method"]
  597. for pd in payload.get("points", []):
  598. pt = SearchPoint(
  599. id=int(pd["id"]),
  600. params=dict(pd.get("params") or {}),
  601. status=pd.get("status", "pending"),
  602. metrics=dict(pd.get("metrics") or {}),
  603. batch_id=int(pd.get("batch_id", 0)),
  604. )
  605. feas = pd.get("feasible")
  606. if feas is not None:
  607. pt.feasibility_report = {"feasible": bool(feas)}
  608. s.points.append(pt)
  609. if s.best_feasible_point is None:
  610. for pt in s.points:
  611. if pt.status == "ok" and (pt.feasibility_report or {}).get("feasible", True):
  612. s.best_feasible_point = pt
  613. break
  614. return search