Skip to content

Making Model reactive#3212

Merged
quaquel merged 55 commits intomesa:mainfrom
quaquel:signal_pathfinding
Feb 6, 2026
Merged

Making Model reactive#3212
quaquel merged 55 commits intomesa:mainfrom
quaquel:signal_pathfinding

Conversation

@quaquel
Copy link
Copy Markdown
Member

@quaquel quaquel commented Jan 26, 2026

This PR makes model.time observable, and a signal is emitted when an agent is registered or deregistered.

This draft pr does the following

  1. Model extends HasObservables
  2. Model.time is declared as being Observable
  3. Add a new @emit decorator that can be used on HasObservable methods
  4. Add @emit to model.register_agents and model.deregister_agents, which emits a signal from the new ModelSignals enum
  5. Renames @computed to @computed_property
  6. Refactors signal enums for better typing support. There is now a root SignalType enum that all relevant enums for signals subclass. So, we have ObservableSignals, ListSignals, and ModelSignals. Under conditions, subclassing of enums is allowed, see the python docs.
  7. It moves the check for subscribers to the start of HasObservables.notify to fully remove any redundancy if there are no subscribers to a signal. The only overhead in that case is a single method call and an if check.
  8. Extensive internal refactoring for performance and code readability reasons
  9. Simplifies the Message dataclass so it can be used generically instead of being tied to ObservableSignals.CHANGE.

With this pr, the following things have become possible

model.observe("time", ObservableSignals.CHANGED, my_handler)
model.observe("agents", ModelSignals.AGENT_ADDED, my_handler)
model.observe("agents", ModelSignals.AGEND_REMOVED, my_handler)

@emit(name_of_obervable, MyCustomSignals.CALLED, when='before')
def my_method(self):
	...

@emit(name_of_obervable, MyCustomSignals.CALLED, when='after')
def my_method(self):
	...

The handler will be called with a Message instance. This is a dataclass with name, owner, signal_type, and additional_kwargs as fields. name is the name of the observable (e.g., time, agents, name_of_observable). owner is the HasObservable instance emitting the signal. signal_type is the type of signal (e.g., ObservableSignals.CHANGED). additional_kwargs contains the payload and this will differ across signal types.

The table below lists the fields for all signal types. Note here that anything sent by @emit will just include all arguments (args) and all keyword arguments of the method call in the payload.

signal type fields
ObservableSignals.CHANGED old, new
ModelSignals.AGENT_ADDED args
ModelSignals.AGENT_REMOVED args
ListSignals.SET old, new
ListSignals.INSERTED index, new
ListSignals.APPENDED index, new
ListSignals.REMOVED index, old
ListSignals.REPLACED index, old, new

closes #3211

@github-actions
Copy link
Copy Markdown

Performance benchmarks:

Model Size Init time [95% CI] Run time [95% CI]
BoltzmannWealth small 🔵 +3.8% [+2.7%, +4.8%] 🔴 +5.5% [+4.7%, +6.2%]
BoltzmannWealth large 🔵 +1.6% [+0.6%, +2.5%] 🔵 +3.1% [+2.3%, +4.0%]
Schelling small 🔵 +0.6% [-0.1%, +1.3%] 🔴 +3.8% [+3.1%, +4.5%]
Schelling large 🔵 +0.7% [-0.1%, +1.4%] 🔵 +3.2% [+2.2%, +4.3%]
WolfSheep small 🔴 +5.4% [+4.6%, +6.3%] 🔴 +4.0% [+3.2%, +4.8%]
WolfSheep large 🔴 +4.5% [+3.1%, +6.0%] 🔴 +3.9% [+3.0%, +4.8%]
BoidFlockers small 🔴 +5.1% [+3.8%, +6.5%] 🔵 +0.2% [-0.5%, +0.9%]
BoidFlockers large 🔴 +5.2% [+3.3%, +7.0%] 🔵 -0.1% [-0.9%, +0.8%]

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Jan 26, 2026

