Skip to content

feat: Add SignalType enum for type-safe signal definitions.#3056

Merged
quaquel merged 6 commits intomesa:mainfrom
codebyNJ:feature/signal-types-enum
Jan 15, 2026
Merged

feat: Add SignalType enum for type-safe signal definitions.#3056
quaquel merged 6 commits intomesa:mainfrom
codebyNJ:feature/signal-types-enum

Conversation

@codebyNJ
Copy link
Copy Markdown
Contributor

@codebyNJ codebyNJ commented Jan 2, 2026

mesa_signals uses plain strings for signal types. Typos are only caught at runtime, there is no IDE autocomplete or discoverability (FIXME in source).

Solution
Add SignalType(str, Enum) (e.g., SignalType.CHANGE) and use it internally while preserving full backward compatibility with string signal names.

Changes

  • mesa_signal.py - added SignalType, updated usages
  • __init__.py - exported SignalType

To Reproduce

from mesa.experimental.mesa_signals import Observable

class M:
    val = Observable()

m = M()

# Typo in signal type — currently only caught at runtime
try:
    m.observe("val", "chang", lambda s: print("changed"))
except ValueError as e:
    print("Runtime error:", e)

# Preferred (type-safe) usage after this change:
from mesa.experimental.mesa_signals import SignalType
m.observe("val", SignalType.CHANGE, lambda s: print("changed"))
m.val = 10  # triggers handler

Testing

  • All mesa_signals tests pass.
  • Ruff linting passed.
  • Benchmarks show no regressions.
  • Backward compatible: SignalType.CHANGE == "change" and existing string usage continues to work.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Jan 2, 2026

Performance benchmarks:

Model Size Init time [95% CI] Run time [95% CI]
BoltzmannWealth small 🔵 -3.4% [-4.4%, -2.3%] 🔵 -1.9% [-2.1%, -1.8%]
BoltzmannWealth large 🔵 -0.6% [-1.4%, +0.2%] 🔵 -1.8% [-4.1%, +1.1%]
Schelling small 🔵 -0.7% [-1.6%, +0.3%] 🔵 +0.4% [-0.1%, +1.0%]
Schelling large 🔵 -1.1% [-1.5%, -0.7%] 🔵 -3.1% [-3.9%, -2.4%]
WolfSheep small 🔵 -0.9% [-1.1%, -0.7%] 🔵 -1.9% [-2.1%, -1.7%]
WolfSheep large 🔵 -0.3% [-5.0%, +4.6%] 🟢 -4.0% [-5.3%, -3.0%]
BoidFlockers small 🔵 -0.8% [-1.0%, -0.6%] 🔵 +1.1% [+0.9%, +1.3%]
BoidFlockers large 🔵 -0.7% [-1.1%, -0.3%] 🔵 +0.7% [+0.5%, +1.0%]

@quaquel
Copy link
Copy Markdown
Member

quaquel commented Jan 2, 2026

I know there was an idea of using an enum in the fixme, but this can also be handled via Literal[] type hinting. What are the merits of both approaches and why would you argue that enums are better? Moreover, as indicated in the FIXME, there is a lot more going on here that might not be doable with a simple enum (hence the fixme comment).

@codebyNJ
Copy link
Copy Markdown
Contributor Author

codebyNJ commented Jan 3, 2026

@quaquel Here's my comparison of both approaches:

Literal[] Pros

  • Zero runtime overhead
  • Purely static, no new classes

Literal[] Cons

  • No runtime validation (typos pass silently at runtime)
  • No discoverability (list(SignalType) not possible)
  • Limited IDE support compared to enums
  • Cannot be extended by subclasses

Enum Pros

  • Runtime validation catches errors
  • Full IDE autocomplete and discoverability
  • SignalType(str, Enum) has no overhead (inherits from str)
  • Extensible: subclasses can add domain-specific signals
  • Introspectable: list(SignalType) works

Regarding the Broader FIXME

"Ideally you can define signal_types throughout the class hierarchy and they are just combined together"

This actually favors enums because:

  1. Custom enums can inherit/extend SignalType
  2. Subclasses like ObservableList could define:
    class ListSignalType(SignalType):
        APPEND = "append"
        REMOVE = "remove"

@quaquel
Copy link
Copy Markdown
Member

quaquel commented Jan 5, 2026

No runtime validation (typos pass silently at runtime)

I don't think this is correct. HasObservables.observe raises a value error if you try to subscribe to a signal type that is not being emitted by the observable.

Limited IDE support compared to enums

I mostly agree, although support for typing and, by extension, Literal is improving rapidly in most IDEs.

Extensible: subclasses can add domain-specific signals
Introspectable: list(SignalType) works

I am not entirely sure how subclassing Enums works, so I would need an example or some time to investigate this myself. However, ideally, it should be trivial to obtain an overview of all observables and, for each observable, the signals it can emit.

@codebyNJ
Copy link
Copy Markdown
Contributor Author

codebyNJ commented Jan 5, 2026

@quaquel

Runtime validation:
You're right - I should have been more precise. HasObservables.observe does validate at runtime, but only when you call observe(). The issue is that typos in signal names passed to emit() or when checking if signal_type in ... won't be caught unless there's an active observer. With enums, typos are caught immediately at parse time by the IDE and at runtime whenever the enum is referenced.

Extensibility and introspection:
For enum subclassing, here's an example:

from enum import Enum

class BaseSignals(str, Enum):
    CHANGED = "changed"
    REMOVED = "removed"

class CellSignals(BaseSignals):
    AGENT_ADDED = "agent_added"
    AGENT_REMOVED = "agent_removed"

# Works with type checkers
def handle_signal(signal: CellSignals): ...

# Introspection
list(CellSignals)  # All signals including inherited ones
BaseSignals.CHANGED in CellSignals  # True

@quaquel
Copy link
Copy Markdown
Member

quaquel commented Jan 5, 2026

Thanks. I'll try to take a quick look myself later today at your example as well as reacquaint myself with this code to see if enums indeed solve all issues I had in mind when I wrote the fixme.

@codebyNJ
Copy link
Copy Markdown
Contributor Author

codebyNJ commented Jan 5, 2026

Sure got it.

@quaquel
Copy link
Copy Markdown
Member

quaquel commented Jan 12, 2026

As a further exploration of this idea, could you also update observable_collections? Here, more signals exist, so it's a nice test to see if using enums works as intended.

@codebyNJ
Copy link
Copy Markdown
Contributor Author

As a further exploration of this idea, could you also update observable_collections? Here, more signals exist, so it's a nice test to see if using enums works as intended.

Sure I will update it.

@codebyNJ codebyNJ force-pushed the feature/signal-types-enum branch from c4c002a to 15a66ac Compare January 13, 2026 07:10
@codebyNJ
Copy link
Copy Markdown
Contributor Author

@quaquel I have updated the observable_collections also with enum.

]


class ListSignalType(str, Enum):
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.

why does this not extent SignalType?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Python Enums can’t be cleanly subclassed, so inheriting SignalType isn’t viable. I added ListSignalType as (str, Enum) to preserve runtime string-equality (e.g. ListSignalType.INSERT == "insert" == SignalType.INSERT), keep full backward compatibility, and get IDE/type-safety for list-specific signals.

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.

Thanks for the clarification.

self.data[index] = value
self.owner.notify(self.name, old_value, value, "replace", index=index)
self.owner.notify(
self.name, old_value, value, ListSignalType.REPLACE, index=index
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.

I do like the clean code due to the enum here. So yes, enums seem like the way to go.

"""Initialize the ObservableList."""
super().__init__()
self.signal_types: set = {"remove", "replace", "change", "insert", "append"}
self.signal_types: set = {
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.

I guess this should be simpler, because it's just all fields of the ListSignalEnum

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I replaced the manual string set with an automatic set of enum members so it always reflects ListSignalType. Added a one-line comment explaining the choice.

@codebyNJ codebyNJ force-pushed the feature/signal-types-enum branch from 4cac8b8 to 15c8167 Compare January 14, 2026 07:19
@quaquel quaquel merged commit f449565 into mesa:main Jan 15, 2026
13 of 14 checks passed
@quaquel quaquel linked an issue Jan 19, 2026 that may be closed by this pull request
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Use Enum for Signal Types in mesa_signals for Type Safety

3 participants