Coverage for src/local_deep_research/benchmarks/comparison/evaluator.py: 89%

314 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-19 23:35 +0000

1""" 

2Configuration comparison for Local Deep Research. 

3 

4This module provides functions for comparing different parameter configurations 

5and evaluating their performance across various metrics. 

6""" 

7 

8import os 

9from datetime import datetime, UTC 

10from pathlib import Path 

11from typing import Any, Dict, List, Optional 

12 

13import numpy as np 

14from loguru import logger 

15 

16from local_deep_research.benchmarks.efficiency.resource_monitor import ( 

17 ResourceMonitor, 

18) 

19from local_deep_research.benchmarks.efficiency.speed_profiler import ( 

20 SpeedProfiler, 

21) 

22from local_deep_research.benchmarks.optimization.metrics import ( 

23 calculate_combined_score, 

24 calculate_quality_metrics, 

25 calculate_resource_metrics, 

26 calculate_speed_metrics, 

27) 

28from local_deep_research.config.llm_config import get_llm 

29from local_deep_research.config.search_config import get_search 

30from local_deep_research.search_system import AdvancedSearchSystem 

31 

32# matplotlib is imported LAZILY via _ensure_plotting_loaded() inside the 

33# visualization helpers below — NOT at module level. A module-level 

34# `import matplotlib.pyplot` executes when this module is imported, and 

35# because benchmarks/__init__.py pulls this module in, that import ran on 

36# the server's import path. matplotlib's import is heavy and, under the 

37# 2-core CI runner's GIL/CPU starvation, stretched to ~60s while holding 

38# the import lock — freezing the whole werkzeug request pipeline (#4431). 

39# These comparison visualizations only run in explicit benchmark 

40# comparisons, never on a request path. 

41# 

42# Module-level placeholders so tests can @patch these names and so the 

43# loader can fill them in place. None until first real visualization. 

44plt = None 

45Circle = None 

46RegularPolygon = None 

47 

48 

49def _ensure_plotting_loaded(): 

50 """Import matplotlib into module globals on first use (see #4431). 

51 

52 Early-returns if already loaded (or a test has patched plt) so it never 

53 clobbers mocks. 

54 """ 

55 global plt, Circle, RegularPolygon 

56 if plt is not None: 

57 return 

58 import matplotlib.pyplot as plt 

59 from matplotlib.patches import Circle, RegularPolygon 

60 

61 

62def compare_configurations( 

63 query: str, 

64 configurations: List[Dict[str, Any]], 

65 output_dir: str = "comparison_results", 

66 model_name: Optional[str] = None, 

67 provider: Optional[str] = None, 

68 search_tool: Optional[str] = None, 

69 repetitions: int = 1, 

70 metric_weights: Optional[Dict[str, float]] = None, 

71) -> Dict[str, Any]: 

72 """ 

73 Compare multiple parameter configurations. 

74 

75 Args: 

76 query: Research query to use for evaluation 

77 configurations: List of parameter configurations to compare 

78 output_dir: Directory to save comparison results 

79 model_name: Name of the LLM model to use 

80 provider: LLM provider 

81 search_tool: Search engine to use 

82 repetitions: Number of repetitions for each configuration 

83 metric_weights: Dictionary of weights for each metric type 

84 

85 Returns: 

86 Dictionary with comparison results 

87 """ 

88 os.makedirs(output_dir, exist_ok=True) 

89 

90 # Default metric weights if not provided 

91 if metric_weights is None: 

92 metric_weights = { 

93 "quality": 0.6, 

94 "speed": 0.4, 

95 "resource": 0.0, # Disabled by default 

96 } 

97 

98 # Verify valid configurations 

99 if not configurations: 

100 logger.error("No configurations provided for comparison") 

101 return {"error": "No configurations provided"} 

102 

103 # Results storage 

104 results = [] 

105 

106 # Process each configuration 