On open issue number 2, I am exploring an idea and would appreciate some feedback. I am thinking of adding an @observable(name_of_observable, signal_type, when) decorator that allows the user to emit a signal before or after calling the method:

@observable("agents", ModelSignals.AGENT_ADDED, when="after")
def register_agent(self, agent):
    ...

In this case, an AGENT_ADDED signal will be emitted after register_agent completes. This signal is part of the model.agents observable. So you would subscribe to it via model.observe("agents", ModelSignals.AGENT_ADDED, my_handler).

What is nice about this approach is that signals are no longer limited to attributes and attribute changes, but can be used more broadly.

However, there are also various drawbacks and unresolved issues.

First, HasObservablespopulates self.observables by checking all fields in the dicts of all classes in the MRO. So I still need to figure out how to add "agents" and the associated signals to the class. Something like what is used for @computed might work here, but I am not sure, since we might want to associate several signals with the same observable (as with AGENT_ADDED and AGENT_REMOVED).

Second, the observable computed stuff is tricky as it is. I don't yet know how these kinds of additional signals would be implemented in it. The ObservableList is kept out of the computed. So that would be the simplest option. But in that case, we might use a different name for the decorator. Something like @emit_signal instead of @observable might make more sense. So, we keep observables and computed confined to simple states and functions of states. While also using signals for wider reactive stuff, but just using emit_signal for this.

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Jan 27, 2026

Thanks for this effort. I will try to dive in later this week(end).

@codebreaker32
Copy link
Copy Markdown
Collaborator

codebreaker32 commented Jan 27, 2026

I’ve been exploring the decorator approach and I think we are moving to the right direction but some minor suggestions from my side

First, HasObservablespopulates self.observables by checking all fields in the dicts of all classes in the MRO. So I still need to figure out how to add "agents" and the associated signals to the class

We don't need manual registration. We can extend descriptor_generator to scan the MRO for decorated methods, exactly as it currently scans for Observable descriptors and @computed properties

If we have the decorator tag the method with an attribute (e.g., _emits_signal), the generator can pick it up:

def descriptor_generator(obj):
    seen_observables = {} 
    
    for base in type(obj).__mro__:
        for name, entry in base_dict.items()
            # existing checks

            # Check for @emits decorated methods
            elif callable(entry) and hasattr(entry, "_emits_signal"):
                obs_name, signal_type = entry._emits_signal
                if obs_name not in seen_observables:
                    seen_observables[obs_name] = set()
                # Accumulate signals
                seen_observables[obs_name].add(signal_type)
    
    for observable_name, signal_types in seen_observables.items():
        yield observable_name, signal_types

This naturally handles the "multiple signals per observable" case (like AGENT_ADDED and AGENT_REMOVED both belonging to "agents") by accumulating them in the seen_observables dict before yielding. To implement this, we will need to have set() structure back instead of Enums

Something like @emit_signal instead of @observable might make more sense

I agree with separating the naming. @observable implies state (like time), whereas these methods represent discrete events. @emits works well because it functions as a verb, mirroring the distinction between state monitoring and event logging.

the observable computed stuff is tricky as it is. I don't yet know how these kinds of additional signals would be implemented in it.

I would advise keeping @emits and @computed completely separate. They represent two different reactive paradigms:

@computed is for State Derivation (Dataflow: A changes B updates).
@emits is for Event Notification (Control flow: Action happens Signal fires).

Trying to make a computed property depend on an event signal (like AGENT_ADDED) gets messy because events don't persist as state. If a user needs to track state changes based on events (e.g., counting agents), they should explicitly observe the event and update a counter, rather than trying to derive it inside a computed property.

Example of why separation matters:

class Model(HasObservables):
    time = Observable()
    
    # This makes sense - computed depends on Observable
    @computed
    def agent_count(self):
        return len(self._agents)
    
    # This does NOT make sense - computed depending on events
    @computed
    def something(self):
        # What would it mean to depend on "agent added" event?
        # Events are point-in-time, not continuous state
        pass

