-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Add ContinuousStates and -Observables #2851
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Add ContinuousObservable class that extends mesa_signals to support agent properties that change continuously over time. This enables modeling realistic phenomena like energy depletion, resource growth, and gradual state changes without manual step-by-step updates. Key features: - Lazy evaluation: values only recalculated when accessed, improving performance - Threshold detection: automatic callbacks when values cross specified thresholds in either direction - Configurable rate functions: support linear, exponential, or custom change patterns - Full integration with existing mesa_signals reactive system: works seamlessly with Observable, Computable, and dependency tracking - Time-aware: integrates with Mesa's time system for accurate elapsed time calculations This provides a foundational building block for modeling needs-based architectures, continuous resource systems, and time-dependent agent behaviors while maintaining the composability and reactivity of mesa_signals. fix imports
Update the wolf-sheep predation example to demonstrate mesa_signals and ContinuousObservable capabilities. Energy now depletes continuously over time rather than in discrete steps, and agent behaviors react automatically to energy state changes. Changes: - Convert Animal base class to use ContinuousObservable for energy tracking with configurable metabolic rates - Add computed properties (is_hungry, can_reproduce) that automatically update based on energy levels - Implement threshold-triggered events for death (energy reaches zero) and survival mode (critical hunger) - Enhance decision logic: sheep prioritize grass when hungry, wolves hunt actively when hungry - Add activity-based metabolic rates (movement costs more energy than resting) - Improve GrassPatch to use Observable and react to state changes with signal handlers - Expand data collection to track average energy levels across populations The example now demonstrates key reactive programming patterns: observable state management, computed properties for decision making, threshold-based event triggering, and automatic dependency tracking. This makes the model more realistic (continuous dynamics) while reducing boilerplate code (declarative state management). fix agent imports
Implement flexible time retrieval in ContinuousObservable to work with different Mesa time management approaches. The _get_time() method tries multiple sources in priority order: 1. model.simulator.time (DEVS/continuous models) 2. model.time (if explicitly set) 3. model.steps (fallback for discrete models) This workaround enables ContinuousObservable to function correctly regardless of whether a model uses discrete event simulation, custom time tracking, or standard step-based progression. Obviously this should be fixed structurally. See projectmesa#2228
The ContinuousObservable descriptor was inheriting Observable's __set__ method, which stored raw numeric values instead of ContinuousState objects. This caused AttributeError when __get__ tried to access state.last_update on a float/numpy.float64. The new __set__ method creates a ContinuousState wrapper on first assignment and updates the existing state on subsequent assignments. This ensures the private attribute always contains a properly structured state object with value, last_update, rate_func, and thresholds attributes. Fixes initialization of continuous observables in agent __init__ methods where energy and other time-varying properties are set.
Changes the ContinuousObservable threshold system from storing callbacks directly to using a signal-based subscription pattern: - Change `_thresholds` from dict to set (stores threshold values only) - Update `add_threshold()` to check for existing subscriptions before subscribing to prevent duplicate callback invocations - Separate concerns: thresholds define WHICH values to watch, observers define WHO to notify when crossed - Add ValueError when attempting to add threshold to non-ContinuousObservable This fixes an issue where registering multiple thresholds with the same callback would cause it to be called multiple times per threshold crossing.
Adds test_continuous_observables.py with 19 tests covering the full functionality of continuous state management in mesa_signals: Basic functionality: - Constant and variable rate depletion over time - Manual value setting (e.g., energy boost from eating) - Time-based lazy evaluation Signal emissions: - Change notifications when values update - Threshold crossing detection (upward and downward) - Multiple threshold management - No signals when values don't change Edge cases: - Zero elapsed time (no spurious updates) - Multiple accesses at same time point (single calculation) - Exact threshold values (inclusive boundary handling) - Negative values (energy going below zero) - NumPy float compatibility (fixes AttributeError with random values) Integration: - Computed properties based on continuous observables - Batch agent creation with numpy arrays - Both DEVSimulator (float time) and ABMSimulator (integer steps) - Wolf-sheep predator-prey scenario with death thresholds All tests use proper discrete event scheduling through Mesa's simulator rather than manual time manipulation, ensuring realistic simulation behavior.
|
I'm a bit mentally exhausted, was a big push to get this finished. I will write up some review points and context later this weekend. The individual commit messages hopefully contain useful details. For now:
Curious on initial thoughts! (CI test failures are unrelated, see widgetti/solara#1110) |
|
I think this idea is very cool. I hope to find time next week to take a closer look. One quick question: how is threshold management squared with the lazy evaluation of ContinousStates/ContinuousObservables? |
BREAKING: Thresholds are now stored per-instance instead of per-class.
Previously, ContinuousObservable stored thresholds at the class level in a shared `_thresholds` set. This caused all instances to check the same threshold values, preventing agents from having individual thresholds based on their specific parameters (e.g., a wolf with 100 starting energy couldn't have a different critical threshold than one with 50 starting energy).
Changes:
- Move threshold storage from descriptor to ContinuousState instances
- Change threshold structure from set to dict: {threshold_value: set(callbacks)}
- Update add_threshold() to access and modify instance-level state
- Ensure threshold initialization happens before adding thresholds
- Add direction parameter to all threshold_crossed signal emissions
This allows each agent to maintain its own set of thresholds, enabling instance-specific reactive behaviors like `self.add_threshold("energy", self.starting_energy * 0.25, callback)`.
38688e2 to
b2f6d7f
Compare
|
Great question:
Lazy evaluation and threshold detection work together because thresholds are checked during value recalculation, not continuously. When someone accesses a The key is that we check the range between old and new values, not discrete sample points. With linear integration ( Important to note: Thresholds fire when values are accessed, not at the exact simulation time they're crossed. If an agent's energy crosses zero at t=50 but nothing accesses it until t=100, the death threshold fires at t=100. This is a fundamental trade-off with lazy evaluation: efficiency in exchange for non-deterministic firing times. In practice, this isn't an issue for most models because agents access their states during decision-making (e.g., checking An "eager mode" could be added in the future where registering a threshold automatically schedules a discrete event at the calculated crossing time, providing deterministic timing at the cost of additional event management overhead. What we do need to enable is non-linear rate functions working with thresholds. |
| self.subscribers[observable][signal_type] = active_observers | ||
|
|
||
| def add_threshold(self, observable_name: str, threshold: float, callback: Callable): | ||
| """Convenience method for adding thresholds.""" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
just nitpicking, but this docstring can use some further elaboration beyond the one line summary. For example, it might be good to give a short explanation of what a treshold is and how it only applies to continuous observables.
|
|
||
| return state.value | ||
|
|
||
| # TODO: A universal truth for time should be implemented structurally in Mesa. See https://github.com/projectmesa/mesa/discussions/2228 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes this really drives home the need for a proper solution.
Also, time should probably be observable, or easy to be made observable, in which case your ContinuousObservable becomes a subclass of Computable.
| Returns: | ||
| List of (threshold_value, direction) tuples for crossed thresholds | ||
| """ | ||
| crossed = [] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can you indicate whether you are only interested in moving above or moving below, or do you get any crossing of the threshold?
quaquel
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see anything obviously wrong here. However, it's been a while since I looked at this code, so I might take another look at this PR annd the rest of signals next week. In the meantime, three clarifying questions.
|
@EwoutH This is very cool! Thanks for this PR |
|
Thanks for your early feedback! I will be in a night train Tuesday evening to Wednesday morning, I probably have some time to further look into it there. |
|
Threshold detection for non-linear rate functions is absolutely not trivial. I explored some options here: I'm not sure which direction I'm leaning personally yet. |
|
@quaquel review with academic literature in mind. Also pay attention to APi and naming. time will be property, keyword for simulator on instancing. |
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
WalkthroughIntroduces a reactive, continuous energy model to the wolf-sheep ecosystem simulation. ContinuousObservable enables time-based energy depletion tied to metabolic rates. Animals now use computed reactive properties responding to energy thresholds, with lifecycle callbacks triggering at critical levels. Model integrates a time-based simulator and tracks average energy per species. Changes
Sequence Diagram(s)sequenceDiagram
participant Simulator
participant Animal
participant EnergyObs as ContinuousObservable<br/>(energy)
participant Computed as Computed<br/>(is_hungry)
participant Callback as Lifecycle<br/>Callback
Simulator->>Animal: step()
Note over Simulator: Time advances
Animal->>EnergyObs: get energy<br/>(queries elapsed time)
activate EnergyObs
EnergyObs->>EnergyObs: calculate new value<br/>using rate_func
EnergyObs->>EnergyObs: check_thresholds()
alt Threshold Crossed
EnergyObs->>Callback: emit threshold_crossed signal
Callback->>Callback: execute callback<br/>(e.g., _on_energy_depleted)
end
EnergyObs-->>Animal: return updated energy
deactivate EnergyObs
Animal->>Computed: read is_hungry
activate Computed
Computed->>EnergyObs: evaluate lambda<br/>(energy < threshold)
EnergyObs-->>Computed: return energy
Computed-->>Animal: return is_hungry result
deactivate Computed
alt is_hungry == true
Animal->>Animal: prioritize food-seeking<br/>movement
else
Animal->>Animal: default wandering
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Tip 📝 Customizable high-level summaries are now available!You can now customize how CodeRabbit generates the high-level summary in your pull requests — including its content, structure, tone, and formatting.
Example:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
mesa/examples/advanced/wolf_sheep/model.py (1)
38-52: HandlesimulatordefaultNoneto avoid AttributeError in__init__
simulatoris annotated with a default ofNone, butself.simulator.setup(self)is called unconditionally. ConstructingWolfSheep()without passing a simulator will fail at initialization.Consider instantiating a default
ABMSimulatorwhensimulatorisNone(and optionally adjusting the type annotation toABMSimulator | None):- simulator: ABMSimulator = None, + simulator: ABMSimulator | None = None, @@ - super().__init__(seed=seed) - - # Initialize time-based simulator for continuous energy dynamics - self.simulator = simulator - self.simulator.setup(self) + super().__init__(seed=seed) + + # Initialize time-based simulator for continuous energy dynamics + self.simulator = simulator or ABMSimulator() + self.simulator.setup(self)
♻️ Duplicate comments (1)
mesa/experimental/mesa_signals/mesa_signal.py (1)
475-507: Clarifyadd_thresholdsemantics and tighten error type/messageThe helper is useful, but a bit opaque from the current docstring and error type/message:
- The docstring doesn’t explain what a “threshold” is in this context (per‑observable, instance‑local, emits
"threshold_crossed"withdirection="up"/"down"only forContinuousObservable).- When the named attribute either doesn’t exist or isn’t a
ContinuousObservable, raisingValueErrorwith a long string is slightly misleading; this is more of a type misuse.Consider:
- Expanding the docstring to briefly define thresholds, note that they are stored per instance in
ContinuousState.thresholds, and that callbacks receive a signal containingthresholdanddirection.- Raising
TypeErrorwhenobsis not aContinuousObservable, with a shorter message, and optionally catchingAttributeErrorfromgetattr(type(self), observable_name)to produce a clearer “unknown observable” error.Example:
- def add_threshold(self, observable_name: str, threshold: float, callback: Callable): - """Convenience method for adding thresholds.""" - obs = getattr(type(self), observable_name) - if not isinstance(obs, ContinuousObservable): - raise ValueError(f"{observable_name} is not a ContinuousObservable") + def add_threshold( + self, observable_name: str, threshold: float, callback: Callable + ) -> None: + """Register a threshold callback on a ContinuousObservable. + + Thresholds are stored per instance and apply only to + :class:`ContinuousObservable` attributes. Whenever the observable's + value crosses the given ``threshold`` (either upward or downward), + a ``"threshold_crossed"`` signal is emitted with ``threshold`` and + ``direction`` (``"up"``/``"down"``) attributes, and ``callback`` is + invoked. + """ + try: + obs = getattr(type(self), observable_name) + except AttributeError as exc: + raise AttributeError( + f"{observable_name!r} is not a known observable on {type(self).__name__}" + ) from exc + if not isinstance(obs, ContinuousObservable): + raise TypeError( + f"{observable_name!r} is not a ContinuousObservable on {type(self).__name__}" + )
🧹 Nitpick comments (3)
mesa/experimental/mesa_signals/mesa_signal.py (1)
642-656: Document threshold crossing semantics more explicitly
check_thresholdsencodes nuanced behavior (inclusive/exclusive comparisons, symmetry for up/down crossings) that’s central to the API, including signaldirection. The current docstring only mentions “returns a list of tuples,” which under‑documents these rules.Consider expanding the docstring to clarify:
- Crossings are detected on the continuous interval from
old_valuetonew_valueassuming monotonic change over that interval (as used by the linear integrator).- The comparison semantics, e.g.:
- Upward:
old_value < threshold <= new_value- Downward:
new_value <= threshold < old_value- That a threshold can fire multiple times over the lifetime of an observable (if values move back and forth across it), and that callbacks must be idempotent if they want “first‑time only” behavior.
That will make the behavior much clearer to users relying on
directionin callbacks.tests/test_continuous_observables.py (1)
376-383: Minor test hygiene: remove debug prints and considerzip(..., strict=True)The new test suite looks thorough and aligns well with the ContinuousObservable API. A couple of small cleanups you might consider:
- In
test_continuous_observable_with_computed, the- In the loops that iterate
zip(agents, initial_energies)andzip(agents, thresholds), addingstrict=Truewould make the expected length match explicit and satisfy Ruff’s B905 suggestion, though in tests this is more of a style choice.These are optional and don’t affect correctness.
Also applies to: 499-501, 873-878
mesa/examples/advanced/wolf_sheep/agents.py (1)
63-67: Clarify whether repeated critical‑hunger crossings should compound metabolic changes
_on_critical_hungermultipliesself.metabolic_rateby0.8on every downward crossing of the “critical hunger” threshold:def _on_critical_hunger(self, signal): """Called when energy becomes critically low.""" if signal.direction == "down": # Increase metabolic efficiency when starving (survival mode) self.metabolic_rate *= 0.8Given that thresholds can fire multiple times (if energy rises above and later falls below the threshold again), this logic compounds the effect each time, potentially driving
metabolic_ratetowards zero over a long run.If the intent is a one‑off “enter survival mode” adjustment, you might either:
- Remove the threshold after the first crossing, or
- Guard the change with a flag (e.g.,
if not self._critical_hunger_applied: ...).If repeated compounding is intentional, consider mentioning that in the docstring to make the behavior explicit.
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (5)
mesa/examples/advanced/wolf_sheep/agents.py(5 hunks)mesa/examples/advanced/wolf_sheep/model.py(6 hunks)mesa/experimental/mesa_signals/__init__.py(1 hunks)mesa/experimental/mesa_signals/mesa_signal.py(2 hunks)tests/test_continuous_observables.py(1 hunks)
🧰 Additional context used
🪛 GitHub Actions: build
mesa/examples/advanced/wolf_sheep/model.py
[error] 159-159: RuntimeError: dictionary changed size during iteration while iterating over agents in Wolf/S Sheep example.
🪛 Ruff (0.14.4)
mesa/examples/advanced/wolf_sheep/agents.py
16-16: Unused lambda argument: value
(ARG005)
16-16: Unused lambda argument: elapsed
(ARG005)
tests/test_continuous_observables.py
33-33: Unused lambda argument: value
(ARG005)
33-33: Unused lambda argument: elapsed
(ARG005)
33-33: Unused lambda argument: agent
(ARG005)
60-60: Unused lambda argument: value
(ARG005)
60-60: Unused lambda argument: elapsed
(ARG005)
89-89: Unused lambda argument: value
(ARG005)
89-89: Unused lambda argument: elapsed
(ARG005)
89-89: Unused lambda argument: agent
(ARG005)
118-118: Unused lambda argument: value
(ARG005)
118-118: Unused lambda argument: elapsed
(ARG005)
118-118: Unused lambda argument: agent
(ARG005)
151-151: Unused lambda argument: value
(ARG005)
151-151: Unused lambda argument: elapsed
(ARG005)
151-151: Unused lambda argument: agent
(ARG005)
178-178: Unused lambda argument: value
(ARG005)
178-178: Unused lambda argument: elapsed
(ARG005)
178-178: Unused lambda argument: agent
(ARG005)
209-209: Unused lambda argument: value
(ARG005)
209-209: Unused lambda argument: elapsed
(ARG005)
209-209: Unused lambda argument: agent
(ARG005)
240-240: Unused lambda argument: value
(ARG005)
240-240: Unused lambda argument: elapsed
(ARG005)
240-240: Unused lambda argument: agent
(ARG005)
275-275: Unused lambda argument: value
(ARG005)
275-275: Unused lambda argument: elapsed
(ARG005)
275-275: Unused lambda argument: agent
(ARG005)
303-303: Unused lambda argument: value
(ARG005)
303-303: Unused lambda argument: elapsed
(ARG005)
303-303: Unused lambda argument: agent
(ARG005)
311-311: Unused method argument: signal
(ARG002)
332-332: Unused lambda argument: value
(ARG005)
332-332: Unused lambda argument: elapsed
(ARG005)
332-332: Unused lambda argument: agent
(ARG005)
361-361: Unused lambda argument: value
(ARG005)
361-361: Unused lambda argument: elapsed
(ARG005)
361-361: Unused lambda argument: agent
(ARG005)
393-393: Unused lambda argument: value
(ARG005)
393-393: Unused lambda argument: elapsed
(ARG005)
393-393: Unused lambda argument: agent
(ARG005)
427-427: Unused lambda argument: value
(ARG005)
427-427: Unused lambda argument: elapsed
(ARG005)
427-427: Unused lambda argument: agent
(ARG005)
453-453: Unused lambda argument: value
(ARG005)
453-453: Unused lambda argument: elapsed
(ARG005)
453-453: Unused lambda argument: agent
(ARG005)
481-481: Unused lambda argument: value
(ARG005)
481-481: Unused lambda argument: elapsed
(ARG005)
481-481: Unused lambda argument: agent
(ARG005)
499-499: zip() without an explicit strict= parameter
Add explicit value for parameter strict=
(B905)
517-517: Unused lambda argument: value
(ARG005)
517-517: Unused lambda argument: elapsed
(ARG005)
517-517: Unused lambda argument: agent
(ARG005)
539-539: Unused lambda argument: value
(ARG005)
539-539: Unused lambda argument: elapsed
(ARG005)
539-539: Unused lambda argument: agent
(ARG005)
562-562: Unused lambda argument: value
(ARG005)
562-562: Unused lambda argument: elapsed
(ARG005)
615-615: Unused lambda argument: value
(ARG005)
615-615: Unused lambda argument: elapsed
(ARG005)
645-645: Unused lambda argument: value
(ARG005)
645-645: Unused lambda argument: elapsed
(ARG005)
645-645: Unused lambda argument: agent
(ARG005)
714-714: Unused lambda argument: value
(ARG005)
714-714: Unused lambda argument: elapsed
(ARG005)
714-714: Unused lambda argument: agent
(ARG005)
753-753: Unused lambda argument: value
(ARG005)
753-753: Unused lambda argument: elapsed
(ARG005)
753-753: Unused lambda argument: agent
(ARG005)
782-782: Unused lambda argument: value
(ARG005)
782-782: Unused lambda argument: elapsed
(ARG005)
782-782: Unused lambda argument: agent
(ARG005)
798-798: Unused lambda argument: value
(ARG005)
798-798: Unused lambda argument: elapsed
(ARG005)
798-798: Unused lambda argument: agent
(ARG005)
845-845: Unused lambda argument: value
(ARG005)
845-845: Unused lambda argument: elapsed
(ARG005)
845-845: Unused lambda argument: agent
(ARG005)
873-873: zip() without an explicit strict= parameter
Add explicit value for parameter strict=
(B905)
mesa/experimental/mesa_signals/mesa_signal.py
479-479: Prefer TypeError exception for invalid type
(TRY004)
479-479: Avoid specifying long messages outside the exception class
(TRY003)
🔇 Additional comments (1)
mesa/experimental/mesa_signals/__init__.py (1)
13-20: ExposeContinuousObservablein the public mesa_signals APIImporting and re‑exporting
ContinuousObservablehere matches the new implementation inmesa_signal.pyand makes the feature discoverable viamesa.experimental.mesa_signals.
| # Register threshold: die when energy reaches zero | ||
| self.add_threshold("energy", 0.0, self._on_energy_depleted) | ||
|
|
||
| # Register threshold: become critically hungry at 25% of starting energy | ||
| self.add_threshold("energy", energy * 0.25, self._on_critical_hunger) | ||
|
|
||
| def _on_energy_depleted(self, signal): | ||
| """Called when energy crosses zero - animal dies.""" | ||
| if signal.direction == "down": # Only trigger on downward crossing | ||
| self.remove() | ||
|
|
||
| def _on_critical_hunger(self, signal): | ||
| """Called when energy becomes critically low.""" | ||
| if signal.direction == "down": | ||
| # Increase metabolic efficiency when starving (survival mode) | ||
| self.metabolic_rate *= 0.8 | ||
|
|
||
| def spawn_offspring(self): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
Avoid removing agents directly from threshold callback to prevent container mutation during iteration
Animal._on_energy_depleted currently calls self.remove() whenever energy crosses zero downward:
def _on_energy_depleted(self, signal):
"""Called when energy crosses zero - animal dies."""
if signal.direction == "down": # Only trigger on downward crossing
self.remove()Because this callback is fired from inside ContinuousObservable.__get__/__set__, it can run while:
- Schedulers are iterating agents in
shuffle_do("step"), and/or - DataCollector reporters are iterating
agents_by_type[...]and accessing.energy.
That matches the RuntimeError: dictionary changed size during iteration reported for the Wolf–Sheep example.
A safer pattern is to mark the agent for removal and let step() perform the actual remove() at a controlled point:
class Animal(CellAgent, HasObservables):
@@
def __init__(
self, model, energy=8, p_reproduce=0.04, energy_from_food=4, cell=None
):
@@
- super().__init__(model)
-
- # Set base metabolic rate (energy loss per time unit when idle)
- self.metabolic_rate = 0.5
+ super().__init__(model)
+
+ # Track whether this agent should be removed due to energy depletion
+ self._needs_removal = False
+
+ # Set base metabolic rate (energy loss per time unit when idle)
+ self.metabolic_rate = 0.5
@@
- def _on_energy_depleted(self, signal):
- """Called when energy crosses zero - animal dies."""
- if signal.direction == "down": # Only trigger on downward crossing
- self.remove()
+ def _on_energy_depleted(self, signal):
+ """Called when energy crosses zero - mark for removal."""
+ if signal.direction == "down": # Only trigger on downward crossing
+ self._needs_removal = True
@@
def step(self):
"""Execute one step of the animal's behavior."""
- # Move to neighboring cell (uses more energy than standing still)
+ # Remove animals that died since last step
+ if getattr(self, "_needs_removal", False):
+ self.remove()
+ return
+
+ # Move to neighboring cell (uses more energy than standing still)
self.metabolic_rate = 1.0 # Movement costs more energy
@@
- # Reproduce if conditions are met (using computed property)
- if self.can_reproduce and self.random.random() < self.p_reproduce:
+ # Reproduce if conditions are met (using computed property)
+ if self.can_reproduce and self.random.random() < self.p_reproduce:
self.spawn_offspring()This preserves the threshold‑driven semantics (death when crossing zero) while avoiding structural mutations of the agent containers during iteration.
Also applies to: 84-99
🏁 Script executed:
#!/bin/bash
# Find and display the Animal class implementation around lines 52-69
fd -e py -p 'agents\.py' | xargs catLength of output: 47437
🏁 Script executed:
#!/bin/bash
# Search for ContinuousObservable implementation and threshold mechanism
fd -e py | xargs rg -n -A10 'class ContinuousObservable' | head -60Length of output: 1167
🏁 Script executed:
#!/bin/bash
# Search for add_threshold and how callbacks are triggered
fd -e py | xargs rg -n 'add_threshold|def add_threshold' -A5 | head -80Length of output: 5342
🏁 Script executed:
#!/bin/bash
# Search for _on_energy_depleted implementation to see current code
fd -e py | xargs rg -n '_on_energy_depleted' -B2 -A5Length of output: 1284
🏁 Script executed:
#!/bin/bash
# Examine ContinuousObservable.__set__ and threshold firing logic
rg -n 'class ContinuousObservable' -A 100 mesa/experimental/mesa_signals/mesa_signal.py | head -150Length of output: 4233
🏁 Script executed:
#!/bin/bash
# Search for where threshold callbacks are actually invoked
rg -n 'threshold_crossed|_check_thresholds|def __set__' mesa/experimental/mesa_signals/mesa_signal.py -A 15 | head -200Length of output: 5006
🏁 Script executed:
#!/bin/bash
# Check scheduler step implementation to see iteration patterns
rg -n 'class.*Scheduler|def shuffle_do|def step' mesa/experimental/engine.py -A 10 | head -100Length of output: 67
🏁 Script executed:
#!/bin/bash
# Search for DataCollector iteration patterns
rg -n 'class DataCollector|agents_by_type|def collect' mesa/datacollection.py -A 8 | head -120Length of output: 1278
🏁 Script executed:
#!/bin/bash
# Look for existing tests that show the RuntimeError issue
rg -n 'RuntimeError.*dict.*changed|dictionary changed size' mesa/examples/ tests/ -B 3 -A 3Length of output: 0
🏁 Script executed:
#!/bin/bash
# Find scheduler implementations
fd -e py mesa/ | xargs rg -l 'class.*Scheduler' | head -5Length of output: 325
🏁 Script executed:
#!/bin/bash
# Look for model.step() and how agents are iterated
rg -n 'def step' mesa/model.py -A 20 | head -80Length of output: 768
🏁 Script executed:
#!/bin/bash
# Check wolf_sheep example for model step or any test files
fd -e py mesa/examples/advanced/wolf_sheep/ | xargs cat | grep -A 20 'def step'Length of output: 441
🏁 Script executed:
#!/bin/bash
# Search for any deferred removal or agent marking patterns already in codebase
rg -n '_needs_removal|_marked_for_removal|pending_removal' mesa/Length of output: 0
🏁 Script executed:
#!/bin/bash
# Check if notify() method actually executes callbacks immediately
rg -n 'def notify' mesa/experimental/mesa_signals/ -A 30 | head -100Length of output: 2391
🏁 Script executed:
#!/bin/bash
# Get full _mesa_notify implementation and subscriber invocation
rg -n '_mesa_notify|def _mesa_notify' mesa/experimental/mesa_signals/mesa_signal.py -A 50 | head -150Length of output: 2419
🏁 Script executed:
#!/bin/bash
# Search for where subscribers are iterated and callbacks invoked
rg -n 'subscribers|callback' mesa/experimental/mesa_signals/mesa_signal.py -B 2 -A 5 | head -200Length of output: 4233
🏁 Script executed:
#!/bin/bash
# Check wolf_sheep model implementation to see how step() is called
fd . mesa/examples/advanced/wolf_sheep/ | xargs catLength of output: 19365
🏁 Script executed:
#!/bin/bash
# Look for scheduler.do() or step execution patterns
rg -n 'class.*Scheduler|def do|shuffle_do' mesa/core/agent.py mesa/agent.py -A 15 | head -100Length of output: 3240
🏁 Script executed:
#!/bin/bash
# Examine if there's a scheduler being used in model step
rg -n 'scheduler|\.step\(\)' mesa/examples/advanced/wolf_sheep/model.py -B 2 -A 5Length of output: 0
Deferred removal pattern required to prevent container mutation during threshold callback execution
The review's concern is valid and critical.
Threshold callbacks execute synchronously during property access, and ContinuousObservable's __get__ and __set__ methods both invoke threshold callbacks immediately. When DataCollector.collect(self) iterates agents and accesses their .energy property to compute statistics (e.g., sum(w.energy for w in m.agents_by_type[Wolf])), this triggers __get__, which fires the threshold callback. If _on_energy_depleted calls self.remove() while the dictionary is being iterated, this causes RuntimeError: dictionary changed size during iteration.
The proposed deferred removal pattern is the correct fix:
- Mark removal with a flag when threshold fires (safe to set flag during iteration)
- Check and execute removal in
step()at a controlled point before returning - Preserves threshold-driven semantics while preventing structural mutations
Locations requiring fixes:
- Add
_needs_removal = Falseinitialization inAnimal.__init__ - Modify
_on_energy_depletedto set flag instead of callingremove() - Add removal check at start of
Animal.step()before other logic
🤖 Prompt for AI Agents
In mesa/examples/advanced/wolf_sheep/agents.py around lines 52-69, threshold
callbacks can synchronously remove agents during container iteration causing
"dictionary changed size during iteration"; to fix, add a boolean flag (e.g.,
self._needs_removal = False) in Animal.__init__, change _on_energy_depleted to
set this flag instead of calling self.remove(), and at the start of
Animal.step() check the flag and call self.remove() if set (perform removal at a
controlled point before other per-step logic).
| # Set up data collection (tracks observable changes automatically) | ||
| model_reporters = { | ||
| "Wolves": lambda m: len(m.agents_by_type[Wolf]), | ||
| "Sheep": lambda m: len(m.agents_by_type[Sheep]), | ||
| "Avg Wolf Energy": lambda m: ( | ||
| sum(w.energy for w in m.agents_by_type[Wolf]) | ||
| / len(m.agents_by_type[Wolf]) | ||
| if len(m.agents_by_type[Wolf]) > 0 | ||
| else 0 | ||
| ), | ||
| "Avg Sheep Energy": lambda m: ( | ||
| sum(s.energy for s in m.agents_by_type[Sheep]) | ||
| / len(m.agents_by_type[Sheep]) | ||
| if len(m.agents_by_type[Sheep]) > 0 | ||
| else 0 | ||
| ), | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
Investigate RuntimeError: dictionary changed size during iteration in Wolf–Sheep example
The new continuous energy and threshold system interacts with:
- Model reporters computing averages by iterating
m.agents_by_type[Wolf]/m.agents_by_type[Sheep]and accessingw.energy/s.energy. step()iterating activated agents viaself.agents_by_type[Sheep].shuffle_do("step")and the corresponding call forWolf.
Given that Animal._on_energy_depleted currently calls self.remove() from an energy threshold callback (see agents.py), removals can now be triggered:
- While reporters iterate over
agents_by_type[...]insidedatacollector.collect(self), or - While the scheduler iterates agents in
shuffle_do("step")and agent code accessesenergy/is_hungry(which may fire thresholds).
Both patterns can cause RuntimeError: dictionary changed size during iteration, matching the pipeline failure at this file around the collect call.
Mitigations to consider (likely in concert with changes in Animal):
- Ensure energy threshold callbacks do not directly remove agents while schedulers or reporters might be iterating; instead, mark agents for removal and let the scheduler or model perform removal at a safe point (e.g., at the start or end of
step). - Alternatively, if the agent containers don’t already do this, ensure they iterate over a stable snapshot (
list(...)) when performingshuffle_door DataCollector traversals.
Please verify the exact call stack of the runtime error and align the removal strategy accordingly; it probably requires updating Animal._on_energy_depleted (see agents.py) rather than just this file.
Also applies to: 147-159
I'll investigate the codebase to verify the claims in this review comment about the potential dictionary iteration issue.
---
🏁 Script executed:
```shell
#!/bin/bash
# Find and examine the model.py file
find . -path "*/advanced/wolf_sheep/model.py" -type f | head -1 | xargs -I {} sh -c 'echo "=== File: {} ===" && cat {}'
Length of output: 5820
🏁 Script executed:
#!/bin/bash
# Find and examine agents.py in the same directory
find . -path "*/advanced/wolf_sheep/agents.py" -type f | head -1 | xargs -I {} sh -c 'echo "=== File: {} ===" && cat {}'Length of output: 8345
🏁 Script executed:
#!/bin/bash
# Search for agents_by_type implementation and iteration methods
rg -n "agents_by_type" --type=py -A 3 -B 1 | head -100Length of output: 6078
🏁 Script executed:
#!/bin/bash
# Search for _on_energy_depleted callback
rg -n "_on_energy_depleted" --type=py -A 5 -B 2Length of output: 1149
🏁 Script executed:
#!/bin/bash
# Search for shuffle_do implementation
rg -n "shuffle_do" --type=py -A 5 -B 2Length of output: 7633
I need to examine the complete shuffle_do implementation and how datacollector.collect() handles model reporters.
---
🏁 Script executed:
```shell
#!/bin/bash
# Get full shuffle_do implementation
rg -n "def shuffle_do" --type=py -A 15
Length of output: 967
🏁 Script executed:
#!/bin/bash
# Check DataCollector.collect() implementation
rg -n "def collect" --type=py -A 20 mesa/datacollection.pyLength of output: 1201
🏁 Script executed:
#!/bin/bash
# Check AgentSet iteration and _agents structure
rg -n "self\._agents" --type=py -B 2 -A 5 mesa/agent.py | head -150Length of output: 5487
I found the vulnerability! Let me verify the exact timing of when threshold callbacks could fire during reporter iteration.
---
🏁 Script executed:
```shell
#!/bin/bash
# Check the experimental simulator and event scheduling
find . -path "*/experimental/devs*" -name "*.py" -type f | head -5 | xargs -I {} sh -c 'echo "=== File: {} ===" && head -100 {}'
Length of output: 8278
🏁 Script executed:
#!/bin/bash
# Check if there's any evidence of concurrent energy updates during collect()
rg -n "schedule_event" --type=py mesa/examples/advanced/wolf_sheep/ -B 2 -A 2Length of output: 934
🏁 Script executed:
#!/bin/bash
# Verify AgentSet __iter__ returns keys() directly without snapshot
rg -n "def __iter__" --type=py mesa/agent.py -A 5Length of output: 481
🏁 Script executed:
#!/bin/bash
# Search for any test reproducing the RuntimeError mentioned in review
rg -n "RuntimeError.*dictionary" --type=pyLength of output: 0
Fix model reporters to safely handle agent removal during iteration
The review comment is correct. The model reporters iterate directly over live AgentSet objects without snapshotting, making them vulnerable to RuntimeError: dictionary changed size during iteration when energy thresholds fire during collection.
The issue occurs at lines 88-104 (and 147-159) where lambdas like sum(w.energy for w in m.agents_by_type[Wolf]) iterate directly over an AgentSet. Since AgentSet.__iter__ returns an un-snapshotted WeakKeyDictionary.keys() iterator, and _on_energy_depleted calls self.remove(), the dictionary is modified mid-iteration.
Solution: Wrap all agent iterations in the model reporters with list() to snapshot the collection before iteration:
model_reporters = {
"Wolves": lambda m: len(m.agents_by_type[Wolf]),
"Sheep": lambda m: len(m.agents_by_type[Sheep]),
"Avg Wolf Energy": lambda m: (
sum(w.energy for w in list(m.agents_by_type[Wolf]))
/ len(list(m.agents_by_type[Wolf]))
if len(m.agents_by_type[Wolf]) > 0
else 0
),
"Avg Sheep Energy": lambda m: (
sum(s.energy for s in list(m.agents_by_type[Sheep]))
/ len(list(m.agents_by_type[Sheep]))
if len(m.agents_by_type[Sheep]) > 0
else 0
),
}Apply the same fix to lines 147–159 and any other direct iterations over agents_by_type.
🤖 Prompt for AI Agents
In mesa/examples/advanced/wolf_sheep/model.py around lines 88–104 (and similarly
147–159), the model_reporters iterate directly over live AgentSet objects which
can be mutated during reporting causing RuntimeError; fix by snapshotting the
agent collections with list(...) before iterating or computing lengths (use
list(m.agents_by_type[Wolf]) and list(m.agents_by_type[Sheep]) in the sum and
len calls) and apply the same change to the reporters at lines 147–159 and any
other places that iterate over m.agents_by_type without first converting to a
list.
| def __set__(self, instance: HasObservables, value): | ||
| """Set the value, ensuring we store a ContinuousState.""" | ||
| # Get or create state | ||
| state = getattr(instance, self.private_name, None) | ||
|
|
||
| if state is None: | ||
| # First time - create ContinuousState | ||
| state = ContinuousState( | ||
| value=float(value), | ||
| last_update=self._get_time(instance), | ||
| rate_func=self._rate_func, | ||
| ) | ||
| setattr(instance, self.private_name, state) | ||
| else: | ||
| # Update existing - just change the value and reset timestamp | ||
| old_value = state.value | ||
| state.value = float(value) | ||
| state.last_update = self._get_time(instance) | ||
|
|
||
| # Notify changes | ||
| instance.notify(self.public_name, old_value, state.value, "change") | ||
|
|
||
| # Check thresholds | ||
| for threshold, direction in state.check_thresholds(old_value, state.value): | ||
| instance.notify( | ||
| self.public_name, | ||
| old_value, | ||
| state.value, | ||
| "threshold_crossed", | ||
| threshold=threshold, | ||
| direction=direction, | ||
| ) | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add cyc‑dependency protection and clear PROCESSING_SIGNALS in ContinuousObservable.__set__
ContinuousObservable.__set__ currently bypasses the cyclical‑dependency guard and PROCESSING_SIGNALS.clear() logic present in Observable.__set__. As a result:
- A
Computedcan both depend on, and write to, the sameContinuousObservablewithout raising the usualValueError, risking subtle cycles. PROCESSING_SIGNALSmay retain stale entries across writes to continuous observables.
Mirroring Observable.__set__ keeps behavior consistent and safer.
You can inline the same guard and clearing while preserving the continuous state logic:
def __set__(self, instance: HasObservables, value):
"""Set the value, ensuring we store a ContinuousState."""
- # Get or create state
- state = getattr(instance, self.private_name, None)
+ # Prevent Computed from mutating an observable it depends on
+ 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}) "
+ f"tries to change {instance.__class__.__name__}.{self.public_name} "
+ "while also being dependent it"
+ )
+
+ # Get or create state
+ state = getattr(instance, self.private_name, None)
if state is None:
@@
- # Check thresholds
- for threshold, direction in state.check_thresholds(old_value, state.value):
- instance.notify(
- self.public_name,
- old_value,
- state.value,
- "threshold_crossed",
- threshold=threshold,
- direction=direction,
- )
+ # Check thresholds
+ for threshold, direction in state.check_thresholds(old_value, state.value):
+ instance.notify(
+ self.public_name,
+ old_value,
+ state.value,
+ "threshold_crossed",
+ threshold=threshold,
+ direction=direction,
+ )
+
+ # We have notified dependents; clear processed signals like Observable.__set__
+ PROCESSING_SIGNALS.clear()🤖 Prompt for AI Agents
In mesa/experimental/mesa_signals/mesa_signal.py around lines 518 to 550,
ContinuousObservable.__set__ creates or updates ContinuousState but omits the
cyc-dependency guard and PROCESSING_SIGNALS.clear() behavior used in
Observable.__set__; add the same protection: at the start of __set__ check if
instance in PROCESSING_SIGNALS and raise ValueError to prevent computed->write
cycles, and ensure PROCESSING_SIGNALS.clear() is invoked in a finally block (or
immediately after notifications) so entries are not left stale across writes;
preserve existing continuous-state creation/update, notifications, and threshold
checks but wrap the operation with the same guard/clear semantics as
Observable.__set__.
|
I'm fine with reviewers using AI tools to help them review a PR, but I'm not triaging AI comments. So @tpike3 please triage the comments and resolve the ones that are not highly relevant in this stage in het PR review process. |
Summary
Adds
ContinuousObservableto mesa_signals, enabling agent states that change continuously over time with automatic threshold detection and signal emission. Demonstrates the feature with an enhanced Wolf-Sheep predator-prey model where energy depletes continuously rather than at discrete time steps.Motive
Agent-based models often need to represent continuously changing states like energy depletion, resource growth, or temperature changes. Currently, modelers must manually calculate state changes at each step or schedule discrete update events, leading to:
This addresses discussion #2529 (Continuous States) and provides a foundational building block toward the behavioral framework outlined in discussion #2538, enabling more realistic agent behaviors driven by internal state dynamics.
Implementation
Core Components:
ContinuousObservabledescriptor: ExtendsObservableto track values that change over time according to a rate function. Uses lazy evaluation - values are only recalculated when accessed, making it efficient even with many agents.ContinuousStatehelper class: Internal state tracker storing the current value, last update time, rate function, and threshold set. Handles time-based calculation and threshold crossing detection.Threshold management:
HasObservables.add_threshold()provides a clean API for registering callbacks when values cross specific thresholds (upward or downward). Uses the existing signal/observer pattern - thresholds emit"threshold_crossed"signals.Time integration: Automatically detects time source from
model.simulator.time,model.time, or falls back tomodel.steps. Works with bothDEVSimulator(float time) andABMSimulator(integer steps).Key design decisions:
Computedproperties and dependency trackingAPI usage examples
Usage Examples
Basic continuous energy depletion:
Reactive behaviors with computed properties:
Enhanced Wolf-Sheep model: The updated example demonstrates continuous energy dynamics, threshold-triggered death/starvation modes, and behavior switching based on computed hunger states. Energy depletes continuously between steps rather than in discrete chunks, creating more realistic predator-prey dynamics.
Additional Notes
ContinuousObservableis an additive featureThis provides the first of two foundational building blocks identified for Mesa's behavioral framework: reactive state management with continuous dynamics. The second block (action/task scheduling) can build naturally on this foundation.
Summary by CodeRabbit
New Features
Documentation
Tests