rpx_benchmark.tasks._pipeline

Shared pipeline: download -> load -> run -> report.

Every task runner calls run_pipeline(). The task module only owns the task-specific bits: a config subclass, a TASK_SPEC, and a one-line run_<task> wrapper around run_pipeline().

The toolkit does not ship models. Users supply a BenchmarkableModel (usually built via rpx.make_numpy_<task>_model(fn) or a custom InputAdapter / OutputAdapter pair) and set it on the config's model field.

  1"""Shared pipeline: download -> load -> run -> report.
  2
  3Every task runner calls :func:`run_pipeline`. The task module only
  4owns the task-specific bits: a config subclass, a ``TASK_SPEC``, and
  5a one-line ``run_<task>`` wrapper around :func:`run_pipeline`.
  6
  7The toolkit does not ship models. Users supply a
  8:class:`BenchmarkableModel` (usually built via
  9``rpx.make_numpy_<task>_model(fn)`` or a custom ``InputAdapter`` /
 10``OutputAdapter`` pair) and set it on the config's ``model`` field.
 11"""
 12
 13from __future__ import annotations
 14
 15from dataclasses import dataclass
 16from pathlib import Path
 17from typing import Dict, Optional, Tuple
 18
 19from ..adapters import BenchmarkableModel
 20from ..api import Difficulty, TaskType
 21from ..cell_log import cells_from_per_sample, write_cells
 22from ..deployment import DeploymentReadinessReport
 23from ..exceptions import ConfigError
 24from ..hub import DEFAULT_REPO_ID, DEFAULT_REVISION, download_split
 25from ..loader import RPXDataset
 26from ..logging_utils import get_logger
 27from ..metrics.registry import BenchmarkResult, MetricSuite
 28from ..profiler import EfficiencyMetadata, SystemCard, count_parameters
 29from ..reports import format_markdown_summary, write_json
 30from ..runner import BenchmarkRunner, ProgressCallback
 31
 32log = get_logger(__name__)
 33
 34PipelineResult = Tuple[BenchmarkResult, DeploymentReadinessReport, Dict[str, Path]]
 35
 36
 37@dataclass
 38class TaskRunConfig:
 39    """Base config for every task runner.
 40
 41    Subclass with ``@dataclass`` when a task wants extra fields. The
 42    user supplies ``model`` — the toolkit doesn't ship any.
 43    """
 44
 45    model: Optional[BenchmarkableModel] = None
 46
 47    split: Difficulty | str = Difficulty.HARD
 48    repo_id: Optional[str] = None
 49    cache_dir: Optional[str] = None
 50    revision: Optional[str] = DEFAULT_REVISION
 51    batch_size: int = 1
 52    #: Refuse CPU fallback when a CUDA run was requested. Production smoke
 53    #: and benchmark launchers set this True; lightweight library users keep
 54    #: the historical best-effort fallback by default.
 55    require_cuda: bool = False
 56    #: Skip the first-sample torch FLOP counter. Useful for smoke gates and
 57    #: memory-constrained GPUs where instrumentation can OOM independently of
 58    #: the real forward pass.
 59    skip_flops: bool = False
 60    #: Keep only the first N manifest entries. This is a sample count for
 61    #: frame tasks and a clip count for Video Depth.
 62    max_samples: Optional[int] = None
 63
 64    #: When set, the pipeline skips :func:`download_split` and loads
 65    #: the manifest from disk. Use for runs against a locally-staged
 66    #: lossless dataset (e.g. ``dataset_hub.cli manifest`` output)
 67    #: before the v2-webp tree has been uploaded to HF. Path must point
 68    #: at the JSON file (typically ``<staging>/manifests/<task>/<split>.json``);
 69    #: the ``root`` inside that JSON resolves modality file paths.
 70    manifest_path: Optional[str] = None
 71
 72    device: str = "cuda"
 73    output_dir: Optional[str] = None
 74    progress: Optional[ProgressCallback] = None
 75    #: Optional protocol-specific metric suite. The default remains the
 76    #: registered public suite for the task.
 77    metric_suite: Optional[MetricSuite] = None
 78
 79    #: When True, mirror the per-run output directory to UTD Box at
 80    #: ``<box_folder_id>/<task>/<model>/<split>/`` after the run finishes.
 81    #: Requires ``BOX_DEVELOPER_TOKEN`` in the environment. Idempotent
 82    #: re-uploads (size-matched skip).
 83    upload_to_box: bool = False
 84    #: Box folder id to root the upload under. Defaults to the team's
 85    #: RPX-Outputs folder. Unused unless ``upload_to_box=True``.
 86    box_folder_id: Optional[str] = None
 87
 88    def __post_init__(self) -> None:
 89        if self.model is None:
 90            raise ConfigError(
 91                f"{type(self).__name__}.model is required. Wrap your "
 92                "model via rpx.make_numpy_<task>_model(fn) or build a "
 93                "BenchmarkableModel with your own input/output adapters.",
 94            )
 95        if isinstance(self.split, str):
 96            try:
 97                self.split = Difficulty(self.split)
 98            except ValueError as e:
 99                raise ConfigError(
100                    f"Unknown split {self.split!r}. Use one of: {[d.value for d in Difficulty]}",
101                ) from e
102        if self.batch_size < 1:
103            raise ConfigError(f"batch_size must be >= 1, got {self.batch_size}")
104        if self.max_samples is not None and self.max_samples < 1:
105            raise ConfigError(f"max_samples must be >= 1, got {self.max_samples}")
106
107
108def resolve_device(requested: str, *, require_cuda: bool = False) -> str:
109    """Fall back to CPU when CUDA was requested but isn't available."""
110    if require_cuda and not requested.startswith("cuda"):
111        raise ConfigError(
112            f"GPU-only run requires device='cuda', got {requested!r}",
113            hint="Use --device cuda, or explicitly opt into CPU diagnostics.",
114        )
115    if not requested.startswith("cuda"):
116        return requested
117    try:
118        import torch
119    except ImportError as exc:
120        if require_cuda:
121            raise ConfigError(
122                "GPU-only run requires a CUDA-enabled PyTorch installation.",
123                hint="Install the PyTorch wheel matching this host, then rerun.",
124            ) from exc
125        return "cpu"
126    if torch.cuda.is_available():
127        return requested
128    if require_cuda:
129        raise ConfigError(
130            "CUDA was required but torch.cuda.is_available() is false.",
131            hint="Check the NVIDIA driver, CUDA-capable PyTorch wheel, and GPU visibility.",
132        )
133    log.warning("CUDA requested but unavailable; falling back to CPU.")
134    return "cpu"
135
136
137def _count_params(model: BenchmarkableModel) -> EfficiencyMetadata:
138    raw = getattr(model, "model", None)
139    if raw is None or not hasattr(raw, "parameters"):
140        return EfficiencyMetadata(model_type="local", notes="non-torch callable")
141    return EfficiencyMetadata(params_m=count_parameters(raw), model_type="local")
142
143
144def run_pipeline(
145    *,
146    task: TaskType,
147    primary_metric: str,
148    cfg: TaskRunConfig,
149    compute_ts: bool = True,
150    compute_sgc: bool = False,
151) -> PipelineResult:
152    """Run a task end-to-end: download, load, predict, score, report.
153
154    Parameters
155    ----------
156    task : TaskType
157    primary_metric : str
158        Metric key that drives ESD-weighted phase scoring.
159    cfg : TaskRunConfig
160        Must already have ``model`` set.
161    compute_ts : bool
162        Compute Temporal Stability. Disable for tasks where adjacent
163        samples are unrelated (e.g. keypoint matching).
164    compute_sgc : bool
165        Compute Stack Geometric Coherence. Only meaningful for
166        segmentation with depth available.
167    """
168    cfg.device = resolve_device(cfg.device, require_cuda=cfg.require_cuda)
169    split_name = cfg.split.value if isinstance(cfg.split, Difficulty) else str(cfg.split)
170    repo_id = cfg.repo_id or DEFAULT_REPO_ID
171
172    log.info("pipeline: task=%s split=%s device=%s", task.value, split_name, cfg.device)
173
174    if cfg.manifest_path:
175        manifest_path = Path(cfg.manifest_path)
176        if not manifest_path.exists():
177            raise ConfigError(
178                f"manifest_path does not exist: {manifest_path}",
179                hint="Generate per-task manifests with "
180                "`python -m rpx_benchmark.dataset_hub.cli manifest "
181                "--src <raw> --staging <out> --splits "
182                "benchmark/data/splits/scene_splits.json` and pass the "
183                "resulting <out>/manifests/<task>/<split>.json path.",
184            )
185        log.info("using local manifest: %s", manifest_path)
186    else:
187        manifest_path = download_split(
188            task=task,
189            split=cfg.split,
190            repo_id=repo_id,
191            cache_dir=cfg.cache_dir,
192            revision=cfg.revision,
193            max_samples=cfg.max_samples,
194        )
195    dataset = RPXDataset.from_manifest(
196        manifest_path,
197        batch_size=cfg.batch_size,
198        max_samples=cfg.max_samples,
199    )
200    log.info("loaded %d samples from %s", len(dataset), manifest_path)
201
202    model = cfg.model
203    name = getattr(model, "name", "model")
204
205    model.setup()
206    efficiency = _count_params(model)
207    if efficiency.params_m is not None:
208        log.info("model %s: %.2f M params", name, efficiency.params_m)
209    # SystemCard auto-detects GPU / precision / batch_size and is
210    # stamped onto every cell row so cross-host aggregation can
211    # disambiguate cells produced on different hardware.
212    efficiency.system_card = SystemCard.auto_detect(
213        precision=getattr(model, "native_precision", "fp32"),
214        batch_size=cfg.batch_size,
215    )
216
217    runner = BenchmarkRunner(
218        model=model,
219        dataset=dataset,
220        metric_suite=cfg.metric_suite or MetricSuite.for_task(task),
221        call_setup=False,
222    )
223    result, dr_report = runner.run_with_report(
224        primary_metric=primary_metric,
225        model_name=name,
226        efficiency=efficiency,
227        compute_ts=compute_ts,
228        compute_sgc_flag=compute_sgc,
229        skip_flops=cfg.skip_flops,
230        progress=cfg.progress,
231    )
232
233    safe_name = name.replace("/", "__")
234    out_dir = Path(cfg.output_dir or f"./rpx_results/{safe_name}/{split_name}")
235    out_dir.mkdir(parents=True, exist_ok=True)
236    json_path = out_dir / "result.json"
237    md_path = out_dir / "summary.md"
238    cells_path = out_dir / "cells.parquet"
239    per_sample_path = out_dir / "per_sample_metrics.parquet"
240
241    try:
242        import pyarrow as pa
243        import pyarrow.parquet as pq
244
245        pq.write_table(pa.Table.from_pylist(result.per_sample), per_sample_path)
246    except ImportError as exc:
247        raise ConfigError(
248            "Writing canonical per-sample metrics requires pyarrow.",
249            hint="Install 'rpx-benchmark[hub]' or use the RPX Docker image.",
250        ) from exc
251
252    # cells.parquet is the canonical artefact downstream Φ / 𝒥 / paper-
253    # table fills read from. Bucket per_sample (one row per frame) into
254    # per-(scene, phase) cells with the mean of every numeric metric.
255    # Mirrors the per-clip pipeline (_video_pipeline.py) so both tasks
256    # feed the same downstream path.
257    cells = cells_from_per_sample(
258        result.per_sample,
259        model_name=name,
260        task=task.value,
261        system_card=efficiency.system_card,
262    )
263    if cells:
264        write_cells(cells, cells_path)
265        log.info("wrote %d cell rows to %s", len(cells), cells_path)
266    else:
267        log.warning(
268            "no cells emitted for %s/%s — per_sample lacks scene/phase fields. "
269            "This usually means the dataset manifest's samples don't carry "
270            "scene_id + phase metadata, or the runner's metric_suite stripped "
271            "them. Downstream Φ/J aggregation will be unable to bucket.",
272            name, split_name,
273        )
274
275    write_json(
276        json_path,
277        task=task.value,
278        model_name=name,
279        split=split_name,
280        repo_id=repo_id,
281        result=result,
282        dr_report=dr_report,
283    )
284    md_path.write_text(
285        format_markdown_summary(
286            task=task.value,
287            model_name=name,
288            split=split_name,
289            repo_id=repo_id,
290            result=result,
291            dr_report=dr_report,
292        ),
293        encoding="utf-8",
294    )
295    log.info("wrote %s and %s", json_path, md_path)
296
297    paths: Dict[str, Path] = {
298        "json": json_path,
299        "markdown": md_path,
300        "out_dir": out_dir,
301        "per_sample_metrics": per_sample_path,
302    }
303    if cells:
304        paths["cells"] = cells_path
305
306    if cfg.upload_to_box:
307        # Lazy import keeps Box deps (requests) out of the import path
308        # for users who never upload.
309        from ..box_upload import DEFAULT_BOX_FOLDER_ID, upload_run_dir
310
311        try:
312            summary = upload_run_dir(
313                out_dir,
314                task=task.value,
315                model_name=name,
316                split=split_name,
317                root_folder_id=cfg.box_folder_id or DEFAULT_BOX_FOLDER_ID,
318            )
319            log.info(
320                "box: %d uploaded, %d skipped → box:%s",
321                summary["uploaded"],
322                summary["skipped"],
323                summary["remote_path"],
324            )
325            paths["box_remote"] = summary["remote_path"]
326        except Exception as e:  # noqa: BLE001
327            # Don't lose the result.json on the floor just because the
328            # token expired — log loudly and continue.
329            log.error(
330                "Box upload failed (%s: %s) — local artefacts at %s are intact; "
331                "use `scripts/sync_results_to_box.py` to recover with a fresh token.",
332                type(e).__name__,
333                e,
334                out_dir,
335            )
336
337    return result, dr_report, paths
338
339
340__all__ = ["TaskRunConfig", "PipelineResult", "resolve_device", "run_pipeline"]
@dataclass
class TaskRunConfig:
 38@dataclass
 39class TaskRunConfig:
 40    """Base config for every task runner.
 41
 42    Subclass with ``@dataclass`` when a task wants extra fields. The
 43    user supplies ``model`` — the toolkit doesn't ship any.
 44    """
 45
 46    model: Optional[BenchmarkableModel] = None
 47
 48    split: Difficulty | str = Difficulty.HARD
 49    repo_id: Optional[str] = None
 50    cache_dir: Optional[str] = None
 51    revision: Optional[str] = DEFAULT_REVISION
 52    batch_size: int = 1
 53    #: Refuse CPU fallback when a CUDA run was requested. Production smoke
 54    #: and benchmark launchers set this True; lightweight library users keep
 55    #: the historical best-effort fallback by default.
 56    require_cuda: bool = False
 57    #: Skip the first-sample torch FLOP counter. Useful for smoke gates and
 58    #: memory-constrained GPUs where instrumentation can OOM independently of
 59    #: the real forward pass.
 60    skip_flops: bool = False
 61    #: Keep only the first N manifest entries. This is a sample count for
 62    #: frame tasks and a clip count for Video Depth.
 63    max_samples: Optional[int] = None
 64
 65    #: When set, the pipeline skips :func:`download_split` and loads
 66    #: the manifest from disk. Use for runs against a locally-staged
 67    #: lossless dataset (e.g. ``dataset_hub.cli manifest`` output)
 68    #: before the v2-webp tree has been uploaded to HF. Path must point
 69    #: at the JSON file (typically ``<staging>/manifests/<task>/<split>.json``);
 70    #: the ``root`` inside that JSON resolves modality file paths.
 71    manifest_path: Optional[str] = None
 72
 73    device: str = "cuda"
 74    output_dir: Optional[str] = None
 75    progress: Optional[ProgressCallback] = None
 76    #: Optional protocol-specific metric suite. The default remains the
 77    #: registered public suite for the task.
 78    metric_suite: Optional[MetricSuite] = None
 79
 80    #: When True, mirror the per-run output directory to UTD Box at
 81    #: ``<box_folder_id>/<task>/<model>/<split>/`` after the run finishes.
 82    #: Requires ``BOX_DEVELOPER_TOKEN`` in the environment. Idempotent
 83    #: re-uploads (size-matched skip).
 84    upload_to_box: bool = False
 85    #: Box folder id to root the upload under. Defaults to the team's
 86    #: RPX-Outputs folder. Unused unless ``upload_to_box=True``.
 87    box_folder_id: Optional[str] = None
 88
 89    def __post_init__(self) -> None:
 90        if self.model is None:
 91            raise ConfigError(
 92                f"{type(self).__name__}.model is required. Wrap your "
 93                "model via rpx.make_numpy_<task>_model(fn) or build a "
 94                "BenchmarkableModel with your own input/output adapters.",
 95            )
 96        if isinstance(self.split, str):
 97            try:
 98                self.split = Difficulty(self.split)
 99            except ValueError as e:
100                raise ConfigError(
101                    f"Unknown split {self.split!r}. Use one of: {[d.value for d in Difficulty]}",
102                ) from e
103        if self.batch_size < 1:
104            raise ConfigError(f"batch_size must be >= 1, got {self.batch_size}")
105        if self.max_samples is not None and self.max_samples < 1:
106            raise ConfigError(f"max_samples must be >= 1, got {self.max_samples}")

Base config for every task runner.

Subclass with @dataclass when a task wants extra fields. The user supplies model — the toolkit doesn't ship any.

TaskRunConfig( model: Optional[rpx_benchmark.adapters.BenchmarkableModel] = None, split: rpx_benchmark.api.Difficulty | str = <Difficulty.HARD: 'hard'>, repo_id: Optional[str] = None, cache_dir: Optional[str] = None, revision: Optional[str] = '51432e5d48895bbafb490ae7c5e34e0f6b7da7d6', batch_size: int = 1, require_cuda: bool = False, skip_flops: bool = False, max_samples: Optional[int] = None, manifest_path: Optional[str] = None, device: str = 'cuda', output_dir: Optional[str] = None, progress: Optional[Callable[[int, Optional[int], str], NoneType]] = None, metric_suite: Optional[rpx_benchmark.metrics.MetricSuite] = None, upload_to_box: bool = False, box_folder_id: Optional[str] = None)
split: rpx_benchmark.api.Difficulty | str = <Difficulty.HARD: 'hard'>
repo_id: Optional[str] = None
cache_dir: Optional[str] = None
revision: Optional[str] = '51432e5d48895bbafb490ae7c5e34e0f6b7da7d6'
batch_size: int = 1
require_cuda: bool = False
skip_flops: bool = False
max_samples: Optional[int] = None
manifest_path: Optional[str] = None
device: str = 'cuda'
output_dir: Optional[str] = None
progress: Optional[Callable[[int, Optional[int], str], NoneType]] = None
metric_suite: Optional[rpx_benchmark.metrics.MetricSuite] = None
upload_to_box: bool = False
box_folder_id: Optional[str] = None
PipelineResult = typing.Tuple[rpx_benchmark.metrics.registry.BenchmarkResult, rpx_benchmark.deployment.DeploymentReadinessReport, typing.Dict[str, pathlib.Path]]
def resolve_device(requested: str, *, require_cuda: bool = False) -> str:
109def resolve_device(requested: str, *, require_cuda: bool = False) -> str:
110    """Fall back to CPU when CUDA was requested but isn't available."""
111    if require_cuda and not requested.startswith("cuda"):
112        raise ConfigError(
113            f"GPU-only run requires device='cuda', got {requested!r}",
114            hint="Use --device cuda, or explicitly opt into CPU diagnostics.",
115        )
116    if not requested.startswith("cuda"):
117        return requested
118    try:
119        import torch
120    except ImportError as exc:
121        if require_cuda:
122            raise ConfigError(
123                "GPU-only run requires a CUDA-enabled PyTorch installation.",
124                hint="Install the PyTorch wheel matching this host, then rerun.",
125            ) from exc
126        return "cpu"
127    if torch.cuda.is_available():
128        return requested
129    if require_cuda:
130        raise ConfigError(
131            "CUDA was required but torch.cuda.is_available() is false.",
132            hint="Check the NVIDIA driver, CUDA-capable PyTorch wheel, and GPU visibility.",
133        )
134    log.warning("CUDA requested but unavailable; falling back to CPU.")
135    return "cpu"

