diff --git a/codecov.yaml b/codecov.yaml index a19c3daee15..5aa4e0dabc1 100644 --- a/codecov.yaml +++ b/codecov.yaml @@ -7,7 +7,6 @@ coverage: ignore: - "benchmarks/**" - - "mesa/experimental/**" - "mesa/visualization/**" comment: off diff --git a/mesa/experimental/__init__.py b/mesa/experimental/__init__.py index 42a510cb9c6..069e418aa27 100644 --- a/mesa/experimental/__init__.py +++ b/mesa/experimental/__init__.py @@ -1,5 +1,5 @@ """Experimental init.""" -from mesa.experimental import cell_space, devs +from mesa.experimental import cell_space, devs, mesa_signals -__all__ = ["cell_space", "devs"] +__all__ = ["cell_space", "devs", "mesa_signals"] diff --git a/mesa/experimental/mesa_signals/__init__.py b/mesa/experimental/mesa_signals/__init__.py new file mode 100644 index 00000000000..a3a0b8053ef --- /dev/null +++ b/mesa/experimental/mesa_signals/__init__.py @@ -0,0 +1,13 @@ +"""Functionality for Observables.""" + +from .mesa_signal import All, Computable, Computed, HasObservables, Observable +from .observable_collections import ObservableList + +__all__ = [ + "Observable", + "ObservableList", + "HasObservables", + "All", + "Computable", + "Computed", +] diff --git a/mesa/experimental/mesa_signals/mesa_signal.py b/mesa/experimental/mesa_signals/mesa_signal.py new file mode 100644 index 00000000000..18128ce3dc0 --- /dev/null +++ b/mesa/experimental/mesa_signals/mesa_signal.py @@ -0,0 +1,470 @@ +"""Core classes for Observables.""" + +from __future__ import annotations + +import contextlib +import functools +import weakref +from abc import ABC, abstractmethod +from collections import defaultdict, namedtuple +from collections.abc import Callable +from typing import Any + +from mesa.experimental.mesa_signals.signals_util import AttributeDict, create_weakref + +__all__ = ["Observable", "HasObservables", "All", "Computable"] + +_hashable_signal = namedtuple("_HashableSignal", "instance name") + +CURRENT_COMPUTED: Computed | None = None # the current Computed that is evaluating +PROCESSING_SIGNALS: set[tuple[str,]] = set() + + +class BaseObservable(ABC): + """Abstract base class for all Observables.""" + + @abstractmethod + def __init__(self, fallback_value=None): + """Initialize a BaseObservable.""" + super().__init__() + self.public_name: str + self.private_name: str + + # fixme can we make this an inner class enum? + # or some SignalTypes helper class? + # its even more complicated. Ideally you can define + # signal_types throughout the class hierarchy and they are just + # combined together. + # while we also want to make sure that any signal being emitted is valid for that class + self.signal_types: set = set() + self.fallback_value = fallback_value + + def __get__(self, instance: HasObservables, owner): + value = getattr(instance, self.private_name) + + if CURRENT_COMPUTED is not None: + # there is a computed dependent on this Observable, so let's add + # this Observable as a parent + CURRENT_COMPUTED._add_parent(instance, self.public_name, value) + + # fixme, this can be done more cleanly + # problem here is that we cannot use self (i.e., the observable), we need to add the instance as well + PROCESSING_SIGNALS.add(_hashable_signal(instance, self.public_name)) + + return value + + def __set_name__(self, owner: HasObservables, name: str): + self.public_name = name + self.private_name = f"_{name}" + # owner.register_observable(self) + + @abstractmethod + def __set__(self, instance: HasObservables, value): + # this only emits an on change signal, subclasses need to specify + # this in more detail + instance.notify( + self.public_name, + getattr(instance, self.private_name, self.fallback_value), + value, + "change", + ) + + def __str__(self): + return f"{self.__class__.__name__}: {self.public_name}" + + +class Observable(BaseObservable): + """Observable class.""" + + def __init__(self, fallback_value=None): + """Initialize an Observable.""" + super().__init__(fallback_value=fallback_value) + + self.signal_types: set = { + "change", + } + + def __set__(self, instance: HasObservables, value): # noqa D103 + if ( + CURRENT_COMPUTED is not None + and _hashable_signal(instance, self.public_name) in PROCESSING_SIGNALS + ): + raise ValueError( + f"cyclical dependency detected: Computed({CURRENT_COMPUTED.name}) tries to change " + f"{instance.__class__.__name__}.{self.public_name} while also being dependent it" + ) + + super().__set__(instance, value) # send the notify + setattr(instance, self.private_name, value) + + PROCESSING_SIGNALS.clear() # we have notified our children, so we can clear this out + + +class Computable(BaseObservable): + """A Computable that is depended on one or more Observables. + + .. code-block:: python + + class MyAgent(Agent): + wealth = Computable() + + def __init__(self, model): + super().__init__(model) + wealth = Computed(func, args, kwargs) + + """ + + # fixme, with new _register_observable thing + # we can do computed without a descriptor, but then you + # don't have attribute like access, you would need to do a call operation to get the value + + def __init__(self): + """Initialize a Computable.""" + super().__init__() + + # fixme have 2 signal: change and is_dirty? + self.signal_types: set = { + "change", + } + + def __get__(self, instance, owner): # noqa: D105 + computed = getattr(instance, self.private_name) + old_value = computed._value + + if CURRENT_COMPUTED is not None: + CURRENT_COMPUTED._add_parent(instance, self.public_name, old_value) + + new_value = computed() + + if new_value != old_value: + instance.notify( + self.public_name, + old_value, + new_value, + "change", + ) + return new_value + else: + return old_value + + def __set__(self, instance: HasObservables, value: Computed): # noqa D103 + if not isinstance(value, Computed): + raise ValueError("value has to be a Computable instance") + + setattr(instance, self.private_name, value) + value.name = self.public_name + value.owner = instance + getattr( + instance, self.public_name + ) # force evaluation of the computed to build the dependency graph + + +class Computed: + def __init__(self, func: Callable, *args, **kwargs): + self.func = func + self.args = args + self.kwargs = kwargs + self._is_dirty = True + self._first = True + self._value = None + self.name: str = "" # set by Computable + self.owner: HasObservables # set by Computable + + self.parents: weakref.WeakKeyDictionary[HasObservables, dict[str, Any]] = ( + weakref.WeakKeyDictionary() + ) + + def __str__(self): + return f"COMPUTED: {self.name}" + + def _set_dirty(self, signal): + if not self._is_dirty: + self._is_dirty = True + self.owner.notify(self.name, self._value, None, "change") + + def _add_parent( + self, parent: HasObservables, name: str, current_value: Any + ) -> None: + """Add a parent Observable. + + Args: + parent: the HasObservable instance to which the Observable belongs + name: the public name of the Observable + current_value: the current value of the Observable + + """ + parent.observe(name, All(), self._set_dirty) + + try: + self.parents[parent][name] = current_value + except KeyError: + self.parents[parent] = {name: current_value} + + def _remove_parents(self): + """Remove all parent Observables.""" + # we can unsubscribe from everything on each parent + for parent in self.parents: + parent.unobserve(All(), All(), self._set_dirty) + + def __call__(self): + global CURRENT_COMPUTED # noqa: PLW0603 + + if self._is_dirty: + changed = False + + if self._first: + # fixme might be a cleaner solution for this + # basically, we have no parents. + changed = True + self._first = False + + # we might be dirty but values might have changed + # back and forth in our parents so let's check to make sure we + # really need to recalculate + if not changed: + for parent in self.parents.keyrefs(): + # does parent still exist? + if parent := parent(): + # if yes, compare old and new values for all + # tracked observables on this parent + for name, old_value in self.parents[parent].items(): + new_value = getattr(parent, name) + if new_value != old_value: + changed = True + break # we need to recalculate + else: + # trick for breaking cleanly out of nested for loops + # see https://stackoverflow.com/questions/653509/breaking-out-of-nested-loops + continue + break + else: + # one of our parents no longer exists + changed = True + break + + if changed: + # the dependencies of the computable function might have changed + # so, we rebuilt + self._remove_parents() + + old = CURRENT_COMPUTED + CURRENT_COMPUTED = self + + try: + self._value = self.func(*self.args, **self.kwargs) + except Exception as e: + raise e + finally: + CURRENT_COMPUTED = old + + self._is_dirty = False + + return self._value + + +class All: + """Helper constant to subscribe to all Observables.""" + + def __init__(self): # noqa: D107 + self.name = "all" + + def __copy__(self): # noqa: D105 + return self + + def __deepcopy__(self, memo): # noqa: D105 + return self + + +class HasObservables: + """HasObservables class.""" + + # we can't use a weakset here because it does not handle bound methods correctly + # also, a list is faster for our use case + subscribers: dict[str, dict[str, list]] + observables: dict[str, set[str]] + + def __init__(self, *args, **kwargs) -> None: + """Initialize a HasObservables.""" + super().__init__(*args, **kwargs) + self.subscribers = defaultdict(functools.partial(defaultdict, list)) + self.observables = dict(descriptor_generator(self)) + + def _register_signal_emitter(self, name: str, signal_types: set[str]): + """Helper function to register an Observable. + + This method can be used to register custom signals that are emitted by + the class for a given attribute, but which cannot be covered by the Observable descriptor + + Args: + name: the name of the signal emitter + signal_types: the set of signals that might be emitted + + """ + self.observables[name] = signal_types + + def observe( + self, + name: str | All, + signal_type: str | All, + handler: Callable, + ): + """Subscribe to the Observable for signal_type. + + Args: + name: name of the Observable to subscribe to + signal_type: the type of signal on the Observable to subscribe to + handler: the handler to call + + Raises: + ValueError: if the Observable is not registered or if the Observable + does not emit the given signal_type + + """ + # fixme should name/signal_type also take a list of str? + if not isinstance(name, All): + if name not in self.observables: + raise ValueError( + f"you are trying to subscribe to {name}, but this Observable is not known" + ) + else: + names = [ + name, + ] + else: + names = self.observables.keys() + + for name in names: + if not isinstance(signal_type, All): + if signal_type not in self.observables[name]: + raise ValueError( + f"you are trying to subscribe to a signal of {signal_type} " + f"on Observable {name}, which does not emit this signal_type" + ) + else: + signal_types = [ + signal_type, + ] + else: + signal_types = self.observables[name] + + ref = create_weakref(handler) + for signal_type in signal_types: + self.subscribers[name][signal_type].append(ref) + + def unobserve(self, name: str | All, signal_type: str | All, handler: Callable): + """Unsubscribe to the Observable for signal_type. + + Args: + name: name of the Observable to unsubscribe from + signal_type: the type of signal on the Observable to unsubscribe to + handler: the handler that is unsubscribing + + """ + names = ( + [ + name, + ] + if not isinstance(name, All) + else self.observables.keys() + ) + + for name in names: + # we need to do this here because signal types might + # differ for name so for each name we need to check + if isinstance(signal_type, All): + signal_types = self.observables[name] + else: + signal_types = [ + signal_type, + ] + for signal_type in signal_types: + with contextlib.suppress(KeyError): + remaining = [] + for ref in self.subscribers[name][signal_type]: + if subscriber := ref(): # noqa: SIM102 + if subscriber != handler: + remaining.append(ref) + self.subscribers[name][signal_type] = remaining + + def clear_all_subscriptions(self, name: str | All): + """Clears all subscriptions for the observable . + + if name is All, all subscriptions are removed + + Args: + name: name of the Observable to unsubscribe for all signal types + + """ + if not isinstance(name, All): + with contextlib.suppress(KeyError): + del self.subscribers[name] + # ignore when unsubscribing to Observables that have no subscription + else: + self.subscribers = defaultdict(functools.partial(defaultdict, list)) + + def notify( + self, + observable: str, + old_value: Any, + new_value: Any, + signal_type: str, + **kwargs, + ): + """Emit a signal. + + Args: + observable: the public name of the observable emitting the signal + old_value: the old value of the observable + new_value: the new value of the observable + signal_type: the type of signal to emit + kwargs: additional keyword arguments to include in the signal + + """ + signal = AttributeDict( + name=observable, + old=old_value, + new=new_value, + owner=self, + type=signal_type, + **kwargs, + ) + + self._mesa_notify(signal) + + def _mesa_notify(self, signal: AttributeDict): + """Send out the signal. + + Args: + signal: the signal + + Notes: + signal must contain name and type attributes because this is how observers are stored. + + """ + # we put this into a helper method, so we can emit signals with other fields + # then the default ones in notify. + observable = signal.name + signal_type = signal.type + + # because we are using a list of subscribers + # we should update this list to subscribers that are still alive + observers = self.subscribers[observable][signal_type] + active_observers = [] + for observer in observers: + if active_observer := observer(): + active_observer(signal) + active_observers.append(observer) + # use iteration to also remove inactive observers + self.subscribers[observable][signal_type] = active_observers + + +def descriptor_generator(obj) -> [str, BaseObservable]: + """Yield the name and signal_types for each Observable defined on obj.""" + # we need to traverse the entire class hierarchy to properly get + # also observables defined in super classes + for base in type(obj).__mro__: + base_dict = vars(base) + + for entry in base_dict.values(): + if isinstance(entry, BaseObservable): + yield entry.public_name, entry.signal_types diff --git a/mesa/experimental/mesa_signals/observable_collections.py b/mesa/experimental/mesa_signals/observable_collections.py new file mode 100644 index 00000000000..1111a8e64ca --- /dev/null +++ b/mesa/experimental/mesa_signals/observable_collections.py @@ -0,0 +1,126 @@ +"""This module defines observable collections classes. + +Observable collections behave like Observable but then for collections. + + +""" + +from collections.abc import Iterable, MutableSequence +from typing import Any + +from .mesa_signal import BaseObservable, HasObservables + +__all__ = [ + "ObservableList", +] + + +class ObservableList(BaseObservable): + """An ObservableList that emits signals on changes to the underlying list.""" + + def __init__(self): + """Initialize the ObservableList.""" + super().__init__() + self.signal_types: set = {"remove", "replace", "change", "insert", "append"} + self.fallback_value = [] + + def __set__(self, instance: "HasObservables", value: Iterable): + """Set the value of the descriptor attribute. + + Args: + instance: The instance on which to set the attribute. + value: The value to set the attribute to. + + """ + super().__set__(instance, value) + setattr( + instance, + self.private_name, + SignalingList(value, instance, self.public_name), + ) + + +class SignalingList(MutableSequence[Any]): + """A basic lists that emits signals on changes.""" + + __slots__ = ["owner", "name", "data"] + + def __init__(self, iterable: Iterable, owner: HasObservables, name: str): + """Initialize a SignalingList. + + Args: + iterable: initial values in the list + owner: the HasObservables instance on which this list is defined + name: the attribute name to which this list is assigned + + """ + self.owner: HasObservables = owner + self.name: str = name + self.data = list(iterable) + + def __setitem__(self, index: int, value: Any) -> None: + """Set item to index. + + Args: + index: the index to set item to + value: the item to set + + """ + old_value = self.data[index] + self.data[index] = value + self.owner.notify(self.name, old_value, value, "replace", index=index) + + def __delitem__(self, index: int) -> None: + """Delete item at index. + + Args: + index: The index of the item to remove + + """ + old_value = self.data + del self.data[index] + self.owner.notify(self.name, old_value, None, "remove", index=index) + + def __getitem__(self, index) -> Any: + """Get item at index. + + Args: + index: The index of the item to retrieve + + Returns: + the item at index + """ + return self.data[index] + + def __len__(self) -> int: + """Return the length of the list.""" + return len(self.data) + + def insert(self, index, value): + """Insert value at index. + + Args: + index: the index to insert value into + value: the value to insert + + """ + self.data.insert(index, value) + self.owner.notify(self.name, None, value, "insert", index=index) + + def append(self, value): + """Insert value at index. + + Args: + index: the index to insert value into + value: the value to insert + + """ + index = len(self.data) + self.data.append(value) + self.owner.notify(self.name, None, value, "append", index=index) + + def __str__(self): + return self.data.__str__() + + def __repr__(self): + return self.data.__repr__() diff --git a/mesa/experimental/mesa_signals/signals_util.py b/mesa/experimental/mesa_signals/signals_util.py new file mode 100644 index 00000000000..f5152452cea --- /dev/null +++ b/mesa/experimental/mesa_signals/signals_util.py @@ -0,0 +1,43 @@ +"""helper functions and classes for mesa signals.""" + +import weakref + +__all__ = [ + "AttributeDict", + "create_weakref", +] + + +class AttributeDict(dict): + """A dict with attribute like access. + + Each value can be accessed as if it were an attribute with its key as attribute name + + """ + + # I want our signals to act like traitlet signals, so this is inspired by trailets Bunch + # and some stack overflow posts. + __setattr__ = dict.__setitem__ + __delattr__ = dict.__delitem__ + + def __getattr__(self, key): # noqa: D105 + try: + return self.__getitem__(key) + except KeyError as e: + # we need to go from key error to attribute error + raise AttributeError(key) from e + + def __dir__(self): # noqa: D105 + # allows us to easily access all defined attributes + names = dir({}) + names.extend(self.keys()) + return names + + +def create_weakref(item, callback=None): + """Helper function to create a correct weakref for any item.""" + if hasattr(item, "__self__"): + ref = weakref.WeakMethod(item, callback) + else: + ref = weakref.ref(item, callback) + return ref diff --git a/tests/test_mesa_signals.py b/tests/test_mesa_signals.py new file mode 100644 index 00000000000..a41afdf46ce --- /dev/null +++ b/tests/test_mesa_signals.py @@ -0,0 +1,290 @@ +"""Tests for mesa_signals.""" + +from unittest.mock import Mock + +import pytest + +from mesa import Agent, Model +from mesa.experimental.mesa_signals import ( + All, + Computable, + Computed, + HasObservables, + Observable, + ObservableList, +) +from mesa.experimental.mesa_signals.signals_util import AttributeDict + + +def test_observables(): + """Test Observable.""" + + class MyAgent(Agent, HasObservables): + some_attribute = Observable() + + def __init__(self, model, value): + super().__init__(model) + some_attribute = value # noqa: F841 + + handler = Mock() + + model = Model(seed=42) + agent = MyAgent(model, 10) + agent.observe("some_attribute", "change", handler) + + agent.some_attribute = 10 + handler.assert_called_once() + + +def test_HasObservables(): + """Test Observable.""" + + class MyAgent(Agent, HasObservables): + some_attribute = Observable() + some_other_attribute = Observable() + + def __init__(self, model, value): + super().__init__(model) + some_attribute = value # noqa: F841 + some_other_attribute = 5 # noqa: F841 + + handler = Mock() + + model = Model(seed=42) + agent = MyAgent(model, 10) + agent.observe("some_attribute", "change", handler) + + subscribers = {entry() for entry in agent.subscribers["some_attribute"]["change"]} + assert handler in subscribers + + agent.unobserve("some_attribute", "change", handler) + subscribers = {entry() for entry in agent.subscribers["some_attribute"]["change"]} + assert handler not in subscribers + + subscribers = { + entry() for entry in agent.subscribers["some_other_attribute"]["change"] + } + assert len(subscribers) == 0 + + # testing All() + agent.observe(All(), "change", handler) + + for attr in ["some_attribute", "some_other_attribute"]: + subscribers = {entry() for entry in agent.subscribers[attr]["change"]} + assert handler in subscribers + + agent.unobserve(All(), "change", handler) + for attr in ["some_attribute", "some_other_attribute"]: + subscribers = {entry() for entry in agent.subscribers[attr]["change"]} + assert handler not in subscribers + assert len(subscribers) == 0 + + # testing for clear_all_subscriptions + nr_observers = 3 + handlers = [Mock() for _ in range(nr_observers)] + for handler in handlers: + agent.observe("some_attribute", "change", handler) + agent.observe("some_other_attribute", "change", handler) + + subscribers = {entry() for entry in agent.subscribers["some_attribute"]["change"]} + assert len(subscribers) == nr_observers + + agent.clear_all_subscriptions("some_attribute") + subscribers = {entry() for entry in agent.subscribers["some_attribute"]["change"]} + assert len(subscribers) == 0 + + subscribers = { + entry() for entry in agent.subscribers["some_other_attribute"]["change"] + } + assert len(subscribers) == 3 + + agent.clear_all_subscriptions(All()) + subscribers = {entry() for entry in agent.subscribers["some_attribute"]["change"]} + assert len(subscribers) == 0 + + subscribers = { + entry() for entry in agent.subscribers["some_other_attribute"]["change"] + } + assert len(subscribers) == 0 + + # test raises + with pytest.raises(ValueError): + agent.observe("some_attribute", "unknonw_signal", handler) + + with pytest.raises(ValueError): + agent.observe("unknonw_attribute", "change", handler) + + +def test_ObservableList(): + """Test ObservableList.""" + + class MyAgent(Agent, HasObservables): + my_list = ObservableList() + + def __init__( + self, + model, + ): + super().__init__(model) + self.my_list = [] + + model = Model(seed=42) + agent = MyAgent(model) + + assert len(agent.my_list) == 0 + + # add + handler = Mock() + agent.observe("my_list", "append", handler) + + agent.my_list.append(1) + assert len(agent.my_list) == 1 + handler.assert_called_once() + handler.assert_called_once_with( + AttributeDict( + name="my_list", new=1, old=None, type="append", index=0, owner=agent + ) + ) + agent.unobserve("my_list", "append", handler) + + # remove + handler = Mock() + agent.observe("my_list", "remove", handler) + + agent.my_list.remove(1) + assert len(agent.my_list) == 0 + handler.assert_called_once() + + agent.unobserve("my_list", "remove", handler) + + # overwrite the existing list + a_list = [1, 2, 3, 4, 5] + handler = Mock() + agent.observe("my_list", "change", handler) + agent.my_list = a_list + assert len(agent.my_list) == len(a_list) + handler.assert_called_once() + + agent.my_list = a_list + assert len(agent.my_list) == len(a_list) + handler.assert_called() + agent.unobserve("my_list", "change", handler) + + # pop + handler = Mock() + agent.observe("my_list", "remove", handler) + + index = 4 + entry = agent.my_list.pop(index) + assert entry == a_list.pop(index) + assert len(agent.my_list) == len(a_list) + handler.assert_called_once() + agent.unobserve("my_list", "remove", handler) + + # insert + handler = Mock() + agent.observe("my_list", "insert", handler) + agent.my_list.insert(0, 5) + handler.assert_called() + agent.unobserve("my_list", "insert", handler) + + # overwrite + handler = Mock() + agent.observe("my_list", "replace", handler) + agent.my_list[0] = 10 + assert agent.my_list[0] == 10 + handler.assert_called_once() + agent.unobserve("my_list", "replace", handler) + + # combine two lists + handler = Mock() + agent.observe("my_list", "append", handler) + a_list = [1, 2, 3, 4, 5] + agent.my_list = a_list + assert len(agent.my_list) == len(a_list) + agent.my_list += a_list + assert len(agent.my_list) == 2 * len(a_list) + handler.assert_called() + + # some more non signalling functionality tests + assert 5 in agent.my_list + assert agent.my_list.index(5) == 4 + + +def test_AttributeDict(): + """Test AttributeDict.""" + + class MyAgent(Agent, HasObservables): + some_attribute = Observable() + + def __init__(self, model, value): + super().__init__(model) + self.some_attribute = value + + def on_change(signal): + assert signal.name == "some_attribute" + assert signal.type == "change" + assert signal.old == 10 + assert signal.new == 5 + assert signal.owner == agent + + items = dir(signal) + for entry in ["name", "type", "old", "new", "owner"]: + assert entry in items + + model = Model(seed=42) + agent = MyAgent(model, 10) + agent.observe("some_attribute", "change", on_change) + agent.some_attribute = 5 + + +def test_Computable(): + """Test Computable and Computed.""" + + class MyAgent(Agent, HasObservables): + some_attribute = Computable() + some_other_attribute = Observable() + + def __init__(self, model, value): + super().__init__(model) + self.some_other_attribute = value + self.some_attribute = Computed(lambda x: x.some_other_attribute * 2, self) + + model = Model(seed=42) + agent = MyAgent(model, 10) + assert agent.some_attribute == 20 + + handler = Mock() + agent.observe("some_attribute", "change", handler) + agent.some_other_attribute = 9 # we change the dependency of computed + handler.assert_called_once() + agent.unobserve("some_attribute", "change", handler) + + handler = Mock() + agent.observe("some_attribute", "change", handler) + assert ( + agent.some_attribute == 18 + ) # this forces a re-evaluation of the value of computed + handler.assert_called_once() # and so, our change handler should be called + agent.unobserve("some_attribute", "change", handler) + + # cyclical dependencies + def computed_func(agent): + # this creates a cyclical dependency + # our computed is dependent on o1, but also modifies o1 + agent.o1 = agent.o1 - 1 + + class MyAgent(Agent, HasObservables): + c1 = Computable() + o1 = Observable() + + def __init__(self, model, value): + super().__init__(model) + self.o1 = value + self.c1 = Computed(computed_func, self) + + model = Model(seed=42) + with pytest.raises(ValueError): + MyAgent(model, 10) + + # parents disappearing