Repository: projectmesa/mesa

Issue:
The grid class of the SPACE file has a private variable _neighborhood_cache which is a dictionary that holds the locations of the neighbourhood units, but will this variable's be modified after it is generated?
**This is the first time I've asked a question on github, so apologies if I've expressed myself unclearly!**
I built a model and when an agent in the model moves, I need to get the location of the units around this agent, but it happens that the next_moves array is not 9 from the length.
Then I modified the get_neighborhood() in grid class to ask before each of its return values to determine if the length of the neighborhood list is 9 or not.But I've never yet found out what operation causes the values in _neighborhood_cache to be have been modified.


I found that when the elements in _neighborhood_cache were first created the value of each of its elements was a list of length 9, but on subsequent fetches directly from _neighborhood_cache the result could be less than 9.   
With some modifications I've found that this isn't caused by the death of the proxy in my model, and I haven't found the cause yet.  

```python
    def get_neighborhood(
        self,
        pos: Coordinate,
        moore: bool,
        include_center: bool = False,
        radius: int = 1,
    ) -> list[Coordinate]:
        """Return a list of cells that are in the neighborhood of a
        certain point.

        Args:
            pos: Coordinate tuple for the neighborhood to get.
            moore: If True, return Moore neighborhood
                   (including diagonals)
                   If False, return Von Neumann neighborhood
                   (exclude diagonals)
            include_center: If True, return the (x, y) cell as well.
                            Otherwise, return surrounding cells only.
            radius: radius, in cells, of neighborhood to get.

        Returns:
            A list of coordinate tuples representing the neighborhood;
            With radius 1, at most 9 if Moore, 5 if Von Neumann (8 and 4
            if not including the center).
        """

        cache_key = (pos, moore, include_center, radius)
        neighborhood = self._neighborhood_cache.get(cache_key, None)

        if neighborhood is not None:
            if len(neighborhood) != 9:
                print('The number of existing fields obtained from the query is less than 9')
                print(self._MyNeighborhoodCache[cache_key])
            return neighborhood

        # We use a list instead of a dict for the neighborhood because it would
        # be easier to port the code to Cython or Numba (for performance
        # purpose), with minimal changes. To better understand how the
        # algorithm was conceived, look at
        # https://github.com/projectmesa/mesa/pull/1476#issuecomment-1306220403
        # and the discussion in that PR in general.
        neighborhood = []

        x, y = pos
        if self.torus:
            x_max_radius, y_max_radius = self.width // 2, self.height // 2
            x_radius, y_radius = min(radius, x_max_radius), min(radius, y_max_radius)

            # For each dimension, in the edge case where the radius is as big as
            # possible and the dimension is even, we need to shrink by one the range
            # of values, to avoid duplicates in neighborhood. For example, if
            # the width is 4, while x, x_radius, and x_max_radius are 2, then
            # (x + dx) has a value from 0 to 4 (inclusive), but this means that
            # the 0 position is repeated since 0 % 4 and 4 % 4 are both 0.
            xdim_even, ydim_even = (self.width + 1) % 2, (self.height + 1) % 2
            kx = int(x_radius == x_max_radius and xdim_even)
            ky = int(y_radius == y_max_radius and ydim_even)

            for dx in range(-x_radius, x_radius + 1 - kx):
                for dy in range(-y_radius, y_radius + 1 - ky):
                    if not moore and abs(dx) + abs(dy) > radius:
                        continue

                    nx, ny = (x + dx) % self.width, (y + dy) % self.height
                    neighborhood.append((nx, ny))
        else:
            x_range = range(max(0, x - radius), min(self.width, x + radius + 1))
            y_range = range(max(0, y - radius), min(self.height, y + radius + 1))

            for nx in x_range:
                for ny in y_range:
                    if not moore and abs(nx - x) + abs(ny - y) > radius:
                        continue

                    neighborhood.append((nx, ny))

        if not include_center and neighborhood:
            neighborhood.remove(pos)

        self._neighborhood_cache[cache_key] = neighborhood
        self._MyNeighborhoodCache[cache_key] = neighborhood
        if len(neighborhood) != 9:
            print('Initial number of generated fields less than 9')

        return neighborhood
``` 


Discussion:
Welcome to MSFT's GitHub Death Star! Do you have a minimal reproducer code for this? It is much easier to investigate with such.
Thank you for your response and willingness to help. I'm happy to report that I have resolved the issue. It turned out that the problem was caused by some oversights on my part, but with your guidance, I was able to identify and correct them. I really appreciate your time and expertise in helping me with this.
> 

The specific reason for this is that the return value of the get_neighborhood function in the SPACE source code is a mutable object (the list neighborhood). This caused the object that got the return value and the neighborhood to actually reference the same list object, rather than creating a new copy.

```python
next_moves = self.model.grid.get_neighborhood(self.pos, self.moore, True)
``` 
And then I made a change to next_moves that resulted in a change to neighborborhood in the SPACE source code as well

I would actually suggest modifying the return of get_neighborhood to neighborhood.copy()
The above is just my rough opinion, it may be wrong, but it will solve the problem I encountered

I'd store the neighborhood as a tuple instead, so that it is immutable.

Resolving patch:
diff --git a/mesa/space.py b/mesa/space.py
--- a/mesa/space.py
+++ b/mesa/space.py
@@ -117,7 +117,7 @@ def __init__(self, width: int, height: int, torus: bool) -> None:
         self._empties_built = False
 
         # Neighborhood Cache
-        self._neighborhood_cache: dict[Any, list[Coordinate]] = {}
+        self._neighborhood_cache: dict[Any, Sequence[Coordinate]] = {}
 
         # Cutoff used inside self.move_to_empty. The parameters are fitted on Python
         # 3.11 and it was verified that they are roughly the same for 3.10. Refer to
@@ -233,7 +233,7 @@ def get_neighborhood(
         moore: bool,
         include_center: bool = False,
         radius: int = 1,
-    ) -> list[Coordinate]:
+    ) -> Sequence[Coordinate]:
         """Return a list of cells that are in the neighborhood of a
         certain point.
 
@@ -307,9 +307,9 @@ def get_neighborhood(
         if not include_center:
             neighborhood.pop(pos, None)
 
-        self._neighborhood_cache[cache_key] = list(neighborhood.keys())
+        self._neighborhood_cache[cache_key] = tuple(neighborhood.keys())
 
-        return list(neighborhood.keys())
+        return tuple(neighborhood.keys())
 
     def iter_neighbors(
         self,
@@ -681,7 +681,7 @@ def get_neighborhood(
         else:
             coordinates.discard(pos)
 
-        neighborhood = sorted(coordinates)
+        neighborhood = tuple(sorted(coordinates))
         self._neighborhood_cache[cache_key] = neighborhood
 
         return neighborhood


Test patch: