Skip to content

Commit 065bea8

Browse files
committed
Implement rename functionality for AgentSet and AgentSetRegistry with conflict handling
1 parent 352f2af commit 065bea8

File tree

6 files changed

+278
-7
lines changed

6 files changed

+278
-7
lines changed

mesa_frames/abstract/agentset.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,50 @@ def random(self) -> Generator:
468468
def space(self) -> mesa_frames.abstract.space.Space | None:
469469
return self.model.space
470470

471+
def rename(self, new_name: str, inplace: bool = True) -> Self:
472+
"""Rename this AgentSet.
473+
474+
If this set is contained in the model's AgentSetRegistry, delegate to
475+
the registry's rename implementation so that name uniqueness and
476+
conflicts are handled consistently. If the set is not yet part of a
477+
registry, update the local name directly.
478+
479+
Parameters
480+
----------
481+
new_name : str
482+
Desired new name for this AgentSet.
483+
484+
Returns
485+
-------
486+
Self
487+
The updated AgentSet (or a renamed copy when ``inplace=False``).
488+
"""
489+
obj = self._get_obj(inplace)
490+
try:
491+
# If contained in registry, delegate to it so conflicts are handled
492+
if self in self.model.sets: # type: ignore[operator]
493+
# Preserve index to retrieve copy when not inplace
494+
idx = None
495+
try:
496+
idx = list(self.model.sets).index(self) # type: ignore[arg-type]
497+
except Exception:
498+
idx = None
499+
reg = self.model.sets.rename(self, new_name, inplace=inplace)
500+
if inplace:
501+
return self
502+
# Non-inplace: return the corresponding set from the copied registry
503+
if idx is not None:
504+
return reg[idx] # type: ignore[index]
505+
# Fallback: look up by name (may be canonicalized)
506+
return reg.get(new_name) # type: ignore[return-value]
507+
except Exception:
508+
# If delegation cannot be resolved, fall back to local rename
509+
obj._name = new_name
510+
return obj
511+
# Not in a registry: local rename
512+
obj._name = new_name
513+
return obj
514+
471515
def __setitem__(
472516
self,
473517
key: str

mesa_frames/abstract/agentsetregistry.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,44 @@ def discard(
9393
return self.remove(sets, inplace=inplace)
9494
return self._get_obj(inplace)
9595

96+
@abstractmethod
97+
def rename(
98+
self,
99+
target: (
100+
mesa_frames.abstract.agentset.AbstractAgentSet
101+
| str
102+
| dict[mesa_frames.abstract.agentset.AbstractAgentSet | str, str]
103+
| list[tuple[mesa_frames.abstract.agentset.AbstractAgentSet | str, str]]
104+
),
105+
new_name: str | None = None,
106+
*,
107+
on_conflict: Literal["canonicalize", "raise"] = "canonicalize",
108+
mode: Literal["atomic", "best_effort"] = "atomic",
109+
inplace: bool = True,
110+
) -> Self:
111+
"""Rename AgentSets in this registry, handling conflicts.
112+
113+
Parameters
114+
----------
115+
target : AgentSet | str | dict | list[tuple]
116+
Single target (instance or existing name) with ``new_name`` provided,
117+
or a mapping/sequence of (target, new_name) pairs for batch rename.
118+
new_name : str | None
119+
New name for single-target rename.
120+
on_conflict : {"canonicalize", "raise"}
121+
When a desired name collides, either canonicalize by appending a
122+
numeric suffix (default) or raise ``ValueError``.
123+
mode : {"atomic", "best_effort"}
124+
In "atomic" mode, validate all renames before applying any. In
125+
"best_effort" mode, apply what can be applied and skip failures.
126+
127+
Returns
128+
-------
129+
Self
130+
Updated registry (or a renamed copy when ``inplace=False``).
131+
"""
132+
...
133+
96134
@abstractmethod
97135
def add(
98136
self,

mesa_frames/concrete/agentset.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def __init__(
103103
self._df = pl.DataFrame()
104104
self._mask = pl.repeat(True, len(self._df), dtype=pl.Boolean, eager=True)
105105

106-
def rename(self, new_name: str) -> str:
106+
def rename(self, new_name: str, inplace: bool = True) -> Self:
107107
"""Rename this agent set. If attached to AgentSetRegistry, delegate for uniqueness enforcement.
108108
109109
Parameters
@@ -113,22 +113,40 @@ def rename(self, new_name: str) -> str:
113113
114114
Returns
115115
-------
116-
str
117-
The final name used (may be canonicalized if duplicates exist).
116+
Self
117+
The updated AgentSet (or a renamed copy when ``inplace=False``).
118118
119119
Raises
120120
------
121121
ValueError
122122
If name conflicts occur and delegate encounters errors.
123123
"""
124+
# Respect inplace semantics consistently with other mutators
125+
obj = self._get_obj(inplace)
126+
124127
# Always delegate to the container's accessor if available through the model's sets
125128
# Check if we have a model and can find the AgentSetRegistry that contains this set
126-
if self in self.model.sets:
127-
return self.model.sets.rename(self._name, new_name)
129+
try:
130+
if self in self.model.sets:
131+
# Save index to locate the copy on non-inplace path
132+
try:
133+
idx = list(self.model.sets).index(self) # type: ignore[arg-type]
134+
except Exception:
135+
idx = None
136+
reg = self.model.sets.rename(self, new_name, inplace=inplace)
137+
if inplace:
138+
return self
139+
if idx is not None:
140+
return reg[idx]
141+
return reg.get(new_name) # type: ignore[return-value]
142+
except Exception:
143+
# Fall back to local rename if delegation fails
144+
obj._name = new_name
145+
return obj
128146

129147
# Set name locally if no container found
130-
self._name = new_name
131-
return new_name
148+
obj._name = new_name
149+
return obj
132150

133151
def add(
134152
self,

mesa_frames/concrete/agentsetregistry.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,111 @@ def add(
113113
obj._ids = new_ids
114114
return obj
115115

116+
def rename(
117+
self,
118+
target: (
119+
AgentSet
120+
| str
121+
| dict[AgentSet | str, str]
122+
| list[tuple[AgentSet | str, str]]
123+
),
124+
new_name: str | None = None,
125+
*,
126+
on_conflict: Literal["canonicalize", "raise"] = "canonicalize",
127+
mode: Literal["atomic", "best_effort"] = "atomic",
128+
inplace: bool = True,
129+
) -> Self:
130+
"""Rename AgentSets with conflict handling.
131+
132+
Supports single-target ``(set | old_name, new_name)`` and batch rename via
133+
dict or list of pairs. Names remain unique across the registry.
134+
"""
135+
136+
# Normalize to list of (index_in_self, desired_name) using the original registry
137+
def _resolve_one(x: AgentSet | str) -> int:
138+
if isinstance(x, AgentSet):
139+
for i, s in enumerate(self._agentsets):
140+
if s is x:
141+
return i
142+
raise KeyError("AgentSet not found in registry")
143+
# name lookup on original registry
144+
for i, s in enumerate(self._agentsets):
145+
if s.name == x:
146+
return i
147+
raise KeyError(f"Agent set '{x}' not found")
148+
149+
if isinstance(target, (AgentSet, str)):
150+
if new_name is None:
151+
raise TypeError("new_name must be provided for single rename")
152+
pairs_idx: list[tuple[int, str]] = [(_resolve_one(target), new_name)]
153+
single = True
154+
elif isinstance(target, dict):
155+
pairs_idx = [(_resolve_one(k), v) for k, v in target.items()]
156+
single = False
157+
else:
158+
pairs_idx = [(_resolve_one(k), v) for k, v in target]
159+
single = False
160+
161+
# Choose object to mutate
162+
obj = self._get_obj(inplace)
163+
# Translate indices to object AgentSets in the selected registry object
164+
target_sets = [obj._agentsets[i] for i, _ in pairs_idx]
165+
166+
# Build the set of names that remain fixed (exclude targets' current names)
167+
targets_set = set(target_sets)
168+
fixed_names: set[str] = {
169+
s.name
170+
for s in obj._agentsets
171+
if s.name is not None and s not in targets_set
172+
} # type: ignore[comparison-overlap]
173+
174+
# Plan final names
175+
final: list[tuple[AgentSet, str]] = []
176+
used = set(fixed_names)
177+
178+
def _canonicalize(base: str) -> str:
179+
if base not in used:
180+
used.add(base)
181+
return base
182+
counter = 1
183+
cand = f"{base}_{counter}"
184+
while cand in used:
185+
counter += 1
186+
cand = f"{base}_{counter}"
187+
used.add(cand)
188+
return cand
189+
190+
errors: list[Exception] = []
191+
for aset, (_idx, desired) in zip(target_sets, pairs_idx):
192+
if on_conflict == "canonicalize":
193+
final_name = _canonicalize(desired)
194+
final.append((aset, final_name))
195+
else: # on_conflict == 'raise'
196+
if desired in used:
197+
err = ValueError(
198+
f"Duplicate agent set name disallowed: '{desired}'"
199+
)
200+
if mode == "atomic":
201+
errors.append(err)
202+
else:
203+
# best_effort: skip this rename
204+
continue
205+
else:
206+
used.add(desired)
207+
final.append((aset, desired))
208+
209+
if errors and mode == "atomic":
210+
# Surface first meaningful error
211+
raise errors[0]
212+
213+
# Apply renames
214+
for aset, newn in final:
215+
# Set the private name directly to avoid external uniqueness hooks
216+
if hasattr(aset, "_name"):
217+
aset._name = newn # type: ignore[attr-defined]
218+
219+
return obj
220+
116221
def replace(
117222
self,
118223
mapping: (dict[int | str, AgentSet] | list[tuple[int | str, AgentSet]]),

tests/test_agentset.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,36 @@ def test_select(self, fix1_AgentSet: ExampleAgentSet):
260260
selected.active_agents["wealth"].to_list() == agents.df["wealth"].to_list()
261261
)
262262

263+
def test_rename(self, fix1_AgentSet: ExampleAgentSet) -> None:
264+
agents = fix1_AgentSet
265+
reg = agents.model.sets
266+
# Inplace rename returns self and updates registry
267+
old_name = agents.name
268+
result = agents.rename("alpha", inplace=True)
269+
assert result is agents
270+
assert agents.name == "alpha"
271+
assert reg.get("alpha") is agents
272+
assert reg.get(old_name) is None
273+
274+
# Add a second set and claim the same name via registry first
275+
other = ExampleAgentSet(agents.model)
276+
other["wealth"] = other.starting_wealth
277+
other["age"] = [1, 2, 3, 4]
278+
reg.add(other)
279+
reg.rename(other, "omega")
280+
# Now rename the first to an existing name; should canonicalize to omega_1
281+
agents.rename("omega", inplace=True)
282+
assert agents.name != "omega"
283+
assert agents.name.startswith("omega_")
284+
assert reg.get(agents.name) is agents
285+
286+
# Non-inplace: returns a renamed copy of the set
287+
copy_set = agents.rename("beta", inplace=False)
288+
assert copy_set is not agents
289+
assert copy_set.name in ("beta", "beta_1")
290+
# Original remains unchanged
291+
assert agents.name not in ("beta", "beta_1")
292+
263293
# Test with a pl.Series[bool]
264294
mask = pl.Series("mask", [True, False, True, True], dtype=pl.Boolean)
265295
selected = agents.select(mask, inplace=False)

tests/test_agentsetregistry.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,42 @@ def test_sort(self, fix_registry_with_two: AgentSetRegistry) -> None:
186186
reg[0]["wealth"].to_list(), reverse=True
187187
)
188188

189+
# Public: rename
190+
def test_rename(self, fix_registry_with_two: AgentSetRegistry) -> None:
191+
reg = fix_registry_with_two
192+
# Single rename by instance, inplace
193+
a0 = reg[0]
194+
reg.rename(a0, "X")
195+
assert a0.name == "X"
196+
assert reg.get("X") is a0
197+
198+
# Rename second to same name should canonicalize
199+
a1 = reg[1]
200+
reg.rename(a1, "X")
201+
assert a1.name != "X" and a1.name.startswith("X_")
202+
assert reg.get(a1.name) is a1
203+
204+
# Non-inplace copy
205+
reg2 = reg.rename(a0, "Y", inplace=False)
206+
assert reg2 is not reg
207+
assert reg.get("Y") is None
208+
assert reg2.get("Y") is not None
209+
210+
# Atomic conflict raise: attempt to rename to existing name
211+
with pytest.raises(ValueError):
212+
reg.rename({a0: a1.name}, on_conflict="raise", mode="atomic")
213+
# Names unchanged
214+
assert reg.get(a1.name) is a1
215+
216+
# Best-effort: one ok, one conflicting → only ok applied
217+
unique_name = "Z_unique"
218+
reg.rename(
219+
{a0: unique_name, a1: unique_name}, on_conflict="raise", mode="best_effort"
220+
)
221+
assert a0.name == unique_name
222+
# a1 stays with its previous (non-unique_name) value
223+
assert a1.name != unique_name
224+
189225
# Dunder: __getattr__
190226
def test__getattr__(self, fix_registry_with_two: AgentSetRegistry) -> None:
191227
reg = fix_registry_with_two

0 commit comments

Comments
 (0)