"""The results store: one Parquet file per finished run, a DuckDB view over them all.
Layout of a store folder::
experiment.json what the experiment is (model, design, seed...)
run-<seed>.parquet one finished run: one row per step
failures/run-<seed>.json a run that raised, with its traceback
Every file is written to a temporary name and moved into place with
``os.replace``, so a killed sweep leaves only whole files and is resumed by
skipping the seeds already present. Rows have the columns of :data:`KEYS`, the
parameters, ``step`` and the outputs.
"""
from __future__ import annotations
import json
import os
import uuid
from pathlib import Path
from typing import Any
import pandas as pd
from modelly._optional import require
from modelly.data.io import TEMP_SUFFIX, write_parquet_atomic
EXTRA = "experiments"
KEYS = ("run_id", "point", "replicate", "seed")
STEP = "step"
RESERVED = (*KEYS, STEP)
RUN_PREFIX = "run-"
RUN_SUFFIX = ".parquet"
FAILURES = "failures"
METADATA = "experiment.json"
VIEW = "runs"
ORDER = "point, replicate, step"
[docs]
class StoreMismatchError(ValueError):
"""The store folder holds the results of a different experiment."""
[docs]
def write_text_atomic(path: Path, text: str) -> None:
"""Write ``text`` to ``path`` (UTF-8) through a temporary file and ``os.replace``."""
path.parent.mkdir(parents=True, exist_ok=True)
temp = path.with_name(f".{path.name}.{uuid.uuid4().hex}{TEMP_SUFFIX}")
try:
temp.write_text(text, encoding="utf-8", newline="\n")
os.replace(temp, path)
finally:
temp.unlink(missing_ok=True)
def _canonical(document: Any) -> str:
"""``document`` as sorted, compact JSON, for comparing fingerprints."""
return json.dumps(document, sort_keys=True, separators=(",", ":"), default=str)
[docs]
class ResultsStore:
"""A folder of per-run Parquet files, append-only and resumable.
Example:
>>> store = ResultsStore("runs/epidemic") # doctest: +SKIP
>>> store.finished() # the seeds already run # doctest: +SKIP
"""
def __init__(self, path: str | Path) -> None:
"""Use (and create on first write) the folder ``path``."""
self.path = Path(path)
[docs]
def run_path(self, seed: int) -> Path:
"""The Parquet file of the run with ``seed``."""
return self.path / f"{RUN_PREFIX}{seed}{RUN_SUFFIX}"
[docs]
def failure_path(self, seed: int) -> Path:
"""The failure record of the run with ``seed``."""
return self.path / FAILURES / f"{RUN_PREFIX}{seed}.json"
[docs]
def finished(self) -> set[int]:
"""The seeds of every run with a results file."""
if not self.path.is_dir():
return set()
names = (p.name for p in self.path.glob(f"{RUN_PREFIX}*{RUN_SUFFIX}"))
return {int(name[len(RUN_PREFIX) : -len(RUN_SUFFIX)]) for name in names}
[docs]
def write_run(self, seed: int, table: Any) -> Path:
"""Store a finished run's ``pyarrow.Table``; clear any earlier failure record."""
path = write_parquet_atomic(table, self.run_path(seed))
self.failure_path(seed).unlink(missing_ok=True)
return path
[docs]
def record_failure(self, seed: int, record: dict[str, Any]) -> None:
"""Keep a failed run's details (error, traceback, attempts) as JSON."""
text = json.dumps(record, indent=2, default=str)
write_text_atomic(self.failure_path(seed), text)
[docs]
def failed(self) -> set[int]:
"""The seeds of every run with a failure record."""
folder = self.path / FAILURES
names = (p.stem for p in folder.glob(f"{RUN_PREFIX}*.json"))
return {int(name[len(RUN_PREFIX) :]) for name in names}
[docs]
def failures(self) -> pd.DataFrame:
"""One row per recorded failure, in seed order."""
records = [
json.loads(self.failure_path(seed).read_text(encoding="utf-8"))
for seed in sorted(self.failed())
]
return pd.DataFrame.from_records(records)
[docs]
def claim(self, fingerprint: dict[str, Any]) -> None:
"""Record what experiment writes here; refuse a store holding another one."""
metadata = self.path / METADATA
if metadata.is_file():
stored = json.loads(metadata.read_text(encoding="utf-8"))
if _canonical(stored) != _canonical(fingerprint):
raise StoreMismatchError(
f"{self.path} holds the results of a different experiment "
f"(see {METADATA}); use another store or delete it"
)
return
write_text_atomic(metadata, json.dumps(fingerprint, indent=2, default=str))
[docs]
def results(self) -> Results:
"""The finished runs, as a DuckDB view named ``runs``."""
return Results(self)
[docs]
class Results:
"""SQL over a store's runs: ``query`` returns a DuckDB relation.
Example:
>>> results.query("SELECT beta, avg(infected) FROM runs GROUP BY beta").df() # doctest: +SKIP
"""
def __init__(self, store: ResultsStore) -> None:
"""Open an in-memory DuckDB connection with the view ``runs`` over the store."""
if not store.finished():
raise LookupError(f"no finished runs in {store.path}")
duckdb = require("duckdb", EXTRA)
self.connection = duckdb.connect()
pattern = (store.path / f"{RUN_PREFIX}*{RUN_SUFFIX}").as_posix()
quoted = pattern.replace("'", "''")
self.connection.sql(
f"CREATE VIEW {VIEW} AS SELECT * FROM " # noqa: S608 (a local path)
f"read_parquet('{quoted}', union_by_name = true)"
)
[docs]
def query(self, sql: str):
"""Run ``sql`` (which reads the view ``runs``); a DuckDB relation."""
return self.connection.sql(sql)
@property
def relation(self):
"""Every row, ordered by point, replicate and step."""
return self.query(f"SELECT * FROM {VIEW} ORDER BY {ORDER}") # noqa: S608
[docs]
def df(self) -> pd.DataFrame:
"""Every row as a pandas DataFrame, ordered by point, replicate and step."""
return self.relation.df()
[docs]
def arrow(self):
"""Every row as a ``pyarrow.Table``, ordered by point, replicate and step."""
return self.relation.to_arrow_table()
def __len__(self) -> int:
"""The number of rows (runs times steps)."""
return self.query(f"SELECT count(*) FROM {VIEW}").fetchall()[0][0] # noqa: S608