-
Notifications
You must be signed in to change notification settings - Fork 8
Add portable benchmark run artifacts #310
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,177 @@ | ||
| # Copyright 2026 The Coval Benchmarks Authors | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| """Portable JSONL artifacts for benchmark runs. | ||
|
|
||
| The database is the source of truth for public aggregates. These artifacts are | ||
| for run-level debugging: one file contains the reproducibility metadata, every | ||
| metric row that was produced, and a compact failure summary. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import hashlib | ||
| from collections import Counter | ||
| from datetime import datetime | ||
| from pathlib import Path | ||
| from typing import Literal | ||
|
|
||
| from pydantic import BaseModel, ConfigDict | ||
|
|
||
| from coval_bench.db.models import Result | ||
|
|
||
|
|
||
| class ArtifactRunHeader(BaseModel): | ||
| """First JSONL record in a run artifact.""" | ||
|
|
||
| record_type: Literal["run"] = "run" | ||
| schema_version: int = 1 | ||
| run_id: int | ||
| runner_sha: str | ||
| dataset_id: str | ||
| dataset_sha256: str | ||
| benchmark_kind: str | ||
| smoke: bool | ||
| scheduled_at: datetime | None | ||
| started_at: datetime | ||
| finished_at: datetime | ||
|
|
||
|
|
||
| class ArtifactResultRow(BaseModel): | ||
| """Sanitized metric row copied from the in-memory Result model.""" | ||
|
|
||
| model_config = ConfigDict(use_enum_values=True) | ||
|
|
||
| record_type: Literal["result"] = "result" | ||
| provider: str | ||
| model: str | ||
| voice: str | None | ||
| benchmark: str | ||
| metric_type: str | ||
| metric_value: float | None | ||
| metric_units: str | None | ||
| audio_filename: str | None | ||
| status: str | ||
| error: str | None | ||
| transcript_sha256: str | None | ||
| transcript_chars: int | None | ||
| http_version: str | None | ||
| submit_to_headers_ms: float | None | ||
|
|
||
|
|
||
| class ArtifactFailureBucket(BaseModel): | ||
| """One grouped failure reason in the artifact summary.""" | ||
|
|
||
| provider: str | ||
| model: str | ||
| metric_type: str | ||
| error: str | ||
| count: int | ||
|
|
||
|
|
||
| class ArtifactSummary(BaseModel): | ||
| """Last JSONL record in a run artifact.""" | ||
|
|
||
| record_type: Literal["summary"] = "summary" | ||
| status: str | ||
| total_results: int | ||
| success_count: int | ||
| fail_count: int | ||
| failure_buckets: list[ArtifactFailureBucket] | ||
|
|
||
|
|
||
| def write_run_artifact( | ||
| *, | ||
| artifact_dir: Path, | ||
| run_id: int, | ||
| runner_sha: str, | ||
| dataset_id: str, | ||
| dataset_sha256: str, | ||
| benchmark_kind: str, | ||
| smoke: bool, | ||
| scheduled_at: datetime | None, | ||
| started_at: datetime, | ||
| finished_at: datetime, | ||
| status: str, | ||
| results: list[Result], | ||
| ) -> Path: | ||
| """Write a single atomic JSONL artifact for a completed or partial run.""" | ||
| artifact_dir.mkdir(parents=True, exist_ok=True) | ||
| final_path = artifact_dir / f"run-{run_id}.jsonl" | ||
| tmp_path = artifact_dir / f".run-{run_id}.jsonl.tmp" | ||
|
|
||
| rows = [_result_row(r) for r in results] | ||
| summary = ArtifactSummary( | ||
| status=status, | ||
| total_results=len(rows), | ||
| success_count=sum(1 for r in rows if r.status == "success"), | ||
| fail_count=sum(1 for r in rows if r.status == "failed"), | ||
| failure_buckets=_failure_buckets(rows), | ||
| ) | ||
| header = ArtifactRunHeader( | ||
| run_id=run_id, | ||
| runner_sha=runner_sha, | ||
| dataset_id=dataset_id, | ||
| dataset_sha256=dataset_sha256, | ||
| benchmark_kind=benchmark_kind, | ||
| smoke=smoke, | ||
| scheduled_at=scheduled_at, | ||
| started_at=started_at, | ||
| finished_at=finished_at, | ||
| ) | ||
|
|
||
| with tmp_path.open("w", encoding="utf-8") as fh: | ||
| fh.write(header.model_dump_json() + "\n") | ||
| for row in rows: | ||
| fh.write(row.model_dump_json() + "\n") | ||
| fh.write(summary.model_dump_json() + "\n") | ||
| tmp_path.replace(final_path) | ||
| return final_path | ||
|
|
||
|
|
||
| def _result_row(result: Result) -> ArtifactResultRow: | ||
| transcript = getattr(result, "transcript", None) | ||
| return ArtifactResultRow( | ||
| provider=str(result.provider), | ||
| model=str(result.model), | ||
| voice=result.voice, | ||
| benchmark=str(result.benchmark), | ||
| metric_type=str(result.metric_type), | ||
| metric_value=result.metric_value, | ||
| metric_units=result.metric_units, | ||
| audio_filename=result.audio_filename, | ||
| status=str(result.status), | ||
| error=result.error, | ||
| transcript_sha256=_transcript_sha256(transcript), | ||
| transcript_chars=len(transcript) if isinstance(transcript, str) else None, | ||
| http_version=result.http_version, | ||
| submit_to_headers_ms=result.submit_to_headers_ms, | ||
| ) | ||
|
|
||
|
|
||
| def _transcript_sha256(transcript: str | None) -> str | None: | ||
| if not isinstance(transcript, str): | ||
| return None | ||
| return hashlib.sha256(transcript.encode("utf-8")).hexdigest() | ||
|
|
||
|
|
||
| def _failure_buckets(rows: list[ArtifactResultRow]) -> list[ArtifactFailureBucket]: | ||
| counts: Counter[tuple[str, str, str, str]] = Counter() | ||
| for row in rows: | ||
| if row.status != "failed" or not row.error: | ||
| continue | ||
| counts[(row.provider, row.model, row.metric_type, row.error)] += 1 | ||
|
|
||
| return [ | ||
| ArtifactFailureBucket( | ||
| provider=provider, | ||
| model=model, | ||
| metric_type=metric_type, | ||
| error=error, | ||
| count=count, | ||
| ) | ||
| for (provider, model, metric_type, error), count in sorted( | ||
| counts.items(), | ||
| key=lambda item: (-item[1], item[0]), | ||
| ) | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -60,6 +60,7 @@ | |
| ModelStatus, | ||
| RegisteredModel, | ||
| ) | ||
| from coval_bench.runner.artifacts import write_run_artifact | ||
| from coval_bench.runner.retry import with_retry | ||
|
|
||
| if TYPE_CHECKING: | ||
|
|
@@ -820,6 +821,46 @@ async def _refresh_series_bucket(writer: Any, run_id: int, settings: Settings) - | |
| return | ||
|
|
||
|
|
||
| async def _write_run_artifact_if_enabled( | ||
| *, | ||
| settings: Settings, | ||
| run_id: int, | ||
| runner_sha: str, | ||
| dataset_id: str, | ||
| dataset_sha256: str, | ||
| benchmark_kind: str, | ||
| smoke: bool, | ||
| scheduled_at: datetime | None, | ||
| started_at: datetime, | ||
| finished_at: datetime, | ||
| status: str, | ||
| results: list[Any], | ||
| ) -> None: | ||
| """Best-effort local artifact write; DB writes remain the source of truth.""" | ||
| if settings.run_artifact_dir is None: | ||
| return | ||
| try: | ||
| artifact_path = await asyncio.to_thread( | ||
| write_run_artifact, | ||
| artifact_dir=settings.run_artifact_dir, | ||
| run_id=run_id, | ||
| runner_sha=runner_sha, | ||
| dataset_id=dataset_id, | ||
| dataset_sha256=dataset_sha256, | ||
| benchmark_kind=benchmark_kind, | ||
| smoke=smoke, | ||
| scheduled_at=scheduled_at, | ||
| started_at=started_at, | ||
| finished_at=finished_at, | ||
| status=status, | ||
| results=results, | ||
| ) | ||
| except Exception: | ||
| logger.warning("run_artifact_write_failed", exc_info=True) | ||
| else: | ||
| logger.info("run_artifact_written", path=str(artifact_path)) | ||
|
|
||
|
|
||
| async def run_benchmarks( | ||
| *, | ||
| settings: Settings, | ||
|
|
@@ -1121,6 +1162,20 @@ def _on_sigterm() -> None: | |
| success_count=success_count, | ||
| fail_count=fail_count, | ||
| ) | ||
| await _write_run_artifact_if_enabled( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| settings=settings, | ||
| run_id=run_id, | ||
| runner_sha=settings.runner_sha, | ||
| dataset_id=run_dataset_id, | ||
| dataset_sha256=dataset_sha256, | ||
| benchmark_kind=benchmark_kind, | ||
| smoke=smoke, | ||
| scheduled_at=scheduled_at, | ||
| started_at=started_at, | ||
| finished_at=finished_at, | ||
| status=str(final_status), | ||
| results=typed_results, | ||
| ) | ||
|
|
||
| duration_s = (finished_at - started_at).total_seconds() | ||
| logger.info( | ||
|
|
@@ -1177,6 +1232,20 @@ def _on_sigterm() -> None: | |
| await asyncio.shield(_refresh_series_bucket(writer, run_id, settings)) | ||
| finished_at = datetime.now(tz=UTC) | ||
| sigterm_duration_s = (finished_at - started_at).total_seconds() | ||
| await _write_run_artifact_if_enabled( | ||
| settings=settings, | ||
| run_id=run_id, | ||
| runner_sha=settings.runner_sha, | ||
| dataset_id=run_dataset_id, | ||
| dataset_sha256=dataset_sha256, | ||
| benchmark_kind=benchmark_kind, | ||
| smoke=smoke, | ||
| scheduled_at=scheduled_at, | ||
| started_at=started_at, | ||
| finished_at=finished_at, | ||
| status=str(RunStatus.PARTIAL), | ||
| results=typed_results, | ||
| ) | ||
| logger.warning( | ||
| "benchmark_run_finished_early_sigterm", | ||
| status=str(RunStatus.PARTIAL), | ||
|
|
@@ -1215,13 +1284,28 @@ def _on_sigterm() -> None: | |
| # metric), update run row, then re-raise so the job exits non-zero. | ||
| err_msg = _truncate(str(exc)) | ||
| log_run_failed(err_msg, exc) | ||
| typed_results = [r for r in all_results if isinstance(r, Result)] | ||
| try: | ||
| await writer.finish_run(run_id, status=RunStatus.FAILED, error=err_msg) | ||
| except Exception as write_exc: | ||
| logger.error( | ||
| "run_row_update_failed_after_failure", | ||
| exc_info=write_exc, | ||
| ) | ||
| await _write_run_artifact_if_enabled( | ||
| settings=settings, | ||
| run_id=run_id, | ||
| runner_sha=settings.runner_sha, | ||
| dataset_id=run_dataset_id, | ||
| dataset_sha256=dataset_sha256, | ||
| benchmark_kind=benchmark_kind, | ||
| smoke=smoke, | ||
| scheduled_at=scheduled_at, | ||
| started_at=started_at, | ||
| finished_at=datetime.now(tz=UTC), | ||
| status=str(RunStatus.FAILED), | ||
| results=typed_results, | ||
| ) | ||
| _emit_posthog( | ||
| posthog_client, | ||
| "benchmark_run_failed", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mkdir -p artifactsnormally creates a0755directory owned by the host user, while the image runs as UID/GID65532:65532. The bind mount preserves host ownership, so the runner cannot create the temporary JSONL file. The write then logsrun_artifact_write_failed, and the documented command exits without the artifact. The example must prepare a directory writable by the container user or run the container with a compatible UID/GID.