Skip to content

Conversation

@EwoutH
Copy link
Member

@EwoutH EwoutH commented Oct 17, 2025

Summary

Adds ContinuousObservable to 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:

  • Balancing trade-offs between update frequency and performance
  • Difficulty detecting exact threshold crossings
  • No standardized patterns for time-varying states
  • Complex coordination of multiple changing states

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:

  1. ContinuousObservable descriptor: Extends Observable to 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.

  2. ContinuousState helper class: Internal state tracker storing the current value, last update time, rate function, and threshold set. Handles time-based calculation and threshold crossing detection.

  3. 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.

  4. Time integration: Automatically detects time source from model.simulator.time, model.time, or falls back to model.steps. Works with both DEVSimulator (float time) and ABMSimulator (integer steps).

Key design decisions:

  • Lazy evaluation prevents unnecessary calculations when states aren't accessed
  • Threshold storage uses a set (values only), callbacks managed through signal subscriptions to avoid duplicate invocations
  • Linear integration for simplicity; extensible to more complex methods
  • Fully integrated with existing Computed properties and dependency tracking

API usage examples

# Continuous value with rate per time unit
energy = ContinuousObservable(initial_value: float, rate_func: (value, elapsed, agent) -> float)

# Set/adjust value (emits "change" + checks thresholds)
self.energy = 120.0
self.energy += 5.0

# Thresholds (fires "threshold_crossed" with signal.threshold, signal.direction)
self.add_threshold("energy", 0.0, callback)

# Subscribe to signals
self.observe("energy", "change", on_change)
self.observe("energy", "threshold_crossed", on_threshold)

# Computed properties that depend on observables
is_hungry = Computable()
self.is_hungry = Computed(lambda: self.energy < 50.0)

Usage Examples

Basic continuous energy depletion:

class Wolf(Agent, HasObservables):
    energy = ContinuousObservable(
        initial_value=100.0,
        rate_func=lambda value, elapsed, agent: -agent.metabolic_rate
    )
    
    def __init__(self, model):
        super().__init__(model)
        self.metabolic_rate = 0.5
        self.energy = 100.0
        
        # Die when energy reaches zero
        self.add_threshold("energy", 0.0, self._on_death)
    
    def _on_death(self, signal):
        if signal.direction == "down":
            self.remove()

Reactive behaviors with computed properties:

class Animal(Agent, HasObservables):
    energy = ContinuousObservable(
        initial_value=100.0,
        rate_func=lambda value, elapsed, agent: -agent.metabolic_rate
    )
    is_hungry = Computable()
    
    def __init__(self, model):
        super().__init__(model)
        self.energy = 100.0
        # Computed property automatically updates when energy changes
        self.is_hungry = Computed(lambda: self.energy < 50.0)
    
    def move(self):
        if self.is_hungry:
            # Hunt actively when hungry
            self.seek_food()
        else:
            # Conserve energy when not hungry
            self.wander()

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

  • Tests: Comprehensive test suite (19 tests) covering basic functionality, signals, edge cases (numpy floats, zero elapsed time, exact thresholds), and integration scenarios
  • Backward compatible: Existing mesa_signals code unchanged; ContinuousObservable is an additive feature
  • Performance: Lazy evaluation ensures minimal overhead; states only recalculate when accessed
  • Future extensions: Foundation for more complex behavioral patterns (needs-based architectures, homeostatic agents) discussed in Behavioral Framework #2538
  • Time management: Uses TODO comment to reference The time variable in mesa.Model #2228 regarding universal time handling in Mesa
  • Documentation: Wolf-Sheep example serves as practical demonstration; could expand docs with more use cases

This 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

    • Introduced ContinuousObservable for time-based value evolution with threshold-crossing detection.
    • Enhanced predator-prey dynamics with reactive energy management, hunger states, and reproduction decision-making.
    • Added energy tracking and reporting for wolf and sheep populations in the advanced example.
  • Documentation

    • Expanded docstrings reflecting reactive energy models and agent behaviors.
  • Tests

    • Added comprehensive test suite for continuous observable functionality.

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.
@EwoutH EwoutH requested a review from quaquel October 17, 2025 11:35
@EwoutH EwoutH added the feature Release notes label label Oct 17, 2025
@EwoutH
Copy link
Member Author

EwoutH commented Oct 17, 2025

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:

  1. Maybe the new ContinuousObservable and ContinuousState classes should go into a separate file instead of mesa_signals.py.
  2. Yes, I know using ContiniousStates with an ABMSimulator (in the wolf_sheep example) doesn't make that much sense in the current implementation. Future work.
  3. We really should re-address The time variable in mesa.Model #2228 sooner than later. model.time always being accessible would make stuff much easier.