If you need to react to events, use observation:

def __init__(self):
    super().__init__()
    self.agent_count = 0
    
    # React to events to update state
    self.observe("agents", ModelSignals.AGENT_ADDED,
                lambda s: setattr(self, 'agent_count', self.agent_count + 1))

Observable + @computed = Dataflow programming (reactive state)
@emits + observe() = Event-driven programming (reactive events)

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Jan 27, 2026

Thanks a lot for the write-up. It cleanly and clearly summarizes what I have been figuring out this afternoon. I'll update this PR hopefully later today along these lines.

Also, @codebreaker32, I was thinking of changing @computable to @computable_property to highlight that it is still a property, implying attribute-style access.

@codebreaker32
Copy link
Copy Markdown
Collaborator

codebreaker32 commented Jan 27, 2026

This is somewhat the rough sketch I was working on, Might help.

class emits:
    def __init__(
        self,
        observable_name: str,
        signal_type: SignalType,
        when: Literal["before", "after"] = "after",
        include_result: bool = False,
    ):
        self.observable_name = observable_name
        self.signal_type = signal_type
        self.when = when
        self.include_result = include_result

    def __call__(self, func: Callable) -> Callable:
        @functools.wraps(func)
        def wrapper(instance: HasObservables, *args, **kwargs):
            # If no one is listening, skip ALL signal logic.
            if not instance._has_subscribers(self.observable_name, self.signal_type):
                return func(instance, *args, **kwargs)

            # ASSUMPTION: First arg is the payload (e.g. agent) It works for now, might optimise later
            # We capture this for signal.new convenience.
            primary_arg = args[0] if args else None
            
            # Emit "before" if requested
            if self.when in ("before"):
                instance.notify(
                    self.observable_name,
                    old_value=None,
                    new_value=primary_arg,
                    signal_type=self.signal_type,
                )
            
            # Execute actual method
            result = func(instance, *args, **kwargs)
            
            # Emit "after" if requested
            if self.when in ("after"):
                signal_kwargs = {}
                if self.include_result:
                    signal_kwargs["result"] = result
                    
                instance.notify(
                    self.observable_name,
                    old_value=None,
                    new_value=primary_arg,
                    signal_type=self.signal_type,
                    **signal_kwargs,
                )
            
            return result
        
        # Tag for auto-discovery by descriptor_generator
        wrapper._emits_signal = (self.observable_name, self.signal_type)
        return wrapper

I was thinking of changing @Computable to @computable_property to highlight that it is still a property, implying attribute-style access.

You mean computed? Yes its a great name, pythonic and unambiguous

@codebreaker32
Copy link
Copy Markdown
Collaborator

I initially added a "both" literal also in the when clause, which is why I was checking with in

@codebreaker32
Copy link
Copy Markdown
Collaborator

Thanks a lot for the write-up

Glad it helped :)

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Jan 27, 2026

Ok, I pushed an update that builds on all the input. Again, thanks a lot.

  1. I added an @emit decorator that can be used to annotate functions or methods so they emit a signal before or after having been called.
  2. I renamed @computed to @computed_property
  3. I updated the inner descriptor_generator to handle the @emit decorator.
  4. I updated model.Model to use @emit on Model.register_agent and Model.deregister_agent.

There is still some work left to be done:

  1. We have to decide on where to put the message payload for @emit. Observable and @computed_property are simple: they change an old value to a new value. @emit, and ObservableList, have a richer payload. However, the current Message dataclass is tailored to the first, and has an additional_kwargs field for richer payloads. Now that we are expanding signals. Do we want to revisit the Message class design? One option would be to have two message types, but I don't like that because it makes writing the generic handlers more difficult. Another option would be to simply drop the old and new fields, and, for Observable and @computed_property, put this info in the additional_kwargs dict.
  2. There is some more cleanup and refactoring I would like to do. The distinction between BaseObservable and Observable serves little purpose with the introduction of @computed_property.
  3. @emit specifies the name of the observable and the signal that will be emitted when the decorated method is called. This makes the descriptor_generator quite straightforward. For Observable and @computed_property, however, we hard-code the signal that will be emitted inside the decorator. I suggest moving the signals that will be emitted into the property itself (i.e., Observable or ComputedProperty), so they are properly encapsulated where they belong. this is now addressed

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Feb 3, 2026

ListSignals uses verbs (INSERT, APPEND) while ModelSignals uses past tense (AGENT_ADDED). Should there be a consistent convention?

Good question, I don't have a strong opinion about this. Note also that Observables emit CHANGED. We could change the list signals to be past tense for consistency.

The @emit decorator emits after the entire method completes. What if users need to emit mid-method, or emit conditionally based on what happened? Is manual notify() still the recommended approach for complex cases?

First, emit allows controllign whether to emit before or after func. I think one is stretching the reactive formalism if one is doing any more complicated stuff. In such cases it might be easier to add am Observable flag and let depedends subscribe to the flag itself instead.

Can subscribers filter by agent type for AGENT_ADDED/AGENT_REMOVED? Or would they need to filter in their handler? This could matter for models with many agent types.

At the moment everything is tied to AGENT_ADDED/AGENT_REMOVED, so you would have to filter in the handler, which is relatively cheap to do. In #3227, I list ideas for future extensions to mesa_signals that might allow more fine grained approaches at the Agent class level.

Handlers that previously accessed signal.old and signal.new directly now need signal.additional_kwargs["old"] (if I understand correctly). I think we should consider whether Message should have optional old/new properties that pull from additional_kwargs for backward compatibility. If we don't want to support this behavior in the long term, we can add a deprecation warning to it.

Mesa_signals is experimental and was not used in any of our own examples. I doubt it is a feature users are using extensively in real models at the moment, so I don't see the need for this.

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Feb 3, 2026

We could change the list signals to be past tense for consistency.

Sounds good.

I think one is stretching the reactive formalism if one is doing any more complicated stuff.

Fair, if it turns out to be a real need we can always deal with it later.

I list ideas for future extensions to mesa_signals that might allow more fine grained approaches at the Agent class level.

Cool, let's discuss after this PR.

Handlers that previously accessed signal.old and signal.new directly now need signal.additional_kwargs["old"]

Aside from the backwards compatibility, signal.old (and signal.new) is of course a way more elegant API than signal.additional_kwargs["old"]. Are those important, if so would properties be nice, and are there any other kwargs that would benefit from properties?

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Feb 3, 2026

Aside from the backwards compatibility, signal.old (and signal.new) is of course a way more elegant API than signal.additional_kwargs["old"]. Are those important, if so would properties be nice, and are there any other kwargs that would benefit from properties?

The problem is that old and new are only meaningful for ObservableSignals.CHANGED. For other signals, they don't make sense. Hence, I decided, in conversation with @codebreaker32, that it makes more sense to have a single generic kwargs dict that contains the message's payload. This is valid across all messages.

The other possible design is to build an entire hierarchy of messages with their own unique fields. But this becomes very unwieldy because now in notify you have to figure out which message type to send and you need a complex registration system for user defined signals and message types.

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Feb 3, 2026

That gives some context, thanks. I'm good with it.

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Feb 3, 2026

Small update in light of the feedback

  1. All signal types now use a past tense
  2. I moved all signal types into their module
  3. I removed the testing code from the example

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Feb 6, 2026

I think these aren't yet addressed, right?

  1. a simple test showing Model.time and agent registration signals working might be useful
  2. a brief note in the PR description about migration from old API would be appreciated
  3. and maybe add a small example in the experimental docs or PR description

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Feb 6, 2026

a simple test showing Model.time and agent registration signals working might be useful
a brief note in the PR description about migration from old API would be appreciated
and maybe add a small example in the experimental docs or PR description

@emit and Observable are both fully tested, so it feels redundant to add tests for that. But if you insist I can do it.

The api changes are already noted, so not sure what more would be needed. I want to open a separate issue on adding an overview of signals and message payloads to the docs somewhere.

What do you mean with an example? How to use the new decorator?

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Feb 6, 2026

With experimental features we often don't have tutorials or example models, so the PR and its description is the only part where you can see more than the source code. We want power users to use and test our experimental features, and we often link to the PR in the release notes/highlights. Therefore I always like our PR description to be up to spec, like following the PR feature template when applicable. It helps users to get started and us to remind why and how we did things.

The way the PR templates are currently structured, they have dual-use for developers and early adopters (as a quick-start guide).

Practically, LLMs are great in helping with this. I often write the motivation myself and then throw in the diff (just add .diff to the URL, like https://github.com/mesa/mesa/pull/3212.diff), the relevant PR template, and other relevant parts of discussion, original PR description, etc. In Claude I have a "Mesa development" project in which the PR feature template is added by default, alongside most important source code (model.py, agent.py, etc.). It gives 90% of the results (which I edit if needed, most often only lightly) for not even a minute of work.

If you don't agree on the usefulness of PR descriptions (for experimental features) or the PR templates itself, let's discuss that separately.

Copy link
Copy Markdown
Member

@EwoutH EwoutH left a comment

Choose a reason for hiding this comment

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

Approving, most changes are in the experimental space and changes to the stable model.py are minimal, easily revertible and non-breaking.

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Feb 6, 2026

I agree on the PR stuff, but this is part of a series of PRs that, once they come together, must be illustrated by examples and in the PR descriptions.

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Feb 6, 2026

That's a fair point. I would approach it this way: Write up the PR description of what this PR does now, and if it (significantly) changes in the feature, just add a line on top:

This PR is outdated, see #XXXX for the current approach

@quaquel quaquel merged commit 507d213 into mesa:main Feb 6, 2026
14 checks passed
@quaquel quaquel deleted the signal_pathfinding branch February 8, 2026 15:42
@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Feb 11, 2026

So we can now subscribe to model.time. What if I only want to do something if:

  • model.time increases by at least 1 since I last checked?
  • model.time passes the next integer?

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Feb 11, 2026

You have to handle that inside the handler. As it should be in my view.

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Feb 11, 2026

Could you give a minimal example?

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Feb 11, 2026

You can look at th #3145, where in the handler it is checked. but a minimum example would be:

def my_handler(message:Message):
    delta = message.kwargs["new"] - message.kwargs["old"]
    if delta < 1:
        return
    # my handler code

# if you want memory for your last action
class MyHandler:
    def __init__(self):
        self.last_action = 0
    
    def __call__(message:Message):
        new = message.kwargs["new"]
        delta = new - self._last_action
        if delta < 1:
            return
        self.last_action = new
        # my action

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Feb 11, 2026

Looks complex.

But maybe I'm overthinking this. If you want to do sometime every 1 timestep anyways, just schedule an recurring event.

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Feb 11, 2026

But maybe I'm overthinking this. If you want to do something every 1 timestep anyway, just schedule a recurring event.

It really depends on what you want to achieve. I would, in general, use event scheduling for simulation stuff, while using signals for looser coupling to e.g., a UI or a data recorder.

@EwoutH
Copy link
Copy Markdown
Member

EwoutH commented Feb 11, 2026

I think time is important enough to warrant some default handlers. Especially if they can take a Schedule.

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Feb 11, 2026

I think time is important enough to warrant some default handlers. Especially if they can take a Schedule.

It might be quite easy to have a base handler class that takes a schedule, or even instantiate a class with a schedule and a callable.

@quaquel
Copy link
Copy Markdown
Member Author

quaquel commented Feb 11, 2026

I think time is important enough to warrant some default handlers. Especially if they can take a Schedule.

Thinking about this a bit more. This conversation focussed mostly on time being observable. In that context, there might be a use case for a default handler. However, I don't think that this generalizes to observables/emitters as such.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Release notes label experimental Release notes label performance Release notes label

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Making Model reactive

4 participants