| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503 |
- """Feasibility-First Search Framework (P3-M2).
- Pure Python implementation (no numpy/scipy/sklearn dependency).
- Implements:
- - LHS (Latin Hypercube Sampling) for initial space coverage
- - Active learning batch selection (distance + uncertainty heuristic)
- - Constrained feasibility prioritization
- - Local trust region refinement
- - Search state management with checkpoint support
- Per third-party review: default mode is feasibility-first
- (constrained Bayesian / active learning), not fixed full-factorial DoE.
- """
- import math
- import random
- import hashlib
- import json
- from dataclasses import dataclass, field
- from typing import Dict, List, Optional, Tuple, Any, Callable
- from datetime import datetime
- from .l0_prescreening import L0PreScreeningEngine, FeasibilityReport
- @dataclass
- class ParameterRange:
- """Definition of a scan parameter range."""
- name: str
- min_value: float
- max_value: float
- step: Optional[float] = None # if set, values are quantized
- unit: str = ""
- description: str = ""
- def normalize(self, value: float) -> float:
- """Normalize value to [0, 1] range."""
- if self.max_value == self.min_value:
- return 0.5
- return (value - self.min_value) / (self.max_value - self.min_value)
- def denormalize(self, norm: float) -> float:
- """Denormalize from [0, 1] to actual range."""
- value = self.min_value + norm * (self.max_value - self.min_value)
- if self.step is not None and self.step > 0:
- value = round(value / self.step) * self.step
- value = max(self.min_value, min(self.max_value, value))
- return value
- def random_value(self, rng: random.Random) -> float:
- """Generate a random value within range."""
- return self.denormalize(rng.random())
- @dataclass
- class SearchPoint:
- """A single point in the search space."""
- id: int
- params: Dict[str, float]
- status: str = "pending" # pending / running / ok / failed / infeasible
- metrics: Dict[str, float] = field(default_factory=dict)
- feasibility_report: Optional[Dict[str, Any]] = None
- surrogate_prediction: Optional[float] = None
- surrogate_uncertainty: Optional[float] = None
- batch_id: int = 0
- created_at: str = ""
- def param_hash(self) -> str:
- """Generate a hash of parameter values for caching/dedup."""
- sorted_params = sorted(self.params.items())
- param_str = json.dumps(sorted_params, sort_keys=True)
- return hashlib.md5(param_str.encode()).hexdigest()[:12]
- @dataclass
- class SearchState:
- """Complete state of an adaptive search run."""
- run_id: str
- parameters: List[ParameterRange]
- points: List[SearchPoint] = field(default_factory=list)
- current_batch: int = 0
- total_budget: int = 80
- used_budget: int = 0
- batch_size: int = 4
- search_method: str = "active_learning"
- trust_region_active: bool = False
- trust_region_center: Optional[Dict[str, float]] = None
- trust_region_radius: float = 0.3 # fraction of normalized range
- best_feasible_point: Optional[SearchPoint] = None
- best_objective_value: float = float('inf')
- objective_metric: str = "tavg_nm" # metric to optimize
- objective_direction: str = "maximize" # maximize / minimize
- convergence_status: str = "searching" # searching / converged / stalled / budget_exhausted
- history: List[Dict[str, Any]] = field(default_factory=list)
- created_at: str = ""
- updated_at: str = ""
- def get_param_names(self) -> List[str]:
- return [p.name for p in self.parameters]
- def get_param_by_name(self, name: str) -> Optional[ParameterRange]:
- for p in self.parameters:
- if p.name == name:
- return p
- return None
- def get_pending_points(self) -> List[SearchPoint]:
- return [p for p in self.points if p.status == "pending"]
- def get_completed_points(self) -> List[SearchPoint]:
- return [p for p in self.points if p.status in ("ok", "failed", "infeasible")]
- def get_feasible_points(self) -> List[SearchPoint]:
- return [p for p in self.points if p.status == "ok" and p.feasibility_report and p.feasibility_report.get("feasible", False)]
- def remaining_budget(self) -> int:
- return self.total_budget - self.used_budget
- class FeasibilityFirstSearch:
- """Feasibility-first adaptive search engine.
- Implements the recommended default path from third-party review:
- L0 pre-screening + initial samples + active learning batch selection
- + local trust region refinement.
- """
- def __init__(
- self,
- parameters: List[ParameterRange],
- l0_engine: Optional[L0PreScreeningEngine] = None,
- total_budget: int = 80,
- batch_size: int = 4,
- initial_samples: int = 16,
- objective_metric: str = "tavg_nm",
- objective_direction: str = "maximize",
- seed: int = 42,
- ):
- self.rng = random.Random(seed)
- self.parameters = parameters
- self.l0_engine = l0_engine or L0PreScreeningEngine()
- self.total_budget = total_budget
- self.batch_size = batch_size
- self.initial_samples = initial_samples
- self.objective_metric = objective_metric
- self.objective_direction = objective_direction
- self.state = SearchState(
- run_id=f"search_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
- parameters=parameters,
- total_budget=total_budget,
- batch_size=batch_size,
- objective_metric=objective_metric,
- objective_direction=objective_direction,
- created_at=datetime.now().isoformat(),
- )
- def latin_hypercube_sample(self, n_samples: int) -> List[Dict[str, float]]:
- """Generate Latin Hypercube Samples in normalized space.
- Pure Python implementation (no numpy dependency).
- """
- n_params = len(self.parameters)
- # Create n_samples intervals per dimension
- intervals = []
- for _ in range(n_params):
- # Shuffle interval assignments
- assignments = list(range(n_samples))
- self.rng.shuffle(assignments)
- intervals.append(assignments)
- samples = []
- for i in range(n_samples):
- sample = {}
- for j, param in enumerate(self.parameters):
- # Random point within assigned interval
- interval_idx = intervals[j][i]
- norm = (interval_idx + self.rng.random()) / n_samples
- sample[param.name] = param.denormalize(norm)
- samples.append(sample)
- return samples
- def _min_distance_to_existing(self, params: Dict[str, float], existing: List[Dict[str, float]]) -> float:
- """Calculate minimum normalized Euclidean distance to existing points."""
- if not existing:
- return float('inf')
- min_dist = float('inf')
- for ex in existing:
- dist_sq = 0.0
- for param in self.parameters:
- v1 = param.normalize(params.get(param.name, param.min_value))
- v2 = param.normalize(ex.get(param.name, param.min_value))
- dist_sq += (v1 - v2) ** 2
- dist = math.sqrt(dist_sq)
- min_dist = min(min_dist, dist)
- return min_dist
- def _is_duplicate(self, params: Dict[str, float], existing_points: List[SearchPoint]) -> bool:
- """Check if parameter set is a duplicate of an existing point."""
- param_hash = hashlib.md5(
- json.dumps(sorted(params.items()), sort_keys=True).encode()
- ).hexdigest()[:12]
- return any(p.param_hash() == param_hash for p in existing_points)
- def generate_initial_batch(self) -> List[SearchPoint]:
- """Generate initial LHS samples, filtered by L0 feasibility.
- Prioritizes feasible points but includes some infeasible for
- boundary learning (active learning needs both classes).
- """
- # Generate more samples than needed, then select diverse subset
- n_candidates = max(self.initial_samples * 3, 20)
- candidates = self.latin_hypercube_sample(n_candidates)
- # L0 pre-screen all candidates
- scored = []
- for params in candidates:
- report = self.l0_engine.evaluate(params)
- scored.append((params, report))
- # Separate feasible and infeasible
- feasible = [(p, r) for p, r in scored if r.feasible]
- infeasible = [(p, r) for p, r in scored if not r.feasible]
- # Select diverse subset: mostly feasible, some infeasible for boundary
- n_feasible = min(len(feasible), int(self.initial_samples * 0.75))
- n_infeasible = min(len(infeasible), self.initial_samples - n_feasible)
- selected = []
- selected_params = []
- # Greedy max-distance selection for feasible points
- feasible.sort(key=lambda x: x[1].pass_rate, reverse=True)
- for params, report in feasible:
- if len(selected) >= n_feasible:
- break
- # Check duplicate against already selected params
- is_dup = any(
- hashlib.md5(json.dumps(sorted(p.items()), sort_keys=True).encode()).hexdigest()[:12] ==
- hashlib.md5(json.dumps(sorted(params.items()), sort_keys=True).encode()).hexdigest()[:12]
- for p, _ in selected
- )
- if not is_dup:
- dist = self._min_distance_to_existing(params, selected_params)
- if dist > 0.1 or len(selected) < 3:
- selected.append((params, report))
- selected_params.append(params)
- # Add infeasible points for boundary learning
- for params, report in infeasible:
- if len(selected) >= self.initial_samples:
- break
- is_dup = any(
- hashlib.md5(json.dumps(sorted(p.items()), sort_keys=True).encode()).hexdigest()[:12] ==
- hashlib.md5(json.dumps(sorted(params.items()), sort_keys=True).encode()).hexdigest()[:12]
- for p, _ in selected
- )
- if not is_dup:
- selected.append((params, report))
- selected_params.append(params)
- # Create SearchPoint objects
- points = []
- for i, (params, report) in enumerate(selected):
- point = SearchPoint(
- id=len(self.state.points) + i,
- params=params,
- status="infeasible" if not report.feasible else "pending",
- feasibility_report=report.to_dict(),
- batch_id=0,
- created_at=datetime.now().isoformat(),
- )
- if not report.feasible:
- # Mark as completed (L0 rejected) without using solver budget
- point.status = "infeasible"
- points.append(point)
- self.state.points.extend(points)
- self.state.current_batch = 1
- self.state.used_budget += sum(1 for p in points if p.status == "pending")
- self.state.updated_at = datetime.now().isoformat()
- return [p for p in points if p.status == "pending"]
- def select_next_batch(self) -> List[SearchPoint]:
- """Select next batch of points using active learning.
- Strategy:
- 1. If feasible points found, activate trust region around best
- 2. Select points balancing:
- - Exploration: high distance from existing points
- - Exploitation: near best feasible point (trust region)
- - Uncertainty: points near feasibility boundary
- 3. L0 pre-screen all candidates, reject obviously infeasible
- """
- if self.state.remaining_budget() <= 0:
- self.state.convergence_status = "budget_exhausted"
- return []
- feasible_points = self.state.get_feasible_points()
- completed_params = [p.params for p in self.state.get_completed_points()]
- # Activate trust region if we have enough feasible points
- if len(feasible_points) >= 3 and not self.state.trust_region_active:
- best = max(feasible_points, key=lambda p: p.metrics.get(self.objective_metric, 0))
- self.state.trust_region_active = True
- self.state.trust_region_center = best.params
- self.state.best_feasible_point = best
- self.state.history.append({
- "event": "trust_region_activated",
- "center": best.params,
- "batch": self.state.current_batch,
- })
- # Generate candidates
- n_candidates = 50
- candidates = []
- if self.state.trust_region_active and self.state.trust_region_center:
- # 70% from trust region, 30% from global exploration
- n_trust = int(n_candidates * 0.7)
- n_global = n_candidates - n_trust
- # Trust region samples (Gaussian-like around center)
- for _ in range(n_trust):
- params = {}
- for param in self.parameters:
- center_norm = param.normalize(self.state.trust_region_center[param.name])
- # Sample with decreasing radius as search progresses
- radius = self.state.trust_region_radius * max(0.3, 1.0 - self.state.used_budget / self.total_budget)
- sample_norm = center_norm + self.rng.gauss(0, radius * 0.3)
- sample_norm = max(0.0, min(1.0, sample_norm))
- params[param.name] = param.denormalize(sample_norm)
- candidates.append(params)
- # Global exploration samples
- candidates.extend(self.latin_hypercube_sample(n_global))
- else:
- # No trust region yet: full LHS exploration
- candidates = self.latin_hypercube_sample(n_candidates)
- # Score and select candidates
- scored_candidates = []
- for params in candidates:
- if self._is_duplicate(params, self.state.points):
- continue
- # L0 pre-screen
- report = self.l0_engine.evaluate(params)
- if not report.feasible:
- continue # Skip obviously infeasible
- # Distance score (exploration)
- dist = self._min_distance_to_existing(params, completed_params)
- dist_score = min(dist / math.sqrt(len(self.parameters)), 1.0)
- # Trust region proximity score (exploitation)
- trust_score = 0.0
- if self.state.trust_region_center:
- dist_to_center_sq = 0.0
- for param in self.parameters:
- v1 = param.normalize(params[param.name])
- v2 = param.normalize(self.state.trust_region_center[param.name])
- dist_to_center_sq += (v1 - v2) ** 2
- dist_to_center = math.sqrt(dist_to_center_sq)
- trust_score = max(0, 1.0 - dist_to_center / self.state.trust_region_radius)
- # Combined score (balance exploration and exploitation)
- if self.state.trust_region_active:
- score = 0.4 * dist_score + 0.6 * trust_score
- else:
- score = dist_score
- scored_candidates.append((params, report, score))
- # Sort by score and select top batch_size
- scored_candidates.sort(key=lambda x: x[2], reverse=True)
- batch_size = min(self.batch_size, self.state.remaining_budget(), len(scored_candidates))
- selected = []
- for i in range(batch_size):
- params, report, score = scored_candidates[i]
- point = SearchPoint(
- id=len(self.state.points) + i,
- params=params,
- status="pending",
- feasibility_report=report.to_dict(),
- batch_id=self.state.current_batch,
- created_at=datetime.now().isoformat(),
- )
- selected.append(point)
- self.state.points.extend(selected)
- self.state.used_budget += len(selected)
- self.state.current_batch += 1
- self.state.updated_at = datetime.now().isoformat()
- return selected
- def report_result(self, point_id: int, metrics: Dict[str, float], status: str = "ok") -> None:
- """Report simulation result for a point.
- Args:
- point_id: ID of the search point
- metrics: Dictionary of metric values
- status: ok / failed
- """
- for point in self.state.points:
- if point.id == point_id:
- point.metrics = metrics
- point.status = status
- # Update best feasible point
- if status == "ok" and self.objective_metric in metrics:
- value = metrics[self.objective_metric]
- is_better = (
- (self.objective_direction == "maximize" and value > self.state.best_objective_value) or
- (self.objective_direction == "minimize" and value < self.state.best_objective_value)
- )
- if is_better or self.state.best_feasible_point is None:
- self.state.best_objective_value = value
- self.state.best_feasible_point = point
- # Check convergence
- self._check_convergence()
- break
- self.state.updated_at = datetime.now().isoformat()
- def _check_convergence(self) -> None:
- """Check if search has converged or stalled."""
- feasible = self.state.get_feasible_points()
- if len(feasible) < 5:
- return
- # Check if objective has improved in last N points
- recent = feasible[-10:] if len(feasible) >= 10 else feasible
- if len(recent) >= 5:
- values = [p.metrics.get(self.objective_metric, 0) for p in recent]
- if self.objective_direction == "maximize":
- improvement = max(values) - max(values[:-3]) if len(values) > 3 else 0
- else:
- improvement = min(values[:-3]) - min(values) if len(values) > 3 else 0
- if improvement < 0.001 and self.state.trust_region_active:
- # Shrink trust region
- self.state.trust_region_radius *= 0.7
- if self.state.trust_region_radius < 0.05:
- self.state.convergence_status = "converged"
- self.state.history.append({
- "event": "converged",
- "reason": "trust_region_shrunk_below_threshold",
- "batch": self.state.current_batch,
- })
- def get_state_summary(self) -> Dict[str, Any]:
- """Get a summary of current search state."""
- return {
- "run_id": self.state.run_id,
- "search_method": self.state.search_method,
- "convergence_status": self.state.convergence_status,
- "total_budget": self.state.total_budget,
- "used_budget": self.state.used_budget,
- "remaining_budget": self.state.remaining_budget(),
- "current_batch": self.state.current_batch,
- "total_points": len(self.state.points),
- "pending_points": len(self.state.get_pending_points()),
- "completed_points": len(self.state.get_completed_points()),
- "feasible_points": len(self.state.get_feasible_points()),
- "trust_region_active": self.state.trust_region_active,
- "trust_region_radius": round(self.state.trust_region_radius, 4),
- "best_objective_value": self.state.best_objective_value if self.state.best_objective_value != float('inf') else None,
- "best_point_params": self.state.best_feasible_point.params if self.state.best_feasible_point else None,
- }
- def export_state(self) -> Dict[str, Any]:
- """Export full search state for checkpointing."""
- return {
- "state": {
- "run_id": self.state.run_id,
- "current_batch": self.state.current_batch,
- "total_budget": self.state.total_budget,
- "used_budget": self.state.used_budget,
- "convergence_status": self.state.convergence_status,
- "trust_region_active": self.state.trust_region_active,
- "trust_region_center": self.state.trust_region_center,
- "trust_region_radius": self.state.trust_region_radius,
- "best_objective_value": self.state.best_objective_value,
- },
- "parameters": [
- {"name": p.name, "min": p.min_value, "max": p.max_value, "step": p.step, "unit": p.unit}
- for p in self.parameters
- ],
- "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 self.state.points
- ],
- }
|