107 for i, config in enumerate(configurations): 

108 logger.info( 

109 f"Evaluating configuration {i + 1}/{len(configurations)}: {config}" 

110 ) 

111 

112 # Name for this configuration 

113 config_name = config.get("name", f"Configuration {i + 1}") 

114 

115 # Results for all repetitions of this configuration 

116 config_results = [] 

117 

118 # Run multiple repetitions 

119 for rep in range(repetitions): 

120 logger.info( 

121 f"Starting repetition {rep + 1}/{repetitions} for {config_name}" 

122 ) 

123 

124 try: 

125 # Run the configuration 

126 result = _evaluate_single_configuration( 

127 query=query, 

128 config=config, 

129 model_name=model_name, 

130 provider=provider, 

131 search_tool=search_tool, 

132 ) 

133 

134 config_results.append(result) 

135 logger.info(f"Completed repetition {rep + 1} for {config_name}") 

136 

137 except Exception as e: 

138 logger.exception( 

139 f"Error in {config_name}, repetition {rep + 1}" 

140 ) 

141 # Add error info but continue with other configurations 

142 config_results.append({"error": str(e), "success": False}) 

143 

144 # Calculate aggregate metrics across repetitions 

145 if config_results: 145 ↛ 107line 145 didn't jump to line 107 because the condition on line 145 was always true

146 # Filter out failed runs 

147 successful_runs = [ 

148 r for r in config_results if r.get("success", False) 

149 ] 

150 

151 if successful_runs: 

152 # Calculate average metrics 

153 avg_metrics = _calculate_average_metrics(successful_runs) 

154 

155 # Calculate overall score 

156 overall_score = calculate_combined_score( 

157 metrics={ 

158 "quality": avg_metrics.get("quality_metrics", {}), 

159 "speed": avg_metrics.get("speed_metrics", {}), 

160 "resource": avg_metrics.get("resource_metrics", {}), 

161 }, 

162 weights=metric_weights, 

163 ) 

164 

165 result_summary = { 

166 "name": config_name, 

167 "configuration": config, 

168 "success": True, 

169 "runs_completed": len(successful_runs), 

170 "runs_failed": len(config_results) - len(successful_runs), 

171 "avg_metrics": avg_metrics, 

172 "overall_score": overall_score, 

173 "individual_results": config_results, 

174 } 

175 else: 

176 # All runs failed 

177 result_summary = { 

178 "name": config_name, 

179 "configuration": config, 

180 "success": False, 

181 "runs_completed": 0, 

182 "runs_failed": len(config_results), 

183 "error": "All runs failed", 

184 "individual_results": config_results, 

185 } 

186 

187 results.append(result_summary) 

188 

189 # Sort results by overall score (if available) 

190 sorted_results = sorted( 

191 [r for r in results if r.get("success", False)], 

192 key=lambda x: x.get("overall_score", 0), 

193 reverse=True, 

194 ) 

195 

196 # Add failed configurations at the end 

197 sorted_results.extend([r for r in results if not r.get("success", False)]) 

198 

199 # Create comparison report 

200 comparison_report = { 

201 "query": query, 

202 "configurations_tested": len(configurations), 

203 "successful_configurations": len( 

204 [r for r in results if r.get("success", False)] 

205 ), 

206 "failed_configurations": len( 

207 [r for r in results if not r.get("success", False)] 

208 ), 

209 "repetitions": repetitions, 

210 "metric_weights": metric_weights, 

211 "timestamp": datetime.now(UTC).isoformat(), 

212 "results": sorted_results, 

213 } 

214 

215 # Save results to file 

216 from ...security.file_write_verifier import write_json_verified 

217 

218 timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") 

219 result_file = str(Path(output_dir) / f"comparison_results_{timestamp}.json") 

220 

221 write_json_verified( 

222 result_file, 

223 comparison_report, 

224 "benchmark.allow_file_output", 

225 context="comparison results", 

226 ) 

227 

228 # Generate visualizations 

229 _viz_dir_path = Path(output_dir) / "visualizations" 

230 _viz_dir_path.mkdir(parents=True, exist_ok=True) 

231 visualizations_dir = str(_viz_dir_path) 

232 

233 _create_comparison_visualizations( 

234 comparison_report, output_dir=visualizations_dir, timestamp=timestamp 

235 ) 

236 

237 logger.info(f"Comparison completed. Results saved to {result_file}") 

238 

239 # Add report path to the result 

240 comparison_report["report_path"] = result_file 

241 

242 return comparison_report 

243 

244 

245def _evaluate_single_configuration( 

246 query: str, 

247 config: Dict[str, Any], 

248 model_name: Optional[str] = None, 

249 provider: Optional[str] = None, 

250 search_tool: Optional[str] = None, 

251) -> Dict[str, Any]: 

252 """ 

253 Evaluate a single configuration. 

254 

255 Args: 

256 query: Research query to evaluate 

257 config: Configuration parameters 

258 model_name: Name of the LLM model to use 

259 provider: LLM provider 

260 search_tool: Search engine to use 

261 

262 Returns: 

263 Dictionary with evaluation results 

264 """ 

265 # Extract configuration parameters 

266 config_model_name = config.get("model_name", model_name) 

267 config_provider = config.get("provider", provider) 

268 config_search_tool = config.get("search_tool", search_tool) 

269 config_iterations = config.get("iterations", 2) 

270 config_questions_per_iteration = config.get("questions_per_iteration", 2) 

271 config_search_strategy = config.get("search_strategy", "source-based") 

272 

273 # Initialize profiling tools 

274 speed_profiler = SpeedProfiler() 

275 resource_monitor = ResourceMonitor(sampling_interval=0.5) 

276 

277 # Start profiling 

278 speed_profiler.start() 

279 resource_monitor.start() 

280 

281 llm = None 

282 search = None 

283 system = None 

284 try: 

285 # Get LLM 

286 with speed_profiler.timer("llm_initialization"): 

287 llm = get_llm( 

288 temperature=config.get("temperature", 0.7), 

289 model_name=config_model_name, 

290 provider=config_provider, 

291 ) 

292 

293 # Set up search engine if specified 

294 with speed_profiler.timer("search_initialization"): 

295 search = None 

296 if config_search_tool: 296 ↛ 297line 296 didn't jump to line 297 because the condition on line 296 was never true

297 search = get_search( 

298 config_search_tool, 

299 llm_instance=llm, 

300 ) 

301 

302 # Create search system 

303 system = AdvancedSearchSystem( # type: ignore[call-arg] 

304 llm=llm, 

305 search=search, # type: ignore[arg-type] 

306 max_iterations=config_iterations, 

307 questions_per_iteration=config_questions_per_iteration, 

308 strategy_name=config_search_strategy, 

309 ) 

310 

311 # Run the analysis 

312 with speed_profiler.timer("analysis"): 

313 results = system.analyze_topic(query) 

314 

315 # Stop profiling 

316 speed_profiler.stop() 

317 resource_monitor.stop() 

318 

319 # Calculate metrics 

320 _system_config = dict(config) 

321 quality_metrics = calculate_quality_metrics( 

322 system_config=_system_config 

323 ) 

324 

325 speed_metrics = calculate_speed_metrics(system_config=_system_config) 

326 

327 resource_metrics = calculate_resource_metrics( 

328 system_config=_system_config 

329 ) 

330 

331 # Return comprehensive results 

332 return { 

333 "query": query, 

334 "config": config, 

335 "success": True, 

336 "findings_count": len(results.get("findings", [])), 

337 "knowledge_length": len(results.get("current_knowledge", "")), 

338 "quality_metrics": quality_metrics, 

339 "speed_metrics": speed_metrics, 

340 "resource_metrics": resource_metrics, 

341 "timing_details": speed_profiler.get_timings(), 

342 "resource_details": resource_monitor.get_combined_stats(), 

343 } 

