Skip to content

FEAT: Add class-level subscribe to HasEmitters#3368

Merged
quaquel merged 15 commits intomesa:mainfrom
Nithurshen:feat/class-level-subscribe
Mar 5, 2026
Merged

FEAT: Add class-level subscribe to HasEmitters#3368
quaquel merged 15 commits intomesa:mainfrom
Nithurshen:feat/class-level-subscribe

Conversation

@Nithurshen
Copy link
Copy Markdown
Contributor

Summary

This PR introduces the ability to subscribe to observable events at the class level via a new @classmethod subscribe. Subscriptions made this way are inherited by all instances of that class, meaning any instance emitting the specified signal will trigger the registered handler.

Motive

This addresses a specific enhancement requested in the Mesa Signals tracking issue #3227. Previously, observing agents globally required relying on model.register_agent or manually binding observe to every instantiated object at runtime. This feature makes global data collection, UI updates, and logging significantly easier and more intuitive by allowing a one time class level binding.

Implementation

  • Modified HasObservables.__init_subclass__ to initialize a distinct _class_subscribers dictionary for every subclass. This ensures that subscribing to a specific Agent subclass doesn't accidentally bind handlers to all other reactive classes.
  • Added @classmethod subscribe(...) which mirrors the signature of the instance-level observe method but registers the handler globally for the class.
  • Strictly utilized create_weakref(handler) for class-level subscribers. This directly addresses the concern raised in the tracking issue by ensuring that the global observer doesn't prevent model instances from being properly garbage-collected during large experiments or parameter sweeps.
  • Updated _has_subscribers and _mesa_notify to seamlessly aggregate and dispatch messages to both instance-level and class-level listeners.
  • Added test_class_level_subscribe to tests/experimental/test_mesa_signals.py to verify functionality across multiple instances.

Usage Examples

You can now easily observe all instances of an agent class without needing to register handlers on them individually:

from mesa.experimental.mesa_signals import HasObservables, Observable
from mesa.experimental.mesa_signals.signal_types import ObservableSignals

class MyAgent(HasObservables):
    state = Observable()

def state_change_handler(msg):
    agent = msg.owner
    old_val = msg.additional_kwargs.get("old")
    new_val = msg.additional_kwargs.get("new")
    print(f"Agent {agent} changed state from {old_val} to {new_val}")

MyAgent.subscribe("state", ObservableSignals.CHANGED, state_change_handler)

agent1 = MyAgent()
agent2 = MyAgent()

agent1.state = "active"   # Prints: Agent <...> changed state from None to active
agent2.state = "waiting"  # Prints: Agent <...> changed state from None to waiting

Additional Notes

@Nithurshen Nithurshen marked this pull request as ready for review February 24, 2026 03:03
@github-actions
Copy link
Copy Markdown

Performance benchmarks:

Model Size Init time [95% CI] Run time [95% CI]
BoltzmannWealth small 🔵 +0.2% [-0.2%, +0.6%] 🔵 -0.4% [-0.5%, -0.2%]
BoltzmannWealth large 🔵 +0.7% [+0.4%, +1.0%] 🟢 -4.7% [-5.6%, -3.6%]
Schelling small 🔵 +0.4% [+0.2%, +0.7%] 🔵 +0.2% [-0.0%, +0.4%]
Schelling large 🔵 +0.8% [+0.4%, +1.3%] 🔵 +1.8% [+0.1%, +3.5%]
WolfSheep small 🔵 +1.4% [+1.2%, +1.5%] 🔵 +0.7% [+0.5%, +1.0%]
WolfSheep large 🔵 +1.4% [+0.9%, +2.0%] 🔵 +1.4% [+0.6%, +2.3%]
BoidFlockers small 🔵 +1.1% [+0.8%, +1.3%] 🔵 -0.8% [-0.9%, -0.6%]
BoidFlockers large 🔵 +1.5% [+1.2%, +2.0%] 🔵 -0.4% [-0.5%, -0.2%]

@github-actions
Copy link
Copy Markdown

Performance benchmarks:

Model Size Init time [95% CI] Run time [95% CI]
BoltzmannWealth small 🔵 +1.1% [+0.7%, +1.5%] 🔵 -1.9% [-2.1%, -1.6%]
BoltzmannWealth large 🔵 +0.9% [+0.5%, +1.1%] 🔵 -1.1% [-1.4%, -0.8%]
Schelling small 🔵 +1.6% [+1.3%, +1.9%] 🔵 -0.3% [-0.5%, -0.1%]
Schelling large 🔵 +0.6% [+0.2%, +0.9%] 🔵 +2.7% [+2.1%, +3.5%]
WolfSheep small 🔵 +2.6% [+2.4%, +2.8%] 🔵 +1.0% [+0.8%, +1.2%]
WolfSheep large 🔵 +2.6% [+1.9%, +3.3%] 🔵 +2.2% [+1.6%, +2.9%]
BoidFlockers small 🔵 +0.3% [+0.1%, +0.5%] 🔵 -0.1% [-0.3%, +0.1%]
BoidFlockers large 🔵 -0.6% [-0.9%, -0.3%] 🔵 -0.2% [-0.3%, -0.0%]

