search.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. """Search router - L0 pre-screening and feasibility-first adaptive search (P3-M2)."""
  2. from typing import List, Dict, Any, Optional
  3. from fastapi import APIRouter, HTTPException
  4. from pydantic import BaseModel, Field
  5. from ..services.l0_prescreening import L0PreScreeningEngine
  6. from ..services.feasibility_search import FeasibilityFirstSearch, ParameterRange
  7. router = APIRouter(prefix="/api/search", tags=["Search"])
  8. # Global search instances (in-memory, P3-M2 prototype)
  9. _search_instances: Dict[str, FeasibilityFirstSearch] = {}
  10. _l0_engine = L0PreScreeningEngine()
  11. class L0CheckRequest(BaseModel):
  12. """Request for L0 feasibility check."""
  13. params: Dict[str, Any] = Field(..., description="Parameter values to check")
  14. class L0CheckResponse(BaseModel):
  15. """Response for L0 feasibility check."""
  16. feasible: bool
  17. total_checks: int
  18. passed_checks: int
  19. failed_checks: int
  20. pass_rate: float
  21. results: List[Dict[str, Any]]
  22. risk_items: List[str]
  23. class SearchCreateRequest(BaseModel):
  24. """Request to create a new adaptive search."""
  25. parameters: List[Dict[str, Any]] = Field(..., description="Parameter range definitions")
  26. total_budget: int = Field(default=80, ge=10, le=500)
  27. batch_size: int = Field(default=4, ge=1, le=16)
  28. initial_samples: int = Field(default=16, ge=4, le=100)
  29. objective_metric: str = Field(default="tavg_nm")
  30. objective_direction: str = Field(default="maximize")
  31. seed: int = Field(default=42)
  32. class SearchPointResponse(BaseModel):
  33. """Response for a search point."""
  34. id: int
  35. params: Dict[str, float]
  36. status: str
  37. metrics: Dict[str, float] = Field(default_factory=dict)
  38. batch_id: int
  39. feasible: Optional[bool] = None
  40. class SearchStateResponse(BaseModel):
  41. """Response for search state summary."""
  42. run_id: str
  43. search_method: str
  44. convergence_status: str
  45. total_budget: int
  46. used_budget: int
  47. remaining_budget: int
  48. current_batch: int
  49. total_points: int
  50. pending_points: int
  51. completed_points: int
  52. feasible_points: int
  53. trust_region_active: bool
  54. trust_region_radius: float
  55. best_objective_value: Optional[float] = None
  56. best_point_params: Optional[Dict[str, float]] = None
  57. class SearchResultRequest(BaseModel):
  58. """Request to report simulation result."""
  59. point_id: int
  60. metrics: Dict[str, float]
  61. status: str = Field(default="ok", description="ok / failed")
  62. @router.post("/l0/check", response_model=L0CheckResponse)
  63. def l0_check(request: L0CheckRequest):
  64. """Run L0 analytic pre-screening on a parameter set."""
  65. report = _l0_engine.evaluate(request.params)
  66. data = report.to_dict()
  67. return L0CheckResponse(**data)
  68. @router.post("/l0/filter")
  69. def l0_filter(param_sets: List[Dict[str, Any]]):
  70. """Filter a list of parameter sets into feasible and infeasible."""
  71. feasible, infeasible = _l0_engine.filter_feasible(param_sets)
  72. return {
  73. "total": len(param_sets),
  74. "feasible_count": len(feasible),
  75. "infeasible_count": len(infeasible),
  76. "feasible": feasible,
  77. "infeasible": infeasible,
  78. }
  79. @router.post("/create")
  80. def create_search(request: SearchCreateRequest):
  81. """Create a new feasibility-first adaptive search run."""
  82. try:
  83. parameters = [
  84. ParameterRange(
  85. name=p["name"],
  86. min_value=float(p["min_value"]),
  87. max_value=float(p["max_value"]),
  88. step=p.get("step"),
  89. unit=p.get("unit", ""),
  90. description=p.get("description", ""),
  91. )
  92. for p in request.parameters
  93. ]
  94. except (KeyError, ValueError) as e:
  95. raise HTTPException(status_code=400, detail=f"Invalid parameter definition: {e}")
  96. search = FeasibilityFirstSearch(
  97. parameters=parameters,
  98. l0_engine=_l0_engine,
  99. total_budget=request.total_budget,
  100. batch_size=request.batch_size,
  101. initial_samples=request.initial_samples,
  102. objective_metric=request.objective_metric,
  103. objective_direction=request.objective_direction,
  104. seed=request.seed,
  105. )
  106. # Generate initial batch
  107. initial_points = search.generate_initial_batch()
  108. _search_instances[search.state.run_id] = search
  109. return {
  110. "run_id": search.state.run_id,
  111. "initial_points": [
  112. {"id": p.id, "params": p.params, "status": p.status, "batch_id": p.batch_id}
  113. for p in initial_points
  114. ],
  115. "state": search.get_state_summary(),
  116. }
  117. @router.get("/{run_id}/state", response_model=SearchStateResponse)
  118. def get_search_state(run_id: str):
  119. """Get current state of a search run."""
  120. if run_id not in _search_instances:
  121. raise HTTPException(status_code=404, detail=f"Search run {run_id} not found")
  122. search = _search_instances[run_id]
  123. return SearchStateResponse(**search.get_state_summary())
  124. @router.post("/{run_id}/next-batch")
  125. def next_batch(run_id: str):
  126. """Select and return the next batch of points to simulate."""
  127. if run_id not in _search_instances:
  128. raise HTTPException(status_code=404, detail=f"Search run {run_id} not found")
  129. search = _search_instances[run_id]
  130. points = search.select_next_batch()
  131. if not points:
  132. return {
  133. "run_id": run_id,
  134. "batch": [],
  135. "convergence_status": search.state.convergence_status,
  136. "message": "No more points to select (budget exhausted or converged)",
  137. }
  138. return {
  139. "run_id": run_id,
  140. "batch_id": search.state.current_batch - 1,
  141. "points": [
  142. {"id": p.id, "params": p.params, "status": p.status, "batch_id": p.batch_id}
  143. for p in points
  144. ],
  145. "state": search.get_state_summary(),
  146. }
  147. @router.post("/{run_id}/report")
  148. def report_result(run_id: str, request: SearchResultRequest):
  149. """Report simulation result for a point."""
  150. if run_id not in _search_instances:
  151. raise HTTPException(status_code=404, detail=f"Search run {run_id} not found")
  152. search = _search_instances[run_id]
  153. search.report_result(request.point_id, request.metrics, request.status)
  154. return {
  155. "run_id": run_id,
  156. "point_id": request.point_id,
  157. "status": "reported",
  158. "state": search.get_state_summary(),
  159. }
  160. @router.get("/{run_id}/points")
  161. def get_points(run_id: str, status: Optional[str] = None):
  162. """Get all points in a search run, optionally filtered by status."""
  163. if run_id not in _search_instances:
  164. raise HTTPException(status_code=404, detail=f"Search run {run_id} not found")
  165. search = _search_instances[run_id]
  166. points = search.state.points
  167. if status:
  168. points = [p for p in points if p.status == status]
  169. return {
  170. "run_id": run_id,
  171. "total": len(points),
  172. "points": [
  173. {
  174. "id": p.id,
  175. "params": p.params,
  176. "status": p.status,
  177. "metrics": p.metrics,
  178. "batch_id": p.batch_id,
  179. "feasible": p.feasibility_report.get("feasible") if p.feasibility_report else None,
  180. }
  181. for p in points
  182. ],
  183. }
  184. @router.get("/{run_id}/export")
  185. def export_search(run_id: str):
  186. """Export full search state for checkpointing."""
  187. if run_id not in _search_instances:
  188. raise HTTPException(status_code=404, detail=f"Search run {run_id} not found")
  189. search = _search_instances[run_id]
  190. return search.export_state()
  191. @router.get("/runs")
  192. def list_runs():
  193. """List all active search runs."""
  194. return {
  195. "runs": [
  196. {"run_id": run_id, "state": search.get_state_summary()}
  197. for run_id, search in _search_instances.items()
  198. ],
  199. }