344 

345 except Exception as e: 

346 # Stop profiling on error 

347 speed_profiler.stop() 

348 resource_monitor.stop() 

349 

350 # Log the error 

351 logger.exception("Error evaluating configuration") 

352 

353 # Return error information 

354 return { 

355 "query": query, 

356 "config": config, 

357 "success": False, 

358 "error": str(e), 

359 "timing_details": speed_profiler.get_timings(), 

360 "resource_details": resource_monitor.get_combined_stats(), 

361 } 

362 finally: 

363 from ...utilities.resource_utils import safe_close 

364 

365 safe_close(system, "evaluator system") 

366 safe_close(search, "evaluator search engine") 

367 safe_close(llm, "evaluator LLM") 

368 

369 

370def _calculate_average_metrics(results: List[Dict[str, Any]]) -> Dict[str, Any]: 

371 """ 

372 Calculate average metrics across multiple runs. 

373 

374 Args: 

375 results: List of individual run results 

376 

377 Returns: 

378 Dictionary with averaged metrics 

379 """ 

380 # Check if there are any successful results 

381 if not results: 

382 return {} 

383 

384 # Initialize average metrics 

385 avg_metrics: Dict[str, Any] = { 

386 "quality_metrics": {}, 

387 "speed_metrics": {}, 

388 "resource_metrics": {}, 

389 } 

390 

391 # Quality metrics 

392 quality_keys = set() 

393 for result in results: 

394 quality_metrics = result.get("quality_metrics", {}) 

395 quality_keys.update(quality_metrics.keys()) 

396 

397 for key in quality_keys: 

398 values = [r.get("quality_metrics", {}).get(key) for r in results] 

399 values = [v for v in values if v is not None] 

400 if values: 400 ↛ 397line 400 didn't jump to line 397 because the condition on line 400 was always true

401 avg_metrics["quality_metrics"][key] = sum(values) / len(values) 

402 

403 # Speed metrics 

404 speed_keys = set() 

405 for result in results: 

406 speed_metrics = result.get("speed_metrics", {}) 

407 speed_keys.update(speed_metrics.keys()) 

408 

409 for key in speed_keys: 

410 values = [r.get("speed_metrics", {}).get(key) for r in results] 

411 values = [v for v in values if v is not None] 

412 if values: 412 ↛ 409line 412 didn't jump to line 409 because the condition on line 412 was always true

413 avg_metrics["speed_metrics"][key] = sum(values) / len(values) 

414 

415 # Resource metrics 

416 resource_keys = set() 

417 for result in results: 

418 resource_metrics = result.get("resource_metrics", {}) 

419 resource_keys.update(resource_metrics.keys()) 

420 

421 for key in resource_keys: 

422 values = [r.get("resource_metrics", {}).get(key) for r in results] 

423 values = [v for v in values if v is not None] 

424 if values: 424 ↛ 421line 424 didn't jump to line 421 because the condition on line 424 was always true

425 avg_metrics["resource_metrics"][key] = sum(values) / len(values) 

426 

427 return avg_metrics 

428 

429 

430def _create_comparison_visualizations( 

431 comparison_report: Dict[str, Any], output_dir: str, timestamp: str 

432): 

433 """ 

434 Create visualizations for the comparison results. 

435 

436 Args: 

437 comparison_report: Comparison report dictionary 

438 output_dir: Directory to save visualizations 

439 timestamp: Timestamp string for filenames 

440 """ 

441 _ensure_plotting_loaded() 

442 # Check if there are successful results 

443 successful_results = [ 

444 r 

445 for r in comparison_report.get("results", []) 

446 if r.get("success", False) 

447 ] 

448 

449 if not successful_results: 

450 logger.warning("No successful configurations to visualize") 

