Source code for modelly.studio.session

"""One browser tab's run: build, step, profile, record, scrub and replay a model.

``StudioSession`` holds no widget: the Solara app calls it and redraws from what it holds,
and the tests drive it directly. Every method that changes the run takes ``lock``, so the
play loop's thread and the widgets' callbacks never see a half-made step.
"""

from __future__ import annotations

import inspect
import threading
from collections import deque
from collections.abc import Callable
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING, Any

import numpy as np

from modelly.lint import lint_paths
from modelly.profile.loader import build_model
from modelly.profile.profiler import DEFAULT_TOP, Profiler
from modelly.studio.params import parameters_of
from modelly.studio.timeline import ReplayTimeline, fall_back, track
from modelly.timetravel.recorder import reseed_model
from modelly.viz.render.portrayal import ClassColours

if TYPE_CHECKING:
    import modelly.lint
    from modelly.core.model import Model
    from modelly.studio.config import StudioConfig

LOG_LINES = 200
# The profiler patches classes process-wide: one session profiles at a time.
_PROFILER_LOCK = threading.Lock()


def _replayed(timeline, step: float, changes: dict, seed: int | None) -> Model:
    """The model ``timeline`` holds at ``step``, with ``changes`` applied, as replayed."""
    model = timeline.at(step)
    _apply(model, changes, seed)
    return model


def _apply(model: Model, changes: dict[str, Any], seed: int | None) -> None:
    """Set the changed scenario values on ``model`` and reseed it when ``seed`` is given."""
    for name, value in changes.items():
        setattr(model.scenario, name, value)
    if seed is not None:
        reseed_model(model, np.random.SeedSequence(seed))


[docs] class StudioSession: """The state of one Studio run, independent of the widgets that show it. Args: model_class: The model to run. config: The Studio settings. agent_portrayal: How the space view draws agents; one colour per class when None. """ def __init__( self, model_class: type[Model], config: StudioConfig, agent_portrayal: Callable | None = None, ) -> None: """Build the first run with the default parameters and ``config.seed``.""" self.model_class = model_class self.config = config self.parameters = parameters_of(model_class) self.values = {p.name: p.default for p in self.parameters if p.editable} self.seed = config.seed self.portrayal = agent_portrayal or ClassColours() self.lock = threading.RLock() self.log: deque[str] = deque(maxlen=LOG_LINES) self.records: deque = deque(maxlen=config.profile_steps) self.model: Model | None = None self.view: Model | None = None self.timeline = None self.reset() # --- the run -------------------------------------------------------------- @property def shown(self) -> Model: """The model the views show: the scrubbed-to state, else the live model.""" return self.view if self.view is not None else self.model @property def replaying(self) -> bool: """Whether scrubbing reruns the model instead of restoring checkpoints.""" return isinstance(self.timeline, ReplayTimeline)
[docs] def say(self, message: str) -> None: """Add a line to the run log.""" time = self.model.time if self.model is not None else 0 self.log.append(f"[t={time:g}] {message}")
def _build(self, values: dict[str, Any], seed: int) -> Model: """A new model with ``values`` and ``seed``.""" return build_model(self.model_class, values, seed) def _start(self, model: Model, start: Callable[[], Model]) -> None: """Make ``model`` the live run, recorded from now on. ``start`` rebuilds the state the run starts from; it is kept only when the model cannot be checkpointed and scrubbing replays the run from there. """ if self.timeline is not None: self.timeline.stop() self.model, self.view = model, None self._run_values, self._run_seed = dict(self.values), self.seed self.records.clear() self.timeline, reason = track( model, start, self.config.every, self.config.memory_budget ) if reason: self.say(f"no checkpoints ({reason}); scrubbing replays the run instead")
[docs] def reset(self) -> None: """Rebuild the model from the panel's values and seed, at step 0.""" with self.lock: start = partial(self._build, dict(self.values), self.seed) self._start(start(), start) self.say(f"new run, seed {self.seed}: {self.values}")
[docs] def step(self, n: int = 1) -> int: """Advance the live model ``n`` steps (fewer if it stops); the steps taken.""" with self.lock: self.view = None for done in range(n): if not self.model.running: self.say( f"the model stopped (running is False) after {done} step(s)" ) return done self._step_once() return n
def _step_once(self) -> None: """One step, profiled when no other session is profiling.""" if _PROFILER_LOCK.acquire(blocking=False): try: with Profiler(self.model) as prof: self.model.step() self.records.extend(prof.records) finally: _PROFILER_LOCK.release() else: self.model.step() failed = getattr(self.timeline, "failed", None) if failed: # the model became unsaveable mid-run self.timeline = fall_back(self.timeline) self.say(f"checkpoints stopped ({failed}); scrubbing replays the run") # --- the timeline ---------------------------------------------------------
[docs] def scrub(self, step: float) -> None: """Show the run at ``step``; at or past the live step, show the live model.""" with self.lock: first, last = self.timeline.span if step >= last: self.view = None return self.view = self.timeline.at(max(step, first))
def _changes(self, model: Model) -> dict[str, Any]: """The panel values changed since the run was built that ``model.scenario`` has.""" changed = {k: v for k, v in self.values.items() if v != self._run_values.get(k)} skipped = sorted(k for k in changed if not hasattr(model.scenario, k)) if skipped: self.say(f"not applied on replay (constructor arguments): {skipped}") return {k: v for k, v in changed.items() if k not in skipped}
[docs] def replay(self) -> None: """Make the scrubbed-to state the live run, with the panel's changed values.""" with self.lock: if self.view is None: return model, step = self.view, self.view.time changes = self._changes(model) seed = self.seed if self.seed != self._run_seed else None _apply(model, changes, seed) start = partial(_replayed, self.timeline, step, changes, seed) self._start(model, start) self.say(f"replayed from step {step:g} with {changes or 'no changes'}")
# --- the panels -----------------------------------------------------------
[docs] def profile_report(self, top: int = DEFAULT_TOP) -> str: """The profiler's report over the last ``config.profile_steps`` steps.""" with self.lock: prof = Profiler(self.model) prof.records = list(self.records) return prof.report(top=top)
[docs] def source_file(self) -> Path | None: """The file the lint panel checks: the target's, else the class's source.""" named = self.config.source_file if named is not None: return named found = inspect.getsourcefile(self.model_class) return Path(found) if found else None
[docs] def lint(self) -> list[modelly.lint.Finding]: """The ``modelly lint`` findings of the model's file.""" path = self.source_file() if path is None: return [] try: return lint_paths([path]) except (OSError, SyntaxError) as error: self.say(f"lint could not read {path}: {error}") return []