| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245 |
- """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()
- ],
- }
|