451 return 

452 

453 # Extract configuration names 

454 config_names = [ 

455 r.get("name", f"Config {i + 1}") 

456 for i, r in enumerate(successful_results) 

457 ] 

458 

459 # 1. Overall score comparison 

460 plt.figure(figsize=(12, 6)) 

461 scores = [r.get("overall_score", 0) for r in successful_results] 

462 

463 # Create horizontal bar chart 

464 plt.barh(config_names, scores, color="skyblue") 

465 plt.xlabel("Overall Score") 

466 plt.ylabel("Configuration") 

467 plt.title("Configuration Performance Comparison") 

468 plt.grid(axis="x", linestyle="--", alpha=0.7) 

469 plt.tight_layout() 

470 plt.savefig( 

471 str(Path(output_dir) / f"overall_score_comparison_{timestamp}.png") 

472 ) 

473 plt.close() 

474 

475 # 2. Quality metrics comparison 

476 quality_metrics = ["overall_quality", "source_count", "lexical_diversity"] 

477 _create_metric_comparison_chart( 

478 successful_results, 

479 config_names, 

480 quality_metrics, 

481 "quality_metrics", 

482 "Quality Metrics Comparison", 

483 str(Path(output_dir) / f"quality_metrics_comparison_{timestamp}.png"), 

484 ) 

485 

486 # 3. Speed metrics comparison 

487 speed_metrics = ["overall_speed", "total_duration", "duration_per_question"] 

488 _create_metric_comparison_chart( 

489 successful_results, 

490 config_names, 

491 speed_metrics, 

492 "speed_metrics", 

493 "Speed Metrics Comparison", 

494 str(Path(output_dir) / f"speed_metrics_comparison_{timestamp}.png"), 

495 ) 

496 

497 # 4. Resource metrics comparison 

498 resource_metrics = [ 

499 "overall_resource", 

500 "process_memory_max_mb", 

501 "system_cpu_avg", 

502 ] 

503 _create_metric_comparison_chart( 

504 successful_results, 

505 config_names, 

506 resource_metrics, 

507 "resource_metrics", 

508 "Resource Usage Comparison", 

509 str(Path(output_dir) / f"resource_metrics_comparison_{timestamp}.png"), 

510 ) 

511 

512 # 5. Spider chart for multi-dimensional comparison 

513 _create_spider_chart( 

514 successful_results, 

515 config_names, 

516 str(Path(output_dir) / f"spider_chart_comparison_{timestamp}.png"), 

517 ) 

518 

519 # 6. Pareto frontier chart for quality vs. speed 

520 _create_pareto_chart( 

521 successful_results, 

522 str(Path(output_dir) / f"pareto_chart_comparison_{timestamp}.png"), 

523 ) 

524 

525 

526def _create_metric_comparison_chart( 

527 results: List[Dict[str, Any]], 

528 config_names: List[str], 

529 metric_keys: List[str], 

530 metric_category: str, 

531 title: str, 

532 output_path: str, 

533): 

534 """ 

535 Create a chart comparing specific metrics across configurations. 

536 

537 Args: 

538 results: List of configuration results 

539 config_names: Names of configurations 

540 metric_keys: Keys of metrics to compare 

541 metric_category: Category of metrics (quality_metrics, speed_metrics, etc.) 

542 title: Chart title 

543 output_path: Path to save the chart 

544 """ 

545 _ensure_plotting_loaded() 

546 # Create figure with multiple subplots (one per metric) 

547 fig, axes = plt.subplots( 

548 len(metric_keys), 1, figsize=(12, 5 * len(metric_keys)) 

549 ) 

550 

551 # Handle case with only one metric 

552 if len(metric_keys) == 1: 

553 axes = [axes] 

554 

555 for i, metric_key in enumerate(metric_keys): 

556 ax = axes[i] 

557 

558 # Get metric values 

559 metric_values = [] 

560 for result in results: 

