rpx_benchmark.cell_log

Per-(scene, phase) metric log — the canonical experiment artifact.

The runner produces a flat list of per-sample metric rows. This module groups them into per-(scene, phase) cells and writes them to a typed log file. The log is the single source of truth for downstream analysis: Φ, JEDI, ESD-stratified summaries, bootstrap CIs, sensitivity sweeps, and the paper's per-task results tables all read from it.

Why a separate log?

  • JEDI bounds must be frozen across the full model zoo before individual model JEDI values are comparable. The cell log lets us collect every model first, then freeze bounds once, then recompute JEDI without re-running models.
  • Sensitivity to scoring choices (α, ε, Wilks vs Pillai, ESD weighting tier, etc.) reduces to a re-read of the same cells.
  • Bootstrap CIs on Φ resample scenes from the cell log; no need to re-run any model.
  • Bug fixes in metric calculators can be partially recovered by re-running on cached predictions; the cell log records what the numbers were at run time so the diff is interpretable.

Storage format

Parquet by default (typed columns, fast pandas/DuckDB reads). When pyarrow is unavailable, falls back to JSON Lines — same schema, slightly larger files.

Schema

One row per (model, task, scene, phase, frame_budget) cell:

model_name str e.g. "DA3 Metric-L" task str TaskType.value, e.g. "monocular_depth" scene_id str canonical scene identifier phase str "clu" | "int" | "cln" difficulty str|null "easy" | "medium" | "hard" (when known) n_samples int number of frames averaged into this cell frame_budget int 0 = full clip / per-frame task; >0 = the subsampled clip size for the Video Depth temporal- resolution ablation (typical values 25, 75, 150). Defaulting to 0 keeps the column meaningful for Image Depth (always 0) while giving Video Depth a single column to slice on. latency_ms_mean float|n mean per-sample latency toolkit_version str rpx_benchmark.__version__ git_sha str|null caller-supplied (e.g. adapter version) timestamp_utc str ISO-8601, when the row was written metric: float|n one column per metric key (prefixed metric: so the metric columns are namespaced and never collide with the fixed-schema columns above)

  1"""Per-(scene, phase) metric log — the canonical experiment artifact.
  2
  3The runner produces a flat list of per-sample metric rows. This module
  4groups them into per-(scene, phase) cells and writes them to a typed
  5log file. The log is the *single source of truth* for downstream
  6analysis: Φ, JEDI, ESD-stratified summaries, bootstrap CIs, sensitivity
  7sweeps, and the paper's per-task results tables all read from it.
  8
  9Why a separate log?
 10-------------------
 11
 12- **JEDI bounds must be frozen across the full model zoo** before
 13  individual model JEDI values are comparable. The cell log lets us
 14  collect every model first, then freeze bounds once, then recompute
 15  JEDI without re-running models.
 16- **Sensitivity to scoring choices** (α, ε, Wilks vs Pillai, ESD
 17  weighting tier, etc.) reduces to a re-read of the same cells.
 18- **Bootstrap CIs on Φ** resample scenes from the cell log; no need to
 19  re-run any model.
 20- **Bug fixes** in metric calculators can be partially recovered by
 21  re-running on cached predictions; the cell log records what the
 22  numbers *were* at run time so the diff is interpretable.
 23
 24Storage format
 25--------------
 26
 27Parquet by default (typed columns, fast pandas/DuckDB reads). When
 28``pyarrow`` is unavailable, falls back to JSON Lines — same schema,
 29slightly larger files.
 30
 31Schema
 32------
 33
 34One row per (model, task, scene, phase, frame_budget) cell:
 35
 36  model_name        str       e.g. "DA3 Metric-L"
 37  task              str       TaskType.value, e.g. "monocular_depth"
 38  scene_id          str       canonical scene identifier
 39  phase             str       "clu" | "int" | "cln"
 40  difficulty        str|null  "easy" | "medium" | "hard" (when known)
 41  n_samples         int       number of frames averaged into this cell
 42  frame_budget      int       0 = full clip / per-frame task; >0 = the
 43                              subsampled clip size for the Video Depth temporal-
 44                              resolution ablation (typical values 25,
 45                              75, 150). Defaulting to 0 keeps the column
 46                              meaningful for Image Depth (always 0) while
 47                              giving Video Depth a single column to slice on.
 48  latency_ms_mean   float|n   mean per-sample latency
 49  toolkit_version   str       rpx_benchmark.__version__
 50  git_sha           str|null  caller-supplied (e.g. adapter version)
 51  timestamp_utc     str       ISO-8601, when the row was written
 52  metric:<name>     float|n   one column per metric key (prefixed
 53                              ``metric:`` so the metric columns are
 54                              namespaced and never collide with the
 55                              fixed-schema columns above)
 56"""
 57
 58from __future__ import annotations
 59
 60import json
 61import os
 62from dataclasses import dataclass
 63from datetime import datetime, timezone
 64from pathlib import Path
 65from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
 66
 67import numpy as np
 68
 69from .exceptions import ConfigTypeError, DatasetError
 70from .logging_utils import get_logger
 71
 72log = get_logger(__name__)
 73
 74
 75# Columns that live in the fixed schema. Metric columns are prefixed
 76# ``metric:`` so they're always namespaced apart.
 77FIXED_COLUMNS: tuple[str, ...] = (
 78    "model_name",
 79    "task",
 80    "scene_id",
 81    "phase",
 82    "difficulty",
 83    "n_samples",
 84    "frame_budget",
 85    "latency_ms_mean",
 86    # Hardware context — required so cross-host aggregation can tell
 87    # apart cells produced on different GPUs / precisions. The runner
 88    # auto-detects via SystemCard.auto_detect() and threads the dict
 89    # into cells_from_per_sample as ``system_card``. None for any field
 90    # the host couldn't detect (e.g. running on CPU has gpu_name="").
 91    "gpu_name",
 92    "gpu_memory_gb",
 93    "precision",
 94    "batch_size",
 95    "toolkit_version",
 96    "git_sha",
 97    "timestamp_utc",
 98)
 99
