Skip to content

Fix: IndexError in select_random_agent when cell collection is empty#2983

Merged
quaquel merged 5 commits intomesa:mainfrom
Nithurshen:fix/select_random_agent
Dec 22, 2025
Merged

Fix: IndexError in select_random_agent when cell collection is empty#2983
quaquel merged 5 commits intomesa:mainfrom
Nithurshen:fix/select_random_agent

Conversation

@Nithurshen
Copy link
Copy Markdown
Contributor

@Nithurshen Nithurshen commented Dec 21, 2025

Summary

This PR fixes a crash in CellCollection.select_random_agent() where calling the method on a collection with no agents raised an IndexError.

It implements a hybrid solution (consensus reached in #2982 ):

Raises a descriptive LookupError if the collection is empty and no default is provided.
Adds a default parameter . Users with sparse grids can pass default=None to avoid the overhead of exception handling.

Bug / Issue

Fixes #2982
In sparse grids or simulations where agents move/die, it is common for a cell's neighborhood to contain no agents. What happened previously is that select_random_agent attempted to call self.random.choice() on list(self.agents). If self.agents was empty, random.choice raised IndexError: Cannot choose from an empty sequence, causing the entire model to crash. The method now checks for empty collections first. If default is not provided it raises LookupError: Cannot select random agent from empty collection. If default is provided then it returns the default value.

Implementation

  1. Modified mesa/discrete_space/cell_collection.py
    def select_random_agent(self, default=_no_default) -> CellAgent | None:
        """Select a random agent from the collection.

        Args:
            default: Value to return if the collection is empty.
                     If not provided, raises LookupError.

        Returns:
            CellAgent: A random agent, or the default value if provided and collection is empty.

        Raises:
            LookupError: If collection is empty and no default is provided.
        """
        agents = list(self.agents)

        if not agents:
            if default is _no_default:
                raise LookupError("Cannot select random agent from empty collection")
            return default

        return self.random.choice(agents)
  1. Added a new test case test_select_random_agent_empty_safe in tests/test_cell_spaces.py:
def test_select_random_agent_empty_safe():
    """Test that select_random_agent returns None when no agents are present."""
    rng = random.Random(42)
    empty_collection = CellCollection([], random=rng)
    with pytest.raises(LookupError):
        empty_collection.select_random_agent()
    assert empty_collection.select_random_agent(default=None) is None
    assert empty_collection.select_random_agent(default="Empty") == "Empty"

Testing

Bug Reproduction Script, credit @Nithin9585

from mesa import Model
from mesa.discrete_space import OrthogonalMooreGrid, CellAgent

class MoneyAgent(CellAgent):
    def __init__(self, model, cell):
        super().__init__(model)
        self.cell = cell
        self.wealth = 100
    
    def step(self):
        if self.wealth > 0:
            # This crashes when neighborhood is empty!
            other = self.cell.neighborhood.select_random_agent()
            other.wealth += 1
            self.wealth -= 1

class MoneyModel(Model):
    def __init__(self, n_agents=5, width=10, height=10):
        super().__init__()
        self.grid = OrthogonalMooreGrid((width, height), random=self.random)
        
        # Sparse grid - most cells empty
        for _ in range(n_agents):
            cell = self.random.choice(list(self.grid.all_cells))
            MoneyAgent(self, cell)
    
    def step(self):
        self.agents.shuffle_do("step")

# Run it
model = MoneyModel()
model.step()  # Crashes!

Output Before

(venv) nithurshen@Nithurshens-MacBook mesa % python3 temp.py  
Traceback (most recent call last):
  File "/Users/nithurshen/Projects/mesa/temp.py", line 32, in <module>
    model.step()  # Crashes!
    ~~~~~~~~~~^^
  File "/Users/nithurshen/Projects/mesa/mesa/model.py", line 136, in _wrapped_step
    self._user_step(*args, **kwargs)
    ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
  File "/Users/nithurshen/Projects/mesa/temp.py", line 28, in step
    self.agents.shuffle_do("step")
    ~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^
  File "/Users/nithurshen/Projects/mesa/mesa/agent.py", line 356, in shuffle_do
    getattr(agent, method)(*args, **kwargs)
    ~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
  File "/Users/nithurshen/Projects/mesa/temp.py", line 13, in step
    other = self.cell.neighborhood.select_random_agent()
  File "/Users/nithurshen/Projects/mesa/mesa/discrete_space/cell_collection.py", line 111, in select_random_agent
    return self.random.choice(list(self.agents))
           ~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^
  File "/Users/nithurshen/.pyenv/versions/3.13.8/lib/python3.13/random.py", line 351, in choice
    raise IndexError('Cannot choose from an empty sequence')
IndexError: Cannot choose from an empty sequence

Output After

(venv) nithurshen@Nithurshens-MacBook mesa % python3 ./temp.py
Traceback (most recent call last):
  File "/Users/nithurshen/Projects/mesa/./temp.py", line 32, in <module>
    model.step()  # Crashes!
    ~~~~~~~~~~^^
  File "/Users/nithurshen/Projects/mesa/mesa/model.py", line 136, in _wrapped_step
    self._user_step(*args, **kwargs)
    ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
  File "/Users/nithurshen/Projects/mesa/./temp.py", line 28, in step
    self.agents.shuffle_do("step")
    ~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^
  File "/Users/nithurshen/Projects/mesa/mesa/agent.py", line 356, in shuffle_do
    getattr(agent, method)(*args, **kwargs)
    ~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
  File "/Users/nithurshen/Projects/mesa/./temp.py", line 13, in step
    other = self.cell.neighborhood.select_random_agent()
  File "/Users/nithurshen/Projects/mesa/mesa/discrete_space/cell_collection.py", line 122, in select_random_agent
    raise LookupError("Cannot select random agent from empty collection")
LookupError: Cannot select random agent from empty collection

Additional Notes

This design balances the Pythonic "EAFP" principle with the performance needs of Agent-Based Modeling.

Maintainers preferred raising an Exception by default to avoid type-checking ambiguity.

Benchmarks provided by @Nithin9895 in #2982 showed that using default=None is ~1.2x faster than try/except blocks on sparse grids (5% density). This opt-in path allows high-performance simulations to avoid the overhead of creating/catching exceptions thousands of times per step.

Credits:

@Nithin9585 for the bug report, reproduction script, and performance benchmarks.

@quaquel and @EwoutH for the design discussion regarding EAFP vs. Performance.

@github-actions
Copy link
Copy Markdown

Performance benchmarks:

Model Size Init time [95% CI] Run time [95% CI]
BoltzmannWealth small 🔵 +0.9% [+0.6%, +1.3%] 🔵 -0.7% [-0.8%, -0.6%]
BoltzmannWealth large 🔵 +0.1% [-1.8%, +3.3%] 🔵 -3.4% [-5.2%, -1.8%]
Schelling small 🔵 -0.7% [-1.7%, +0.3%] 🔵 +0.5% [-0.1%, +1.1%]
Schelling large 🔵 +0.4% [-0.5%, +2.2%] 🔵 -3.2% [-5.7%, -1.2%]
WolfSheep small 🔵 +0.7% [-0.2%, +1.3%] 🔵 +0.9% [+0.6%, +1.1%]
WolfSheep large 🔵 -0.7% [-3.9%, +1.0%] 🔵 +0.2% [-0.7%, +0.9%]
BoidFlockers small 🔵 -2.2% [-2.5%, -1.8%] 🔵 -1.7% [-1.9%, -1.4%]
BoidFlockers large 🔵 -3.0% [-3.5%, -2.4%] 🔵 -2.2% [-2.4%, -1.9%]

@quaquel
Copy link
Copy Markdown
Member

quaquel commented Dec 21, 2025

I disagree with this fix. In my view, returning None is a bad habit because it forces the user to perform a check on the return type. It is, in fact, more Pythonic, in my experience, to use try-except structures for control flow. See for example the first answer to this question on stack overflow.

@Nithurshen
Copy link
Copy Markdown
Contributor Author

I disagree with this fix. In my view, returning None is a bad habit because it forces the user to perform a check on the return type. It is, in fact, more Pythonic, in my experience, to use try-except structures for control flow. See for example the first answer to this question on stack overflow.

I have updated the PR according to the agreed fix in #2986. Kindly review it


T = TypeVar("T", bound="Cell")

_no_default = object()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why did you choose to do it this way instead of using None as in dict.get? If the idea is to mimic a well established pattern like dict.get, why deviate from it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The reason for the sentinel is that we want to distinguish between two specific scenarios:

  1. select_random_agent() (No argument) Should raise LookupError.
  2. select_random_agent(default=None) (Explicit None) Should return None

If I defined the signature as def select_random_agent(self, default=None):, then 1 would automatically return None instead of raising an error. The function would have no way of knowing if the user forgot the argument (which should error) or explicitly passed None (which should not).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Logically, I understood what was happening. I am just not sure what the cleanest solution is. Conceptually, we are taking inspiration from dict.get, which uses None. However, dict.get will never raise an exception, so it's not a perfect analogy.

So, I am fine with the suggested approach. It offers both exceptions as default, but also control, including the use of None.

2 minor quibbles: first, since this becomes part of the signature of the method, naming this matters. no_default is not really clear. In 32986, @Nithin9585 uses RAISES which is perhaps clearer. Second, for the same reason, I would not use an underscore in the name.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have renamed it to RAISES, as you requested.

@Nithurshen Nithurshen requested a review from quaquel December 22, 2025 10:58
Copy link
Copy Markdown
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.

Can you have a look at the naming of the sentinel? Otherwise, this is good to go.

@Nithurshen Nithurshen requested a review from quaquel December 22, 2025 14:29
@quaquel quaquel merged commit d35b05c into mesa:main Dec 22, 2025
12 of 13 checks passed
@Nithurshen Nithurshen deleted the fix/select_random_agent branch December 22, 2025 14:47
@EwoutH EwoutH added the bug Release notes label label Dec 23, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Release notes label

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CellCollection.select_random_agent() crashes when no agents in cells

3 participants