561 metrics = result.get("avg_metrics", {}).get(metric_category, {}) 

562 value = metrics.get(metric_key) 

563 

564 # Handle time values for better visualization 

565 if "duration" in metric_key and value is not None: 

566 # Convert to seconds if > 60 seconds, minutes if > 60 minutes 

567 if value > 3600: 

568 value = value / 3600 # Convert to hours 

569 metric_key += " (hours)" 

570 elif value > 60: 

571 value = value / 60 # Convert to minutes 

572 metric_key += " (minutes)" 

573 else: 

574 metric_key += " (seconds)" 

575 

576 metric_values.append(value if value is not None else 0) 

577 

578 # Create horizontal bar chart 

579 bars = ax.barh(config_names, metric_values, color="lightblue") 

580 ax.set_xlabel(metric_key.replace("_", " ").title()) 

581 ax.set_title(f"{metric_key.replace('_', ' ').title()}") 

582 ax.grid(axis="x", linestyle="--", alpha=0.7) 

583 

584 # Add value labels to bars 

585 for bar in bars: 

586 width = bar.get_width() 

587 label_x_pos = width * 1.01 

588 ax.text( 

589 label_x_pos, 

590 bar.get_y() + bar.get_height() / 2, 

591 f"{width:.2f}", 

592 va="center", 

593 ) 

594 

595 plt.suptitle(title, fontsize=16) 

596 plt.tight_layout() 

597 plt.savefig(output_path) 

598 plt.close() 

599 

600 

601def _create_spider_chart( 

602 results: List[Dict[str, Any]], config_names: List[str], output_path: str 

603): 

604 """ 

605 Create a spider chart comparing metrics across configurations. 

606 

607 Args: 

608 results: List of configuration results 

609 config_names: Names of configurations 

610 output_path: Path to save the chart 

611 """ 

612 _ensure_plotting_loaded() 

613 # Try to import the radar chart module 

614 try: 

615 from matplotlib.path import Path 

616 from matplotlib.projections import register_projection 

617 from matplotlib.projections.polar import PolarAxes 

618 from matplotlib.spines import Spine 

619 

620 def radar_factory(num_vars, frame="circle"): 

621 """Create a radar chart with `num_vars` axes.""" 

622 # Calculate evenly-spaced axis angles 

623 theta = np.linspace(0, 2 * np.pi, num_vars, endpoint=False) 

624 

625 class RadarAxes(PolarAxes): 

626 name = "radar" 

627 

628 def __init__(self, *args, **kwargs): 

629 super().__init__(*args, **kwargs) 

630 self.set_theta_zero_location("N") 

631 

632 def fill(self, *args, closed=True, **kwargs): 

633 return super().fill(closed=closed, *args, **kwargs) 

634 

635 def plot(self, *args, **kwargs): 

636 return super().plot(*args, **kwargs) 

637 

638 def set_varlabels(self, labels): 

639 self.set_thetagrids(np.degrees(theta), labels) 

640 

641 def _gen_axes_patch(self): 

642 if frame == "circle": 

643 return Circle((0.5, 0.5), 0.5) 

644 if frame == "polygon": 

645 return RegularPolygon( 

646 (0.5, 0.5), num_vars, radius=0.5, edgecolor="k" 

647 ) 

648 raise ValueError("Unknown value for 'frame': %s" % frame) # noqa: TRY301 — inside nested method definition, not caught by enclosing try 

649 

650 def _gen_axes_spines(self): # type: ignore[misc] 

651 if frame == "circle": 

652 return super()._gen_axes_spines() 

653 if frame == "polygon": 

654 spine_type = Spine.circular_spine 

655 verts = unit_poly_verts(num_vars) 

656 vertices = [(0.5, 0.5)] + verts 

657 codes = ( 

658 [Path.MOVETO] 

659 + [Path.LINETO] * num_vars 

660 + [Path.CLOSEPOLY] 

661 ) 

662 path = Path(vertices, codes) 

663 spine = Spine(self, spine_type, path) # type: ignore[arg-type] 

664 spine.set_transform(self.transAxes) 

665 return {"polar": spine} 

666 raise ValueError("Unknown value for 'frame': %s" % frame) # noqa: TRY301 — inside nested method definition, not caught by enclosing try 

667 

668 def unit_poly_verts(num_vars): 

669 """Return vertices of polygon for radar chart.""" 

670 verts = [] 

671 for i in range(num_vars): 

672 angle = theta[i] 

673 verts.append( 

674 (0.5 * (1 + np.cos(angle)), 0.5 * (1 + np.sin(angle))) 

675 ) 

676 return verts 

677 

678 register_projection(RadarAxes) 

679 return theta 

680 

681 # Select metrics for the spider chart 

682 metrics = [ 

683 {"name": "Quality", "key": "quality_metrics.overall_quality"}, 

684 {"name": "Speed", "key": "speed_metrics.overall_speed"}, 

685 { 

686 "name": "Sources", 

687 "key": "quality_metrics.normalized_source_count", 

688 }, 

689 { 

690 "name": "Content", 

691 "key": "quality_metrics.normalized_knowledge_length", 

692 }, 

693 { 

694 "name": "Memory", 

695 "key": "resource_metrics.normalized_memory_usage", 

696 "invert": True, 

697 }, 

698 ] 

699 

700 # Extract metric values 

701 spoke_labels = [m["name"] for m in metrics] 

702 num_vars = len(spoke_labels) 

703 theta = radar_factory(num_vars) 

704 

705 fig, ax = plt.subplots( 

706 figsize=(10, 10), subplot_kw={"projection": "radar"} 

707 ) 

708 

709 # Color map for different configurations 

710 colors = plt.cm.viridis(np.linspace(0, 1, len(results))) # type: ignore[attr-defined] 

711 

712 for i, result in enumerate(results): 

713 values = [] 

714 for metric in metrics: 

715 # Extract metric value using the key path (e.g., "quality_metrics.overall_quality") 

716 key_parts = metric["key"].split(".") 

717 value: Any = result.get("avg_metrics", {}) 

718 for part in key_parts: 

719 value = value.get(part, 0) if isinstance(value, dict) else 0 

720 

721 # Invert if needed (for metrics where lower is better) 

722 if metric.get("invert", False): 

723 value = 1.0 - value 

724 

725 values.append(value) 

726 

727 # Plot this configuration 

728 ax.plot( 

729 theta, 

730 values, 

731 color=colors[i], 

732 linewidth=2, 

733 label=config_names[i], 

734 ) 

735 ax.fill(theta, values, color=colors[i], alpha=0.25) 

736 

737 # Set chart properties 

738 ax.set_varlabels(spoke_labels) # type: ignore[attr-defined] 

739 plt.legend(loc="best", bbox_to_anchor=(0.5, 0.1)) 

740 plt.title("Multi-Dimensional Configuration Comparison", size=16, y=1.05) 

741 plt.tight_layout() 

742 

743 # Save chart 

744 plt.savefig(output_path) 

745 plt.close() 

746 

747 except Exception as e: 

748 logger.exception("Error creating spider chart") 

749 # Create a text-based chart as fallback 

750 plt.figure(figsize=(10, 6)) 

751 plt.text( 

752 0.5, 

753 0.5, 

754 f"Spider chart could not be created: {e!s}", 

755 horizontalalignment="center", 

756 verticalalignment="center", 

757 ) 

758 plt.axis("off") 

759 plt.savefig(output_path) 

760 plt.close() 

761 

762 

763def _create_pareto_chart(results: List[Dict[str, Any]], output_path: str): 

764 """ 

765 Create a Pareto frontier chart showing quality vs. speed tradeoff. 

766 

767 Args: 

768 results: List of configuration results 

769 output_path: Path to save the chart 

770 """ 

