"""Search router - L0 pre-screening and feasibility-first adaptive search (P3-M2).""" from typing import List, Dict, Any, Optional from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field from ..services.l0_prescreening import L0PreScreeningEngine from ..services.feasibility_search import FeasibilityFirstSearch, ParameterRange router = APIRouter(prefix="/api/search", tags=["Search"]) # Global search instances (in-memory, P3-M2 prototype) _search_instances: Dict[str, FeasibilityFirstSearch] = {} _l0_engine = L0PreScreeningEngine() class L0CheckRequest(BaseModel): """Request for L0 feasibility check.""" params: Dict[str, Any] = Field(..., description="Parameter values to check") class L0CheckResponse(BaseModel): """Response for L0 feasibility check.""" feasible: bool total_checks: int passed_checks: int failed_checks: int pass_rate: float results: List[Dict[str, Any]] risk_items: List[str] class SearchCreateRequest(BaseModel): """Request to create a new adaptive search.""" parameters: List[Dict[str, Any]] = Field(..., description="Parameter range definitions") total_budget: int = Field(default=80, ge=10, le=500) batch_size: int = Field(default=4, ge=1, le=16) initial_samples: int = Field(default=16, ge=4, le=100) objective_metric: str = Field(default="tavg_nm") objective_direction: str = Field(default="maximize") seed: int = Field(default=42) class SearchPointResponse(BaseModel): """Response for a search point.""" id: int params: Dict[str, float] status: str metrics: Dict[str, float] = Field(default_factory=dict) batch_id: int feasible: Optional[bool] = None class SearchStateResponse(BaseModel): """Response for search state summary.""" run_id: str search_method: str convergence_status: str total_budget: int used_budget: int remaining_budget: int current_batch: int total_points: int pending_points: int completed_points: int feasible_points: int trust_region_active: bool trust_region_radius: float best_objective_value: Optional[float] = None best_point_params: Optional[Dict[str, float]] = None points_history: List[Dict[str, Any]] = Field(default_factory=list) infeasible_points: int = 0 failed_points: int = 0 batch_summary: List[Dict[str, Any]] = Field(default_factory=list) l0_summary: Optional[Dict[str, Any]] = None class SearchResultRequest(BaseModel): """Request to report simulation result.""" point_id: int metrics: Dict[str, float] status: str = Field(default="ok", description="ok / failed") @router.post("/l0/check", response_model=L0CheckResponse) def l0_check(request: L0CheckRequest): """Run L0 analytic pre-screening on a parameter set.""" report = _l0_engine.evaluate(request.params) data = report.to_dict() return L0CheckResponse(**data) @router.post("/l0/filter") def l0_filter(param_sets: List[Dict[str, Any]]): """Filter a list of parameter sets into feasible and infeasible.""" feasible, infeasible = _l0_engine.filter_feasible(param_sets) return { "total": len(param_sets), "feasible_count": len(feasible), "infeasible_count": len(infeasible), "feasible": feasible, "infeasible": infeasible, } @router.post("/create") def create_search(request: SearchCreateRequest): """Create a new feasibility-first adaptive search run.""" try: parameters = [ ParameterRange( name=p["name"], min_value=float(p["min_value"]), max_value=float(p["max_value"]), step=p.get("step"), unit=p.get("unit", ""), description=p.get("description", ""), ) for p in request.parameters ] except (KeyError, ValueError) as e: raise HTTPException(status_code=400, detail=f"Invalid parameter definition: {e}") search = FeasibilityFirstSearch( parameters=parameters, l0_engine=_l0_engine, total_budget=request.total_budget, batch_size=request.batch_size, initial_samples=request.initial_samples, objective_metric=request.objective_metric, objective_direction=request.objective_direction, seed=request.seed, ) # Generate initial batch initial_points = search.generate_initial_batch() _search_instances[search.state.run_id] = search return { "run_id": search.state.run_id, "initial_points": [ {"id": p.id, "params": p.params, "status": p.status, "batch_id": p.batch_id} for p in initial_points ], "state": search.get_state_summary(), } @router.get("/{run_id}/state", response_model=SearchStateResponse) def get_search_state(run_id: str): """Get current state of a search run.""" if run_id not in _search_instances: raise HTTPException(status_code=404, detail=f"Search run {run_id} not found") search = _search_instances[run_id] return SearchStateResponse(**search.get_state_summary()) @router.post("/{run_id}/next-batch") def next_batch(run_id: str): """Select and return the next batch of points to simulate.""" if run_id not in _search_instances: raise HTTPException(status_code=404, detail=f"Search run {run_id} not found") search = _search_instances[run_id] points = search.select_next_batch() if not points: return { "run_id": run_id, "batch": [], "convergence_status": search.state.convergence_status, "message": "No more points to select (budget exhausted or converged)", } return { "run_id": run_id, "batch_id": search.state.current_batch - 1, "points": [ {"id": p.id, "params": p.params, "status": p.status, "batch_id": p.batch_id} for p in points ], "state": search.get_state_summary(), } @router.post("/{run_id}/report") def report_result(run_id: str, request: SearchResultRequest): """Report simulation result for a point.""" if run_id not in _search_instances: raise HTTPException(status_code=404, detail=f"Search run {run_id} not found") search = _search_instances[run_id] search.report_result(request.point_id, request.metrics, request.status) return { "run_id": run_id, "point_id": request.point_id, "status": "reported", "state": search.get_state_summary(), } @router.get("/{run_id}/points") def get_points(run_id: str, status: Optional[str] = None): """Get all points in a search run, optionally filtered by status.""" if run_id not in _search_instances: raise HTTPException(status_code=404, detail=f"Search run {run_id} not found") search = _search_instances[run_id] points = search.state.points if status: points = [p for p in points if p.status == status] return { "run_id": run_id, "total": len(points), "points": [ { "id": p.id, "params": p.params, "status": p.status, "metrics": p.metrics, "batch_id": p.batch_id, "feasible": p.feasibility_report.get("feasible") if p.feasibility_report else None, } for p in points ], } @router.get("/{run_id}/export") def export_search(run_id: str): """Export full search state for checkpointing.""" if run_id not in _search_instances: raise HTTPException(status_code=404, detail=f"Search run {run_id} not found") search = _search_instances[run_id] return search.export_state() @router.get("/runs") def list_runs(): """List all active search runs.""" return { "runs": [ {"run_id": run_id, "state": search.get_state_summary()} for run_id, search in _search_instances.items() ], }