100METRIC_PREFIX = "metric:"
101
102
103def metric_col(name: str) -> str:
104    """Namespaced column name for a metric: ``metric:absrel``, etc."""
105    return f"{METRIC_PREFIX}{name}"
106
107
108def metric_keys(row: Mapping[str, Any]) -> List[str]:
109    """Return the metric keys present in a cell row (without prefix)."""
110    return [k[len(METRIC_PREFIX) :] for k in row if k.startswith(METRIC_PREFIX)]
111
112
113# --------------------------------------------------------------------------- #
114# Grouping
115# --------------------------------------------------------------------------- #
116
117
118def _toolkit_version() -> str:
119    try:
120        from . import __version__
121
122        return str(__version__)
123    except Exception:  # noqa: BLE001 — best-effort, never fail the log write
124        return "unknown"
125
126
127def cells_from_per_sample(
128    per_sample_metrics: Sequence[Mapping[str, Any]],
129    *,
130    model_name: str,
131    task: str,
132    git_sha: Optional[str] = None,
133    metric_keys: Optional[Sequence[str]] = None,
134    system_card: Optional[Any] = None,
135) -> List[Dict[str, Any]]:
136    """Group per-sample metric rows into per-(scene, phase) cells.
137
138    Each output cell carries the *mean* of every metric across the frames
139    that fell in that (scene, phase) bucket. Non-numeric fields (id,
140    difficulty) are taken from the first row in the bucket; difficulty
141    is assumed stable per (scene, phase).
142
143    Parameters
144    ----------
145    per_sample_metrics
146        Same list the runner builds: each dict has metric keys + ``id``
147        / ``phase`` / ``scene`` / ``difficulty`` / ``latency_ms``.
148    model_name, task
149        Stamped onto every emitted row.
150    git_sha
151        Optional adapter version stamp.
152    metric_keys
153        If given, only these metric columns are emitted. Otherwise all
154        numeric keys that aren't fixed-schema metadata are kept.
155    system_card
156        Optional :class:`~rpx_benchmark.profiler.SystemCard` dict (as
157        produced by ``card.to_dict()``). When provided, ``gpu_name``,
158        ``gpu_memory_gb``, ``precision``, and ``batch_size`` from the
159        card are stamped onto every emitted cell so cross-host /
160        cross-precision aggregation can disambiguate them. None means
161        those columns will be empty / None in the output — fine for
162        synthetic tests but not for production sweeps.
163
164    Returns
165    -------
166    list of dict
167        One row per cell, ready to feed :func:`write_cells`.
168    """
169    metadata_keys = {"id", "phase", "difficulty", "scene", "latency_ms"}
170
171    # Auto-detect metric keys from the first row if not given.
172    if metric_keys is None and per_sample_metrics:
173        first = per_sample_metrics[0]
174        metric_keys = [
175            k
176            for k, v in first.items()
177            if k not in metadata_keys and isinstance(v, (int, float)) and not isinstance(v, bool)
178        ]
179    metric_keys = list(metric_keys or [])
180
181    # Bucket by (scene, phase).
182    buckets: Dict[tuple[Any, Any], List[Mapping[str, Any]]] = {}
183    for row in per_sample_metrics:
184        scene = row.get("scene")
185        phase = row.get("phase")
186        if scene is None or phase is None:
187            continue
188        buckets.setdefault((scene, phase), []).append(row)
189
190    timestamp = datetime.now(timezone.utc).isoformat()
191    version = _toolkit_version()
192
193    # Accept either a SystemCard dataclass or its .to_dict() form for
194    # ergonomics — third-party users on the public API typically have
195    # the dataclass, internal pipelines pass dicts.
196    if system_card is None:
197        sc: Mapping[str, Any] = {}
198    elif hasattr(system_card, "to_dict"):
199        sc = system_card.to_dict()
200    elif isinstance(system_card, Mapping):
201        sc = system_card
202    else:
203        raise ConfigTypeError(
204            f"system_card must be a SystemCard, a dict, or None; "
205            f"got {type(system_card).__name__}",
206            hint="pass SystemCard.auto_detect(), its to_dict() value, or None",
207        )
208    sc_gpu_name = sc.get("gpu_name") or None
209    sc_gpu_mem = sc.get("gpu_memory_gb")
210    sc_precision = sc.get("precision") or None
211    sc_batch_size = sc.get("batch_size")
212
213    cells: List[Dict[str, Any]] = []
214    for (scene, phase), rows in buckets.items():
215        # frame_budget is the first-seen value for the cell (per-sample
216        # rows for one (scene, phase) should always carry the same
217        # budget — they come from one VideoDepthDataset run). Defaults to 0
218        # for per-frame tasks that don't subsample.
219        budget = next(
220            (int(r["frame_budget"]) for r in rows if isinstance(r.get("frame_budget"), int)),
221            0,
222        )
223        cell: Dict[str, Any] = {
224            "model_name": model_name,
225            "task": task,
226            "scene_id": str(scene),
227            "phase": str(phase.value if hasattr(phase, "value") else phase),
228            "difficulty": _first_str(rows, "difficulty"),
229            "n_samples": len(rows),
230            "frame_budget": budget,
231            "latency_ms_mean": _mean_or_none(rows, "latency_ms"),
232            # Hardware context from the runner's SystemCard. Columns
233            # always present in the schema; values are None when the
234            # caller didn't pass a system_card.
235            "gpu_name": sc_gpu_name,
236            "gpu_memory_gb": sc_gpu_mem,
237            "precision": sc_precision,
238            "batch_size": sc_batch_size,
239            "toolkit_version": version,
240            "git_sha": git_sha,
241            "timestamp_utc": timestamp,
242        }
243        for k in metric_keys:
244            vals = [r[k] for r in rows if isinstance(r.get(k), (int, float))]
245            cell[metric_col(k)] = float(np.mean(vals)) if vals else None
246        cells.append(cell)
247
248    return cells
249
250
251def _first_str(rows: Sequence[Mapping[str, Any]], key: str) -> Optional[str]:
252    """First non-null string-like value for ``key`` across rows."""
253    for r in rows:
254        v = r.get(key)
255        if v is None:
256            continue
257        return str(v.value if hasattr(v, "value") else v)
258    return None
259
260
261def _mean_or_none(rows: Sequence[Mapping[str, Any]], key: str) -> Optional[float]:
262    vals = [r[key] for r in rows if isinstance(r.get(key), (int, float))]
263    return float(np.mean(vals)) if vals else None
264
265
266def cell_from_metrics(
267    metrics: Mapping[str, Any],
268    *,
269    model_name: str,
270    task: str,
271    scene_id: str,
272    phase: Any,
273    n_samples: int,
274    difficulty: Any = None,
275    latency_ms_mean: Optional[float] = None,
276    git_sha: Optional[str] = None,
277    metric_keys: Optional[Sequence[str]] = None,
278    timestamp_utc: Optional[str] = None,
279    frame_budget: int = 0,
280    system_card: Optional[Any] = None,
281) -> Dict[str, Any]:
282    """Build one canonical (scene, phase) cell from *pre-aggregated* metrics.
283
284    Companion to :func:`cells_from_per_sample`.  Use this for
285    clip/sequence-level tasks (Video Depth, tracking) where one model forward
286    already yields a single cell's worth of numbers — there are no
287    per-sample rows to group.  The emitted row has the **identical
288    schema** (:data:`FIXED_COLUMNS` + ``metric:`` columns), so per-frame
289    and per-clip cells merge into one ``cells.parquet`` and feed the same
290    cross-phase / phase×difficulty Φ analysis.
291
292    Parameters
293    ----------
294    metrics
295        Already-aggregated metric name → scalar (e.g. the output of
296        ``evaluate_video_depth_clip``). ``NaN`` values are preserved (the metric
297        was attempted but undefined — e.g. OPW with no flow backend);
298        the downstream MANOVA drops them.
299    model_name, task, scene_id, phase, n_samples
300        Fixed-schema identity of the cell. ``phase`` / ``difficulty`` may
301        be enums (``.value`` is taken) or plain strings.
302    difficulty
303        The scene's ESD tier (easy/medium/hard) — required for
304        cross-split Φ; ``None`` when unknown.
305    """
306    if metric_keys is None:
307        metric_keys = [
308            k for k, v in metrics.items() if isinstance(v, (int, float)) and not isinstance(v, bool)
309        ]
310
311    def _label(v: Any) -> Optional[str]:
312        return None if v is None else str(v.value if hasattr(v, "value") else v)
313
314    if system_card is None:
315        sc = {}
316    elif hasattr(system_card, "to_dict"):
317        sc = system_card.to_dict()
318    elif isinstance(system_card, Mapping):
319        sc = system_card
320    else:
321        raise ConfigTypeError("system_card must be a SystemCard, a dict, or None")
322
323    cell: Dict[str, Any] = {
324        "gpu_name": sc.get("gpu_name") or None,
325        "gpu_memory_gb": sc.get("gpu_memory_gb"),
326        "precision": sc.get("precision") or None,
327        "batch_size": sc.get("batch_size"),
328        "model_name": model_name,
329        "task": task,
330        "scene_id": str(scene_id),
331        "phase": _label(phase),
332        "difficulty": _label(difficulty),
333        "n_samples": int(n_samples),
334        "frame_budget": int(frame_budget),
335        "latency_ms_mean": latency_ms_mean,
336        "toolkit_version": _toolkit_version(),
337        "git_sha": git_sha,
338        "timestamp_utc": timestamp_utc or datetime.now(timezone.utc).isoformat(),
339    }
340    for k in metric_keys:
341        v = metrics.get(k)
342        cell[metric_col(k)] = (
343            float(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else None
344        )
345    return cell
346
347
348# --------------------------------------------------------------------------- #
349# Writers
350# --------------------------------------------------------------------------- #
351
352
353@dataclass
354class WriteResult:
355    """Outcome of a :func:`write_cells` call."""
356
357    path: Path
358    format: str  # "parquet" | "jsonl"
359    n_rows: int
360    metric_columns: List[str]
361
362    def to_dict(self) -> Dict[str, Any]:
363        return {
364            "path": str(self.path),
365            "format": self.format,
366            "n_rows": self.n_rows,
367            "metric_columns": list(self.metric_columns),
368        }
369
370
371def _try_parquet() -> bool:
372    try:
373        import pyarrow  # noqa: F401
374
375        return True
376    except ImportError:
377        return False
378
379
380def write_cells(
381    cells: Sequence[Mapping[str, Any]],
382    path: os.PathLike,
383    *,
384    format: str = "auto",
385) -> WriteResult:
386    """Write cells to disk.
387
388    Parameters
389    ----------
390    cells
391        Output of :func:`cells_from_per_sample`.
392    path
393        File path. If ``format='auto'`` and the extension is ``.parquet``,
394        Parquet is preferred; otherwise JSON Lines. Missing extension
395        defaults to Parquet when ``pyarrow`` is available, JSONL otherwise.
396    format
397        Override: ``"parquet"`` | ``"jsonl"`` | ``"auto"``.
398
399    Returns
400    -------
401    WriteResult
402        Path, chosen format, and the metric-column names that were written.
403    """
404    out = Path(path)
405    out.parent.mkdir(parents=True, exist_ok=True)
406
407    if format == "auto":
408        if out.suffix.lower() == ".jsonl":
409            format = "jsonl"
410        elif out.suffix.lower() in (".parquet", ".pq"):
411            format = "parquet"
412        elif _try_parquet():
413            format = "parquet"
414            out = out.with_suffix(".parquet")
415        else:
416            format = "jsonl"
417            out = out.with_suffix(".jsonl")
418
419    if format == "parquet":
420        if not _try_parquet():
421            raise DatasetError(
422                "Parquet write requested but pyarrow is not installed.",
423                hint="pip install 'rpx-benchmark[hub]' (or pyarrow directly).",
424            )
425        _write_parquet(cells, out)
426    elif format == "jsonl":
427        _write_jsonl(cells, out)
428    else:
429        raise DatasetError(
430            f"unknown cell-log format {format!r}",
431            hint="Use 'parquet', 'jsonl', or 'auto'.",
432        )
433
434    metric_columns = sorted({k for row in cells for k in row if k.startswith(METRIC_PREFIX)})
435    return WriteResult(
436        path=out,
437        format=format,
438        n_rows=len(cells),
439        metric_columns=metric_columns,
440    )
441
442
443def _write_parquet(cells: Sequence[Mapping[str, Any]], path: Path) -> None:
444    import pyarrow as pa
445    import pyarrow.parquet as pq
446
447    if not cells:
448        # Write an empty file with just the fixed schema so readers don't
449        # crash on a missing column. Metric columns are unknown until we
450        # see data.
451        schema = pa.schema(
452            [
453                pa.field("model_name", pa.string()),
454                pa.field("task", pa.string()),
455                pa.field("scene_id", pa.string()),
456                pa.field("phase", pa.string()),
457                pa.field("difficulty", pa.string()),
458                pa.field("n_samples", pa.int64()),
459                pa.field("frame_budget", pa.int64()),
460                pa.field("latency_ms_mean", pa.float64()),
461                pa.field("gpu_name", pa.string()),
462                pa.field("gpu_memory_gb", pa.float64()),
463                pa.field("precision", pa.string()),
464                pa.field("batch_size", pa.int64()),
465                pa.field("toolkit_version", pa.string()),
466                pa.field("git_sha", pa.string()),
467                pa.field("timestamp_utc", pa.string()),
468            ]
469        )
470        pq.write_table(pa.Table.from_arrays([[] for _ in schema], schema=schema), path)
471        return
472
473    # Union of every column key across rows so missing values become null.
474    all_keys: List[str] = list(FIXED_COLUMNS)
475    seen = set(all_keys)
476    for row in cells:
477        for k in row:
478            if k not in seen and k.startswith(METRIC_PREFIX):
479                all_keys.append(k)
480                seen.add(k)
481
482    columns: Dict[str, List[Any]] = {k: [] for k in all_keys}
483    for row in cells:
484        for k in all_keys:
485            columns[k].append(row.get(k))
486
487    table = pa.table(columns)
488    pq.write_table(table, path)
489
490
491def _write_jsonl(cells: Sequence[Mapping[str, Any]], path: Path) -> None:
492    with path.open("w") as f:
493        for row in cells:
494            f.write(json.dumps(row) + "\n")
495
496
497# --------------------------------------------------------------------------- #
498# Readers
499# --------------------------------------------------------------------------- #
500
501
502def read_cells(path: os.PathLike) -> List[Dict[str, Any]]:
503    """Read cells back into a list of dicts.
504
505    Format detected from extension: ``.parquet`` / ``.pq`` → Parquet,
506    anything else → JSON Lines.
507    """
508    p = Path(path)
509    if not p.is_file():
510        raise DatasetError(f"cell log not found: {p}")
511
512    if p.suffix.lower() in (".parquet", ".pq"):
513        try:
514            import pyarrow.parquet as pq
515        except ImportError as e:
516            raise DatasetError(
517                f"can't read {p} — pyarrow not installed.",
518                hint="pip install 'rpx-benchmark[hub]'",
519            ) from e
520        table = pq.read_table(p)
521        return table.to_pylist()
522
523    rows: List[Dict[str, Any]] = []
524    with p.open() as f:
525        for line in f:
526            line = line.strip()
527            if line:
528                rows.append(json.loads(line))
529    return rows
530
531
532def merge_cells(paths: Iterable[os.PathLike]) -> List[Dict[str, Any]]:
533    """Concatenate multiple cell logs into one list.
534
535    Per-log column sets may differ (different tasks have different
536    metric columns); the union schema is preserved on output.
537    """
538    merged: List[Dict[str, Any]] = []
539    for p in paths:
540        merged.extend(read_cells(p))
541    return merged
542
543
544__all__ = [
545    "FIXED_COLUMNS",
546    "METRIC_PREFIX",
547    "WriteResult",
548    "cells_from_per_sample",
549    "merge_cells",
550    "metric_col",
551    "metric_keys",
552    "read_cells",
553    "write_cells",
554]
FIXED_COLUMNS: tuple[str, ...] = ('model_name', 'task', 'scene_id', 'phase', 'difficulty', 'n_samples', 'frame_budget', 'latency_ms_mean', 'gpu_name', 'gpu_memory_gb', 'precision', 'batch_size', 'toolkit_version', 'git_sha', 'timestamp_utc')
METRIC_PREFIX = 'metric:'
@dataclass
class WriteResult:
354@dataclass
355class WriteResult:
356    """Outcome of a :func:`write_cells` call."""
357
358    path: Path
359    format: str  # "parquet" | "jsonl"
360    n_rows: int
361    metric_columns: List[str]
362
363    def to_dict(self) -> Dict[str, Any]:
364        return {
365            "path": str(self.path),
366            "format": self.format,
367            "n_rows": self.n_rows,
368            "metric_columns": list(self.metric_columns),
369        }

Outcome of a write_cells() call.

WriteResult( path: pathlib.Path, format: str, n_rows: int, metric_columns: List[str])
path: pathlib.Path
format: str
n_rows: int
metric_columns: List[str]
def to_dict(self) -> Dict[str, Any]:
363    def to_dict(self) -> Dict[str, Any]:
364        return {
365            "path": str(self.path),
366            "format": self.format,
367            "n_rows": self.n_rows,
368            "metric_columns": list(self.metric_columns),
369        }
def cells_from_per_sample( per_sample_metrics: Sequence[Mapping[str, Any]], *, model_name: str, task: str, git_sha: Optional[str] = None, metric_keys: Optional[Sequence[str]] = None, system_card: Optional[Any] = None) -> List[Dict[str, Any]]:
128def cells_from_per_sample(
129    per_sample_metrics: Sequence[Mapping[str, Any]],
130    *,
131    model_name: str,
132    task: str,
133    git_sha: Optional[str] = None,
134    metric_keys: Optional[Sequence[str]] = None,
135    system_card: Optional[Any] = None,
136) -> List[Dict[str, Any]]:
137    """Group per-sample metric rows into per-(scene, phase) cells.
138
139    Each output cell carries the *mean* of every metric across the frames
140    that fell in that (scene, phase) bucket. Non-numeric fields (id,
141    difficulty) are taken from the first row in the bucket; difficulty
142    is assumed stable per (scene, phase).
143
144    Parameters
145    ----------
146    per_sample_metrics
147        Same list the runner builds: each dict has metric keys + ``id``
148        / ``phase`` / ``scene`` / ``difficulty`` / ``latency_ms``.
149    model_name, task
150        Stamped onto every emitted row.
151    git_sha
152        Optional adapter version stamp.
153    metric_keys
154        If given, only these metric columns are emitted. Otherwise all
155        numeric keys that aren't fixed-schema metadata are kept.
156    system_card
157        Optional :class:`~rpx_benchmark.profiler.SystemCard` dict (as
158        produced by ``card.to_dict()``). When provided, ``gpu_name``,
159        ``gpu_memory_gb``, ``precision``, and ``batch_size`` from the
160        card are stamped onto every emitted cell so cross-host /
161        cross-precision aggregation can disambiguate them. None means
162        those columns will be empty / None in the output — fine for
163        synthetic tests but not for production sweeps.
164
165    Returns
166    -------
167    list of dict
168        One row per cell, ready to feed :func:`write_cells`.
169    """
170    metadata_keys = {"id", "phase", "difficulty", "scene", "latency_ms"}
171
172    # Auto-detect metric keys from the first row if not given.
173    if metric_keys is None and per_sample_metrics:
174        first = per_sample_metrics[0]
175        metric_keys = [
176            k
177            for k, v in first.items()
178            if k not in metadata_keys and isinstance(v, (int, float)) and not isinstance(v, bool)
179        ]
180    metric_keys = list(metric_keys or [])
181
182    # Bucket by (scene, phase).
183    buckets: Dict[tuple[Any, Any], List[Mapping[str, Any]]] = {}
184    for row in per_sample_metrics:
185        scene = row.get("scene")
186        phase = row.get("phase")
187        if scene is None or phase is None:
188            continue
189        buckets.setdefault((scene, phase), []).append(row)
190
191    timestamp = datetime.now(timezone.utc).isoformat()
192    version = _toolkit_version()
193
194    # Accept either a SystemCard dataclass or its .to_dict() form for
195    # ergonomics — third-party users on the public API typically have
196    # the dataclass, internal pipelines pass dicts.
197    if system_card is None:
198        sc: Mapping[str, Any] = {}
199    elif hasattr(system_card, "to_dict"):
200        sc = system_card.to_dict()
201    elif isinstance(system_card, Mapping):
202        sc = system_card
203    else:
204        raise ConfigTypeError(
205            f"system_card must be a SystemCard, a dict, or None; "
206            f"got {type(system_card).__name__}",
207            hint="pass SystemCard.auto_detect(), its to_dict() value, or None",
208        )
209    sc_gpu_name = sc.get("gpu_name") or None
210    sc_gpu_mem = sc.get("gpu_memory_gb")
211    sc_precision = sc.get("precision") or None
212    sc_batch_size = sc.get("batch_size")
213
214    cells: List[Dict[str, Any]] = []
215    for (scene, phase), rows in buckets.items():
216        # frame_budget is the first-seen value for the cell (per-sample
217        # rows for one (scene, phase) should always carry the same
218        # budget — they come from one VideoDepthDataset run). Defaults to 0
219        # for per-frame tasks that don't subsample.
220        budget = next(
221            (int(r["frame_budget"]) for r in rows if isinstance(r.get("frame_budget"), int)),
222            0,
223        )
224        cell: Dict[str, Any] = {
225            "model_name": model_name,
226            "task": task,
227            "scene_id": str(scene),
228            "phase": str(phase.value if hasattr(phase, "value") else phase),
229            "difficulty": _first_str(rows, "difficulty"),
230            "n_samples": len(rows),
231            "frame_budget": budget,
232            "latency_ms_mean": _mean_or_none(rows, "latency_ms"),
233            # Hardware context from the runner's SystemCard. Columns
234            # always present in the schema; values are None when the
235            # caller didn't pass a system_card.
236            "gpu_name": sc_gpu_name,
237            "gpu_memory_gb": sc_gpu_mem,
238            "precision": sc_precision,
239            "batch_size": sc_batch_size,
240            "toolkit_version": version,
241            "git_sha": git_sha,
242            "timestamp_utc": timestamp,
243        }
244        for k in metric_keys:
245            vals = [r[k] for r in rows if isinstance(r.get(k), (int, float))]
246            cell[metric_col(k)] = float(np.mean(vals)) if vals else None
247        cells.append(cell)
248
249    return cells

Group per-sample metric rows into per-(scene, phase) cells.

Each output cell carries the mean of every metric across the frames that fell in that (scene, phase) bucket. Non-numeric fields (id, difficulty) are taken from the first row in the bucket; difficulty is assumed stable per (scene, phase).

Parameters

per_sample_metrics Same list the runner builds: each dict has metric keys + id / phase / scene / difficulty / latency_ms. model_name, task Stamped onto every emitted row. git_sha Optional adapter version stamp. metric_keys If given, only these metric columns are emitted. Otherwise all numeric keys that aren't fixed-schema metadata are kept. system_card Optional ~rpx_benchmark.profiler.SystemCard dict (as produced by card.to_dict()). When provided, gpu_name, gpu_memory_gb, precision, and batch_size from the card are stamped onto every emitted cell so cross-host / cross-precision aggregation can disambiguate them. None means those columns will be empty / None in the output — fine for synthetic tests but not for production sweeps.

Returns

list of dict One row per cell, ready to feed write_cells().

def merge_cells(paths: Iterable[os.PathLike]) -> List[Dict[str, Any]]:
533def merge_cells(paths: Iterable[os.PathLike]) -> List[Dict[str, Any]]:
534    """Concatenate multiple cell logs into one list.
535
536    Per-log column sets may differ (different tasks have different
537    metric columns); the union schema is preserved on output.
538    """
539    merged: List[Dict[str, Any]] = []
540    for p in paths:
541        merged.extend(read_cells(p))
542    return merged

Concatenate multiple cell logs into one list.

Per-log column sets may differ (different tasks have different metric columns); the union schema is preserved on output.

def metric_col(name: str) -> str:
104def metric_col(name: str) -> str:
105    """Namespaced column name for a metric: ``metric:absrel``, etc."""
106    return f"{METRIC_PREFIX}{name}"

Namespaced column name for a metric: metric:absrel, etc.

def metric_keys(row: Mapping[str, Any]) -> List[str]:
109def metric_keys(row: Mapping[str, Any]) -> List[str]:
110    """Return the metric keys present in a cell row (without prefix)."""
111    return [k[len(METRIC_PREFIX) :] for k in row if k.startswith(METRIC_PREFIX)]

Return the metric keys present in a cell row (without prefix).

def read_cells(path: os.PathLike) -> List[Dict[str, Any]]:
503def read_cells(path: os.PathLike) -> List[Dict[str, Any]]:
504    """Read cells back into a list of dicts.
505
506    Format detected from extension: ``.parquet`` / ``.pq`` → Parquet,
507    anything else → JSON Lines.
508    """
509    p = Path(path)
510    if not p.is_file():
511        raise DatasetError(f"cell log not found: {p}")
512
513    if p.suffix.lower() in (".parquet", ".pq"):
514        try:
515            import pyarrow.parquet as pq
516        except ImportError as e:
517            raise DatasetError(
518                f"can't read {p} — pyarrow not installed.",
519                hint="pip install 'rpx-benchmark[hub]'",
520            ) from e
521        table = pq.read_table(p)
522        return table.to_pylist()
523
524    rows: List[Dict[str, Any]] = []
525    with p.open() as f:
526        for line in f:
527            line = line.strip()
528            if line:
529                rows.append(json.loads(line))
530    return rows

Read cells back into a list of dicts.

Format detected from extension: .parquet / .pq → Parquet, anything else → JSON Lines.

def write_cells( cells: Sequence[Mapping[str, Any]], path: os.PathLike, *, format: str = 'auto') -> WriteResult:
381def write_cells(
382    cells: Sequence[Mapping[str, Any]],
383    path: os.PathLike,
384    *,
385    format: str = "auto",
386) -> WriteResult:
387    """Write cells to disk.
388
389    Parameters
390    ----------
391    cells
392        Output of :func:`cells_from_per_sample`.
393    path
394        File path. If ``format='auto'`` and the extension is ``.parquet``,
395        Parquet is preferred; otherwise JSON Lines. Missing extension
396        defaults to Parquet when ``pyarrow`` is available, JSONL otherwise.
397    format
398        Override: ``"parquet"`` | ``"jsonl"`` | ``"auto"``.
399
400    Returns
401    -------
402    WriteResult
403        Path, chosen format, and the metric-column names that were written.
404    """
405    out = Path(path)
406    out.parent.mkdir(parents=True, exist_ok=True)
407
408    if format == "auto":
409        if out.suffix.lower() == ".jsonl":
410            format = "jsonl"
411        elif out.suffix.lower() in (".parquet", ".pq"):
412            format = "parquet"
413        elif _try_parquet():
414            format = "parquet"
415            out = out.with_suffix(".parquet")
416        else:
417            format = "jsonl"
418            out = out.with_suffix(".jsonl")
419
420    if format == "parquet":
421        if not _try_parquet():
422            raise DatasetError(
423                "Parquet write requested but pyarrow is not installed.",
424                hint="pip install 'rpx-benchmark[hub]' (or pyarrow directly).",
425            )
426        _write_parquet(cells, out)
427    elif format == "jsonl":
428        _write_jsonl(cells, out)
429    else:
430        raise DatasetError(
431            f"unknown cell-log format {format!r}",
432            hint="Use 'parquet', 'jsonl', or 'auto'.",
433        )
434
435    metric_columns = sorted({k for row in cells for k in row if k.startswith(METRIC_PREFIX)})
436    return WriteResult(
437        path=out,
438        format=format,
439        n_rows=len(cells),
440        metric_columns=metric_columns,
441    )

Write cells to disk.

Parameters

cells Output of cells_from_per_sample(). path File path. If format='auto' and the extension is .parquet, Parquet is preferred; otherwise JSON Lines. Missing extension defaults to Parquet when pyarrow is available, JSONL otherwise. format Override: "parquet" | "jsonl" | "auto".

Returns

WriteResult Path, chosen format, and the metric-column names that were written.