771 _ensure_plotting_loaded() 

772 # Extract quality and speed metrics 

773 quality_scores = [] 

774 speed_scores = [] 

775 names = [] 

776 

777 for result in results: 

778 metrics = result.get("avg_metrics", {}) 

779 quality = metrics.get("quality_metrics", {}).get("overall_quality", 0) 

780 

781 # For speed, we use inverse of duration (so higher is better) 

782 duration = metrics.get("speed_metrics", {}).get("total_duration", 1) 

783 speed = 1.0 / max(duration, 0.001) # Avoid division by zero 

784 

785 quality_scores.append(quality) 

786 speed_scores.append(speed) 

787 names.append(result.get("name", "Configuration")) 

788 

789 # Create scatter plot 

790 plt.figure(figsize=(10, 8)) 

791 plt.scatter(quality_scores, speed_scores, s=100, alpha=0.7) 

792 

793 # Add labels for each point 

794 for i, name in enumerate(names): 

795 plt.annotate( 

796 name, 

797 (quality_scores[i], speed_scores[i]), 

798 xytext=(5, 5), 

799 textcoords="offset points", 

800 ) 

801 

802 # Identify Pareto frontier 

803 pareto_points = [] 

804 for i, (q, s) in enumerate(zip(quality_scores, speed_scores, strict=False)): 

805 is_pareto = True 

806 for q2, s2 in zip(quality_scores, speed_scores, strict=False): 

807 if q2 > q and s2 > s: # Dominated 

808 is_pareto = False 

809 break 

810 if is_pareto: 

811 pareto_points.append(i) 

812 

813 # Highlight Pareto frontier 

814 pareto_quality = [quality_scores[i] for i in pareto_points] 

815 pareto_speed = [speed_scores[i] for i in pareto_points] 

816 

817 # Sort pareto points for line drawing 

818 pareto_sorted = sorted( 

819 zip(pareto_quality, pareto_speed, pareto_points, strict=False) 

820 ) 

821 pareto_quality = [p[0] for p in pareto_sorted] 

822 pareto_speed = [p[1] for p in pareto_sorted] 

823 pareto_indices = [p[2] for p in pareto_sorted] 

824 

825 # Draw Pareto frontier line 

826 plt.plot(pareto_quality, pareto_speed, "r--", linewidth=2) 

827 

828 # Highlight Pareto optimal points 

829 plt.scatter( 

830 [quality_scores[i] for i in pareto_indices], 

831 [speed_scores[i] for i in pareto_indices], 

832 s=150, 

833 facecolors="none", 

834 edgecolors="r", 

835 linewidth=2, 

836 ) 

837 

838 # Add labels for Pareto optimal configurations 

839 for i in pareto_indices: 

840 plt.annotate( 

841 names[i], 

842 (quality_scores[i], speed_scores[i]), 

843 xytext=(8, 8), 

844 textcoords="offset points", 

845 bbox={"boxstyle": "round,pad=0.5", "fc": "yellow", "alpha": 0.7}, 

846 ) 

847 

848 # Set chart properties 

849 plt.xlabel("Quality Score (higher is better)") 

850 plt.ylabel("Speed Score (higher is better)") 

851 plt.title("Quality vs. Speed Tradeoff (Pareto Frontier)", size=14) 

852 plt.grid(True, linestyle="--", alpha=0.7) 

853 

854 # Add explanation 

855 plt.figtext( 

856 0.5, 

857 0.01, 

858 "Points on the red line are Pareto optimal configurations\n" 

859 "(no other configuration is better in both quality and speed)", 

860 ha="center", 

861 fontsize=10, 

862 bbox={"boxstyle": "round", "fc": "white", "alpha": 0.7}, 

863 ) 

864 

865 plt.tight_layout() 

866 plt.savefig(output_path) 

867 plt.close()