Fall back to CPU when CUDA was requested but isn't available.

def run_pipeline( *, task: rpx_benchmark.api.TaskType, primary_metric: str, cfg: TaskRunConfig, compute_ts: bool = True, compute_sgc: bool = False) -> Tuple[rpx_benchmark.metrics.registry.BenchmarkResult, rpx_benchmark.deployment.DeploymentReadinessReport, Dict[str, pathlib.Path]]:
145def run_pipeline(
146    *,
147    task: TaskType,
148    primary_metric: str,
149    cfg: TaskRunConfig,
150    compute_ts: bool = True,
151    compute_sgc: bool = False,
152) -> PipelineResult:
153    """Run a task end-to-end: download, load, predict, score, report.
154
155    Parameters
156    ----------
157    task : TaskType
158    primary_metric : str
159        Metric key that drives ESD-weighted phase scoring.
160    cfg : TaskRunConfig
161        Must already have ``model`` set.
162    compute_ts : bool
163        Compute Temporal Stability. Disable for tasks where adjacent
164        samples are unrelated (e.g. keypoint matching).
165    compute_sgc : bool
166        Compute Stack Geometric Coherence. Only meaningful for
167        segmentation with depth available.
168    """
169    cfg.device = resolve_device(cfg.device, require_cuda=cfg.require_cuda)
170    split_name = cfg.split.value if isinstance(cfg.split, Difficulty) else str(cfg.split)
171    repo_id = cfg.repo_id or DEFAULT_REPO_ID
172
173    log.info("pipeline: task=%s split=%s device=%s", task.value, split_name, cfg.device)
174
175    if cfg.manifest_path:
176        manifest_path = Path(cfg.manifest_path)
177        if not manifest_path.exists():
178            raise ConfigError(
179                f"manifest_path does not exist: {manifest_path}",
180                hint="Generate per-task manifests with "
181                "`python -m rpx_benchmark.dataset_hub.cli manifest "
182                "--src <raw> --staging <out> --splits "
183                "benchmark/data/splits/scene_splits.json` and pass the "
184                "resulting <out>/manifests/<task>/<split>.json path.",
185            )
186        log.info("using local manifest: %s", manifest_path)
187    else:
188        manifest_path = download_split(
189            task=task,
190            split=cfg.split,
191            repo_id=repo_id,
192            cache_dir=cfg.cache_dir,
193            revision=cfg.revision,
194            max_samples=cfg.max_samples,
195        )
196    dataset = RPXDataset.from_manifest(
197        manifest_path,
198        batch_size=cfg.batch_size,
199        max_samples=cfg.max_samples,
200    )
201    log.info("loaded %d samples from %s", len(dataset), manifest_path)
202
203    model = cfg.model
204    name = getattr(model, "name", "model")
205
206    model.setup()
207    efficiency = _count_params(model)
208    if efficiency.params_m is not None:
209        log.info("model %s: %.2f M params", name, efficiency.params_m)
210    # SystemCard auto-detects GPU / precision / batch_size and is
211    # stamped onto every cell row so cross-host aggregation can
212    # disambiguate cells produced on different hardware.
213    efficiency.system_card = SystemCard.auto_detect(
214        precision=getattr(model, "native_precision", "fp32"),
215        batch_size=cfg.batch_size,
216    )
217
218    runner = BenchmarkRunner(
219        model=model,
220        dataset=dataset,
221        metric_suite=cfg.metric_suite or MetricSuite.for_task(task),
222        call_setup=False,
223    )
224    result, dr_report = runner.run_with_report(
225        primary_metric=primary_metric,
226        model_name=name,
227        efficiency=efficiency,
228        compute_ts=compute_ts,
229        compute_sgc_flag=compute_sgc,
230        skip_flops=cfg.skip_flops,
231        progress=cfg.progress,
232    )
233
234    safe_name = name.replace("/", "__")
235    out_dir = Path(cfg.output_dir or f"./rpx_results/{safe_name}/{split_name}")
236    out_dir.mkdir(parents=True, exist_ok=True)
237    json_path = out_dir / "result.json"
238    md_path = out_dir / "summary.md"
239    cells_path = out_dir / "cells.parquet"
240    per_sample_path = out_dir / "per_sample_metrics.parquet"
241
242    try:
243        import pyarrow as pa
244        import pyarrow.parquet as pq
245
246        pq.write_table(pa.Table.from_pylist(result.per_sample), per_sample_path)
247    except ImportError as exc:
248        raise ConfigError(
249            "Writing canonical per-sample metrics requires pyarrow.",
250            hint="Install 'rpx-benchmark[hub]' or use the RPX Docker image.",
251        ) from exc
252
253    # cells.parquet is the canonical artefact downstream Φ / 𝒥 / paper-
254    # table fills read from. Bucket per_sample (one row per frame) into
255    # per-(scene, phase) cells with the mean of every numeric metric.
256    # Mirrors the per-clip pipeline (_video_pipeline.py) so both tasks
257    # feed the same downstream path.
258    cells = cells_from_per_sample(
259        result.per_sample,
260        model_name=name,
261        task=task.value,
262        system_card=efficiency.system_card,
263    )
264    if cells:
265        write_cells(cells, cells_path)
266        log.info("wrote %d cell rows to %s", len(cells), cells_path)
267    else:
268        log.warning(
269            "no cells emitted for %s/%s — per_sample lacks scene/phase fields. "
270            "This usually means the dataset manifest's samples don't carry "
271            "scene_id + phase metadata, or the runner's metric_suite stripped "
272            "them. Downstream Φ/J aggregation will be unable to bucket.",
273            name, split_name,
274        )
275
276    write_json(
277        json_path,
278        task=task.value,
279        model_name=name,
280        split=split_name,
281        repo_id=repo_id,
282        result=result,
283        dr_report=dr_report,
284    )
285    md_path.write_text(
286        format_markdown_summary(
287            task=task.value,
288            model_name=name,
289            split=split_name,
290            repo_id=repo_id,
291            result=result,
292            dr_report=dr_report,
293        ),
294        encoding="utf-8",
295    )
296    log.info("wrote %s and %s", json_path, md_path)
297
298    paths: Dict[str, Path] = {
299        "json": json_path,
300        "markdown": md_path,
301        "out_dir": out_dir,
302        "per_sample_metrics": per_sample_path,
303    }
304    if cells:
305        paths["cells"] = cells_path
306
307    if cfg.upload_to_box:
308        # Lazy import keeps Box deps (requests) out of the import path
309        # for users who never upload.
310        from ..box_upload import DEFAULT_BOX_FOLDER_ID, upload_run_dir
311
312        try:
313            summary = upload_run_dir(
314                out_dir,
315                task=task.value,
316                model_name=name,
317                split=split_name,
318                root_folder_id=cfg.box_folder_id or DEFAULT_BOX_FOLDER_ID,
319            )
320            log.info(
321                "box: %d uploaded, %d skipped → box:%s",
322                summary["uploaded"],
323                summary["skipped"],
324                summary["remote_path"],
325            )
326            paths["box_remote"] = summary["remote_path"]
327        except Exception as e:  # noqa: BLE001
328            # Don't lose the result.json on the floor just because the
329            # token expired — log loudly and continue.
330            log.error(
331                "Box upload failed (%s: %s) — local artefacts at %s are intact; "
332                "use `scripts/sync_results_to_box.py` to recover with a fresh token.",
333                type(e).__name__,
334                e,
335                out_dir,
336            )
337
338    return result, dr_report, paths

Run a task end-to-end: download, load, predict, score, report.

Parameters

task : TaskType primary_metric : str Metric key that drives ESD-weighted phase scoring. cfg : TaskRunConfig Must already have model set. compute_ts : bool Compute Temporal Stability. Disable for tasks where adjacent samples are unrelated (e.g. keypoint matching). compute_sgc : bool Compute Stack Geometric Coherence. Only meaningful for segmentation with depth available.