Curious on initial thoughts!

(CI test failures are unrelated, see widgetti/solara#1110)

@quaquel
Copy link
Member

quaquel commented Oct 17, 2025

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)`.
@EwoutH
Copy link
Member Author

EwoutH commented Oct 17, 2025

Great question:

how is threshold management squared with the lazy evaluation of ContinousStates/ContinuousObservables?

Lazy evaluation and threshold detection work together because thresholds are checked during value recalculation, not continuously. When someone accesses a ContinuousObservable (e.g., agent.energy), the system calculates how much time has elapsed since the last access, computes the new value, and then checks if any thresholds were crossed during that transition.

The key is that we check the range between old and new values, not discrete sample points. With linear integration (value + rate * elapsed_time), we can mathematically determine if a threshold was crossed even if we never computed the value at that exact moment. For example, if energy goes from 100 → 40 over 60 time units and there's a threshold at 50, we detect that crossing because old_value (100) > threshold (50) >= new_value (40).

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 is_hungry during movement) and data collectors sample states regularly. For the Wolf-Sheep example, agents effectively check their energy every step through computed properties and movement decisions.

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."""
Copy link
Member

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
Copy link
Member

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 = []
Copy link
Member

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?

Copy link
Member

@quaquel quaquel left a 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.

@tpike3
Copy link
Member

tpike3 commented Oct 19, 2025

@EwoutH This is very cool! Thanks for this PR

@EwoutH
Copy link
Member Author

EwoutH commented Oct 19, 2025

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.

@EwoutH
Copy link
Member Author

EwoutH commented Oct 21, 2025

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.

@EwoutH EwoutH requested a review from tpike3 October 28, 2025 16:22
@EwoutH EwoutH requested a review from quaquel November 11, 2025 12:29
@EwoutH
Copy link
Member Author

EwoutH commented Nov 11, 2025

@quaquel review with academic literature in mind. Also pay attention to APi and naming.

time will be property, keyword for simulator on instancing.

@tpike3
Copy link
Member

tpike3 commented Nov 16, 2025

@coderabbitai full review

@coderabbitai
Copy link

coderabbitai bot commented Nov 16, 2025

✅ Actions performed

Full review triggered.

@coderabbitai
Copy link

coderabbitai bot commented Nov 16, 2025

Walkthrough

Introduces 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

Cohort / File(s) Summary
Mesa Signals Infrastructure
mesa/experimental/mesa_signals/mesa_signal.py, mesa/experimental/mesa_signals/__init__.py
Added public ContinuousObservable class supporting time-based value evolution with threshold crossing detection and callbacks. Implemented add_threshold method on HasObservables for registering thresholds and callbacks. Extended all exports to include ContinuousObservable.
Wolf-Sheep Agent Behavior
mesa/examples/advanced/wolf_sheep/agents.py
Refactored Animal to inherit from HasObservables with energy as ContinuousObservable decaying via metabolic_rate. Added reactive computed properties is_hungry and can_reproduce. Implemented lifecycle callbacks _on_energy_depleted and _on_critical_hunger for threshold-based state changes. Updated Sheep and Wolf movement/feeding logic to prioritize based on computed hunger state. Converted GrassPatch to HasObservables with observable fully_grown and regrowth scheduling.
Wolf-Sheep Model Integration
mesa/examples/advanced/wolf_sheep/model.py
Integrated simulator for time-based energy dynamics. Extended data collection with "Avg Wolf Energy" and "Avg Sheep Energy" reporters. Updated agent initialization to accept explicit energy values derived from RNG.
Continuous Observable Tests
tests/test_continuous_observables.py
Comprehensive test suite covering ContinuousObservable behavior, variable rate handling, threshold mechanics, computed properties, multi-agent scenarios, predator-prey interactions, and batch agent creation with per-agent thresholds.

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Careful attention needed: ContinuousObservable threshold detection logic and time-based value calculation in mesa_signal.py — verify threshold crossing boundary conditions, time fallback logic, and signal emission correctness
  • Cross-system interaction: Verify simulator integration in agents.py and model.py properly initializes and accesses timing information for continuous energy depletion
  • Behavioral logic: Confirm Sheep and Wolf movement/feeding decisions correctly prioritize computed properties and handle reactive state changes without unintended side effects
  • Test coverage: Review test suite scope for edge cases in threshold detection, multi-agent independence, and numpy compatibility

Poem

🐰 Observe the energy flowing soft and slow,
Through thresholds crossed where hungry bunnies go,
With signals firing as the time moves near,
The reactive creatures nibble without fear,
Continuous depletion, renewal too—
Mesa's pulse now beats so steady and true! 🌿

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.17% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check ❓ Inconclusive The title 'Add ContinuousStates and -Observables' is vague and uses non-standard phrasing with 'and -Observables' instead of 'and Observables', making it unclear and potentially misleading about the actual changes. Use a clearer, more standard title such as 'Add ContinuousObservable for continuous agent state dynamics' to accurately reflect the main feature being introduced.
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The pull request description is comprehensive, well-structured, and covers all essential aspects: summary, motivation, implementation details, API usage examples, testing approach, backward compatibility, and notes on performance and future work.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

  • Provide custom instructions to shape the summary (bullet lists, tables, contributor stats, etc.).
  • Use high_level_summary_in_walkthrough to move the summary from the description to the walkthrough section.

Example:

"Create a concise high-level summary as a bullet-point list. Then include a Markdown table showing lines added and removed by each contributing author."


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a 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: Handle simulator default None to avoid AttributeError in __init__

simulator is annotated with a default of None, but self.simulator.setup(self) is called unconditionally. Constructing WolfSheep() without passing a simulator will fail at initialization.

Consider instantiating a default ABMSimulator when simulator is None (and optionally adjusting the type annotation to ABMSimulator | 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: Clarify add_threshold semantics and tighten error type/message

The 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" with direction="up"/"down" only for ContinuousObservable).
  • When the named attribute either doesn’t exist or isn’t a ContinuousObservable, raising ValueError with 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 containing threshold and direction.
  • Raising TypeError when obs is not a ContinuousObservable, with a shorter message, and optionally catching AttributeError from getattr(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_thresholds encodes nuanced behavior (inclusive/exclusive comparisons, symmetry for up/down crossings) that’s central to the API, including signal direction. 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_value to new_value assuming 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 direction in callbacks.

tests/test_continuous_observables.py (1)

376-383: Minor test hygiene: remove debug prints and consider zip(..., 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 print statements around energy and hunger are purely diagnostic and can be removed to keep test output clean.
  • In the loops that iterate zip(agents, initial_energies) and zip(agents, thresholds), adding strict=True would 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_hunger multiplies self.metabolic_rate by 0.8 on 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.8

Given 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_rate towards 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

📥 Commits

Reviewing files that changed from the base of the PR and between c20d251 and b2f6d7f.

📒 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: Expose ContinuousObservable in the public mesa_signals API

Importing and re‑exporting ContinuousObservable here matches the new implementation in mesa_signal.py and makes the feature discoverable via mesa.experimental.mesa_signals.

Comment on lines +52 to 69
# 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):
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 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 cat

Length of output: 47437


🏁 Script executed:

#!/bin/bash
# Search for ContinuousObservable implementation and threshold mechanism
fd -e py | xargs rg -n -A10 'class ContinuousObservable' | head -60

Length 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 -80

Length 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 -A5

Length 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 -150

Length 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 -200

Length 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 -100

Length 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 -120

Length 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 3

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Find scheduler implementations
fd -e py mesa/ | xargs rg -l 'class.*Scheduler' | head -5

Length 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 -80

Length 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 -100

Length 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 -150

Length 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 -200

Length 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 cat

Length 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 -100

Length 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 5

Length 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 = False initialization in Animal.__init__
  • Modify _on_energy_depleted to set flag instead of calling remove()
  • 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).

Comment on lines +88 to 104
# 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
),
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 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 accessing w.energy / s.energy.
  • step() iterating activated agents via self.agents_by_type[Sheep].shuffle_do("step") and the corresponding call for Wolf.

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[...] inside datacollector.collect(self), or
  • While the scheduler iterates agents in shuffle_do("step") and agent code accesses energy / 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 performing shuffle_do or 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 -100

Length of output: 6078


🏁 Script executed:

#!/bin/bash
# Search for _on_energy_depleted callback
rg -n "_on_energy_depleted" --type=py -A 5 -B 2

Length of output: 1149


🏁 Script executed:

#!/bin/bash
# Search for shuffle_do implementation
rg -n "shuffle_do" --type=py -A 5 -B 2

Length 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.py

Length 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 -150

Length 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 2

Length 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 5

Length of output: 481


🏁 Script executed:

#!/bin/bash
# Search for any test reproducing the RuntimeError mentioned in review
rg -n "RuntimeError.*dictionary" --type=py

Length 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.

Comment on lines +518 to +550
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,
)

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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 Computed can both depend on, and write to, the same ContinuousObservable without raising the usual ValueError, risking subtle cycles.
  • PROCESSING_SIGNALS may 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__.

@EwoutH
Copy link
Member Author

EwoutH commented Nov 16, 2025

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.

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

Labels

feature Release notes label

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants