Source code for modelly.timetravel.diff

"""Find where two runs diverge: first in their collected data, then in state.

``diff`` takes two runs given as models, recorders, ``DataCollector`` objects
or model-variable DataFrames (``get_model_vars_dataframe()``):

- collected data (both runs have a collector or a DataFrame): the first
  collection step at which a model reporter differs, or, from collectors, the
  first agent and reporter whose agent record differs;
- state (both runs are models or recorders): the first model scalar, agent or
  agent attribute that differs (see :mod:`modelly.timetravel.snapshot`). Two
  recorders are compared at their common checkpoints, then replayed one time
  unit at a time from the last matching one, so the state difference is at
  the first step where the runs diverge; two models are compared as they are.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

import pandas as pd

from modelly.core.model import Model
from modelly.data import DataCollector
from modelly.timetravel.recorder import Recorder
from modelly.timetravel.report import Difference, RunDiff
from modelly.timetravel.snapshot import (
    first_state_difference,
    first_value_difference,
    model_state,
)

if TYPE_CHECKING:
    from collections.abc import Iterable, Iterator

Run = Model | Recorder | DataCollector | pd.DataFrame
Rows = list[tuple[float, dict[str, Any]]]
# Replay granularity between two checkpoints: one model step.
STEP = 1.0


def _final(run: Run) -> Model | None:
    """The model a run ends with, or None for collected data alone."""
    if isinstance(run, Recorder):
        return run.at(run.span[1])
    return run if isinstance(run, Model) else None


def _collected(run: Run) -> DataCollector | pd.DataFrame | None:
    """The collected data of a run, if it has any."""
    if isinstance(run, DataCollector | pd.DataFrame):
        return run
    model = _final(run)
    return getattr(model, "datacollector", None)


def _model_rows(data: DataCollector | pd.DataFrame) -> Rows:
    """``(step, {reporter: value})`` per collection."""
    if isinstance(data, pd.DataFrame):
        records = data.to_dict("records")
        return [(float(step), row) for step, row in zip(data.index, records)]
    names = list(data.model_vars)
    values = zip(*(data.model_vars[name] for name in names))
    return [
        (s, dict(zip(names, row))) for s, row in zip(data._collection_steps, values)
    ]


def _first_row_difference(a: Rows, b: Rows, **who: Any) -> Difference | None:
    """The first step at which two row lists differ."""
    for (step_a, row_a), (step_b, row_b) in zip(a, b):
        if step_a != step_b:
            return Difference(min(step_a, step_b), "collected at", step_a, step_b)
        found = first_value_difference(row_a, row_b, step_a, **who)
        if found is not None:
            return found
    if len(a) != len(b):
        step = (a if len(a) > len(b) else b)[min(len(a), len(b))][0]
        return Difference(step, "collections", len(a), len(b))
    return None


def _agent_rows(data: DataCollector, step: float) -> dict[int, dict[str, Any]]:
    """``{unique_id: {reporter: value}}`` of one agent collection."""
    names = list(data.agent_reporters)
    rows = data._agent_records.get(step, [])
    return {row[1]: dict(zip(names, row[2:])) for row in rows}


def _first_agent_record_difference(
    a: DataCollector, b: DataCollector
) -> Difference | None:
    """The first step, agent and reporter whose agent record differs."""
    for step in sorted(a._agent_records.keys() | b._agent_records.keys()):
        rows_a, rows_b = _agent_rows(a, step), _agent_rows(b, step)
        for uid in sorted(rows_a.keys() | rows_b.keys()):
            if uid not in rows_a or uid not in rows_b:
                return Difference(step, "recorded", uid in rows_a, uid in rows_b, uid)
            found = first_value_difference(rows_a[uid], rows_b[uid], step, agent=uid)
            if found is not None:
                return found
    return None


def _earliest(found: Iterable[Difference | None]) -> Difference | None:
    """The difference with the lowest step, the first one on a tie."""
    present = [d for d in found if d is not None]
    return min(present, key=lambda d: d.step, default=None)


[docs] def data_difference(a: Run, b: Run) -> Difference | None: """The first difference in the data two runs collected.""" data_a, data_b = _collected(a), _collected(b) if data_a is None or data_b is None: return None model_vars = _first_row_difference(_model_rows(data_a), _model_rows(data_b)) if isinstance(data_a, pd.DataFrame) or isinstance(data_b, pd.DataFrame): return model_vars return _earliest([model_vars, _first_agent_record_difference(data_a, data_b)])
def _compare(a: Model, b: Model) -> Difference | None: """The first state difference of two models, at the first one's time.""" return first_state_difference(model_state(a), model_state(b), a.time) def _walk(rec: Recorder, start: float, stop: float) -> Iterator[Model]: """The recorded run at each step from ``start`` up to before ``stop``. One model is replayed forward, restoring a checkpoint instead wherever the run has one, so changes recorded with ``checkpoint()`` are seen. """ recorded = set(rec.steps) model = rec.at(start) yield model while model.time + STEP < stop: target = model.time + STEP if target in recorded: model = rec.at(target) else: model._advance_time(target) yield model def _refine(a: Recorder, b: Recorder, start: float, found: Difference) -> Difference: """Compare both runs step by step from ``start`` up to ``found``.""" for model_a, model_b in zip( _walk(a, start, found.step), _walk(b, start, found.step) ): earlier = _compare(model_a, model_b) if earlier is not None: return earlier return found def _recorded_difference(a: Recorder, b: Recorder) -> Difference | None: """The first state difference of two recorded runs.""" end = min(a.span[1], b.span[1]) common = sorted(s for s in set(a.steps) & set(b.steps) if s < end) previous = None for step in [*common, end]: found = _compare(a.at(step), b.at(step)) if found is not None: return found if previous is None else _refine(a, b, previous, found) previous = step return None
[docs] def state_difference(a: Run, b: Run) -> Difference | None: """The first difference in the state of two runs.""" if isinstance(a, Recorder) and isinstance(b, Recorder): return _recorded_difference(a, b) model_a, model_b = _final(a), _final(b) if model_a is None or model_b is None: return None return _compare(model_a, model_b)
[docs] def diff(run_a: Run, run_b: Run) -> RunDiff: """Where two runs first diverge. Args: run_a: A model, a ``Recorder``, a ``DataCollector`` or a DataFrame of model variables. run_b: The run to compare it with, of any of the same kinds. Returns: A :class:`RunDiff`: ``str()`` of it is a readable report, and its ``data`` and ``state`` fields say step, agent and attribute. """ for run in (run_a, run_b): if not isinstance(run, Model | Recorder | DataCollector | pd.DataFrame): raise TypeError(f"Cannot diff a {type(run).__name__}.") return RunDiff(data_difference(run_a, run_b), state_difference(run_a, run_b))