feasibility_search.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  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. feasible_points = self.state.get_feasible_points()
  257. completed_params = [p.params for p in self.state.get_completed_points()]
  258. # Activate trust region if we have enough feasible points
  259. if len(feasible_points) >= 3 and not self.state.trust_region_active:
  260. # C4 fix: select anchor by optimization direction
  261. if self.objective_direction == "minimize":
  262. best = min(feasible_points, key=lambda p: p.metrics.get(self.objective_metric, float("inf")))
  263. else:
  264. best = max(feasible_points, key=lambda p: p.metrics.get(self.objective_metric, 0))
  265. self.state.trust_region_active = True
  266. self.state.trust_region_center = best.params
  267. self.state.best_feasible_point = best
  268. self.state.history.append({
  269. "event": "trust_region_activated",
  270. "center": best.params,
  271. "batch": self.state.current_batch,
  272. })
  273. # Generate candidates
  274. n_candidates = 50
  275. candidates = []
  276. if self.state.trust_region_active and self.state.trust_region_center:
  277. # 70% from trust region, 30% from global exploration
  278. n_trust = int(n_candidates * 0.7)
  279. n_global = n_candidates - n_trust
  280. # Trust region samples (Gaussian-like around center)
  281. for _ in range(n_trust):
  282. params = {}
  283. for param in self.parameters:
  284. center_norm = param.normalize(self.state.trust_region_center[param.name])
  285. # Sample with decreasing radius as search progresses
  286. radius = self.state.trust_region_radius * max(0.3, 1.0 - self.state.used_budget / self.total_budget)
  287. sample_norm = center_norm + self.rng.gauss(0, radius * 0.3)
  288. sample_norm = max(0.0, min(1.0, sample_norm))
  289. params[param.name] = param.denormalize(sample_norm)
  290. candidates.append(params)
  291. # Global exploration samples
  292. candidates.extend(self.latin_hypercube_sample(n_global))
  293. else:
  294. # No trust region yet: full LHS exploration
  295. candidates = self.latin_hypercube_sample(n_candidates)
  296. # Score and select candidates
  297. scored_candidates = []
  298. for params in candidates:
  299. if self._is_duplicate(params, self.state.points):
  300. continue
  301. # L0 pre-screen
  302. report = self.l0_engine.evaluate(params)
  303. if not report.feasible:
  304. continue # Skip obviously infeasible
  305. # Distance score (exploration)
  306. dist = self._min_distance_to_existing(params, completed_params)
  307. dist_score = min(dist / math.sqrt(len(self.parameters)), 1.0)
  308. # Trust region proximity score (exploitation)
  309. trust_score = 0.0
  310. if self.state.trust_region_center:
  311. dist_to_center_sq = 0.0
  312. for param in self.parameters:
  313. v1 = param.normalize(params[param.name])
  314. v2 = param.normalize(self.state.trust_region_center[param.name])
  315. dist_to_center_sq += (v1 - v2) ** 2
  316. dist_to_center = math.sqrt(dist_to_center_sq)
  317. trust_score = max(0, 1.0 - dist_to_center / self.state.trust_region_radius)
  318. # Combined score (balance exploration and exploitation)
  319. if self.state.trust_region_active:
  320. score = 0.4 * dist_score + 0.6 * trust_score
  321. else:
  322. score = dist_score
  323. scored_candidates.append((params, report, score))
  324. # Sort by score and select top batch_size
  325. scored_candidates.sort(key=lambda x: x[2], reverse=True)
  326. batch_size = min(self.batch_size, self.state.remaining_budget(), len(scored_candidates))
  327. selected = []
  328. for i in range(batch_size):
  329. params, report, score = scored_candidates[i]
  330. point = SearchPoint(
  331. id=len(self.state.points) + i,
  332. params=params,
  333. status="pending",
  334. feasibility_report=report.to_dict(),
  335. batch_id=self.state.current_batch,
  336. created_at=datetime.now().isoformat(),
  337. )
  338. selected.append(point)
  339. self.state.points.extend(selected)
  340. self.state.used_budget += len(selected)
  341. self.state.current_batch += 1
  342. self.state.updated_at = datetime.now().isoformat()
  343. return selected
  344. def report_result(self, point_id: int, metrics: Dict[str, float], status: str = "ok") -> None:
  345. """Report simulation result for a point.
  346. Args:
  347. point_id: ID of the search point
  348. metrics: Dictionary of metric values
  349. status: ok / failed
  350. """
  351. for point in self.state.points:
  352. if point.id == point_id:
  353. point.metrics = metrics
  354. point.status = status
  355. # Update best feasible point
  356. if status == "ok" and self.objective_metric in metrics:
  357. value = metrics[self.objective_metric]
  358. is_better = (
  359. (self.objective_direction == "maximize" and value > self.state.best_objective_value) or
  360. (self.objective_direction == "minimize" and value < self.state.best_objective_value)
  361. )
  362. if is_better or self.state.best_feasible_point is None:
  363. self.state.best_objective_value = value
  364. self.state.best_feasible_point = point
  365. # C5 fix: migrate trust region center to new best point
  366. if self.state.trust_region_active:
  367. self.state.trust_region_center = point.params
  368. # Check convergence
  369. self._check_convergence()
  370. break
  371. self.state.updated_at = datetime.now().isoformat()
  372. def _check_convergence(self) -> None:
  373. """Check if search has converged or stalled."""
  374. feasible = self.state.get_feasible_points()
  375. if len(feasible) < 5:
  376. return
  377. # Check if objective has improved in last N points
  378. recent = feasible[-10:] if len(feasible) >= 10 else feasible
  379. if len(recent) >= 5:
  380. values = [p.metrics.get(self.objective_metric, 0) for p in recent]
  381. if self.objective_direction == "maximize":
  382. improvement = max(values) - max(values[:-3]) if len(values) > 3 else 0
  383. else:
  384. improvement = min(values[:-3]) - min(values) if len(values) > 3 else 0
  385. if improvement < 0.001 and self.state.trust_region_active:
  386. # Shrink trust region
  387. self.state.trust_region_radius *= 0.7
  388. if self.state.trust_region_radius < 0.05:
  389. self.state.convergence_status = "converged"
  390. self.state.history.append({
  391. "event": "converged",
  392. "reason": "trust_region_shrunk_below_threshold",
  393. "batch": self.state.current_batch,
  394. })
  395. def get_state_summary(self) -> Dict[str, Any]:
  396. """Get a summary of current search state."""
  397. return {
  398. "run_id": self.state.run_id,
  399. "search_method": self.state.search_method,
  400. "convergence_status": self.state.convergence_status,
  401. "total_budget": self.state.total_budget,
  402. "used_budget": self.state.used_budget,
  403. "remaining_budget": self.state.remaining_budget(),
  404. "current_batch": self.state.current_batch,
  405. "total_points": len(self.state.points),
  406. "pending_points": len(self.state.get_pending_points()),
  407. "completed_points": len(self.state.get_completed_points()),
  408. "feasible_points": len(self.state.get_feasible_points()),
  409. "trust_region_active": self.state.trust_region_active,
  410. "trust_region_radius": round(self.state.trust_region_radius, 4),
  411. "best_objective_value": self.state.best_objective_value if self.state.best_objective_value != float('inf') else None,
  412. "best_point_params": self.state.best_feasible_point.params if self.state.best_feasible_point else None,
  413. }
  414. def export_state(self) -> Dict[str, Any]:
  415. """Export full search state for checkpointing."""
  416. return {
  417. "state": {
  418. "run_id": self.state.run_id,
  419. "current_batch": self.state.current_batch,
  420. "total_budget": self.state.total_budget,
  421. "used_budget": self.state.used_budget,
  422. "convergence_status": self.state.convergence_status,
  423. "trust_region_active": self.state.trust_region_active,
  424. "trust_region_center": self.state.trust_region_center,
  425. "trust_region_radius": self.state.trust_region_radius,
  426. "best_objective_value": self.state.best_objective_value,
  427. },
  428. "parameters": [
  429. {"name": p.name, "min": p.min_value, "max": p.max_value, "step": p.step, "unit": p.unit}
  430. for p in self.parameters
  431. ],
  432. "points": [
  433. {
  434. "id": p.id,
  435. "params": p.params,
  436. "status": p.status,
  437. "metrics": p.metrics,
  438. "batch_id": p.batch_id,
  439. "feasible": p.feasibility_report.get("feasible") if p.feasibility_report else None,
  440. }
  441. for p in self.state.points
  442. ],
  443. }