key in self.subscribers and len(self.subscribers[key]) > 0
)
has_class_subscribers = (
hasattr(self, "_class_subscribers")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this check should not be needed if _class_subscribers is implemented correctly.

active_observer(signal)
active_observers.append(observer)
# use iteration to also remove inactive observers
if key in self.subscribers:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this check seems redundant.

Comment on lines +473 to +483
class_observers = self._class_subscribers[key]
active_class_observers = []
for observer in class_observers:
if active_class_observer := observer():
active_class_observer(signal)
active_class_observers.append(observer)

if active_class_observers:
self._class_subscribers[key] = active_class_observers
else:
del self._class_subscribers[key]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like code repetition. I am wondering whether this could not be implemented in a way that would avoid the apparent code duplication.

return signal_type

@classmethod
def subscribe(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure about the naming here. We now have Class.subcribe and Instance.observe. What about e.g., Class.observe_class or something along those lines?

Comment on lines +551 to +555
names = (
[observable_name]
if isinstance(observable_name, str)
else (cls.observables.keys() if observable_name is ALL else observable_name)
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please refactor this into something more readable.

for st in signal_types:
cls._class_subscribers[(name, st)].append(ref)


Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there seems to be overlap with observe code, so can this be simplified in a way that avoids code duplications?

@Nithurshen Nithurshen changed the title FEAT: Add class-level subscribe to HasObservables FEAT: Add class-level subscribe to HasEmitters Feb 24, 2026
@Nithurshen Nithurshen requested a review from quaquel February 24, 2026 17:52
@quaquel
Copy link
Copy Markdown
Member

quaquel commented Feb 25, 2026

  1. What about unobserve_class? At a minimum, this class method should be there to mimic the instance level observe and unobserve.
  2. What about clear_all_subscriptions? This currently clears everything at the instance level. How do we want to handle class level subscriptions in this?

This second question points to a wider architectural question. At the moment, this PR maintains two lists of subscribers: instance-level subscriptions and class-level subscriptions. To me this is a sensible design. However, an alternative would be to let the instance at creation "inherit" all class-level subscriptions by adding these to the instance subscription dict. The advantage of this is that clear_all_subscriptions then does wat is says. However, it also means that any class level new subscriptions that happen after instantiation are not well defined. Thoughs on this are welcome.

@Nithurshen
Copy link
Copy Markdown
Contributor Author

Regarding the architectural question, I definitely think keeping the two list design is the most robust approach. If we were to let instances inherit class-level subscriptions by copying them into the instance dictionary during __init__, we would run into a few major issues:

  1. If a user calls observe_class after some instances are already created, those older instances wouldn't receive the new subscription since their __init__ has already run.
  2. If a user later calls unobserve_class, we would have no way to remove that observer from already-instantiated objects without tracking every single instance in memory.
  3. If a user calls agent1.clear_all_subscriptions(), it shouldn't unexpectedly wipe out a global class-level observer (like a DataCollector or UI renderer) that the rest of the model relies on.

To resolve this and address the rest of your feedback, I have made the following changes:

  • Added unobserve_class and clear_all_class_subscriptions to ensure class-level observers have the same lifecycle controls as instance-level ones.
  • Refactored the internal logic into shared _register_observer and _unregister_observer helpers to keep the code DRY and eliminate the overlap between the class and instance methods.
  • Merged the dispatch logic in _mesa_notify into a single loop over (self.subscribers, self._class_subscribers), utilizing .get() to remove the redundant if key in... checks.
  • Typed _class_subscribers as a ClassVar and initialized it on the base class, completely removing the need for the hasattr check overhead.
  • Renamed the method to observe_class for consistency, and refactored _process_name and _process_signal_type into @classmethods to remove the nested ternary operators.

Let me know if this implementation aligns with your vision.

else:
del self.subscribers[key]
@classmethod
def _clear_all_subscriptions_from_dict(cls, subs_dict: dict, name: ObservableName):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def _clear_all_subscriptions_from_dict(cls, subs_dict: dict, name: ObservableName):
def _clear_all_subscriptions(cls, subs_dict: dict, name: ObservableName):

@Nithurshen
Copy link
Copy Markdown
Contributor Author

@quaquel, Is there still anything you wish to be changed?


signal_types = target_signals or self.observables[name]
for name in names:
signal_types = target_signals or cls.observables[name]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might want to clarify the logic here. If signal_type is ALL, target_signals returns None and that is handled nex in this statement.

@quaquel
Copy link
Copy Markdown
Member

quaquel commented Mar 4, 2026

Sorry took me a while to review the code. It looks fine with one remaining request.

@Nithurshen Nithurshen requested a review from quaquel March 5, 2026 01:43
@quaquel quaquel merged commit 42c95d1 into mesa:main Mar 5, 2026
14 checks passed
@Nithurshen Nithurshen deleted the feat/class-level-subscribe branch March 5, 2026 12:04
@EwoutH EwoutH added the experimental Release notes label label Mar 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

experimental Release notes label

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants