Skip to content

Enable type-hinted Scenario subclassing and fix Model generic typing#3168

Merged
EwoutH merged 7 commits intomesa:mainfrom
EwoutH:scenario_subclassing
Jan 18, 2026
Merged

Enable type-hinted Scenario subclassing and fix Model generic typing#3168
EwoutH merged 7 commits intomesa:mainfrom
EwoutH:scenario_subclassing

Conversation

@EwoutH
Copy link
Copy Markdown
Member

@EwoutH EwoutH commented Jan 18, 2026

Summary

This PR enhances the Scenario class to support type-hinted subclassing and makes Model generic over scenario types, providing better IDE support and type safety for scenario parameters.

Took some time to get everything working at the same time, but now it does! I did test it on an updated version of the EpsteinCivilViolence model.

Motivation

Currently, when using scenarios in Mesa models, there are two issues:

  1. No type hint support: When subclassing Scenario with typed attributes, IDEs don't recognize these attributes as instance attributes, causing false warnings and lack of autocomplete.
  2. Type information lost: When passing a typed scenario subclass to a Model, the type is lost and model.scenario is typed as base Scenario, losing all the specific parameter type information.
  3. Shared instance problem: Using a module-level scenario instance causes failures when creating multiple model instances, as the scenario's model reference remains set to the first model.

Changes

  1. Enhanced Scenario class for type-hinted subclassing
    • Added __init_subclass__ to collect type-annotated class attributes as defaults once per class (not per instance)
    • Class defaults are cached in _scenario_defaults and applied during __init__
    • Uses __slots__ = ("__dict__", "_scenario_id", "model") pattern:
      • __slots__ for Mesa's fixed internal attributes (_scenario_id, model)
      • __dict__ for unlimited dynamic scenario parameters
    • Improved error messages and documentation with examples
  2. Made Model generic over scenario type
    • Added S: Scenario type parameter to Model[A: Agent, S: Scenario]
    • Updated scenario property and __init__ to use generic type S
  3. Minor improvements
    • Fixed __setattr__ to check self.model exists before accessing model.running
    • Added comprehensive tests for scenario subclassing
    • Improved type annotations throughout

No breaking changes.

Design decisions

Why not dataclasses?

The @dataclass decorator would require all fields to be predefined, preventing the flexible **kwargs pattern that allows users to pass arbitrary parameters without subclassing (e.g., Scenario(rng=42, any_param=value)). The current implementation supports both simple Scenario(**kwargs) for quick prototyping and type-hinted subclassing for production code, without the complexities of dataclass inheritance (field ordering requirements, field() specifications).

Why __init_subclass__ over __init__?

Per reviewer feedback: Collecting class-level annotations in __init__ is wasteful since it happens for every instance. Using __init_subclass__ performs MRO traversal once when the subclass is created, caching the results in _scenario_defaults for all instances. This is the idiomatic Python 3.6+ pattern for class customization at subclass creation time.

Testing

Added three new test cases:

  1. test_scenario_subclassing: Verifies type-hinted subclassing works correctly
  2. test_scenario_subclass_with_model: Verifies scenario subclasses work with Model
  3. test_scenario_fresh_instance_per_model: Ensures no shared state issues

Example Usage

from mesa import Model, Agent
from mesa.experimental.scenarios import Scenario

# Define typed scenario
class MyScenario(Scenario):
    citizen_density: float = 0.7
    cop_vision: int = 7
    movement: bool = True

# Use in model with full type support
class MyModel(Model):
    def __init__(self, scenario: MyScenario | None = None):
        if scenario is None:
            scenario = MyScenario(rng=42)  # Apply the default scenario
        super().__init__(scenario=scenario)
        
        # Full IDE autocomplete! ✅
        print(self.scenario.citizen_density)
        print(self.scenario.cop_vision)

Follow-up on #3103, and issues found in #3167 regarding scenario type hints and shared instance problems.

Enhanced the Scenario class to support both simple instantiation and type-hinted subclassing, allowing class-level defaults to be overridden by instance parameters. Improved documentation and enforced immutability of parameters during model execution. These changes make scenario configuration more flexible and robust for complex models.
The Model class now accepts a second generic type parameter S for the scenario type.
Introduces tests to verify that Scenario can be subclassed with type-hinted attributes, that scenario subclasses integrate correctly with Model, and that each model receives a fresh scenario instance to avoid shared state.
@quaquel
Copy link
Copy Markdown
Member

quaquel commented Jan 18, 2026

I don't have time to review this at the moment. Just one question: why not dataclasses?

@github-actions
Copy link
Copy Markdown

Performance benchmarks:

Model Size Init time [95% CI] Run time [95% CI]
BoltzmannWealth small 🟢 -5.9% [-6.6%, -5.1%] 🔵 +1.7% [+1.6%, +1.8%]
BoltzmannWealth large 🟢 -6.3% [-6.6%, -6.0%] 🔵 +2.2% [+0.7%, +3.4%]
Schelling small 🟢 -5.5% [-5.6%, -5.4%] 🔵 +0.3% [+0.1%, +0.4%]
Schelling large 🟢 -3.5% [-3.7%, -3.3%] 🔵 +1.9% [+1.2%, +2.8%]
WolfSheep small 🔵 -0.6% [-0.8%, -0.4%] 🔵 +0.1% [-0.0%, +0.2%]
WolfSheep large 🔵 -0.1% [-0.5%, +0.2%] 🔵 +0.7% [+0.4%, +1.2%]
BoidFlockers small 🔵 -0.2% [-0.7%, +0.1%] 🔵 +0.1% [-0.1%, +0.2%]
BoidFlockers large 🔵 -0.1% [-0.4%, +0.2%] 🔵 -0.2% [-0.4%, -0.1%]

@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Jan 18, 2026

Just one question: why not dataclasses?

Great question. I did consider (and try) that at first, but there are a few problems.

The @dataclass decorator would require all fields to be predefined, and thereby it wouldn't support the flexible **kwargs pattern that allows users to pass arbitrary parameters without subclassing (e.g., Scenario(rng=42, any_param=value)). So adopting that would make subclassing a requirement instead of an option.

This implementation uses __slots__ with dynamic __dict__ updates, which supports both the simple Scenario(**kwargs) pattern for quick prototyping and the type-hinted subclass pattern. It handles inheritance through explicit MRO traversal without the complexities of dataclass inheritance (like field ordering requirements and field() specifications). It also provides slightly better memory efficiency.

Comment on lines +68 to +76
# Collect class-level annotated attributes as defaults
defaults = {}
for cls in reversed(self.__class__.__mro__):
if cls is Scenario or cls is object:
continue
# Get type-annotated attributes
annotations = getattr(cls, "__annotations__", {})
for key in annotations:
if hasattr(cls, key) and not key.startswith("_"):
Copy link
Copy Markdown
Member

@quaquel quaquel Jan 18, 2026

Choose a reason for hiding this comment

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

This is a class-level thing, so it's a bit wasteful to do it in the __init__, which is instance-specific. There is probably some metaclass or other trick to doing this only once when creating the class.

From a quick search, I think that a simple scenario meta class with __init_subclass__ magic might do the trick

Below just some snippets to get started.

class ScenarioMeta:
	def __new__meta, name, bases, class_dict):
		# The __new__ method of metaclasses is run after the class statement’s entire body has been processed.
		# the mro stuff might be handled in here


class Scenario(metaclass=ScenarioMeta):
	# existing code

	# another option, can be done separately or instead of the metaclass approach
	def __init_subclass__(cls):
		# This method is called whenever the containing class is subclassed. 
        #cls is then the new subclass. If defined as a normal instance 
        #method, this method is implicitly converted to a class method.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch. I don’t think we need the metaclass, but can just directly use __init_subclass__ :

class Scenario:
    def __init_subclass__(cls):
        """Called once when a subclass is created."""
        # Collect defaults once and cache on the class
        defaults = {}
        for base in reversed(cls.__mro__):
            if base is Scenario or base is object:
                continue
            annotations = getattr(base, '__annotations__', {})
            for key in annotations:
                if hasattr(base, key) and not key.startswith('_'):
                    defaults[key] = getattr(base, key)
        
        # Cache on the class itself
        cls._scenario_defaults = defaults
    
    def __init__(self, *, rng=None, **kwargs):
        # Just use the cached defaults
        self.__dict__.update(self.__class__._scenario_defaults)
        self.__dict__.update(kwargs)
        self.__dict__['rng'] = rng

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.

It works we a few minor additions. I have pushed the change to this PR.

Comment on lines +88 to +89
def __len__(self):
"""Return number of parameters."""
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.

so the idea is that the __dict__ contains the scenario paramters, while we use __slots__ for the overarching registration stuff (such as scenario_id)?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, indeed. The __slots__ = ("__dict__", "_scenario_id", "model") part uses slots for Mesa’s fixed internal attributes (_scenario_id for unique identification and model for the model reference), explicitly including __dict__ to allow unlimited dynamic scenario parameters (like citizen_density, cop_vision, etc.).

This gives the memory efficiency of __slots__ for the structural attributes that every scenario needs, while maintaining the flexibility of __dict__ for user-defined parameters that vary between different scenario types.

@EwoutH
Copy link
Copy Markdown
Member Author

EwoutH commented Jan 18, 2026

If we fix pre-commit I think we're ready to merge.

@EwoutH EwoutH merged commit c1500fe into mesa:main Jan 18, 2026
14 checks passed
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.

2 participants