"""Benchmark runner scaffold for extraction configuration comparisons. Provides the framework for running extraction benchmarks across different configurations. The actual model invocations require the cluster, but results can be stored and compared locally. Validates: Requirements 16.2, 16.3 """ from __future__ import annotations import json import time from datetime import datetime, timezone from pathlib import Path from typing import Any from pydantic import BaseModel, Field from services.intelligence_pipeline_v3.benchmark.configurations import ( BenchmarkConfig, ) # --------------------------------------------------------------------------- # Result Models # --------------------------------------------------------------------------- class BenchmarkDocumentResult(BaseModel): """Result of running one document through one benchmark configuration.""" document_id: str = Field(description="Identifier of the source document") raw_output: str | None = Field( default=None, description="Raw model output text (before parsing)", ) parsed_output: dict[str, Any] | None = Field( default=None, description="Parsed JSON output if extraction succeeded", ) schema_valid: bool = Field( default=False, description="Whether the output passed JSON Schema validation", ) retries: int = Field( default=0, ge=0, description="Number of retries needed to get valid output", ) duration_ms: int = Field( default=0, ge=0, description="Total wall-clock time in milliseconds", ) input_tokens: int = Field( default=0, ge=0, description="Input tokens consumed", ) output_tokens: int = Field( default=0, ge=0, description="Output tokens generated", ) error: str | None = Field( default=None, description="Error message if extraction failed", ) class BenchmarkRun(BaseModel): """A complete benchmark run: one configuration applied to multiple documents.""" config_name: str = Field(description="Configuration used for this run") timestamp: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), description="When this run was executed", ) document_ids: list[str] = Field( default_factory=list, description="Documents included in this run", ) results: list[BenchmarkDocumentResult] = Field( default_factory=list, description="Per-document results", ) @property def success_count(self) -> int: """Number of documents that produced valid output.""" return sum(1 for r in self.results if r.schema_valid and r.error is None) @property def failure_count(self) -> int: """Number of documents that failed or produced invalid output.""" return len(self.results) - self.success_count @property def schema_validity_rate(self) -> float: """Fraction of results that passed schema validation.""" if not self.results: return 0.0 return self.success_count / len(self.results) @property def mean_duration_ms(self) -> float: """Average duration across all results.""" if not self.results: return 0.0 return sum(r.duration_ms for r in self.results) / len(self.results) @property def total_input_tokens(self) -> int: """Total input tokens across all results.""" return sum(r.input_tokens for r in self.results) @property def total_output_tokens(self) -> int: """Total output tokens across all results.""" return sum(r.output_tokens for r in self.results) # --------------------------------------------------------------------------- # Artifact Storage # --------------------------------------------------------------------------- _DEFAULT_ARTIFACT_DIR = Path("artifacts/benchmark") def _ensure_artifact_dir(base: Path) -> Path: """Create artifact directory if it does not exist.""" base.mkdir(parents=True, exist_ok=True) return base def save_benchmark_run( run: BenchmarkRun, artifact_dir: Path | None = None, ) -> Path: """Persist a benchmark run as a JSON artifact. Args: run: The benchmark run to save. artifact_dir: Directory to write to. Defaults to artifacts/benchmark/. Returns: Path to the written JSON file. """ base = artifact_dir or _DEFAULT_ARTIFACT_DIR _ensure_artifact_dir(base) ts = run.timestamp.strftime("%Y%m%d_%H%M%S") filename = f"{run.config_name}_{ts}.json" path = base / filename path.write_text( run.model_dump_json(indent=2), encoding="utf-8", ) return path def load_benchmark_run(path: Path) -> BenchmarkRun: """Load a benchmark run from a JSON artifact. Args: path: Path to the JSON artifact file. Returns: Deserialized BenchmarkRun. """ data = json.loads(path.read_text(encoding="utf-8")) return BenchmarkRun.model_validate(data) # --------------------------------------------------------------------------- # Runner # --------------------------------------------------------------------------- class BenchmarkRunner: """Runs extraction benchmarks using a given configuration. The runner provides the scaffolding for executing benchmarks. Actual LLM invocation is delegated to an inference callable. When no inference callable is provided, results are recorded as errors (useful for dry-run / configuration testing). """ def __init__( self, config: BenchmarkConfig, artifact_dir: Path | None = None, inference_fn: Any | None = None, ) -> None: """Initialize the benchmark runner. Args: config: Benchmark configuration to use for all runs. artifact_dir: Where to store result artifacts. inference_fn: Optional async callable(document_text, config) -> dict. If None, documents are recorded as not-run errors. """ self.config = config self.artifact_dir = artifact_dir or _DEFAULT_ARTIFACT_DIR self._inference_fn = inference_fn async def run_single_document( self, document_id: str, document_text: str, json_schema: dict[str, Any] | None = None, ) -> BenchmarkDocumentResult: """Run a single document through the configured extraction. Args: document_id: Unique document identifier. document_text: Full document text to extract from. json_schema: Optional JSON Schema for validation. Returns: BenchmarkDocumentResult with extraction outcome. """ if self._inference_fn is None: return BenchmarkDocumentResult( document_id=document_id, error="No inference function configured (dry-run mode)", ) start = time.perf_counter() try: result = await self._inference_fn(document_text, self.config) duration_ms = int((time.perf_counter() - start) * 1000) raw_output = result.get("raw_output", "") parsed_output = result.get("parsed_output") schema_valid = result.get("schema_valid", False) input_tokens = result.get("input_tokens", 0) output_tokens = result.get("output_tokens", 0) retries = result.get("retries", 0) return BenchmarkDocumentResult( document_id=document_id, raw_output=raw_output, parsed_output=parsed_output, schema_valid=schema_valid, retries=retries, duration_ms=duration_ms, input_tokens=input_tokens, output_tokens=output_tokens, ) except Exception as exc: duration_ms = int((time.perf_counter() - start) * 1000) return BenchmarkDocumentResult( document_id=document_id, duration_ms=duration_ms, error=str(exc), ) async def run_batch( self, documents: list[tuple[str, str]], json_schema: dict[str, Any] | None = None, save_artifacts: bool = True, ) -> BenchmarkRun: """Run a batch of documents through the configured extraction. Args: documents: List of (document_id, document_text) tuples. json_schema: Optional JSON Schema for validation. save_artifacts: Whether to persist results as JSON artifacts. Returns: BenchmarkRun with all document results. """ results: list[BenchmarkDocumentResult] = [] document_ids: list[str] = [] for doc_id, doc_text in documents: document_ids.append(doc_id) result = await self.run_single_document( document_id=doc_id, document_text=doc_text, json_schema=json_schema, ) results.append(result) run = BenchmarkRun( config_name=self.config.config_name, document_ids=document_ids, results=results, ) if save_artifacts: save_benchmark_run(run, self.artifact_dir) return run