"""One run of an experiment: the task a worker receives and the function it calls.
A :class:`RunTask` holds only plain values (the model as an import path, the
parameters, the seed's entropy and spawn key), so it pickles for processes,
Dask and Ray, and serialises to JSON for SLURM and PBS job arrays. Workers
started with ``spawn`` import the model by that path; nothing is inherited.
The run's generator comes from ``SeedSequence(entropy, spawn_key=(point,
replicate))``: the same numbers whatever executor, worker or order runs it.
"""
from __future__ import annotations
import hashlib
import importlib
import importlib.util
import sys
import traceback
from collections.abc import Callable, Mapping
from dataclasses import asdict, dataclass
from functools import cached_property
from pathlib import Path
from typing import Any
import numpy as np
from modelly._optional import require
from modelly.experiments.store import KEYS, STEP, ResultsStore
from modelly.profile.loader import build_model
DONE, FAILED = "done", "failed"
REFERENCE = ":" # "module:name" is an import reference, a bare name an attribute
INT64_MAX = np.iinfo(np.int64).max
SCALARS = (int, float, bool, str, type(None))
FILE_MODULE_PREFIX = "_modelly_experiment_"
FILE_KEY_LENGTH = 16 # hex digits of the path hash naming a model file's module
# Objects named by reference in this process: lets threads and the serial
# executor run classes and functions a fresh process could not import.
_OBJECTS: dict[str, Any] = {}
[docs]
def reference(obj: Any) -> str:
"""``module:qualname`` of a class or function; a script's ``__main__`` becomes its path."""
module, name = obj.__module__, obj.__qualname__
main_file = getattr(sys.modules.get("__main__"), "__file__", None)
if module == "__main__" and main_file:
module = str(Path(main_file).resolve())
ref = f"{module}{REFERENCE}{name}"
_OBJECTS[ref] = obj
return ref
[docs]
def spawn_safe(ref: str) -> bool:
"""Whether a fresh process can import ``ref`` (not a notebook, lambda or closure)."""
module, _, name = ref.rpartition(REFERENCE)
return module != "__main__" and "<" not in name
[docs]
def resolve(ref: str) -> Any:
"""The object ``ref`` names, imported on first use in this process."""
if ref in _OBJECTS:
return _OBJECTS[ref]
module, _, name = ref.rpartition(REFERENCE)
found = (
_load_file(module)
if module.endswith(".py")
else importlib.import_module(module)
)
for part in name.split("."):
found = getattr(found, part)
_OBJECTS[ref] = found
return found
def _load_file(source: str) -> Any:
"""Import the ``.py`` file ``source`` under a name unique to its path."""
path = Path(source).resolve()
digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:FILE_KEY_LENGTH]
name = f"{FILE_MODULE_PREFIX}{digest}"
if name in sys.modules:
return sys.modules[name]
sys.path.insert(0, str(path.parent))
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
[docs]
def run_seed(entropy: int, point: int, replicate: int) -> int:
"""The integer seed recorded for a run (and the key resume works by)."""
sequence = np.random.SeedSequence(entropy, spawn_key=(point, replicate))
return int(sequence.generate_state(1, dtype=np.uint64)[0]) & int(INT64_MAX)
[docs]
@dataclass(frozen=True)
class RunTask:
"""Everything one worker needs to run one (design point, replicate)."""
model: str
params: dict[str, Any]
point: int
replicate: int
entropy: int
steps: int
outputs: dict[str, str]
store: str
retries: int = 0
record_failures: bool = True
@cached_property
def seed(self) -> int:
"""This run's seed: a function of the experiment seed, point and replicate."""
return run_seed(self.entropy, self.point, self.replicate)
@property
def run_id(self) -> str:
"""``<point>-<replicate>``."""
return f"{self.point}-{self.replicate}"
[docs]
def seed_sequence(self) -> np.random.SeedSequence:
"""The SeedSequence child of this (point, replicate)."""
return np.random.SeedSequence(
self.entropy, spawn_key=(self.point, self.replicate)
)
[docs]
def to_dict(self) -> dict[str, Any]:
"""The task as JSON-ready values."""
return asdict(self)
[docs]
@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> RunTask:
"""The task :meth:`to_dict` wrote."""
return cls(**data)
[docs]
@dataclass(frozen=True)
class RunOutcome:
"""What a worker reports back: the run, its status and the error if it failed."""
run_id: str
seed: int
status: str
attempts: int = 1
error: str | None = None
def _reader(spec: str) -> Callable[[Any], Any]:
"""A function reading one output from a model: an attribute path or a reference."""
if REFERENCE in spec:
function = resolve(spec)
return function
def read(model: Any) -> Any:
value = model
for part in spec.split("."):
value = getattr(value, part)
return value() if callable(value) else value
return read
def _scalar(name: str, value: Any) -> Any:
"""``value`` as a plain Python scalar, or a TypeError naming the output."""
value = value.item() if isinstance(value, np.generic) else value
if not isinstance(value, SCALARS):
raise TypeError(f"output {name!r} must be a number, bool or str, got {value!r}")
return value
[docs]
def simulate(task: RunTask) -> Any:
"""Build the model, step it ``task.steps`` times and return the rows as a pyarrow Table.
Row 0 is the state after construction; the run stops early when
``model.running`` becomes False.
"""
pa = require("pyarrow", "experiments")
model = build_model(resolve(task.model), dict(task.params), task.seed_sequence())
readers = {name: _reader(spec) for name, spec in task.outputs.items()}
columns: dict[str, list[Any]] = {STEP: [], **{name: [] for name in readers}}
for step in range(task.steps + 1):
if step and not model.running:
break
if step:
model.step()
columns[STEP].append(step)
for name, read in readers.items():
columns[name].append(_scalar(name, read(model)))
rows = len(columns[STEP])
keys = dict(zip(KEYS, (task.run_id, task.point, task.replicate, task.seed)))
constant = {**keys, **task.params}
return pa.table({**{k: [v] * rows for k, v in constant.items()}, **columns})
[docs]
def run_task(task: RunTask) -> RunOutcome:
"""Run ``task`` (retrying ``task.retries`` times), store it, report the outcome.
The function every executor calls; it never raises for a model error.
"""
store = ResultsStore(task.store)
error = ""
attempts = task.retries + 1
for attempt in range(1, attempts + 1):
try:
store.write_run(task.seed, simulate(task))
return RunOutcome(task.run_id, task.seed, DONE, attempt)
except Exception: # a model error is the run's outcome, not the sweep's
error = traceback.format_exc()
if task.record_failures:
record = {"run_id": task.run_id, "point": task.point}
record |= {"replicate": task.replicate, "seed": task.seed}
record |= {"params": task.params, "attempts": attempts, "error": error}
store.record_failure(task.seed, record)
return RunOutcome(task.run_id, task.seed, FAILED, attempts, error)