From a193c37d68b4273e41ded95ca7f5559bffa6debe Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Thu, 14 May 2026 17:09:06 +0530 Subject: [PATCH 01/22] add typed membership backend foundation with invariant tests. --- mesa/experimental/meta_agents/__init__.py | 3 +- mesa/experimental/meta_agents/backend.py | 120 ++++++++++++++++++ tests/experimental/test_meta_agent_backend.py | 83 ++++++++++++ 3 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 mesa/experimental/meta_agents/backend.py create mode 100644 tests/experimental/test_meta_agent_backend.py diff --git a/mesa/experimental/meta_agents/__init__.py b/mesa/experimental/meta_agents/__init__.py index f4f3231295b..4396f42c88d 100644 --- a/mesa/experimental/meta_agents/__init__.py +++ b/mesa/experimental/meta_agents/__init__.py @@ -20,6 +20,7 @@ """ +from .backend import MembershipBackend from .meta_agent import MetaAgent -__all__ = ["MetaAgent"] +__all__ = ["MembershipBackend", "MetaAgent"] diff --git a/mesa/experimental/meta_agents/backend.py b/mesa/experimental/meta_agents/backend.py new file mode 100644 index 00000000000..fbac4804c60 --- /dev/null +++ b/mesa/experimental/meta_agents/backend.py @@ -0,0 +1,120 @@ +"""Backend foundation for typed overlapping meta-agent memberships. + +Phase 1 scope: +- Canonical typed membership representation +- Safe update operations +- Invariant checks +- Internal-only API (no public facade changes yet) +""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Hashable, Iterable + +Triplet = tuple[Hashable, Hashable, str] + + +class MembershipBackend: + """Canonical backend for typed overlapping memberships.""" + + def __init__(self) -> None: + """Initialize empty triplet storage and bidirectional indexes.""" + self._triplets: set[Triplet] = set() + self._by_agent: dict[Hashable, set[tuple[Hashable, str]]] = defaultdict(set) + self._by_group: dict[Hashable, set[tuple[Hashable, str]]] = defaultdict(set) + + def add_membership(self, agent: Hashable, group: Hashable, relation: str) -> None: + """Add one typed membership edge if it does not already exist.""" + if not isinstance(relation, str): + raise TypeError("relation must be a string") + triplet = (agent, group, relation) + if triplet in self._triplets: + return + self._triplets.add(triplet) + self._by_agent[agent].add((group, relation)) + self._by_group[agent].add((agent, relation)) + + def bulk_add(self, memberships: Iterable[Triplet]) -> None: + """Add many typed membership edges.""" + for agent, group, relation in memberships: + self.add_membership(agent, group, relation) + + def remove_membership( + self, agent: Hashable, group: Hashable, relation: str + ) -> None: + """Remove one typed membership edge if present.""" + triplet = (agent, group, relation) + if triplet not in self._triplets: + return + self._triplets.remove(triplet) + + self._by_agent[agent].discard((group, relation)) + if not self._by_agent[agent]: + del self._by_agent[agent] + + self._by_group[group].discard((agent, relation)) + if not self._by_group[group]: + del self._by_group[group] + + def replace_relation( + self, agent: Hashable, group: Hashable, old_relation: str, new_relation: str + ) -> None: + """Replace one relation label for one agent-group pair.""" + self.remove_membership(agent, group, old_relation) + self.add_membership(agent, group, new_relation) + + def remove_agent(self, agent: Hashable) -> None: + """Remove an agent and all incident memberships.""" + edges = list(self._by_agent.get(agent, set())) + for group, relation in edges: + self.remove_membership(agent, group, relation) + + def remove_group(self, group: Hashable) -> None: + """Remove a group and all incident memberships.""" + edges = list(self._by_group.get(group, set())) + for agent, relation in edges: + self.remove_membership(agent, group, relation) + + def groups_of(self, agent: Hashable, relation: str | None = None) -> set[Hashable]: + """Return groups for an agent, optionally filtered by relation.""" + entries = self._by_agent.get(agent, set()) + if relation is None: + return {group for group, _ in entries} + return {group for group, rel in entries if rel == relation} + + def agents_of(self, group: Hashable, relation: str | None = None) -> set[Hashable]: + """Return agents for a group, optionally filtered relation.""" + entries = self._by_group.get(group, set()) + if relation is None: + return {agent for agent, _ in entries} + return {agent for agent, rel in entries if rel == relation} + + def relations_between(self, agent: Hashable, group: Hashable) -> set[str]: + """Return all relation types between one agent and one group.""" + return { + relation + for linked_group, relation in self._by_agent.get(agent, set()) + if linked_group == group + } + + def as_triplets(self) -> set[Triplet]: + """Return all memberships as canonical triplets.""" + return set(self._triplets) + + def assert_invariants(self) -> None: + """Assert internal consistency between triplets and indexes.""" + # Triplets must match both indexes. + for agent, group, relation in self._triplets: + assert (group, relation) in self._by_agent.get(agent, set()) + assert (agent, relation) in self._by_group.get(group, set()) + + # Agent index must match triplets + for agent, edges in self._by_agent.items(): + for group, relation in edges: + assert (agent, group, relation) in self._triplets + + # Group index must match triplets + for group, edges in self._by_group.items(): + for agent, relation in edges: + assert (agent, group, relation) in self._triplets diff --git a/tests/experimental/test_meta_agent_backend.py b/tests/experimental/test_meta_agent_backend.py new file mode 100644 index 00000000000..1edcede3e0a --- /dev/null +++ b/tests/experimental/test_meta_agent_backend.py @@ -0,0 +1,83 @@ +"""Tests for typed membership backend.""" + +from mesa.experimental.meta_agents.backend import MembershipBackend + + +def test_add_and_query(): + """Add edges and verify basic query behavior.""" + backend = MembershipBackend() + backend.add_membership("a1", "g1", "member") + backend.add_membership("a2", "g1", "member") + backend.add_membership("a1", "g2", "leader") + + assert backend.groups_of("a1") == {"g1", "g2"} + assert backend.groups_of("a1", relation="member") == {"g1"} + assert backend.agents_of("g1") == {"a1", "a2"} + assert backend.relations_between("a1", "g1") == {"member"} + backend.assert_invariants() + + +def test_typed_overlap_same_pair(): + """Allow multiple relation labels on the same agent-group pair.""" + backend = MembershipBackend() + backend.add_membership("a1", "g1", "member") + backend.add_membership("a1", "g1", "mentor") + + assert backend.relations_between("a1", "g1") == {"member", "mentor"} + assert backend.groups_of("a1", relation="mentor") == {"g1"} + backend.assert_invariants() + + +def test_idempotent_add_and_remove(): + """Repeated add/remove call should remain safe and deterministic.""" + backend = MembershipBackend() + backend.add_membership("a1", "g1", "member") + backend.add_membership("a1", "g1", "member") # idempotent add + + assert backend.as_triplets() == {("a1", "g1", "member")} + + backend.remove_membership("a1", "g1", "member") + backend.remove_membership("a1", "g1", "member") # idempotent remove + assert backend.as_triplets == set() + backend.assert_invariants() + + +def test_replace_relation(): + """Replace an existing relation label for one edge.""" + backend = MembershipBackend() + backend.add_membership("a1", "g1", "member") + backend.replace_relation("a1", "g1", "member", "leader") + + assert backend.relations_between("a1", "g1") == {"leader"} + assert backend.groups_of("a1", relation="member") == set() + backend.assert_invariants() + + +def test_remove_agent_cascades_edges(): + """Removing an agent should clear all its incident adges.""" + backend = MembershipBackend() + backend.bulk_add( + [("a1", "g1", "member"), ("a1", "g2", "leader"), ("a2", "g1", "member")] + ) + + backend.remove_agent("a1") + + assert backend.groups_of("a1") == set() + assert backend.agents_of("g1") == {"a2"} + assert backend.agents_of("g2") == set() + backend.assert_invariants() + + +def test_remove_group_cascades_edges(): + """Removing a group should clear all incident adges.""" + backend = MembershipBackend() + backend.bulk_add( + [("a1", "g1", "member"), ("a1", "g2", "leader"), ("a2", "g1", "member")] + ) + + backend.remove_group("g1") + + assert backend.agents_of("g1") == set() + assert backend.groups_of("a1") == set() + assert backend.groups_of("a1") == {"g2"} + backend.assert_invariants() From 64139cb65c1db2759b342d61f058284514f50bfa Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Thu, 14 May 2026 21:06:28 +0530 Subject: [PATCH 02/22] add typed membership backend foundation with invariant tests. --- mesa/experimental/meta_agents/backend.py | 2 +- tests/experimental/test_meta_agent_backend.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/mesa/experimental/meta_agents/backend.py b/mesa/experimental/meta_agents/backend.py index fbac4804c60..1c0001abda8 100644 --- a/mesa/experimental/meta_agents/backend.py +++ b/mesa/experimental/meta_agents/backend.py @@ -33,7 +33,7 @@ def add_membership(self, agent: Hashable, group: Hashable, relation: str) -> Non return self._triplets.add(triplet) self._by_agent[agent].add((group, relation)) - self._by_group[agent].add((agent, relation)) + self._by_group[group].add((agent, relation)) def bulk_add(self, memberships: Iterable[Triplet]) -> None: """Add many typed membership edges.""" diff --git a/tests/experimental/test_meta_agent_backend.py b/tests/experimental/test_meta_agent_backend.py index 1edcede3e0a..429d4184cd7 100644 --- a/tests/experimental/test_meta_agent_backend.py +++ b/tests/experimental/test_meta_agent_backend.py @@ -38,7 +38,7 @@ def test_idempotent_add_and_remove(): backend.remove_membership("a1", "g1", "member") backend.remove_membership("a1", "g1", "member") # idempotent remove - assert backend.as_triplets == set() + assert backend.as_triplets() == set() backend.assert_invariants() @@ -69,7 +69,7 @@ def test_remove_agent_cascades_edges(): def test_remove_group_cascades_edges(): - """Removing a group should clear all incident adges.""" + """Removing a group should clear all incident edges.""" backend = MembershipBackend() backend.bulk_add( [("a1", "g1", "member"), ("a1", "g2", "leader"), ("a2", "g1", "member")] @@ -78,6 +78,6 @@ def test_remove_group_cascades_edges(): backend.remove_group("g1") assert backend.agents_of("g1") == set() - assert backend.groups_of("a1") == set() assert backend.groups_of("a1") == {"g2"} + assert backend.groups_of("a2") == set() backend.assert_invariants() From 4bd8a11fdd3d93c115708da25065b1830d85c748 Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Sat, 16 May 2026 13:00:22 +0530 Subject: [PATCH 03/22] add typed membership backend foundation with invariant tests. --- mesa/experimental/meta_agents/backend.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mesa/experimental/meta_agents/backend.py b/mesa/experimental/meta_agents/backend.py index 1c0001abda8..2040315ae68 100644 --- a/mesa/experimental/meta_agents/backend.py +++ b/mesa/experimental/meta_agents/backend.py @@ -1,6 +1,5 @@ """Backend foundation for typed overlapping meta-agent memberships. -Phase 1 scope: - Canonical typed membership representation - Safe update operations - Invariant checks From 48e747a8ee4d8e701e5062af48fbee21e349d580 Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Sun, 17 May 2026 20:38:43 +0530 Subject: [PATCH 04/22] normalize id-based removals, generalize relation keys and add perf TODOs. --- mesa/experimental/meta_agents/backend.py | 97 ++++++++++++------- ...backend.py => test_meta_agents_backend.py} | 12 ++- 2 files changed, 75 insertions(+), 34 deletions(-) rename tests/experimental/{test_meta_agent_backend.py => test_meta_agents_backend.py} (89%) diff --git a/mesa/experimental/meta_agents/backend.py b/mesa/experimental/meta_agents/backend.py index 2040315ae68..5edc7d63124 100644 --- a/mesa/experimental/meta_agents/backend.py +++ b/mesa/experimental/meta_agents/backend.py @@ -11,7 +11,8 @@ from collections import defaultdict from collections.abc import Hashable, Iterable -Triplet = tuple[Hashable, Hashable, str] +RelationKey = Hashable +Triplet = tuple[Hashable, Hashable, RelationKey] class MembershipBackend: @@ -20,19 +21,29 @@ class MembershipBackend: def __init__(self) -> None: """Initialize empty triplet storage and bidirectional indexes.""" self._triplets: set[Triplet] = set() - self._by_agent: dict[Hashable, set[tuple[Hashable, str]]] = defaultdict(set) - self._by_group: dict[Hashable, set[tuple[Hashable, str]]] = defaultdict(set) - - def add_membership(self, agent: Hashable, group: Hashable, relation: str) -> None: + self._by_agent: dict[Hashable, set[tuple[Hashable, RelationKey]]] = defaultdict( + set + ) + self._by_group: dict[Hashable, set[tuple[Hashable, RelationKey]]] = defaultdict( + set + ) + + def _to_id(self, entity: Hashable) -> Hashable: + """Normalize entities to canonical IDs when available.""" + return getattr(entity, "unique_id", entity) + + def add_membership( + self, agent: Hashable, group: Hashable, relation: RelationKey + ) -> None: """Add one typed membership edge if it does not already exist.""" - if not isinstance(relation, str): - raise TypeError("relation must be a string") - triplet = (agent, group, relation) + agent_id = self._to_id(agent) + group_id = self._to_id(group) + triplet = (agent_id, group_id, relation) if triplet in self._triplets: return self._triplets.add(triplet) - self._by_agent[agent].add((group, relation)) - self._by_group[group].add((agent, relation)) + self._by_agent[agent_id].add((group_id, relation)) + self._by_group[group_id].add((agent_id, relation)) def bulk_add(self, memberships: Iterable[Triplet]) -> None: """Add many typed membership edges.""" @@ -40,24 +51,30 @@ def bulk_add(self, memberships: Iterable[Triplet]) -> None: self.add_membership(agent, group, relation) def remove_membership( - self, agent: Hashable, group: Hashable, relation: str + self, agent: Hashable, group: Hashable, relation: RelationKey ) -> None: """Remove one typed membership edge if present.""" - triplet = (agent, group, relation) + agent_id = self._to_id(agent) + group_id = self._to_id(group) + triplet = (agent_id, group_id, relation) if triplet not in self._triplets: return self._triplets.remove(triplet) - self._by_agent[agent].discard((group, relation)) - if not self._by_agent[agent]: - del self._by_agent[agent] + self._by_agent[agent_id].discard((group_id, relation)) + if not self._by_agent[agent_id]: + del self._by_agent[agent_id] - self._by_group[group].discard((agent, relation)) - if not self._by_group[group]: - del self._by_group[group] + self._by_group[group_id].discard((agent_id, relation)) + if not self._by_group[group_id]: + del self._by_group[group_id] def replace_relation( - self, agent: Hashable, group: Hashable, old_relation: str, new_relation: str + self, + agent: Hashable, + group: Hashable, + old_relation: RelationKey, + new_relation: RelationKey, ) -> None: """Replace one relation label for one agent-group pair.""" self.remove_membership(agent, group, old_relation) @@ -65,36 +82,50 @@ def replace_relation( def remove_agent(self, agent: Hashable) -> None: """Remove an agent and all incident memberships.""" - edges = list(self._by_agent.get(agent, set())) - for group, relation in edges: - self.remove_membership(agent, group, relation) + agent_id = self._to_id(agent) + # TODO(perf): Current removal is O(degree(agent)) with per-edge updates. + # Revisit with bulk/index-aware deletion once benchmark baselines are in place. + edges = list(self._by_agent.get(agent_id, set())) + for group_id, relation in edges: + self.remove_membership(agent_id, group_id, relation) def remove_group(self, group: Hashable) -> None: """Remove a group and all incident memberships.""" - edges = list(self._by_group.get(group, set())) - for agent, relation in edges: - self.remove_membership(agent, group, relation) - - def groups_of(self, agent: Hashable, relation: str | None = None) -> set[Hashable]: + group_id = self._to_id(group) + # TODO(perf): Current removal is O(degree(agent)) with per-edge updates. + # Revisit with bulk/index-aware deletion once benchmark baselines are in place. + edges = list(self._by_group.get(group_id, set())) + for agent_id, relation in edges: + self.remove_membership(agent_id, group_id, relation) + + def groups_of( + self, agent: Hashable, relation: RelationKey | None = None + ) -> set[Hashable]: """Return groups for an agent, optionally filtered by relation.""" - entries = self._by_agent.get(agent, set()) + agent_id = self._to_id(agent) + entries = self._by_agent.get(agent_id, set()) if relation is None: return {group for group, _ in entries} return {group for group, rel in entries if rel == relation} - def agents_of(self, group: Hashable, relation: str | None = None) -> set[Hashable]: + def agents_of( + self, group: Hashable, relation: RelationKey | None = None + ) -> set[Hashable]: """Return agents for a group, optionally filtered relation.""" - entries = self._by_group.get(group, set()) + group_id = self._to_id(group) + entries = self._by_group.get(group_id, set()) if relation is None: return {agent for agent, _ in entries} return {agent for agent, rel in entries if rel == relation} - def relations_between(self, agent: Hashable, group: Hashable) -> set[str]: + def relations_between(self, agent: Hashable, group: Hashable) -> set[RelationKey]: """Return all relation types between one agent and one group.""" + agent_id = self._to_id(agent) + group_id = self._to_id(group) return { relation - for linked_group, relation in self._by_agent.get(agent, set()) - if linked_group == group + for linked_group, relation in self._by_agent.get(agent_id, set()) + if linked_group == group_id } def as_triplets(self) -> set[Triplet]: diff --git a/tests/experimental/test_meta_agent_backend.py b/tests/experimental/test_meta_agents_backend.py similarity index 89% rename from tests/experimental/test_meta_agent_backend.py rename to tests/experimental/test_meta_agents_backend.py index 429d4184cd7..cbb190ef104 100644 --- a/tests/experimental/test_meta_agent_backend.py +++ b/tests/experimental/test_meta_agents_backend.py @@ -54,7 +54,7 @@ def test_replace_relation(): def test_remove_agent_cascades_edges(): - """Removing an agent should clear all its incident adges.""" + """Removing an agent should clear all its incident edges.""" backend = MembershipBackend() backend.bulk_add( [("a1", "g1", "member"), ("a1", "g2", "leader"), ("a2", "g1", "member")] @@ -81,3 +81,13 @@ def test_remove_group_cascades_edges(): assert backend.groups_of("a1") == {"g2"} assert backend.groups_of("a2") == set() backend.assert_invariants() + + +def test_non_string_relation_key(): + """Allow non-string hashable relation keys.""" + backend = MembershipBackend() + rel = ("role", 1) + backend.add_membership("a1", "g1", rel) + + assert backend.relations_between("a1", "g1") == {rel} + backend.assert_invariants() From 6e4c4bc585599f9b75dfbecd5b01623df095d07c Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Wed, 20 May 2026 21:36:01 +0530 Subject: [PATCH 05/22] update docstring --- mesa/experimental/meta_agents/backend.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mesa/experimental/meta_agents/backend.py b/mesa/experimental/meta_agents/backend.py index 5edc7d63124..e7a9992a332 100644 --- a/mesa/experimental/meta_agents/backend.py +++ b/mesa/experimental/meta_agents/backend.py @@ -29,7 +29,11 @@ def __init__(self) -> None: ) def _to_id(self, entity: Hashable) -> Hashable: - """Normalize entities to canonical IDs when available.""" + """Normalize entity to canonical ID. + + Uses ``mesa.agent.Agent.unique_id`` when available; otherwise returns + the entity as-is (for already-hashable external IDs). + """ return getattr(entity, "unique_id", entity) def add_membership( From a1edb53c04ad004713bcab7c4651c998a8fe1a5d Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Tue, 26 May 2026 09:36:37 +0530 Subject: [PATCH 06/22] add warehouse model to test the membership backend --- mesa/examples/__init__.py | 2 + mesa/examples/advanced/warehouse/__init__.py | 5 + mesa/examples/advanced/warehouse/agents.py | 153 ++++++++++++++++++ .../advanced/warehouse/make_warehouse.py | 47 ++++++ mesa/examples/advanced/warehouse/model.py | 135 ++++++++++++++++ tests/examples/test_warehouse_model.py | 109 +++++++++++++ 6 files changed, 451 insertions(+) create mode 100644 mesa/examples/advanced/warehouse/__init__.py create mode 100644 mesa/examples/advanced/warehouse/agents.py create mode 100644 mesa/examples/advanced/warehouse/make_warehouse.py create mode 100644 mesa/examples/advanced/warehouse/model.py create mode 100644 tests/examples/test_warehouse_model.py diff --git a/mesa/examples/__init__.py b/mesa/examples/__init__.py index 90e6cd1e2a3..e6ec078e57d 100644 --- a/mesa/examples/__init__.py +++ b/mesa/examples/__init__.py @@ -2,6 +2,7 @@ from mesa.examples.advanced.epstein_civil_violence.model import EpsteinCivilViolence from mesa.examples.advanced.pd_grid.model import PdGrid from mesa.examples.advanced.sugarscape_g1mt.model import SugarscapeG1mt +from mesa.examples.advanced.warehouse.model import WarehouseModel from mesa.examples.advanced.wolf_sheep.model import WolfSheep from mesa.examples.basic.boid_flockers.model import BoidFlockers from mesa.examples.basic.boltzmann_wealth_model.model import BoltzmannWealth @@ -19,5 +20,6 @@ "Schelling", "SugarscapeG1mt", "VirusOnNetwork", + "WarehouseModel", "WolfSheep", ] diff --git a/mesa/examples/advanced/warehouse/__init__.py b/mesa/examples/advanced/warehouse/__init__.py new file mode 100644 index 00000000000..c42277d0a4f --- /dev/null +++ b/mesa/examples/advanced/warehouse/__init__.py @@ -0,0 +1,5 @@ +"""Warehouse meta-agent example.""" + +from mesa.examples.advanced.warehouse.model import WarehouseModel + +__all__ = ["WarehouseModel"] diff --git a/mesa/examples/advanced/warehouse/agents.py b/mesa/examples/advanced/warehouse/agents.py new file mode 100644 index 00000000000..b3a900e2aea --- /dev/null +++ b/mesa/examples/advanced/warehouse/agents.py @@ -0,0 +1,153 @@ +"""Agents used by warehouse meta-agent example.""" + +from __future__ import annotations + +from queue import PriorityQueue + +import mesa +from mesa.discrete_space import FixedAgent + + +class InventoryAgent(FixedAgent): + """Represents an inventory item in the warehouse.""" + + def __init__(self, model, cell, item: str): + super().__init__(model) + self.cell = cell + self.item = item + self.quantity = 1000 + + +class RouteAgent(mesa.Agent): + """Handle path finding for the warehouse robots.""" + + def __init__(self, model): + super().__init__(model) + + def find_path(self, start, goal) -> list[tuple[int, int, int]] | None: + """Find a path from ``start`` to ``goal`` using A* search.""" + + def heuristic(a, b) -> int: + dx = abs(a[0] - b[0]) + dy = abs(a[1] - b[1]) + return dx + dy + + open_set = PriorityQueue() + open_set.put((0, start.coordinate)) + came_from = {} + g_score = {start.coordinate: 0} + + while not open_set.empty(): + _, current = open_set.get() + + if current[:2] == goal.coordinate[:2]: + path = [] + while current in came_from: + path.append(current) + current = came_from[current] + path.reverse() + path.insert(0, start.coordinate) + path.pop() + return path + + for n_cell in self.model.warehouse[current].neighborhood: + coord = n_cell.coordinate + + # Only consider orthoginal neighbors in x/y plane. + if abs(coord[0] - current[0]) + abs(coord[1] - current[1]) != 1: + continue + + tentative_g_score = g_score[current] + 1 + if not n_cell.is_empty: + tentative_g_score += 50 + + if coord not in g_score or tentative_g_score < g_score[coord]: + g_score[coord] = tentative_g_score + f_score = tentative_g_score + heuristic(coord, goal.coordinate) + open_set.put((f_score, coord)) + came_from[coord] = current + + return None + + +class SensorAgent(mesa.Agent): + """Detect obstacles and move the robot along a computed path.""" + + def __init__(self, model): + super().__init__(model) + + def move( + self, coord: tuple[int, int, int], path: list[tuple[int, int, int]] + ) -> str: + """Move one step along the current path.""" + + if coord not in path: + raise ValueError("Current coordinate not in path.") + + idx = path.index(coord) + if idx + 1 >= len(path): + return "movement complete" + + next_cell = self.model.warehouse[path[idx + 1]] + if next_cell.is_empty: + self.meta_agent.cell = next_cell + return "moving" + + neighbors = self.model.warehouse[self.meta_agent.cell.coordinate].neighborhood + empty_neighbors = [n for n in neighbors if n.is_empty] + if empty_neighbors: + self.meta_agent.cell = self.random.choice(empty_neighbors) + + new_path = self.meta_agent.get_constituting_agent_instance( + RouteAgent + ).find_path(self.meta_agent.cell, self.meta_agent.item.cell) + self.meta_agent.path = new_path + return "recalculating" + + +class WorkerAgent(mesa.Agent): + """Handle inverntory pickup and delivery to the loading dock.""" + + def __init__(self, model, ld, cs): + super().__init__(model) + self.loading_dock = ld + self.charging_station = cs + self.path: list[tuple[int, int, int]] | None = None + self.carrying: str | None = None + self.item: InventoryAgent | None = None + + def initiate_task(self, item: InventoryAgent): + """Start a new inventory task.""" + self.item = item + self.path = self.find_path(self.cell, item.cell) + + def continue_task(self): + """Continue the current task if the robot has one.""" + if self.path is None or self.item is None: + return + + status = self.meta_agent.get_constituting_agent_instance(SensorAgent).move( + self.cell.coordinate, self.path + ) + + if status == "movement complete" and self.meta_agent.status == "inventory": + source_coordinate = self.meta_agent.cell.coordinate + target_level = self.item.cell.coordinate[2] + self.meta_agent.cell = self.model.warehouse[ + (source_coordinate[0], source_coordinate[1], target_level) + ] + self.meta_agent.status = "loading" + self.carrying = self.item.item + self.item.quantity -= 1 + + loading_coordinate = self.meta_agent.cell.coordinate + self.meta_agent.cell = self.model.warehouse[ + (loading_coordinate[0], loading_coordinate[1], 0) + ] + self.path = self.find_path(self.cell, self.loading_dock) + + if status == "movement complete" and self.meta_agent.status == "loading": + self.carrying = None + self.meta_agent.status = "open" + self.path = None + self.item = None diff --git a/mesa/examples/advanced/warehouse/make_warehouse.py b/mesa/examples/advanced/warehouse/make_warehouse.py new file mode 100644 index 00000000000..2fc41829d38 --- /dev/null +++ b/mesa/examples/advanced/warehouse/make_warehouse.py @@ -0,0 +1,47 @@ +"""Generate a compact warehouse layout for the meta-agent example.""" + +from __future__ import annotations + +import random +import string +from random import Random + +import numpy as np + +DEFAULT_ROWS = 8 +DEFAULT_COLS = 8 +DEFAULT_HEIGHT = 2 +LOADING_DOCK_COORDS = [(0, 0, 0), (0, 2, 0)] +CHARGING_STATION_COORDS = [(7, 5, 0), (7, 7, 0)] + + +def generate_item_code(rng: Random) -> str: + """Generate a short random inventory code.""" + letter = rng.choice(string.ascii_uppercase) + number = rng.randint(10, 99) + return f"{letter}{number}" + + +def make_warehouse( + rows: int = DEFAULT_ROWS, + cols: int = DEFAULT_COLS, + height: int = DEFAULT_HEIGHT, + rng: Random | None = None, +) -> np.ndarray: + """Generate a 3D warehouse array with loading docks and inventory.""" + rng = rng or random.Random(0) + + warehouse = np.full((rows, cols, height), " ", dtype=object) + + for r, c, h in LOADING_DOCK_COORDS: + warehouse[r, c, h] = "LD" + + for r, c, h in CHARGING_STATION_COORDS: + warehouse[r, c, h] = "CS" + + for r in range(2, rows - 1, 3): + for c in range(1, cols, 3): + for h in range(height): + warehouse[r, c, h] = generate_item_code(rng) + + return warehouse diff --git a/mesa/examples/advanced/warehouse/model.py b/mesa/examples/advanced/warehouse/model.py new file mode 100644 index 00000000000..5a8daf4f89f --- /dev/null +++ b/mesa/examples/advanced/warehouse/model.py @@ -0,0 +1,135 @@ +"""Warehouse meta-agent example built on the membership backend.""" + +from __future__ import annotations + +import mesa +from mesa.discrete_space import OrthogonalMooreGrid +from mesa.discrete_space.cell_agent import CellAgent +from mesa.examples.advanced.warehouse.agents import ( + InventoryAgent, + RouteAgent, + SensorAgent, + WorkerAgent, +) +from mesa.examples.advanced.warehouse.make_warehouse import ( + CHARGING_STATION_COORDS, + LOADING_DOCK_COORDS, + make_warehouse, +) +from mesa.experimental.meta_agents.backend import MembershipBackend +from mesa.experimental.meta_agents.meta_agent import MetaAgent, create_meta_agent + + +class WarehouseModel(mesa.Model): + """Model for simulating warehouse robots assembled from sub-agents.""" + + def __init__(self, rng=42): + """Create the warehouse, inventory, and robot meta-agents.""" + super().__init__(rng=rng) + self.inventory = {} + self.membership_backend = MembershipBackend() + + layout = make_warehouse(rng=self.random) + self.warehouse = OrthogonalMooreGrid( + (layout.shape[0], layout.shape[1], layout.shape[2]), + torus=False, + capacity=1, + random=self.random, + ) + + # Inventory agents live in the storage rows of the warehouse. + for row in range(2, layout.shape[0] - 1, 3): + for col in range(layout.shape[1]): + for height in range(layout.shape[2]): + item = layout[row][col][height] + if item.strip(): + InventoryAgent(self, self.warehouse[row, col, height], item) + + self.robot_agent_type: type | None = None + self.RobotAgent = None + + # One robot is created per loading dock / charging station pair. + for loading_dock, charging_station in zip( + LOADING_DOCK_COORDS, CHARGING_STATION_COORDS, strict=True + ): + router = RouteAgent(self) + sensor = SensorAgent(self) + worker = WorkerAgent( + self, + self.warehouse[loading_dock], + self.warehouse[charging_station], + ) + + def remove_robot(robot): + """Remove robot memberships even if the meta-agent teardown fails.""" + try: + MetaAgent.remove(robot) + finally: + robot.model.membership_backend.remove_group(robot) + + meta = create_meta_agent( + self, + "RobotAgent", + [router, sensor, worker], + CellAgent, + meta_attributes={ + "cell": self.warehouse[charging_station], + "status": "open", + }, + meta_methods={"remove": remove_robot}, + assume_constituting_agent_attributes=True, + assume_constituting_agent_methods=True, + ) + + if meta is None: + continue + + if self.robot_agent_type is None: + self.robot_agent_type = type(meta) + + self.RobotAgent = meta + self._record_robot_memberships(meta) + + def _record_robot_memberships(self, robot) -> None: + """Mirror a robot's constituting relationships into the backend.""" + self.membership_backend.bulk_add( + [ + ( + robot.get_constituting_agent_instance(RouteAgent), + robot, + "router", + ), + ( + robot.get_constituting_agent_instance(SensorAgent), + robot, + "sensor", + ), + ( + robot.get_constituting_agent_instance(WorkerAgent), + robot, + "worker", + ), + ] + ) + + def central_move(self, robot): + """Delegate path execution to the robot's worker role.""" + robot.move(robot.cell.coordinate, robot.path) + + def step(self): + """Advance the model by one step.""" + if self.robot_agent_type is None: + return + + for robot in self.agents_by_type[self.robot_agent_type]: + agent_list = self.agents_by_type[InventoryAgent].to_list() + + if robot.status == "open": + item = self.random.choice(agent_list) + if item.quantity > 0: + robot.initiate_task(item) + robot.status = "inventory" + self.central_move(robot) + + else: + robot.continue_task() diff --git a/tests/examples/test_warehouse_model.py b/tests/examples/test_warehouse_model.py new file mode 100644 index 00000000000..269d867b9f6 --- /dev/null +++ b/tests/examples/test_warehouse_model.py @@ -0,0 +1,109 @@ +"""Tests for the warehouse meta-agent example.""" + +from __future__ import annotations + +import gc +import weakref + +from mesa.examples import WarehouseModel +from mesa.examples.advanced.warehouse.agents import ( + InventoryAgent, + RouteAgent, + SensorAgent, + WorkerAgent, +) +from mesa.examples.advanced.warehouse.make_warehouse import LOADING_DOCK_COORDS + + +def test_warehouse_model_uses_membership_backend(): + """Robot memberships should be mirrored into the backend and cleaned up.""" + model = WarehouseModel(rng=42) + backend = model.membership_backend + robot_type = model.robot_agent_type + + assert robot_type is not None + + robots = sorted(model.agents_by_type[robot_type], key=lambda agent: agent.unique_id) + assert len(robots) == len(LOADING_DOCK_COORDS) + + expected_triplets = set() + for robot in robots: + route = robot.get_constituting_agent_instance(RouteAgent) + sensor = robot.get_constituting_agent_instance(SensorAgent) + worker = robot.get_constituting_agent_instance(WorkerAgent) + + expected_triplets.update( + { + (route.unique_id, robot.unique_id, "router"), + (sensor.unique_id, robot.unique_id, "sensor"), + (worker.unique_id, robot.unique_id, "worker"), + } + ) + + assert backend.groups_of(route) == {robot.unique_id} + assert backend.groups_of(sensor) == {robot.unique_id} + assert backend.groups_of(worker) == {robot.unique_id} + assert backend.relations_between(route, robot) == {"router"} + assert backend.relations_between(sensor, robot) == {"sensor"} + assert backend.relations_between(worker, robot) == {"worker"} + + assert backend.as_triplets() == expected_triplets + backend.assert_invariants() + + before_step = backend.as_triplets() + model.step() + assert backend.as_triplets() == before_step + backend.assert_invariants() + + ref = weakref.ref(model) + model.remove_all_agents() + + assert backend.as_triplets() == set() + backend.assert_invariants() + + del robots, robot, robot_type, expected_triplets, route, sensor, worker, before_step + del model + gc.collect() + assert ref() is None + + +def test_warehouse_robot_completes_inventory_cycle(): + """A robot should complete the full inventory and loading workflow.""" + model = WarehouseModel(rng=42) + backend = model.membership_backend + robot_type = model.robot_agent_type + + assert robot_type is not None + + robot = sorted(model.agents_by_type[robot_type], key=lambda agent: agent.unique_id)[ + 0 + ] + item = min( + model.agents_by_type[InventoryAgent], + key=lambda agent: ( + abs(agent.cell.coordinate[0] - robot.cell.coordinate[0]) + + abs(agent.cell.coordinate[1] - robot.cell.coordinate[1]), + agent.unique_id, + ), + ) + + start_triplets = backend.as_triplets() + start_quantity = item.quantity + + robot.initiate_task(item) + robot.status = "inventory" + + for _ in range(200): + if robot.status == "open": + break + robot.continue_task() + else: + raise AssertionError("Robot did not complete the inventory cycle") + + assert robot.status == "open" + assert robot.carrying is None + assert robot.item is None + assert robot.path is None + assert item.quantity == start_quantity - 1 + assert backend.as_triplets() == start_triplets + backend.assert_invariants() From 054297a450462b37a2aba5f150a970fee76e7863 Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Fri, 29 May 2026 14:21:35 +0530 Subject: [PATCH 07/22] wire membership backend into alliance formation model --- .../advanced/alliance_formation/Readme.md | 3 +- .../advanced/alliance_formation/model.py | 19 +++++++- mesa/experimental/meta_agents/__init__.py | 5 ++- .../examples/test_alliance_formation_model.py | 45 +++++++++++++++++++ 4 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 tests/examples/test_alliance_formation_model.py diff --git a/mesa/examples/advanced/alliance_formation/Readme.md b/mesa/examples/advanced/alliance_formation/Readme.md index 75a708cad76..ec9278027db 100644 --- a/mesa/examples/advanced/alliance_formation/Readme.md +++ b/mesa/examples/advanced/alliance_formation/Readme.md @@ -14,7 +14,7 @@ To provide a simple demonstration of this capability is an alliance formation mo In this simulation n agents are created, who have two attributes (1) power and (2) preference. Each attribute is a number between 0 and 1 over a gaussian distribution. Agents then randomly select other agents and use the [bilateral shapley value](https://en.wikipedia.org/wiki/Shapley_value) to determine if they should form an alliance. If the expected utility support an alliances, the agent creates a meta-agent. Subsequent steps may add agents to the meta-agent, create new instances of similar hierarchy, or create a new hierarchy level where meta-agents form an alliance of meta-agents. In this visualization of this model a new meta-agent hierarchy will be a larger node and a new color. -In MetaAgents current configuration, agents being part of multiple meta-agents is not supported. +This example records memberships in the backend so agents can participate in multiple overlapping memberships. If you would like to see an example of explicit meta-agent formation see the [warehouse model in the Mesa example's repository](https://github.com/mesa/mesa-examples/tree/main/examples/warehouse) @@ -45,4 +45,3 @@ Then run the example: solara run app.py Open the displayed local URL in your browser. - diff --git a/mesa/examples/advanced/alliance_formation/model.py b/mesa/examples/advanced/alliance_formation/model.py index 74db7e96de8..b9defe2d576 100644 --- a/mesa/examples/advanced/alliance_formation/model.py +++ b/mesa/examples/advanced/alliance_formation/model.py @@ -4,6 +4,7 @@ import mesa from mesa import Agent from mesa.examples.advanced.alliance_formation.agents import AllianceAgent +from mesa.experimental.meta_agents.backend import MembershipBackend from mesa.experimental.meta_agents.meta_agent import ( create_meta_agent, find_combinations, @@ -37,6 +38,7 @@ def __init__(self, scenario: AllianceScenario = AllianceScenario): super().__init__(scenario=scenario) self.network = nx.Graph() # Initialize the network self.datacollector = mesa.DataCollector(model_reporters={"Network": "network"}) + self.membership_backend = MembershipBackend() # Create Agents power = self.rng.normal(scenario.mean, scenario.std_dev, scenario.n) @@ -60,6 +62,12 @@ def add_link(self, meta_agent, agents): for agent in agents: self.network.add_edge(meta_agent.unique_id, agent.unique_id) + def _record_alliance_membership(self, meta_agent, agents) -> None: + """Mirror alliance membership into the backend.""" + self.membership_backend.bulk_add( + [(agent, meta_agent, "member") for agent in agents] + ) + def calculate_shapley_value(self, agents): """ Calculate the Shapley value of the two agents. @@ -173,11 +181,17 @@ def step(self): ) for alliance, attributes in combinations: - class_name = f"MetaAgentLevel{attributes[2]}" + alliance_members = tuple( + sorted(alliance, key=lambda agent: agent.unique_id) + ) + alliance_signature = "_".join( + str(agent.unique_id) for agent in alliance_members + ) + class_name = f"MetaAgentLevel{attributes[2]}_{alliance_signature}" meta = create_meta_agent( self, class_name, - alliance, + alliance_members, Agent, meta_attributes={ "level": attributes[2], @@ -194,3 +208,4 @@ def step(self): level=meta.level, ) self.add_link(meta, meta.agents) + self._record_alliance_membership(meta, meta.agents) diff --git a/mesa/experimental/meta_agents/__init__.py b/mesa/experimental/meta_agents/__init__.py index 4396f42c88d..5e0b70a59cd 100644 --- a/mesa/experimental/meta_agents/__init__.py +++ b/mesa/experimental/meta_agents/__init__.py @@ -13,8 +13,9 @@ - A company where the subagents are employees, departments, buildings, etc. - A city where the subagents are people, buildings, streets, etc. -Currently meta-agents are restricted to one parent agent for each subagent/ -one meta-agent per subagent. +The rewrite direction is to support overlapping memberships through the +``meta_agents`` set on each subagent, while ``meta_agent`` remains a +backward-compatible single-parent pointer during the transition. Goal is to assess usage and expand functionality. diff --git a/tests/examples/test_alliance_formation_model.py b/tests/examples/test_alliance_formation_model.py new file mode 100644 index 00000000000..3b49269db4c --- /dev/null +++ b/tests/examples/test_alliance_formation_model.py @@ -0,0 +1,45 @@ +"""Tests for the allianceformation meta-agent example.""" + +from __future__ import annotations + +from mesa.examples.advanced.alliance_formation.model import ( + AllianceScenario, + MultiLevelAllianceModel, +) + + +def test_alliance_model_records_overlapping_memberships(monkeypatch): + """The backend should preserve overlap when an agent joins mutiple alliances.""" + model = MultiLevelAllianceModel( + scenario=AllianceScenario(n=3, mean=0.5, std_dev=0.0, rng=42) + ) + agents = sorted(model.agents, key=lambda agent: agent.unique_id) + agent_0, agent_1, agent_2 = agents + + def fake_find_combinations(*args, **kwargs): + return [ + ((agent_0, agent_1), (1.0, 0.5, 0)), + ((agent_0, agent_2), (1.0, 0.4, 0)), + ] + + monkeypatch.setattr( + "mesa.examples.advanced.alliance_formation.model.find_combinations", + fake_find_combinations, + ) + + model.step() + + backend = model.membership_backend + + assert len(agent_0.meta_agents) == 2 + assert backend.groups_of(agent_0) == { + meta.unique_id for meta in agent_0.meta_agents + } + + expected_triplets = set() + for agent in agents: + for meta in agent.meta_agents: + expected_triplets.add((agent.unique_id, meta.unique_id, "member")) + + assert backend.as_triplets() == expected_triplets + backend.assert_invariants() From 311e7314dcfd319d1a98764167c4aab83d3b6177 Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Mon, 15 Jun 2026 19:07:41 +0530 Subject: [PATCH 08/22] Add explicit entity indexing and fix warehouse robot workflow --- mesa/examples/advanced/warehouse/agents.py | 57 ++--- mesa/experimental/meta_agents/__init__.py | 13 +- mesa/experimental/meta_agents/backend.py | 8 +- mesa/experimental/meta_agents/identity.py | 222 ++++++++++++++++++ mesa/experimental/meta_agents/meta_agent.py | 19 ++ mesa/model.py | 9 + tests/experimental/test_meta_agents.py | 79 ++++++- .../experimental/test_meta_agents_backend.py | 52 ++++ 8 files changed, 428 insertions(+), 31 deletions(-) create mode 100644 mesa/experimental/meta_agents/identity.py diff --git a/mesa/examples/advanced/warehouse/agents.py b/mesa/examples/advanced/warehouse/agents.py index b3a900e2aea..4eb81fb8ed7 100644 --- a/mesa/examples/advanced/warehouse/agents.py +++ b/mesa/examples/advanced/warehouse/agents.py @@ -80,6 +80,7 @@ def move( self, coord: tuple[int, int, int], path: list[tuple[int, int, int]] ) -> str: """Move one step along the current path.""" + robot = getattr(self, "meta_agent", self) if coord not in path: raise ValueError("Current coordinate not in path.") @@ -90,18 +91,18 @@ def move( next_cell = self.model.warehouse[path[idx + 1]] if next_cell.is_empty: - self.meta_agent.cell = next_cell + robot.cell = next_cell return "moving" - neighbors = self.model.warehouse[self.meta_agent.cell.coordinate].neighborhood + neighbors = self.model.warehouse[robot.cell.coordinate].neighborhood empty_neighbors = [n for n in neighbors if n.is_empty] if empty_neighbors: - self.meta_agent.cell = self.random.choice(empty_neighbors) + robot.cell = self.random.choice(empty_neighbors) - new_path = self.meta_agent.get_constituting_agent_instance( - RouteAgent - ).find_path(self.meta_agent.cell, self.meta_agent.item.cell) - self.meta_agent.path = new_path + new_path = robot.get_constituting_agent_instance(RouteAgent).find_path( + robot.cell, robot.item.cell + ) + robot.path = new_path return "recalculating" @@ -118,36 +119,38 @@ def __init__(self, model, ld, cs): def initiate_task(self, item: InventoryAgent): """Start a new inventory task.""" - self.item = item - self.path = self.find_path(self.cell, item.cell) + robot = getattr(self, "meta_agent", self) + robot.item = item + robot.path = robot.find_path(robot.cell, item.cell) def continue_task(self): """Continue the current task if the robot has one.""" - if self.path is None or self.item is None: + robot = getattr(self, "meta_agent", self) + if robot.path is None or robot.item is None: return - status = self.meta_agent.get_constituting_agent_instance(SensorAgent).move( - self.cell.coordinate, self.path + status = robot.get_constituting_agent_instance(SensorAgent).move( + robot.cell.coordinate, robot.path ) - if status == "movement complete" and self.meta_agent.status == "inventory": - source_coordinate = self.meta_agent.cell.coordinate - target_level = self.item.cell.coordinate[2] - self.meta_agent.cell = self.model.warehouse[ + if status == "movement complete" and robot.status == "inventory": + source_coordinate = robot.cell.coordinate + target_level = robot.item.cell.coordinate[2] + robot.cell = self.model.warehouse[ (source_coordinate[0], source_coordinate[1], target_level) ] - self.meta_agent.status = "loading" - self.carrying = self.item.item - self.item.quantity -= 1 + robot.status = "loading" + robot.carrying = robot.item.item + robot.item.quantity -= 1 - loading_coordinate = self.meta_agent.cell.coordinate - self.meta_agent.cell = self.model.warehouse[ + loading_coordinate = robot.cell.coordinate + robot.cell = self.model.warehouse[ (loading_coordinate[0], loading_coordinate[1], 0) ] - self.path = self.find_path(self.cell, self.loading_dock) + robot.path = robot.find_path(robot.cell, robot.loading_dock) - if status == "movement complete" and self.meta_agent.status == "loading": - self.carrying = None - self.meta_agent.status = "open" - self.path = None - self.item = None + if status == "movement complete" and robot.status == "loading": + robot.carrying = None + robot.status = "open" + robot.path = None + robot.item = None diff --git a/mesa/experimental/meta_agents/__init__.py b/mesa/experimental/meta_agents/__init__.py index 5e0b70a59cd..37e66e83c4e 100644 --- a/mesa/experimental/meta_agents/__init__.py +++ b/mesa/experimental/meta_agents/__init__.py @@ -17,6 +17,11 @@ ``meta_agents`` set on each subagent, while ``meta_agent`` remains a backward-compatible single-parent pointer during the transition. +The experimental identity layer adds an explicit ``entity_index`` registry to +models that use meta-agents. It assigns each tracked atomic agent and +meta-agent a stable ``entity_id`` so membership bookkeeping does not have to +lean on ad hoc object references or mutable ``unique_id`` values. + Goal is to assess usage and expand functionality. """ @@ -24,4 +29,10 @@ from .backend import MembershipBackend from .meta_agent import MetaAgent -__all__ = ["MembershipBackend", "MetaAgent"] +__all__ = [ + "EntityIndex", + "EntityRecord", + "MembershipBackend", + "MetaAgent", + "ensure_entyity_index", +] diff --git a/mesa/experimental/meta_agents/backend.py b/mesa/experimental/meta_agents/backend.py index e7a9992a332..4529d2f4191 100644 --- a/mesa/experimental/meta_agents/backend.py +++ b/mesa/experimental/meta_agents/backend.py @@ -31,9 +31,13 @@ def __init__(self) -> None: def _to_id(self, entity: Hashable) -> Hashable: """Normalize entity to canonical ID. - Uses ``mesa.agent.Agent.unique_id`` when available; otherwise returns - the entity as-is (for already-hashable external IDs). + Uses an explicit ``entity_id`` when available, then falls back to + ``mesa.agent.Agent.unique_id`` and finally the entity as-is (for already + hashable external IDs). """ + entity_id = getattr(entity, "entity_id", None) + if entity_id is not None: + return entity_id return getattr(entity, "unique_id", entity) def add_membership( diff --git a/mesa/experimental/meta_agents/identity.py b/mesa/experimental/meta_agents/identity.py new file mode 100644 index 00000000000..eaef1d4b74b --- /dev/null +++ b/mesa/experimental/meta_agents/identity.py @@ -0,0 +1,222 @@ +"""Explicit entity indexing for atomic agents and meta-agents. + +The meta-agent rewrite currently leans on ``unique_id`` as convenient lookup +key, but that is still an implicit identity scheme. This module provides a +small registry that assigns each tracked entity a stable ``entity_id`` and keeps +its current object reference, kind, and ``unique_id`` in sync. +""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Hashable +from dataclasses import dataclass +from typing import Any, Literal + +EntityKind = Literal["atomic", "meta"] + + +@dataclass(slots=True) +class EntityRecord: + """Snapshot of one registered entity.""" + + entity_id: int + entity: Any + kind: EntityKind + unique_id: Hashable | None + + @property + def class_name(self) -> str: + """Return the concrete class name for debugging and display.""" + return self.entity.__class__.__name__ + + +class EntityIndex: + """Stable registry for entities participating in meta-agent workflows.""" + + def __init__(self) -> None: + """Create an empty entity index.""" + self._next_entity_id = 1 + self._records_by_id: dict[int, EntityRecord] = {} + self._entity_id_by_object: dict[int, int] = {} + self._entity_ids_by_unique_id: dict[Hashable, set[int]] = defaultdict(set) + + def _infer_kind(self, entity: Any) -> EntityKind: + """Infer the entity kind when a caller does not provide one.""" + return "meta" if hasattr(entity, "_constituting_set") else "atomic" + + def _set_entity_id(self, entity: Any, entity_id: int) -> None: + """Persist the stable entity id on the entity object when possible.""" + try: + setattr(entity, "entity_id", entity_id) + except Exception: + # Some external objects may not allow attribute assignment. + # The registry still tracks them via object identity. + pass + + def _sync_unique_id_alias( + self, record: EntityRecord, unique_id: Hashable | None + ) -> None: + """Keep the reverse lookup table aligned with the entity's unique_id.""" + if record.unique_id == unique_id: + return + + if record.unique_id is not None: + ids = self._entity_ids_by_unique_id.get(record.unique_id) + if ids is not None: + ids.discard(record.entity_id) + if not ids: + del self._entity_ids_by_unique_id[record.unique_id] + + record.unique_id = unique_id + if unique_id is not None: + self._entity_ids_by_unique_id[unique_id].add(record.entity_id) + + def register(self, entity: Any, kind: EntityKind | None = None) -> EntityRecord: + """Register an entity and return its stable record. + + Re-registering an existing object is idempotent. The stored record is + refreshed so callers can update the kind or pick up a changed + ``unique_id`` without changing the stable ``entity_id``. + """ + object_key = id(entity) + unique_id = getattr(entity, "unique_id", None) + existing_entity_id = self._entity_id_by_object.get(object_key) + + if existing_entity_id is not None: + record = self._records_by_id[existing_entity_id] + if kind is not None: + record.kind = kind + self._sync_unique_id_alias(record, unique_id) + record.entity = entity + self._set_entity_id(entity, record.entity_id) + return record + + entity_id = self._next_entity_id + self._next_entity_id += 1 + record = EntityRecord( + entity_id=entity_id, + entity=entity, + kind=kind or self._infer_kind(entity), + unique_id=unique_id, + ) + self._records_by_id[entity_id] = record + self._entity_id_by_object[object_key] = entity_id + self._set_entity_id(entity, entity_id) + + if unique_id is not None: + self._entity_ids_by_unique_id[unique_id].add(entity_id) + + return record + + def entity_id_for(self, entity: Any) -> int: + """Return the stable entity id for a registered object.""" + record = self.record_for(entity) + return record.entity_id + + def record_for(self, entity_or_id: Any) -> EntityRecord: + """Return the record for an entity object, entity_id, or unique_id. + + ``entity_id`` lookup is preferred. ``unique_id`` lookup is supported for + compatibility, but it is secondary to the explicit identity layer. + """ + if isinstance(entity_or_id, EntityRecord): + return entity_or_id + + if isinstance(entity_or_id, int) and entity_or_id in self._records_by_id: + return self._records_by_id[entity_or_id] + + object_key = id(entity_or_id) + entity_id = self._entity_id_by_object.get(object_key) + if entity_id is not None: + return self._records_by_id[entity_id] + + raise KeyError(f"Unknown entity or entity id: {entity_or_id!r}") + + def entity_for(self, entity_or_id: Any) -> Any: + """Return the live entity object for a record, object, or id.""" + return self.record_for(entity_or_id).entity + + def kind_for(self, entity_or_id: Any) -> EntityKind: + """Return the registered kind for an entity.""" + return self.record_for(entity_or_id).kind + + def contains(self, entity_or_id: Any) -> bool: + """Return whether the registry knows about the given entity.""" + try: + self.record_for(entity_or_id) + except KeyError: + return False + return True + + def entities(self, kind: EntityKind | None = None) -> list[Any]: + """Return the live entities, optionally filtered by kind.""" + return [record.entity for record in self.records(kind=kind)] + + def records(self, kind: EntityKind | None = None) -> list[EntityRecord]: + """Return all records, optionally filtered by kind.""" + records = list(self._records_by_id.values()) + if kind is None: + return records + return [record for record in records if record.kind == kind] + + def remove(self, entity_or_id: Any) -> None: + """Remove an entity from the registry if it exists.""" + try: + record = self.record_for(entity_or_id) + except KeyError: + return + + self._records_by_id.pop(record.entity_id, None) + self._entity_id_by_object.pop(id(record.entity), None) + if record.unique_id is not None: + ids = self._entity_ids_by_unique_id.get(record.unique_id) + if ids is not None: + ids.discard(record.entity_id) + if not ids: + del self._entity_ids_by_unique_id[record.unique_id] + + def assert_invariants(self) -> None: + """Verify the registry's forward and reverse indexes are aligned.""" + for entity_id, record in self._records_by_id.items(): + assert record.entity_id == entity_id + assert self._entity_id_by_object[id(record.entity)] == entity_id + if record.unique_id is not None: + assert entity_id in self._entity_ids_by_unique_id[record.unique_id] + + for unique_id, entity_ids in self._entity_ids_by_unique_id.items(): + for entity_id in entity_ids: + assert self._records_by_id[entity_id].unique_id == unique_id + + def __getstate__(self) -> dict[str, Any]: + """Return a pickle-friendly snapshot of the registry state.""" + return { + "_next_entity_id": self._next_entity_id, + "_records_by_id": self._records_by_id, + "_entity_ids_by_unique_id": { + unique_id: set(entity_ids) + for unique_id, entity_ids in self._entity_ids_by_unique_id.items() + }, + } + + def __setstate__(self, state: dict[str, Any]) -> None: + """Restore the registry state and rebuild object-id indexes.""" + self._next_entity_id = state["_next_entity_id"] + self._records_by_id = state["_records_by_id"] + self._entity_id_by_object = {} + self._entity_ids_by_unique_id = defaultdict(set) + + for unique_id, entity_ids in state["_entity_ids_by_unique_id"].items(): + self._entity_ids_by_unique_id[unique_id].update(entity_ids) + + for entity_id, record in self._records_by_id.items(): + self._entity_id_by_object[id(record.entity)] = entity_id + + +def ensure_entity_index(model: Any) -> EntityIndex: + """Return the model's entity index, creating it lazily when needed.""" + entity_index = getattr(model, "entity_index", None) + if entity_index is None: + entity_index = EntityIndex() + setattr(model, "entity_index", entity_index) + return entity_index diff --git a/mesa/experimental/meta_agents/meta_agent.py b/mesa/experimental/meta_agents/meta_agent.py index 121bbdc5102..6105e8be478 100644 --- a/mesa/experimental/meta_agents/meta_agent.py +++ b/mesa/experimental/meta_agents/meta_agent.py @@ -44,6 +44,7 @@ from typing import Any from mesa.agent import Agent, AgentSet +from mesa.experimental.meta_agents.identity import ensure_entity_index def _unique_id_sort_key(agent: Agent) -> tuple[bool, Any]: @@ -229,11 +230,15 @@ def add_attributes( # Prevent collision of attributes with meta-agent instantiation mesa_primitives = [ "unique_id", + "entity_id", "model", "pos", "name", "random", "rng", + "meta_agents", + "meta_agent", + "_constituting_set", ] if assume_constituting_agent_attributes: @@ -276,6 +281,7 @@ def add_attributes( add_attributes(meta_agent, agents, meta_attributes) add_methods(meta_agent, agents, meta_methods) meta_agent.add_constituting_agents(agents) + ensure_entity_index(model).register(meta_agent, kind="meta") return meta_agent else: @@ -291,6 +297,7 @@ def add_attributes( # add_attributes() will handle inferred attributes if needed add_attributes(meta_agent_instance, agents, meta_attributes) add_methods(meta_agent_instance, agents, meta_methods) + ensure_entity_index(model).register(meta_agent_instance, kind="meta") return meta_agent_instance else: # Path 3 - Create a new meta-agent class @@ -310,6 +317,7 @@ def add_attributes( # add_attributes() will handle inferred attributes if needed add_attributes(meta_agent_instance, agents, meta_attributes) add_methods(meta_agent_instance, agents, meta_methods) + ensure_entity_index(model).register(meta_agent_instance, kind="meta") return meta_agent_instance @@ -344,8 +352,15 @@ def __init__( self._constituting_set = AgentSet(agents or [], random=model.random) self.name = name + entity_index = ensure_entity_index(self.model) + entity_index.register(self, kind="meta") + # Add ref to meta_agent in constituting_agents for agent in self._constituting_set: + entity_index.register( + agent, + kind="meta" if isinstance(agent, MetaAgent) else "atomic", + ) if not hasattr(agent, "meta_agents"): agent.meta_agents = set() agent.meta_agents.add(self) @@ -422,6 +437,10 @@ def add_constituting_agents( new_agents (set[Agent]): The agents to add to MetaAgent constituting_set. """ for agent in new_agents: + ensure_entity_index(self.model).register( + agent, + kind="meta" if isinstance(agent, MetaAgent) else "atomic", + ) self._constituting_set.add(agent) if not hasattr(agent, "meta_agents"): agent.meta_agents = set() diff --git a/mesa/model.py b/mesa/model.py index 1419e84050a..47f78282046 100644 --- a/mesa/model.py +++ b/mesa/model.py @@ -264,6 +264,11 @@ def register_agent(self, agent: A): agent.unique_id = self.agent_id_counter self.agent_id_counter += 1 + # keep the explicit identity layer in sync with the core agent registry. + from mesa.experimental.meta_agents.identity import ensure_entity_index + + ensure_entity_index(self).register(agent, kind="atomic") + # because AgentSet requires model, we cannot use defaultdict # tricks with a function won't work because model then cannot be pickled try: @@ -292,6 +297,10 @@ def deregister_agent(self, agent: A): self._agents_by_type[type(agent)].remove(agent) self._all_agents.remove(agent) + from mesa.experimental.meta_agents.identity import ensure_entity_index + + ensure_entity_index(self).remove(agent) + _mesa_logger.debug(f"deregistered agent with agent_id {agent.unique_id}") def run_model(self) -> None: diff --git a/tests/experimental/test_meta_agents.py b/tests/experimental/test_meta_agents.py index 068bdec53df..d9ee11c07a9 100644 --- a/tests/experimental/test_meta_agents.py +++ b/tests/experimental/test_meta_agents.py @@ -1,10 +1,10 @@ """Tests for the meta_agents module.""" import pytest - from mesa import Agent, Model from mesa.discrete_space.cell_agent import CellAgent from mesa.discrete_space.grid import OrthogonalMooreGrid +from mesa.experimental.meta_agents.identity import ensure_entity_index from mesa.experimental.meta_agents.meta_agent import ( MetaAgent, create_meta_agent, @@ -170,6 +170,65 @@ def test_meta_agent_integration(setup_agents): assert meta_agent.custom_method() == "custom_method_value" +def test_entity_index_tracks_atomic_and_meta_agents(setup_agents): + """The explicit entity index should assign stable ids to both entity kinds.""" + model, agents = setup_agents + meta_agent = MetaAgent(model, set(agents)) + entity_index = ensure_entity_index(model) + + meta_record = entity_index.record_for(meta_agent) + atomic_records = [entity_index.record_for(agent) for agent in agents] + + assert meta_record.kind == "meta" + assert meta_record.entity is meta_agent + assert meta_record.entity_id == meta_agent.entity_id + assert all(record.kind == "atomic" for record in atomic_records) + assert all( + record.entity_id == agent.entity_id + for record, agent in zip(atomic_records, agents) + ) + assert entity_index.entity_for(meta_agent.entity_id) is meta_agent + assert {record.entity for record in entity_index.records()} == {meta_agent, *agents} + + +def test_entity_index_lookup_survives_membership_updates(setup_agents): + """Membership churn should not change the explicit entity ids.""" + model, agents = setup_agents + entity_index = ensure_entity_index(model) + + meta_agent = MetaAgent(model, {agents[0], agents[1]}) + original_meta_id = meta_agent.entity_id + original_agent_id = agents[0].entity_id + + meta_agent.remove_constituting_agents({agents[0]}) + meta_agent.add_constituting_agents({agents[0], agents[2]}) + meta_agent.unique_id = "renamed-meta-agent" + entity_index.register(meta_agent, kind="meta") + + assert agents[0].entity_id == original_agent_id + assert meta_agent.entity_id == original_meta_id + assert entity_index.entity_for(original_meta_id) is meta_agent + assert entity_index.entity_for(original_agent_id) is agents[0] + assert entity_index.kind_for(original_meta_id) == "meta" + assert entity_index.kind_for(original_agent_id) == "atomic" + entity_index.assert_invariants() + + +def test_entity_index_preserves_meta_kind_for_nested_meta_agents(): + """Nested meta-agents should stay classified as meta in the registry.""" + model = Model() + atomic_agent = Agent(model) + child_meta = MetaAgent(model, {atomic_agent}, name="Child") + parent_meta = MetaAgent(model, {child_meta}, name="Parent") + entity_index = ensure_entity_index(model) + + assert entity_index.kind_for(atomic_agent.entity_id) == "atomic" + assert entity_index.kind_for(child_meta.entity_id) == "meta" + assert entity_index.kind_for(parent_meta.entity_id) == "meta" + assert parent_meta.agents == {child_meta} + entity_index.assert_invariants() + + def test_evaluate_combination(setup_agents): """Test the evaluate_combination function. @@ -396,6 +455,24 @@ def test_meta_agent_remove_cleans_up_references(setup_agents): assert meta_agent not in model.agents +def test_meta_agent_remove_cleans_entity_index(setup_agents): + """Meta-agent teardown should only remove meta-agent record.""" + model, agents = setup_agents + meta_agent = MetaAgent(model, set(agents)) + entity_index = ensure_entity_index(model) + + meta_entity_id = meta_agent.entity_id + atomic_entity_ids = {agent.entity_id for agent in agents} + + meta_agent.remove() + + assert not entity_index.contains(meta_entity_id) + assert all(entity_index.contains(entity_id) for entity_id in atomic_entity_ids) + assert all( + entity_index.kind_for(entity_id) == "atomic" for entity_id in atomic_entity_ids + ) + + def test_meta_agent_remove_with_multiple_memberships(): """Test remove() when agents belong to multiple meta-agents.""" model = Model() diff --git a/tests/experimental/test_meta_agents_backend.py b/tests/experimental/test_meta_agents_backend.py index cbb190ef104..b852dc07312 100644 --- a/tests/experimental/test_meta_agents_backend.py +++ b/tests/experimental/test_meta_agents_backend.py @@ -1,6 +1,10 @@ """Tests for typed membership backend.""" +import pytest +from mesa import Agent, Model from mesa.experimental.meta_agents.backend import MembershipBackend +from mesa.experimental.meta_agents.identity import ensure_entity_index +from mesa.experimental.meta_agents.meta_agent import MetaAgent def test_add_and_query(): @@ -91,3 +95,51 @@ def test_non_string_relation_key(): assert backend.relations_between("a1", "g1") == {rel} backend.assert_invariants() + + +def test_backend_prefers_explicit_entity_ids(): + """Meta-Agent membership bookkeeping should use stable entity ids.""" + model = Model() + agent = Agent(model) + meta_agent = MetaAgent(model, {agent}, name="Group") + backend = MembershipBackend() + + backend.add_membership(agent, meta_agent, "member") + + assert backend.as_triplets() == {(agent.entity_id, meta_agent.entity_id, "member")} + assert backend.groups_of(agent) == {meta_agent.entity_id} + assert backend.agents_of(meta_agent) == {agent.entity_id} + backend.assert_invariants() + + +def test_backend_registry_lookup_stays_stable_after_unique_id_updates(): + """Changing unqiue id should not disturb explicit entity lookup.""" + model = Model() + agent = Agent(model) + meta_agent = MetaAgent(model, {agent}, name="Group") + entity_index = ensure_entity_index(model) + + original_entity_id = meta_agent.entity_id + meta_agent.unique_id = "renamed-group" + entity_index.register(meta_agent, kind="meta") + + assert entity_index.entity_for(original_entity_id) is meta_agent + assert entity_index.kind_for(original_entity_id) == "meta" + assert entity_index.entity_id_for(agent) == agent.entity_id + entity_index.assert_invariants() + + +def test_model_deregister_clean_atomic_entity_index_entries(): + """Removing an atomic agent from the model should drop its identity record.""" + model = Model() + agent = Agent(model) + entity_index = ensure_entity_index(model) + + entity_id = agent.entity_id + assert entity_index.entity_for(entity_id) is agent + + agent.remove() + + assert not entity_index.contains(entity_id) + with pytest.raises(KeyError): + entity_index.entity_for(entity_id) From a98de208d83bc643f1b7245b9cec315d7aee9974 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 05:45:31 +0000 Subject: [PATCH 09/22] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- mesa/experimental/meta_agents/identity.py | 4 ++-- tests/experimental/test_meta_agents.py | 1 + tests/experimental/test_meta_agents_backend.py | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/mesa/experimental/meta_agents/identity.py b/mesa/experimental/meta_agents/identity.py index eaef1d4b74b..18aeeebef94 100644 --- a/mesa/experimental/meta_agents/identity.py +++ b/mesa/experimental/meta_agents/identity.py @@ -48,7 +48,7 @@ def _infer_kind(self, entity: Any) -> EntityKind: def _set_entity_id(self, entity: Any, entity_id: int) -> None: """Persist the stable entity id on the entity object when possible.""" try: - setattr(entity, "entity_id", entity_id) + entity.entity_id = entity_id except Exception: # Some external objects may not allow attribute assignment. # The registry still tracks them via object identity. @@ -218,5 +218,5 @@ def ensure_entity_index(model: Any) -> EntityIndex: entity_index = getattr(model, "entity_index", None) if entity_index is None: entity_index = EntityIndex() - setattr(model, "entity_index", entity_index) + model.entity_index = entity_index return entity_index diff --git a/tests/experimental/test_meta_agents.py b/tests/experimental/test_meta_agents.py index d9ee11c07a9..5b8d51fa9c5 100644 --- a/tests/experimental/test_meta_agents.py +++ b/tests/experimental/test_meta_agents.py @@ -1,6 +1,7 @@ """Tests for the meta_agents module.""" import pytest + from mesa import Agent, Model from mesa.discrete_space.cell_agent import CellAgent from mesa.discrete_space.grid import OrthogonalMooreGrid diff --git a/tests/experimental/test_meta_agents_backend.py b/tests/experimental/test_meta_agents_backend.py index b852dc07312..cbf0edd9fd0 100644 --- a/tests/experimental/test_meta_agents_backend.py +++ b/tests/experimental/test_meta_agents_backend.py @@ -1,6 +1,7 @@ """Tests for typed membership backend.""" import pytest + from mesa import Agent, Model from mesa.experimental.meta_agents.backend import MembershipBackend from mesa.experimental.meta_agents.identity import ensure_entity_index From 414f055102e47a2ba2d384268aaf02934dda1329 Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Mon, 29 Jun 2026 22:01:09 +0530 Subject: [PATCH 10/22] remove entity indexing --- mesa/experimental/meta_agents/__init__.py | 41 +--- mesa/experimental/meta_agents/backend.py | 10 +- mesa/experimental/meta_agents/identity.py | 222 ------------------ mesa/experimental/meta_agents/meta_agent.py | 16 -- mesa/model.py | 9 - tests/experimental/test_meta_agents.py | 78 ------ .../experimental/test_meta_agents_backend.py | 46 +--- 7 files changed, 18 insertions(+), 404 deletions(-) delete mode 100644 mesa/experimental/meta_agents/identity.py diff --git a/mesa/experimental/meta_agents/__init__.py b/mesa/experimental/meta_agents/__init__.py index 37e66e83c4e..b8e64f869f5 100644 --- a/mesa/experimental/meta_agents/__init__.py +++ b/mesa/experimental/meta_agents/__init__.py @@ -1,38 +1,17 @@ -"""This method is for dynamically creating new agents (meta-agents). - -Meta-agents are defined as agents composed of existing agents. - -Meta-agents are created dynamically with a pointer to the model, name of the meta-agent,, -iterable of agents to belong to the new meta-agents, any new functions for the meta-agent, -any new attributes for the meta-agent, whether to retain sub-agent functions, -whether to retain sub-agent attributes. - -Examples of meta-agents: -- An autonomous car where the subagents are the wheels, sensors, -battery, computer etc. and the meta-agent is the car itself. -- A company where the subagents are employees, departments, buildings, etc. -- A city where the subagents are people, buildings, streets, etc. - -The rewrite direction is to support overlapping memberships through the -``meta_agents`` set on each subagent, while ``meta_agent`` remains a -backward-compatible single-parent pointer during the transition. - -The experimental identity layer adds an explicit ``entity_index`` registry to -models that use meta-agents. It assigns each tracked atomic agent and -meta-agent a stable ``entity_id`` so membership bookkeeping does not have to -lean on ad hoc object references or mutable ``unique_id`` values. - -Goal is to assess usage and expand functionality. - -""" +"""Experimental meta-agent membership helpers.""" from .backend import MembershipBackend -from .meta_agent import MetaAgent +from .meta_agent import ( + MetaAgent, + create_meta_agent, + evaluate_combination, + find_combinations, +) __all__ = [ - "EntityIndex", - "EntityRecord", "MembershipBackend", "MetaAgent", - "ensure_entyity_index", + "create_meta_agent", + "evaluate_combination", + "find_combinations", ] diff --git a/mesa/experimental/meta_agents/backend.py b/mesa/experimental/meta_agents/backend.py index 4529d2f4191..2a2fbd629c4 100644 --- a/mesa/experimental/meta_agents/backend.py +++ b/mesa/experimental/meta_agents/backend.py @@ -3,7 +3,7 @@ - Canonical typed membership representation - Safe update operations - Invariant checks -- Internal-only API (no public facade changes yet) +- Shared by the public facade and compatibility helpers """ from __future__ import annotations @@ -31,13 +31,9 @@ def __init__(self) -> None: def _to_id(self, entity: Hashable) -> Hashable: """Normalize entity to canonical ID. - Uses an explicit ``entity_id`` when available, then falls back to - ``mesa.agent.Agent.unique_id`` and finally the entity as-is (for already - hashable external IDs). + Uses ``mesa.agent.Agent.unique_id`` when available and finally the entity + as-is (for already hashable external IDs). """ - entity_id = getattr(entity, "entity_id", None) - if entity_id is not None: - return entity_id return getattr(entity, "unique_id", entity) def add_membership( diff --git a/mesa/experimental/meta_agents/identity.py b/mesa/experimental/meta_agents/identity.py deleted file mode 100644 index 18aeeebef94..00000000000 --- a/mesa/experimental/meta_agents/identity.py +++ /dev/null @@ -1,222 +0,0 @@ -"""Explicit entity indexing for atomic agents and meta-agents. - -The meta-agent rewrite currently leans on ``unique_id`` as convenient lookup -key, but that is still an implicit identity scheme. This module provides a -small registry that assigns each tracked entity a stable ``entity_id`` and keeps -its current object reference, kind, and ``unique_id`` in sync. -""" - -from __future__ import annotations - -from collections import defaultdict -from collections.abc import Hashable -from dataclasses import dataclass -from typing import Any, Literal - -EntityKind = Literal["atomic", "meta"] - - -@dataclass(slots=True) -class EntityRecord: - """Snapshot of one registered entity.""" - - entity_id: int - entity: Any - kind: EntityKind - unique_id: Hashable | None - - @property - def class_name(self) -> str: - """Return the concrete class name for debugging and display.""" - return self.entity.__class__.__name__ - - -class EntityIndex: - """Stable registry for entities participating in meta-agent workflows.""" - - def __init__(self) -> None: - """Create an empty entity index.""" - self._next_entity_id = 1 - self._records_by_id: dict[int, EntityRecord] = {} - self._entity_id_by_object: dict[int, int] = {} - self._entity_ids_by_unique_id: dict[Hashable, set[int]] = defaultdict(set) - - def _infer_kind(self, entity: Any) -> EntityKind: - """Infer the entity kind when a caller does not provide one.""" - return "meta" if hasattr(entity, "_constituting_set") else "atomic" - - def _set_entity_id(self, entity: Any, entity_id: int) -> None: - """Persist the stable entity id on the entity object when possible.""" - try: - entity.entity_id = entity_id - except Exception: - # Some external objects may not allow attribute assignment. - # The registry still tracks them via object identity. - pass - - def _sync_unique_id_alias( - self, record: EntityRecord, unique_id: Hashable | None - ) -> None: - """Keep the reverse lookup table aligned with the entity's unique_id.""" - if record.unique_id == unique_id: - return - - if record.unique_id is not None: - ids = self._entity_ids_by_unique_id.get(record.unique_id) - if ids is not None: - ids.discard(record.entity_id) - if not ids: - del self._entity_ids_by_unique_id[record.unique_id] - - record.unique_id = unique_id - if unique_id is not None: - self._entity_ids_by_unique_id[unique_id].add(record.entity_id) - - def register(self, entity: Any, kind: EntityKind | None = None) -> EntityRecord: - """Register an entity and return its stable record. - - Re-registering an existing object is idempotent. The stored record is - refreshed so callers can update the kind or pick up a changed - ``unique_id`` without changing the stable ``entity_id``. - """ - object_key = id(entity) - unique_id = getattr(entity, "unique_id", None) - existing_entity_id = self._entity_id_by_object.get(object_key) - - if existing_entity_id is not None: - record = self._records_by_id[existing_entity_id] - if kind is not None: - record.kind = kind - self._sync_unique_id_alias(record, unique_id) - record.entity = entity - self._set_entity_id(entity, record.entity_id) - return record - - entity_id = self._next_entity_id - self._next_entity_id += 1 - record = EntityRecord( - entity_id=entity_id, - entity=entity, - kind=kind or self._infer_kind(entity), - unique_id=unique_id, - ) - self._records_by_id[entity_id] = record - self._entity_id_by_object[object_key] = entity_id - self._set_entity_id(entity, entity_id) - - if unique_id is not None: - self._entity_ids_by_unique_id[unique_id].add(entity_id) - - return record - - def entity_id_for(self, entity: Any) -> int: - """Return the stable entity id for a registered object.""" - record = self.record_for(entity) - return record.entity_id - - def record_for(self, entity_or_id: Any) -> EntityRecord: - """Return the record for an entity object, entity_id, or unique_id. - - ``entity_id`` lookup is preferred. ``unique_id`` lookup is supported for - compatibility, but it is secondary to the explicit identity layer. - """ - if isinstance(entity_or_id, EntityRecord): - return entity_or_id - - if isinstance(entity_or_id, int) and entity_or_id in self._records_by_id: - return self._records_by_id[entity_or_id] - - object_key = id(entity_or_id) - entity_id = self._entity_id_by_object.get(object_key) - if entity_id is not None: - return self._records_by_id[entity_id] - - raise KeyError(f"Unknown entity or entity id: {entity_or_id!r}") - - def entity_for(self, entity_or_id: Any) -> Any: - """Return the live entity object for a record, object, or id.""" - return self.record_for(entity_or_id).entity - - def kind_for(self, entity_or_id: Any) -> EntityKind: - """Return the registered kind for an entity.""" - return self.record_for(entity_or_id).kind - - def contains(self, entity_or_id: Any) -> bool: - """Return whether the registry knows about the given entity.""" - try: - self.record_for(entity_or_id) - except KeyError: - return False - return True - - def entities(self, kind: EntityKind | None = None) -> list[Any]: - """Return the live entities, optionally filtered by kind.""" - return [record.entity for record in self.records(kind=kind)] - - def records(self, kind: EntityKind | None = None) -> list[EntityRecord]: - """Return all records, optionally filtered by kind.""" - records = list(self._records_by_id.values()) - if kind is None: - return records - return [record for record in records if record.kind == kind] - - def remove(self, entity_or_id: Any) -> None: - """Remove an entity from the registry if it exists.""" - try: - record = self.record_for(entity_or_id) - except KeyError: - return - - self._records_by_id.pop(record.entity_id, None) - self._entity_id_by_object.pop(id(record.entity), None) - if record.unique_id is not None: - ids = self._entity_ids_by_unique_id.get(record.unique_id) - if ids is not None: - ids.discard(record.entity_id) - if not ids: - del self._entity_ids_by_unique_id[record.unique_id] - - def assert_invariants(self) -> None: - """Verify the registry's forward and reverse indexes are aligned.""" - for entity_id, record in self._records_by_id.items(): - assert record.entity_id == entity_id - assert self._entity_id_by_object[id(record.entity)] == entity_id - if record.unique_id is not None: - assert entity_id in self._entity_ids_by_unique_id[record.unique_id] - - for unique_id, entity_ids in self._entity_ids_by_unique_id.items(): - for entity_id in entity_ids: - assert self._records_by_id[entity_id].unique_id == unique_id - - def __getstate__(self) -> dict[str, Any]: - """Return a pickle-friendly snapshot of the registry state.""" - return { - "_next_entity_id": self._next_entity_id, - "_records_by_id": self._records_by_id, - "_entity_ids_by_unique_id": { - unique_id: set(entity_ids) - for unique_id, entity_ids in self._entity_ids_by_unique_id.items() - }, - } - - def __setstate__(self, state: dict[str, Any]) -> None: - """Restore the registry state and rebuild object-id indexes.""" - self._next_entity_id = state["_next_entity_id"] - self._records_by_id = state["_records_by_id"] - self._entity_id_by_object = {} - self._entity_ids_by_unique_id = defaultdict(set) - - for unique_id, entity_ids in state["_entity_ids_by_unique_id"].items(): - self._entity_ids_by_unique_id[unique_id].update(entity_ids) - - for entity_id, record in self._records_by_id.items(): - self._entity_id_by_object[id(record.entity)] = entity_id - - -def ensure_entity_index(model: Any) -> EntityIndex: - """Return the model's entity index, creating it lazily when needed.""" - entity_index = getattr(model, "entity_index", None) - if entity_index is None: - entity_index = EntityIndex() - model.entity_index = entity_index - return entity_index diff --git a/mesa/experimental/meta_agents/meta_agent.py b/mesa/experimental/meta_agents/meta_agent.py index 6105e8be478..b5b0df7181f 100644 --- a/mesa/experimental/meta_agents/meta_agent.py +++ b/mesa/experimental/meta_agents/meta_agent.py @@ -44,7 +44,6 @@ from typing import Any from mesa.agent import Agent, AgentSet -from mesa.experimental.meta_agents.identity import ensure_entity_index def _unique_id_sort_key(agent: Agent) -> tuple[bool, Any]: @@ -230,7 +229,6 @@ def add_attributes( # Prevent collision of attributes with meta-agent instantiation mesa_primitives = [ "unique_id", - "entity_id", "model", "pos", "name", @@ -281,7 +279,6 @@ def add_attributes( add_attributes(meta_agent, agents, meta_attributes) add_methods(meta_agent, agents, meta_methods) meta_agent.add_constituting_agents(agents) - ensure_entity_index(model).register(meta_agent, kind="meta") return meta_agent else: @@ -297,7 +294,6 @@ def add_attributes( # add_attributes() will handle inferred attributes if needed add_attributes(meta_agent_instance, agents, meta_attributes) add_methods(meta_agent_instance, agents, meta_methods) - ensure_entity_index(model).register(meta_agent_instance, kind="meta") return meta_agent_instance else: # Path 3 - Create a new meta-agent class @@ -317,7 +313,6 @@ def add_attributes( # add_attributes() will handle inferred attributes if needed add_attributes(meta_agent_instance, agents, meta_attributes) add_methods(meta_agent_instance, agents, meta_methods) - ensure_entity_index(model).register(meta_agent_instance, kind="meta") return meta_agent_instance @@ -352,15 +347,8 @@ def __init__( self._constituting_set = AgentSet(agents or [], random=model.random) self.name = name - entity_index = ensure_entity_index(self.model) - entity_index.register(self, kind="meta") - # Add ref to meta_agent in constituting_agents for agent in self._constituting_set: - entity_index.register( - agent, - kind="meta" if isinstance(agent, MetaAgent) else "atomic", - ) if not hasattr(agent, "meta_agents"): agent.meta_agents = set() agent.meta_agents.add(self) @@ -437,10 +425,6 @@ def add_constituting_agents( new_agents (set[Agent]): The agents to add to MetaAgent constituting_set. """ for agent in new_agents: - ensure_entity_index(self.model).register( - agent, - kind="meta" if isinstance(agent, MetaAgent) else "atomic", - ) self._constituting_set.add(agent) if not hasattr(agent, "meta_agents"): agent.meta_agents = set() diff --git a/mesa/model.py b/mesa/model.py index 47f78282046..1419e84050a 100644 --- a/mesa/model.py +++ b/mesa/model.py @@ -264,11 +264,6 @@ def register_agent(self, agent: A): agent.unique_id = self.agent_id_counter self.agent_id_counter += 1 - # keep the explicit identity layer in sync with the core agent registry. - from mesa.experimental.meta_agents.identity import ensure_entity_index - - ensure_entity_index(self).register(agent, kind="atomic") - # because AgentSet requires model, we cannot use defaultdict # tricks with a function won't work because model then cannot be pickled try: @@ -297,10 +292,6 @@ def deregister_agent(self, agent: A): self._agents_by_type[type(agent)].remove(agent) self._all_agents.remove(agent) - from mesa.experimental.meta_agents.identity import ensure_entity_index - - ensure_entity_index(self).remove(agent) - _mesa_logger.debug(f"deregistered agent with agent_id {agent.unique_id}") def run_model(self) -> None: diff --git a/tests/experimental/test_meta_agents.py b/tests/experimental/test_meta_agents.py index 5b8d51fa9c5..068bdec53df 100644 --- a/tests/experimental/test_meta_agents.py +++ b/tests/experimental/test_meta_agents.py @@ -5,7 +5,6 @@ from mesa import Agent, Model from mesa.discrete_space.cell_agent import CellAgent from mesa.discrete_space.grid import OrthogonalMooreGrid -from mesa.experimental.meta_agents.identity import ensure_entity_index from mesa.experimental.meta_agents.meta_agent import ( MetaAgent, create_meta_agent, @@ -171,65 +170,6 @@ def test_meta_agent_integration(setup_agents): assert meta_agent.custom_method() == "custom_method_value" -def test_entity_index_tracks_atomic_and_meta_agents(setup_agents): - """The explicit entity index should assign stable ids to both entity kinds.""" - model, agents = setup_agents - meta_agent = MetaAgent(model, set(agents)) - entity_index = ensure_entity_index(model) - - meta_record = entity_index.record_for(meta_agent) - atomic_records = [entity_index.record_for(agent) for agent in agents] - - assert meta_record.kind == "meta" - assert meta_record.entity is meta_agent - assert meta_record.entity_id == meta_agent.entity_id - assert all(record.kind == "atomic" for record in atomic_records) - assert all( - record.entity_id == agent.entity_id - for record, agent in zip(atomic_records, agents) - ) - assert entity_index.entity_for(meta_agent.entity_id) is meta_agent - assert {record.entity for record in entity_index.records()} == {meta_agent, *agents} - - -def test_entity_index_lookup_survives_membership_updates(setup_agents): - """Membership churn should not change the explicit entity ids.""" - model, agents = setup_agents - entity_index = ensure_entity_index(model) - - meta_agent = MetaAgent(model, {agents[0], agents[1]}) - original_meta_id = meta_agent.entity_id - original_agent_id = agents[0].entity_id - - meta_agent.remove_constituting_agents({agents[0]}) - meta_agent.add_constituting_agents({agents[0], agents[2]}) - meta_agent.unique_id = "renamed-meta-agent" - entity_index.register(meta_agent, kind="meta") - - assert agents[0].entity_id == original_agent_id - assert meta_agent.entity_id == original_meta_id - assert entity_index.entity_for(original_meta_id) is meta_agent - assert entity_index.entity_for(original_agent_id) is agents[0] - assert entity_index.kind_for(original_meta_id) == "meta" - assert entity_index.kind_for(original_agent_id) == "atomic" - entity_index.assert_invariants() - - -def test_entity_index_preserves_meta_kind_for_nested_meta_agents(): - """Nested meta-agents should stay classified as meta in the registry.""" - model = Model() - atomic_agent = Agent(model) - child_meta = MetaAgent(model, {atomic_agent}, name="Child") - parent_meta = MetaAgent(model, {child_meta}, name="Parent") - entity_index = ensure_entity_index(model) - - assert entity_index.kind_for(atomic_agent.entity_id) == "atomic" - assert entity_index.kind_for(child_meta.entity_id) == "meta" - assert entity_index.kind_for(parent_meta.entity_id) == "meta" - assert parent_meta.agents == {child_meta} - entity_index.assert_invariants() - - def test_evaluate_combination(setup_agents): """Test the evaluate_combination function. @@ -456,24 +396,6 @@ def test_meta_agent_remove_cleans_up_references(setup_agents): assert meta_agent not in model.agents -def test_meta_agent_remove_cleans_entity_index(setup_agents): - """Meta-agent teardown should only remove meta-agent record.""" - model, agents = setup_agents - meta_agent = MetaAgent(model, set(agents)) - entity_index = ensure_entity_index(model) - - meta_entity_id = meta_agent.entity_id - atomic_entity_ids = {agent.entity_id for agent in agents} - - meta_agent.remove() - - assert not entity_index.contains(meta_entity_id) - assert all(entity_index.contains(entity_id) for entity_id in atomic_entity_ids) - assert all( - entity_index.kind_for(entity_id) == "atomic" for entity_id in atomic_entity_ids - ) - - def test_meta_agent_remove_with_multiple_memberships(): """Test remove() when agents belong to multiple meta-agents.""" model = Model() diff --git a/tests/experimental/test_meta_agents_backend.py b/tests/experimental/test_meta_agents_backend.py index cbf0edd9fd0..1027b326d76 100644 --- a/tests/experimental/test_meta_agents_backend.py +++ b/tests/experimental/test_meta_agents_backend.py @@ -1,10 +1,7 @@ """Tests for typed membership backend.""" -import pytest - from mesa import Agent, Model from mesa.experimental.meta_agents.backend import MembershipBackend -from mesa.experimental.meta_agents.identity import ensure_entity_index from mesa.experimental.meta_agents.meta_agent import MetaAgent @@ -98,8 +95,8 @@ def test_non_string_relation_key(): backend.assert_invariants() -def test_backend_prefers_explicit_entity_ids(): - """Meta-Agent membership bookkeeping should use stable entity ids.""" +def test_backend_uses_unique_ids_for_mesa_agents(): + """Meta-agent membership bookkeeping should use unique_id values.""" model = Model() agent = Agent(model) meta_agent = MetaAgent(model, {agent}, name="Group") @@ -107,40 +104,7 @@ def test_backend_prefers_explicit_entity_ids(): backend.add_membership(agent, meta_agent, "member") - assert backend.as_triplets() == {(agent.entity_id, meta_agent.entity_id, "member")} - assert backend.groups_of(agent) == {meta_agent.entity_id} - assert backend.agents_of(meta_agent) == {agent.entity_id} + assert backend.as_triplets() == {(agent.unique_id, meta_agent.unique_id, "member")} + assert backend.groups_of(agent) == {meta_agent.unique_id} + assert backend.agents_of(meta_agent) == {agent.unique_id} backend.assert_invariants() - - -def test_backend_registry_lookup_stays_stable_after_unique_id_updates(): - """Changing unqiue id should not disturb explicit entity lookup.""" - model = Model() - agent = Agent(model) - meta_agent = MetaAgent(model, {agent}, name="Group") - entity_index = ensure_entity_index(model) - - original_entity_id = meta_agent.entity_id - meta_agent.unique_id = "renamed-group" - entity_index.register(meta_agent, kind="meta") - - assert entity_index.entity_for(original_entity_id) is meta_agent - assert entity_index.kind_for(original_entity_id) == "meta" - assert entity_index.entity_id_for(agent) == agent.entity_id - entity_index.assert_invariants() - - -def test_model_deregister_clean_atomic_entity_index_entries(): - """Removing an atomic agent from the model should drop its identity record.""" - model = Model() - agent = Agent(model) - entity_index = ensure_entity_index(model) - - entity_id = agent.entity_id - assert entity_index.entity_for(entity_id) is agent - - agent.remove() - - assert not entity_index.contains(entity_id) - with pytest.raises(KeyError): - entity_index.entity_for(entity_id) From a5c6f43371d44b98f9f253cd5296623a1c846063 Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Thu, 14 May 2026 17:09:06 +0530 Subject: [PATCH 11/22] add typed membership backend foundation with invariant tests. --- mesa/experimental/meta_agents/__init__.py | 4 + mesa/experimental/meta_agents/facade.py | 228 ++++++++++++++++++ tests/experimental/test_meta_agent_backend.py | 83 +++++++ tests/experimental/test_meta_agents_facade.py | 105 ++++++++ 4 files changed, 420 insertions(+) create mode 100644 mesa/experimental/meta_agents/facade.py create mode 100644 tests/experimental/test_meta_agent_backend.py create mode 100644 tests/experimental/test_meta_agents_facade.py diff --git a/mesa/experimental/meta_agents/__init__.py b/mesa/experimental/meta_agents/__init__.py index b8e64f869f5..6cf62fffdd7 100644 --- a/mesa/experimental/meta_agents/__init__.py +++ b/mesa/experimental/meta_agents/__init__.py @@ -1,6 +1,7 @@ """Experimental meta-agent membership helpers.""" from .backend import MembershipBackend +from .facade import MembershipEdge, MembershipView, MetaAgentFacade from .meta_agent import ( MetaAgent, create_meta_agent, @@ -10,6 +11,9 @@ __all__ = [ "MembershipBackend", + "MembershipEdge", + "MembershipView", + "MetaAgentFacade", "MetaAgent", "create_meta_agent", "evaluate_combination", diff --git a/mesa/experimental/meta_agents/facade.py b/mesa/experimental/meta_agents/facade.py new file mode 100644 index 00000000000..6e9000f1c38 --- /dev/null +++ b/mesa/experimental/meta_agents/facade.py @@ -0,0 +1,228 @@ +"""Public facade for the experimental meta-agent membership backend.""" + +from __future__ import annotations + +from collections.abc import Callable, Hashable, Iterable +from dataclasses import dataclass +from typing import Any + +from mesa.agent import Agent + +from .backend import MembershipBackend, RelationKey, Triplet +from .meta_agent import create_meta_agent + + +@dataclass(frozen=True, slots=True) +class MembershipEdge: + """A user-facing membership edge with live objects instead of backend ids.""" + + agent: Any + group: Any + relation: RelationKey + + +@dataclass(frozen=True, slots=True) +class MembershipView: + """Read-only snapshot of memberships for one entity.""" + + subject: Any + memberships: tuple[MembershipEdge, ...] + + def __iter__(self): + """Iterate over the resolved memberships.""" + return iter(self.memberships) + + def __len__(self) -> int: + """Return the number of resolved memberships.""" + return len(self.memberships) + + @property + def edges(self) -> tuple[MembershipEdge, ...]: + """Alias for ``memberships`` to keep the view easy to inspect.""" + return self.memberships + + def as_triplets(self) -> set[tuple[Any, Any, RelationKey]]: + """Return the memberships as live-object triplets.""" + return { + (edge.agent, edge.group, edge.relation) for edge in self.memberships + } + + @property + def agents(self) -> set[Any]: + """Return all unique agents referenced by the view.""" + return {edge.agent for edge in self.memberships} + + @property + def groups(self) -> set[Any]: + """Return all unique groups referenced by the view.""" + return {edge.group for edge in self.memberships} + + @property + def relations(self) -> set[RelationKey]: + """Return all unique relation labels referenced by the view.""" + return {edge.relation for edge in self.memberships} + + +class MetaAgentFacade: + """Thin public facade over :class:`MembershipBackend`.""" + + def __init__(self, model: Any, backend: MembershipBackend | None = None) -> None: + """Create a facade bound to one model.""" + self.model = model + self.backend = backend or MembershipBackend() + + def _entity_id(self, entity: Hashable) -> Hashable: + """Return the backend identity for a live entity or hashable external id.""" + return getattr(entity, "unique_id", entity) + + def _live_entity_lookup(self) -> dict[Hashable, Any]: + """Build a lookup from backend ids back to live model objects.""" + lookup: dict[Hashable, Any] = {} + for entity in self.model.agents: + entity_id = getattr(entity, "unique_id", None) + if entity_id is not None: + lookup[entity_id] = entity + return lookup + + def _resolve_entity(self, entity_id: Hashable) -> Any: + """Resolve a backend id back to a live object when possible.""" + return self._live_entity_lookup().get(entity_id, entity_id) + + def _resolve_view( + self, entity: Hashable, triplets: Iterable[Triplet] + ) -> MembershipView: + """Convert backend triplets into a user-facing snapshot.""" + lookup = self._live_entity_lookup() + resolved_edges: list[MembershipEdge] = [] + for agent_id, group_id, relation in sorted( + triplets, + key=lambda triplet: ( + str(triplet[0]), + str(triplet[1]), + repr(triplet[2]), + ), + ): + resolved_edges.append( + MembershipEdge( + agent=lookup.get(agent_id, agent_id), + group=lookup.get(group_id, group_id), + relation=relation, + ) + ) + + return MembershipView( + subject=self._resolve_entity(entity), + memberships=tuple(resolved_edges), + ) + + def _detach_entity(self, entity: Hashable) -> MembershipView: + """Remove all incident memberships and update live objects when available.""" + snapshot = self.query_memberships(entity) + + self.backend.remove_agent(entity) + self.backend.remove_group(entity) + + for edge in snapshot.memberships: + group = edge.group + member = edge.agent + if hasattr(group, "remove_constituting_agents"): + group.remove_constituting_agents({member}) + + return snapshot + + def create( + self, + new_agent_class: str, + agents: Iterable[Any], + mesa_agent_type: type[Agent] | None, + meta_attributes: dict[str, Any] | None = None, + meta_methods: dict[str, Callable] | None = None, + assume_constituting_agent_methods: bool = False, + assume_constituting_agent_attributes: bool = False, + relation: RelationKey = "member", + memberships: Iterable[tuple[Any, RelationKey]] | None = None, + ) -> Any | None: + """Create a meta-agent and record its memberships in the backend.""" + agents = list(agents) + member_relations = list(memberships) if memberships is not None else None + if member_relations is not None and not agents: + agents = [member for member, _ in member_relations] + + meta_agent = create_meta_agent( + self.model, + new_agent_class, + agents, + mesa_agent_type, + meta_attributes=meta_attributes, + meta_methods=meta_methods, + assume_constituting_agent_methods=assume_constituting_agent_methods, + assume_constituting_agent_attributes=assume_constituting_agent_attributes, + ) + + if meta_agent is None: + return None + + if member_relations is None: + member_relations = [(agent, relation) for agent in agents] + + self.backend.bulk_add( + [(member, meta_agent, rel) for member, rel in member_relations] + ) + + return meta_agent + + def add_member( + self, + group: Hashable, + member: Hashable, + relation: RelationKey = "member", + ) -> MembershipView: + """Add one member to one group and keep the object layer in sync.""" + already_linked = bool(self.backend.relations_between(member, group)) + self.backend.add_membership(member, group, relation) + + if not already_linked and hasattr(group, "add_constituting_agents"): + group.add_constituting_agents({member}) + + return self.query_memberships(member) + + def remove_member( + self, + group: Hashable, + member: Hashable, + relation: RelationKey = "member", + ) -> MembershipView: + """Remove one member from one group and keep the object layer in sync.""" + self.backend.remove_membership(member, group, relation) + + if not self.backend.relations_between(member, group) and hasattr( + group, "remove_constituting_agents" + ): + group.remove_constituting_agents({member}) + + return self.query_memberships(member) + + def query_memberships( + self, entity: Hashable, relation: RelationKey | None = None + ) -> MembershipView: + """Return a resolved, read-only snapshot of one entity's memberships.""" + entity_id = self._entity_id(entity) + triplets = ( + triplet + for triplet in self.backend.as_triplets() + if (triplet[0] == entity_id or triplet[1] == entity_id) + and (relation is None or triplet[2] == relation) + ) + return self._resolve_view(entity_id, triplets) + + def dissolve(self, entity: Hashable) -> MembershipView: + """Remove an entity's memberships and delete it from the model when possible.""" + snapshot = self._detach_entity(entity) + live_entity = self._resolve_entity(self._entity_id(entity)) + if hasattr(live_entity, "remove"): + live_entity.remove() + return snapshot + + def deactivate(self, entity: Hashable) -> MembershipView: + """Remove an entity from all memberships without deleting the object.""" + return self._detach_entity(entity) diff --git a/tests/experimental/test_meta_agent_backend.py b/tests/experimental/test_meta_agent_backend.py new file mode 100644 index 00000000000..1edcede3e0a --- /dev/null +++ b/tests/experimental/test_meta_agent_backend.py @@ -0,0 +1,83 @@ +"""Tests for typed membership backend.""" + +from mesa.experimental.meta_agents.backend import MembershipBackend + + +def test_add_and_query(): + """Add edges and verify basic query behavior.""" + backend = MembershipBackend() + backend.add_membership("a1", "g1", "member") + backend.add_membership("a2", "g1", "member") + backend.add_membership("a1", "g2", "leader") + + assert backend.groups_of("a1") == {"g1", "g2"} + assert backend.groups_of("a1", relation="member") == {"g1"} + assert backend.agents_of("g1") == {"a1", "a2"} + assert backend.relations_between("a1", "g1") == {"member"} + backend.assert_invariants() + + +def test_typed_overlap_same_pair(): + """Allow multiple relation labels on the same agent-group pair.""" + backend = MembershipBackend() + backend.add_membership("a1", "g1", "member") + backend.add_membership("a1", "g1", "mentor") + + assert backend.relations_between("a1", "g1") == {"member", "mentor"} + assert backend.groups_of("a1", relation="mentor") == {"g1"} + backend.assert_invariants() + + +def test_idempotent_add_and_remove(): + """Repeated add/remove call should remain safe and deterministic.""" + backend = MembershipBackend() + backend.add_membership("a1", "g1", "member") + backend.add_membership("a1", "g1", "member") # idempotent add + + assert backend.as_triplets() == {("a1", "g1", "member")} + + backend.remove_membership("a1", "g1", "member") + backend.remove_membership("a1", "g1", "member") # idempotent remove + assert backend.as_triplets == set() + backend.assert_invariants() + + +def test_replace_relation(): + """Replace an existing relation label for one edge.""" + backend = MembershipBackend() + backend.add_membership("a1", "g1", "member") + backend.replace_relation("a1", "g1", "member", "leader") + + assert backend.relations_between("a1", "g1") == {"leader"} + assert backend.groups_of("a1", relation="member") == set() + backend.assert_invariants() + + +def test_remove_agent_cascades_edges(): + """Removing an agent should clear all its incident adges.""" + backend = MembershipBackend() + backend.bulk_add( + [("a1", "g1", "member"), ("a1", "g2", "leader"), ("a2", "g1", "member")] + ) + + backend.remove_agent("a1") + + assert backend.groups_of("a1") == set() + assert backend.agents_of("g1") == {"a2"} + assert backend.agents_of("g2") == set() + backend.assert_invariants() + + +def test_remove_group_cascades_edges(): + """Removing a group should clear all incident adges.""" + backend = MembershipBackend() + backend.bulk_add( + [("a1", "g1", "member"), ("a1", "g2", "leader"), ("a2", "g1", "member")] + ) + + backend.remove_group("g1") + + assert backend.agents_of("g1") == set() + assert backend.groups_of("a1") == set() + assert backend.groups_of("a1") == {"g2"} + backend.assert_invariants() diff --git a/tests/experimental/test_meta_agents_facade.py b/tests/experimental/test_meta_agents_facade.py new file mode 100644 index 00000000000..6c92e78db52 --- /dev/null +++ b/tests/experimental/test_meta_agents_facade.py @@ -0,0 +1,105 @@ +"""Tests for the public meta-agent facade.""" + +from mesa import Agent, Model + +from mesa.experimental.meta_agents import ( + MembershipEdge, + MembershipView, + MetaAgentFacade, +) + + +def test_facade_create_records_backend_memberships(): + """Create should return live objects and record backend triplets.""" + model = Model() + facade = MetaAgentFacade(model) + agent_1 = Agent(model) + agent_2 = Agent(model) + + meta_agent = facade.create("Group", [agent_1, agent_2], Agent) + + assert meta_agent is not None + assert facade.backend.as_triplets() == { + (agent_1.unique_id, meta_agent.unique_id, "member"), + (agent_2.unique_id, meta_agent.unique_id, "member"), + } + + view = facade.query_memberships(agent_1) + + assert isinstance(view, MembershipView) + assert view.subject is agent_1 + assert view.as_triplets() == {(agent_1, meta_agent, "member")} + assert len(view) == 1 + assert isinstance(view.memberships[0], MembershipEdge) + assert view.memberships[0].agent is agent_1 + assert view.memberships[0].group is meta_agent + + +def test_facade_remove_member_preserves_overlapping_memberships(): + """Removing one relation should keep unrelated memberships intact.""" + model = Model() + facade = MetaAgentFacade(model) + agent = Agent(model) + partner = Agent(model) + group_one = facade.create("GroupOne", [agent, partner], Agent) + group_two = facade.create("GroupTwo", [agent], Agent) + + assert group_one is not None + assert group_two is not None + assert len(agent.meta_agents) == 2 + + view = facade.remove_member(group_one, agent) + + assert view.as_triplets() == {(agent, group_two, "member")} + assert facade.backend.groups_of(agent) == {group_two.unique_id} + assert group_one not in agent.meta_agents + assert group_two in agent.meta_agents + assert partner.meta_agents == {group_one} + + +def test_facade_dissolve_cleans_only_target_group(): + """Dissolving a group should keep overlapping memberships on other groups.""" + model = Model() + facade = MetaAgentFacade(model) + agent_1 = Agent(model) + agent_2 = Agent(model) + agent_3 = Agent(model) + group_one = facade.create("GroupOne", [agent_1, agent_2], Agent) + group_two = facade.create("GroupTwo", [agent_1, agent_3], Agent) + + assert group_one is not None + assert group_two is not None + + snapshot = facade.dissolve(group_one) + + assert snapshot.as_triplets() == { + (agent_1, group_one, "member"), + (agent_2, group_one, "member"), + } + assert facade.backend.groups_of(agent_1) == {group_two.unique_id} + assert facade.backend.groups_of(agent_2) == set() + assert facade.backend.groups_of(agent_3) == {group_two.unique_id} + assert group_one not in model.agents + assert group_two in model.agents + assert group_one not in agent_1.meta_agents + assert group_two in agent_1.meta_agents + + +def test_facade_deactivate_detaches_all_memberships_without_removing_entity(): + """Deactivate should clear memberships but keep the entity registered.""" + model = Model() + facade = MetaAgentFacade(model) + agent_1 = Agent(model) + agent_2 = Agent(model) + group = facade.create("Group", [agent_1, agent_2], Agent) + + assert group is not None + + snapshot = facade.deactivate(agent_1) + + assert snapshot.as_triplets() == {(agent_1, group, "member")} + assert facade.backend.groups_of(agent_1) == set() + assert agent_1 in model.agents + assert group not in agent_1.meta_agents + assert agent_1.meta_agent is None + assert group in agent_2.meta_agents From 786654c7192587c5a7e34b21a60aa8d5cc54c5f6 Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Thu, 14 May 2026 21:06:28 +0530 Subject: [PATCH 12/22] add typed membership backend foundation with invariant tests. --- tests/experimental/test_meta_agent_backend.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/experimental/test_meta_agent_backend.py b/tests/experimental/test_meta_agent_backend.py index 1edcede3e0a..429d4184cd7 100644 --- a/tests/experimental/test_meta_agent_backend.py +++ b/tests/experimental/test_meta_agent_backend.py @@ -38,7 +38,7 @@ def test_idempotent_add_and_remove(): backend.remove_membership("a1", "g1", "member") backend.remove_membership("a1", "g1", "member") # idempotent remove - assert backend.as_triplets == set() + assert backend.as_triplets() == set() backend.assert_invariants() @@ -69,7 +69,7 @@ def test_remove_agent_cascades_edges(): def test_remove_group_cascades_edges(): - """Removing a group should clear all incident adges.""" + """Removing a group should clear all incident edges.""" backend = MembershipBackend() backend.bulk_add( [("a1", "g1", "member"), ("a1", "g2", "leader"), ("a2", "g1", "member")] @@ -78,6 +78,6 @@ def test_remove_group_cascades_edges(): backend.remove_group("g1") assert backend.agents_of("g1") == set() - assert backend.groups_of("a1") == set() assert backend.groups_of("a1") == {"g2"} + assert backend.groups_of("a2") == set() backend.assert_invariants() From 45c00f7a076cccb2ddb9a1eed98d65002341ed60 Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Sun, 17 May 2026 20:38:43 +0530 Subject: [PATCH 13/22] normalize id-based removals, generalize relation keys and add perf TODOs. --- tests/experimental/test_meta_agent_backend.py | 83 ------------------- .../experimental/test_meta_agents_backend.py | 1 + 2 files changed, 1 insertion(+), 83 deletions(-) delete mode 100644 tests/experimental/test_meta_agent_backend.py diff --git a/tests/experimental/test_meta_agent_backend.py b/tests/experimental/test_meta_agent_backend.py deleted file mode 100644 index 429d4184cd7..00000000000 --- a/tests/experimental/test_meta_agent_backend.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Tests for typed membership backend.""" - -from mesa.experimental.meta_agents.backend import MembershipBackend - - -def test_add_and_query(): - """Add edges and verify basic query behavior.""" - backend = MembershipBackend() - backend.add_membership("a1", "g1", "member") - backend.add_membership("a2", "g1", "member") - backend.add_membership("a1", "g2", "leader") - - assert backend.groups_of("a1") == {"g1", "g2"} - assert backend.groups_of("a1", relation="member") == {"g1"} - assert backend.agents_of("g1") == {"a1", "a2"} - assert backend.relations_between("a1", "g1") == {"member"} - backend.assert_invariants() - - -def test_typed_overlap_same_pair(): - """Allow multiple relation labels on the same agent-group pair.""" - backend = MembershipBackend() - backend.add_membership("a1", "g1", "member") - backend.add_membership("a1", "g1", "mentor") - - assert backend.relations_between("a1", "g1") == {"member", "mentor"} - assert backend.groups_of("a1", relation="mentor") == {"g1"} - backend.assert_invariants() - - -def test_idempotent_add_and_remove(): - """Repeated add/remove call should remain safe and deterministic.""" - backend = MembershipBackend() - backend.add_membership("a1", "g1", "member") - backend.add_membership("a1", "g1", "member") # idempotent add - - assert backend.as_triplets() == {("a1", "g1", "member")} - - backend.remove_membership("a1", "g1", "member") - backend.remove_membership("a1", "g1", "member") # idempotent remove - assert backend.as_triplets() == set() - backend.assert_invariants() - - -def test_replace_relation(): - """Replace an existing relation label for one edge.""" - backend = MembershipBackend() - backend.add_membership("a1", "g1", "member") - backend.replace_relation("a1", "g1", "member", "leader") - - assert backend.relations_between("a1", "g1") == {"leader"} - assert backend.groups_of("a1", relation="member") == set() - backend.assert_invariants() - - -def test_remove_agent_cascades_edges(): - """Removing an agent should clear all its incident adges.""" - backend = MembershipBackend() - backend.bulk_add( - [("a1", "g1", "member"), ("a1", "g2", "leader"), ("a2", "g1", "member")] - ) - - backend.remove_agent("a1") - - assert backend.groups_of("a1") == set() - assert backend.agents_of("g1") == {"a2"} - assert backend.agents_of("g2") == set() - backend.assert_invariants() - - -def test_remove_group_cascades_edges(): - """Removing a group should clear all incident edges.""" - backend = MembershipBackend() - backend.bulk_add( - [("a1", "g1", "member"), ("a1", "g2", "leader"), ("a2", "g1", "member")] - ) - - backend.remove_group("g1") - - assert backend.agents_of("g1") == set() - assert backend.groups_of("a1") == {"g2"} - assert backend.groups_of("a2") == set() - backend.assert_invariants() diff --git a/tests/experimental/test_meta_agents_backend.py b/tests/experimental/test_meta_agents_backend.py index 1027b326d76..942226c81eb 100644 --- a/tests/experimental/test_meta_agents_backend.py +++ b/tests/experimental/test_meta_agents_backend.py @@ -1,6 +1,7 @@ """Tests for typed membership backend.""" from mesa import Agent, Model + from mesa.experimental.meta_agents.backend import MembershipBackend from mesa.experimental.meta_agents.meta_agent import MetaAgent From 47fb48b16a05883a650d67ecc4446d5b886bfdd3 Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Sun, 5 Jul 2026 09:27:34 +0530 Subject: [PATCH 14/22] initial facade implementation --- mesa/experimental/meta_agents/__init__.py | 2 +- mesa/experimental/meta_agents/facade.py | 4 +--- tests/experimental/test_meta_agents_backend.py | 1 - tests/experimental/test_meta_agents_facade.py | 1 - 4 files changed, 2 insertions(+), 6 deletions(-) diff --git a/mesa/experimental/meta_agents/__init__.py b/mesa/experimental/meta_agents/__init__.py index 6cf62fffdd7..814b5b33d80 100644 --- a/mesa/experimental/meta_agents/__init__.py +++ b/mesa/experimental/meta_agents/__init__.py @@ -13,8 +13,8 @@ "MembershipBackend", "MembershipEdge", "MembershipView", - "MetaAgentFacade", "MetaAgent", + "MetaAgentFacade", "create_meta_agent", "evaluate_combination", "find_combinations", diff --git a/mesa/experimental/meta_agents/facade.py b/mesa/experimental/meta_agents/facade.py index 6e9000f1c38..3883b24bad6 100644 --- a/mesa/experimental/meta_agents/facade.py +++ b/mesa/experimental/meta_agents/facade.py @@ -43,9 +43,7 @@ def edges(self) -> tuple[MembershipEdge, ...]: def as_triplets(self) -> set[tuple[Any, Any, RelationKey]]: """Return the memberships as live-object triplets.""" - return { - (edge.agent, edge.group, edge.relation) for edge in self.memberships - } + return {(edge.agent, edge.group, edge.relation) for edge in self.memberships} @property def agents(self) -> set[Any]: diff --git a/tests/experimental/test_meta_agents_backend.py b/tests/experimental/test_meta_agents_backend.py index 942226c81eb..1027b326d76 100644 --- a/tests/experimental/test_meta_agents_backend.py +++ b/tests/experimental/test_meta_agents_backend.py @@ -1,7 +1,6 @@ """Tests for typed membership backend.""" from mesa import Agent, Model - from mesa.experimental.meta_agents.backend import MembershipBackend from mesa.experimental.meta_agents.meta_agent import MetaAgent diff --git a/tests/experimental/test_meta_agents_facade.py b/tests/experimental/test_meta_agents_facade.py index 6c92e78db52..553d0b91293 100644 --- a/tests/experimental/test_meta_agents_facade.py +++ b/tests/experimental/test_meta_agents_facade.py @@ -1,7 +1,6 @@ """Tests for the public meta-agent facade.""" from mesa import Agent, Model - from mesa.experimental.meta_agents import ( MembershipEdge, MembershipView, From c7772089e3e3d36e9fd71a7c4b506c7c3454962c Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Mon, 6 Jul 2026 17:48:49 +0530 Subject: [PATCH 15/22] replace facade naming with meta-agents API alias --- mesa/experimental/meta_agents/__init__.py | 3 +- mesa/experimental/meta_agents/backend.py | 2 +- mesa/experimental/meta_agents/facade.py | 232 +---------------- .../meta_agents/meta_agents_api.py | 238 ++++++++++++++++++ ...ents_facade.py => test_meta_agents_api.py} | 66 +++-- 5 files changed, 291 insertions(+), 250 deletions(-) create mode 100644 mesa/experimental/meta_agents/meta_agents_api.py rename tests/experimental/{test_meta_agents_facade.py => test_meta_agents_api.py} (53%) diff --git a/mesa/experimental/meta_agents/__init__.py b/mesa/experimental/meta_agents/__init__.py index 814b5b33d80..6736f9ef890 100644 --- a/mesa/experimental/meta_agents/__init__.py +++ b/mesa/experimental/meta_agents/__init__.py @@ -1,13 +1,13 @@ """Experimental meta-agent membership helpers.""" from .backend import MembershipBackend -from .facade import MembershipEdge, MembershipView, MetaAgentFacade from .meta_agent import ( MetaAgent, create_meta_agent, evaluate_combination, find_combinations, ) +from .meta_agents_api import MembershipEdge, MembershipView, MetaAgentFacade, MetaAgents __all__ = [ "MembershipBackend", @@ -15,6 +15,7 @@ "MembershipView", "MetaAgent", "MetaAgentFacade", + "MetaAgents", "create_meta_agent", "evaluate_combination", "find_combinations", diff --git a/mesa/experimental/meta_agents/backend.py b/mesa/experimental/meta_agents/backend.py index 2a2fbd629c4..ee19f16f09f 100644 --- a/mesa/experimental/meta_agents/backend.py +++ b/mesa/experimental/meta_agents/backend.py @@ -3,7 +3,7 @@ - Canonical typed membership representation - Safe update operations - Invariant checks -- Shared by the public facade and compatibility helpers +- Shared by the public meta-agents API and compatibility helpers """ from __future__ import annotations diff --git a/mesa/experimental/meta_agents/facade.py b/mesa/experimental/meta_agents/facade.py index 3883b24bad6..d46b24ccfc2 100644 --- a/mesa/experimental/meta_agents/facade.py +++ b/mesa/experimental/meta_agents/facade.py @@ -1,226 +1,12 @@ -"""Public facade for the experimental meta-agent membership backend.""" +"""Legacy compatibility shim for the experimental meta-agents API.""" -from __future__ import annotations +from .meta_agents_api import MembershipEdge, MembershipView, MetaAgents -from collections.abc import Callable, Hashable, Iterable -from dataclasses import dataclass -from typing import Any +MetaAgentFacade = MetaAgents -from mesa.agent import Agent - -from .backend import MembershipBackend, RelationKey, Triplet -from .meta_agent import create_meta_agent - - -@dataclass(frozen=True, slots=True) -class MembershipEdge: - """A user-facing membership edge with live objects instead of backend ids.""" - - agent: Any - group: Any - relation: RelationKey - - -@dataclass(frozen=True, slots=True) -class MembershipView: - """Read-only snapshot of memberships for one entity.""" - - subject: Any - memberships: tuple[MembershipEdge, ...] - - def __iter__(self): - """Iterate over the resolved memberships.""" - return iter(self.memberships) - - def __len__(self) -> int: - """Return the number of resolved memberships.""" - return len(self.memberships) - - @property - def edges(self) -> tuple[MembershipEdge, ...]: - """Alias for ``memberships`` to keep the view easy to inspect.""" - return self.memberships - - def as_triplets(self) -> set[tuple[Any, Any, RelationKey]]: - """Return the memberships as live-object triplets.""" - return {(edge.agent, edge.group, edge.relation) for edge in self.memberships} - - @property - def agents(self) -> set[Any]: - """Return all unique agents referenced by the view.""" - return {edge.agent for edge in self.memberships} - - @property - def groups(self) -> set[Any]: - """Return all unique groups referenced by the view.""" - return {edge.group for edge in self.memberships} - - @property - def relations(self) -> set[RelationKey]: - """Return all unique relation labels referenced by the view.""" - return {edge.relation for edge in self.memberships} - - -class MetaAgentFacade: - """Thin public facade over :class:`MembershipBackend`.""" - - def __init__(self, model: Any, backend: MembershipBackend | None = None) -> None: - """Create a facade bound to one model.""" - self.model = model - self.backend = backend or MembershipBackend() - - def _entity_id(self, entity: Hashable) -> Hashable: - """Return the backend identity for a live entity or hashable external id.""" - return getattr(entity, "unique_id", entity) - - def _live_entity_lookup(self) -> dict[Hashable, Any]: - """Build a lookup from backend ids back to live model objects.""" - lookup: dict[Hashable, Any] = {} - for entity in self.model.agents: - entity_id = getattr(entity, "unique_id", None) - if entity_id is not None: - lookup[entity_id] = entity - return lookup - - def _resolve_entity(self, entity_id: Hashable) -> Any: - """Resolve a backend id back to a live object when possible.""" - return self._live_entity_lookup().get(entity_id, entity_id) - - def _resolve_view( - self, entity: Hashable, triplets: Iterable[Triplet] - ) -> MembershipView: - """Convert backend triplets into a user-facing snapshot.""" - lookup = self._live_entity_lookup() - resolved_edges: list[MembershipEdge] = [] - for agent_id, group_id, relation in sorted( - triplets, - key=lambda triplet: ( - str(triplet[0]), - str(triplet[1]), - repr(triplet[2]), - ), - ): - resolved_edges.append( - MembershipEdge( - agent=lookup.get(agent_id, agent_id), - group=lookup.get(group_id, group_id), - relation=relation, - ) - ) - - return MembershipView( - subject=self._resolve_entity(entity), - memberships=tuple(resolved_edges), - ) - - def _detach_entity(self, entity: Hashable) -> MembershipView: - """Remove all incident memberships and update live objects when available.""" - snapshot = self.query_memberships(entity) - - self.backend.remove_agent(entity) - self.backend.remove_group(entity) - - for edge in snapshot.memberships: - group = edge.group - member = edge.agent - if hasattr(group, "remove_constituting_agents"): - group.remove_constituting_agents({member}) - - return snapshot - - def create( - self, - new_agent_class: str, - agents: Iterable[Any], - mesa_agent_type: type[Agent] | None, - meta_attributes: dict[str, Any] | None = None, - meta_methods: dict[str, Callable] | None = None, - assume_constituting_agent_methods: bool = False, - assume_constituting_agent_attributes: bool = False, - relation: RelationKey = "member", - memberships: Iterable[tuple[Any, RelationKey]] | None = None, - ) -> Any | None: - """Create a meta-agent and record its memberships in the backend.""" - agents = list(agents) - member_relations = list(memberships) if memberships is not None else None - if member_relations is not None and not agents: - agents = [member for member, _ in member_relations] - - meta_agent = create_meta_agent( - self.model, - new_agent_class, - agents, - mesa_agent_type, - meta_attributes=meta_attributes, - meta_methods=meta_methods, - assume_constituting_agent_methods=assume_constituting_agent_methods, - assume_constituting_agent_attributes=assume_constituting_agent_attributes, - ) - - if meta_agent is None: - return None - - if member_relations is None: - member_relations = [(agent, relation) for agent in agents] - - self.backend.bulk_add( - [(member, meta_agent, rel) for member, rel in member_relations] - ) - - return meta_agent - - def add_member( - self, - group: Hashable, - member: Hashable, - relation: RelationKey = "member", - ) -> MembershipView: - """Add one member to one group and keep the object layer in sync.""" - already_linked = bool(self.backend.relations_between(member, group)) - self.backend.add_membership(member, group, relation) - - if not already_linked and hasattr(group, "add_constituting_agents"): - group.add_constituting_agents({member}) - - return self.query_memberships(member) - - def remove_member( - self, - group: Hashable, - member: Hashable, - relation: RelationKey = "member", - ) -> MembershipView: - """Remove one member from one group and keep the object layer in sync.""" - self.backend.remove_membership(member, group, relation) - - if not self.backend.relations_between(member, group) and hasattr( - group, "remove_constituting_agents" - ): - group.remove_constituting_agents({member}) - - return self.query_memberships(member) - - def query_memberships( - self, entity: Hashable, relation: RelationKey | None = None - ) -> MembershipView: - """Return a resolved, read-only snapshot of one entity's memberships.""" - entity_id = self._entity_id(entity) - triplets = ( - triplet - for triplet in self.backend.as_triplets() - if (triplet[0] == entity_id or triplet[1] == entity_id) - and (relation is None or triplet[2] == relation) - ) - return self._resolve_view(entity_id, triplets) - - def dissolve(self, entity: Hashable) -> MembershipView: - """Remove an entity's memberships and delete it from the model when possible.""" - snapshot = self._detach_entity(entity) - live_entity = self._resolve_entity(self._entity_id(entity)) - if hasattr(live_entity, "remove"): - live_entity.remove() - return snapshot - - def deactivate(self, entity: Hashable) -> MembershipView: - """Remove an entity from all memberships without deleting the object.""" - return self._detach_entity(entity) +__all__ = [ + "MembershipEdge", + "MembershipView", + "MetaAgentFacade", + "MetaAgents", +] diff --git a/mesa/experimental/meta_agents/meta_agents_api.py b/mesa/experimental/meta_agents/meta_agents_api.py new file mode 100644 index 00000000000..2844c50308a --- /dev/null +++ b/mesa/experimental/meta_agents/meta_agents_api.py @@ -0,0 +1,238 @@ +"""Public meta-agents API for the experimental membership backend.""" + +from __future__ import annotations + +from collections.abc import Callable, Hashable, Iterable +from dataclasses import dataclass +from typing import Any + +from mesa.agent import Agent + +from .backend import MembershipBackend, RelationKey, Triplet +from .meta_agent import create_meta_agent + + +@dataclass(frozen=True, slots=True) +class MembershipEdge: + """A user-facing membership edge with live objects instead of backend ids.""" + + agent: Any + group: Any + relation: RelationKey + + +@dataclass(frozen=True, slots=True) +class MembershipView: + """Read-only snapshot of memberships for one entity.""" + + subject: Any + memberships: tuple[MembershipEdge, ...] + + def __iter__(self): + """Iterate over the resolved memberships.""" + return iter(self.memberships) + + def __len__(self) -> int: + """Return the number of resolved memberships.""" + return len(self.memberships) + + @property + def edges(self) -> tuple[MembershipEdge, ...]: + """Alias for ``memberships`` to keep the view easy to inspect.""" + return self.memberships + + def as_triplets(self) -> set[tuple[Any, Any, RelationKey]]: + """Return the memberships as live-object triplets.""" + return {(edge.agent, edge.group, edge.relation) for edge in self.memberships} + + @property + def agents(self) -> set[Any]: + """Return all unique agents referenced by the view.""" + return {edge.agent for edge in self.memberships} + + @property + def groups(self) -> set[Any]: + """Return all unique groups referenced by the view.""" + return {edge.group for edge in self.memberships} + + @property + def relations(self) -> set[RelationKey]: + """Return all unique relation labels referenced by the view.""" + return {edge.relation for edge in self.memberships} + + +class MetaAgents: + """Public meta-agents interface over :class:`MembershipBackend`.""" + + def __init__(self, model: Any, backend: MembershipBackend | None = None) -> None: + """Create a meta-agents API bound to one model.""" + self.model = model + self.backend = backend or MembershipBackend() + + def _entity_id(self, entity: Hashable) -> Hashable: + """Return the backend identity for a live entity or hashable external id.""" + return getattr(entity, "unique_id", entity) + + def _live_entity_lookup(self) -> dict[Hashable, Any]: + """Build a lookup from backend ids back to live model objects.""" + lookup: dict[Hashable, Any] = {} + for entity in self.model.agents: + entity_id = getattr(entity, "unique_id", None) + if entity_id is not None: + lookup[entity_id] = entity + return lookup + + def _resolve_entity(self, entity_id: Hashable) -> Any: + """Resolve a backend id back to a live object when possible.""" + return self._live_entity_lookup().get(entity_id, entity_id) + + def _resolve_view( + self, entity: Hashable, triplets: Iterable[Triplet] + ) -> MembershipView: + """Convert backend triplets into a user-facing snapshot.""" + lookup = self._live_entity_lookup() + resolved_edges: list[MembershipEdge] = [] + for agent_id, group_id, relation in sorted( + triplets, + key=lambda triplet: ( + str(triplet[0]), + str(triplet[1]), + repr(triplet[2]), + ), + ): + resolved_edges.append( + MembershipEdge( + agent=lookup.get(agent_id, agent_id), + group=lookup.get(group_id, group_id), + relation=relation, + ) + ) + + return MembershipView( + subject=self._resolve_entity(entity), + memberships=tuple(resolved_edges), + ) + + def _detach_entity(self, entity: Hashable) -> MembershipView: + """Remove all incident memberships and update live objects when available.""" + snapshot = self.query_memberships(entity) + + self.backend.remove_agent(entity) + self.backend.remove_group(entity) + + for edge in snapshot.memberships: + group = edge.group + member = edge.agent + if hasattr(group, "remove_constituting_agents"): + group.remove_constituting_agents({member}) + + return snapshot + + def create( + self, + new_agent_class: str, + agents: Iterable[Any], + mesa_agent_type: type[Agent] | None, + meta_attributes: dict[str, Any] | None = None, + meta_methods: dict[str, Callable] | None = None, + assume_constituting_agent_methods: bool = False, + assume_constituting_agent_attributes: bool = False, + relation: RelationKey = "member", + memberships: Iterable[tuple[Any, RelationKey]] | None = None, + ) -> Any | None: + """Create a meta-agent and record its memberships in the backend.""" + agents = list(agents) + member_relations = list(memberships) if memberships is not None else None + if member_relations is not None and not agents: + agents = [member for member, _ in member_relations] + + meta_agent = create_meta_agent( + self.model, + new_agent_class, + agents, + mesa_agent_type, + meta_attributes=meta_attributes, + meta_methods=meta_methods, + assume_constituting_agent_methods=assume_constituting_agent_methods, + assume_constituting_agent_attributes=assume_constituting_agent_attributes, + ) + + if meta_agent is None: + return None + + if member_relations is None: + member_relations = [(agent, relation) for agent in agents] + + self.backend.bulk_add( + [(member, meta_agent, rel) for member, rel in member_relations] + ) + + return meta_agent + + def add_member( + self, + group: Hashable, + member: Hashable, + relation: RelationKey = "member", + ) -> MembershipView: + """Add one member to one group and keep the object layer in sync.""" + already_linked = bool(self.backend.relations_between(member, group)) + self.backend.add_membership(member, group, relation) + + if not already_linked and hasattr(group, "add_constituting_agents"): + group.add_constituting_agents({member}) + + return self.query_memberships(member) + + def remove_member( + self, + group: Hashable, + member: Hashable, + relation: RelationKey = "member", + ) -> MembershipView: + """Remove one member from one group and keep the object layer in sync.""" + self.backend.remove_membership(member, group, relation) + + if not self.backend.relations_between(member, group) and hasattr( + group, "remove_constituting_agents" + ): + group.remove_constituting_agents({member}) + + return self.query_memberships(member) + + def query_memberships( + self, entity: Hashable, relation: RelationKey | None = None + ) -> MembershipView: + """Return a resolved, read-only snapshot of one entity's memberships.""" + entity_id = self._entity_id(entity) + triplets = ( + triplet + for triplet in self.backend.as_triplets() + if (triplet[0] == entity_id or triplet[1] == entity_id) + and (relation is None or triplet[2] == relation) + ) + return self._resolve_view(entity_id, triplets) + + def dissolve(self, entity: Hashable) -> MembershipView: + """Remove an entity's memberships and delete it from the model when possible.""" + snapshot = self._detach_entity(entity) + live_entity = self._resolve_entity(self._entity_id(entity)) + if hasattr(live_entity, "remove"): + live_entity.remove() + return snapshot + + def deactivate(self, entity: Hashable) -> MembershipView: + """Remove an entity from all memberships without deleting the object.""" + return self._detach_entity(entity) + + +# Legacy compatibility alias for the transition period. +MetaAgentFacade = MetaAgents + + +__all__ = [ + "MembershipEdge", + "MembershipView", + "MetaAgentFacade", + "MetaAgents", +] diff --git a/tests/experimental/test_meta_agents_facade.py b/tests/experimental/test_meta_agents_api.py similarity index 53% rename from tests/experimental/test_meta_agents_facade.py rename to tests/experimental/test_meta_agents_api.py index 553d0b91293..e683ee5102f 100644 --- a/tests/experimental/test_meta_agents_facade.py +++ b/tests/experimental/test_meta_agents_api.py @@ -1,29 +1,30 @@ -"""Tests for the public meta-agent facade.""" +"""Tests for the public meta-agents API.""" from mesa import Agent, Model from mesa.experimental.meta_agents import ( MembershipEdge, MembershipView, MetaAgentFacade, + MetaAgents, ) -def test_facade_create_records_backend_memberships(): +def test_meta_agents_create_records_backend_memberships(): """Create should return live objects and record backend triplets.""" model = Model() - facade = MetaAgentFacade(model) + meta_agents = MetaAgents(model) agent_1 = Agent(model) agent_2 = Agent(model) - meta_agent = facade.create("Group", [agent_1, agent_2], Agent) + meta_agent = meta_agents.create("Group", [agent_1, agent_2], Agent) assert meta_agent is not None - assert facade.backend.as_triplets() == { + assert meta_agents.backend.as_triplets() == { (agent_1.unique_id, meta_agent.unique_id, "member"), (agent_2.unique_id, meta_agent.unique_id, "member"), } - view = facade.query_memberships(agent_1) + view = meta_agents.query_memberships(agent_1) assert isinstance(view, MembershipView) assert view.subject is agent_1 @@ -34,70 +35,85 @@ def test_facade_create_records_backend_memberships(): assert view.memberships[0].group is meta_agent -def test_facade_remove_member_preserves_overlapping_memberships(): +def test_meta_agents_compatibility_alias_preserves_old_name(): + """The old class name should still resolve to the new API.""" + assert MetaAgentFacade is MetaAgents + + model = Model() + alias = MetaAgentFacade(model) + agent = Agent(model) + group = alias.create("Group", [agent], Agent) + + assert group is not None + assert alias.backend.as_triplets() == { + (agent.unique_id, group.unique_id, "member") + } + + +def test_meta_agents_remove_member_preserves_overlapping_memberships(): """Removing one relation should keep unrelated memberships intact.""" model = Model() - facade = MetaAgentFacade(model) + meta_agents = MetaAgents(model) agent = Agent(model) partner = Agent(model) - group_one = facade.create("GroupOne", [agent, partner], Agent) - group_two = facade.create("GroupTwo", [agent], Agent) + group_one = meta_agents.create("GroupOne", [agent, partner], Agent) + group_two = meta_agents.create("GroupTwo", [agent], Agent) assert group_one is not None assert group_two is not None assert len(agent.meta_agents) == 2 - view = facade.remove_member(group_one, agent) + view = meta_agents.remove_member(group_one, agent) assert view.as_triplets() == {(agent, group_two, "member")} - assert facade.backend.groups_of(agent) == {group_two.unique_id} + assert meta_agents.backend.groups_of(agent) == {group_two.unique_id} assert group_one not in agent.meta_agents assert group_two in agent.meta_agents assert partner.meta_agents == {group_one} -def test_facade_dissolve_cleans_only_target_group(): +def test_meta_agents_dissolve_cleans_only_target_group(): """Dissolving a group should keep overlapping memberships on other groups.""" model = Model() - facade = MetaAgentFacade(model) + meta_agents = MetaAgents(model) agent_1 = Agent(model) agent_2 = Agent(model) agent_3 = Agent(model) - group_one = facade.create("GroupOne", [agent_1, agent_2], Agent) - group_two = facade.create("GroupTwo", [agent_1, agent_3], Agent) + group_one = meta_agents.create("GroupOne", [agent_1, agent_2], Agent) + group_two = meta_agents.create("GroupTwo", [agent_1, agent_3], Agent) assert group_one is not None assert group_two is not None - snapshot = facade.dissolve(group_one) + snapshot = meta_agents.dissolve(group_one) assert snapshot.as_triplets() == { (agent_1, group_one, "member"), (agent_2, group_one, "member"), } - assert facade.backend.groups_of(agent_1) == {group_two.unique_id} - assert facade.backend.groups_of(agent_2) == set() - assert facade.backend.groups_of(agent_3) == {group_two.unique_id} + assert meta_agents.backend.groups_of(agent_1) == {group_two.unique_id} + assert meta_agents.backend.groups_of(agent_2) == set() + assert meta_agents.backend.groups_of(agent_3) == {group_two.unique_id} assert group_one not in model.agents assert group_two in model.agents assert group_one not in agent_1.meta_agents assert group_two in agent_1.meta_agents -def test_facade_deactivate_detaches_all_memberships_without_removing_entity(): +def test_meta_agents_deactivate_detaches_all_memberships_without_removing_entity(): """Deactivate should clear memberships but keep the entity registered.""" model = Model() - facade = MetaAgentFacade(model) + meta_agents = MetaAgents(model) agent_1 = Agent(model) agent_2 = Agent(model) - group = facade.create("Group", [agent_1, agent_2], Agent) + group = meta_agents.create("Group", [agent_1, agent_2], Agent) assert group is not None - snapshot = facade.deactivate(agent_1) + snapshot = meta_agents.deactivate(agent_1) assert snapshot.as_triplets() == {(agent_1, group, "member")} - assert facade.backend.groups_of(agent_1) == set() + assert meta_agents.backend.groups_of(agent_1) == set() assert agent_1 in model.agents assert group not in agent_1.meta_agents assert agent_1.meta_agent is None From 9b6a82813c21beee05a101be700dc6c49c839405 Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Mon, 6 Jul 2026 17:54:15 +0530 Subject: [PATCH 16/22] ruff --- tests/experimental/test_meta_agents_api.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/experimental/test_meta_agents_api.py b/tests/experimental/test_meta_agents_api.py index e683ee5102f..2b568c30589 100644 --- a/tests/experimental/test_meta_agents_api.py +++ b/tests/experimental/test_meta_agents_api.py @@ -45,9 +45,7 @@ def test_meta_agents_compatibility_alias_preserves_old_name(): group = alias.create("Group", [agent], Agent) assert group is not None - assert alias.backend.as_triplets() == { - (agent.unique_id, group.unique_id, "member") - } + assert alias.backend.as_triplets() == {(agent.unique_id, group.unique_id, "member")} def test_meta_agents_remove_member_preserves_overlapping_memberships(): From 9913e0936ebde04a705a521adcfb618f18448bd8 Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Fri, 10 Jul 2026 19:36:05 +0530 Subject: [PATCH 17/22] update examples --- benchmarks/README.md | 4 ++- benchmarks/configurations.py | 32 ++++++++++++++++++++ mesa/examples/README.md | 2 ++ mesa/examples/advanced/warehouse/__init__.py | 4 +-- mesa/examples/advanced/warehouse/model.py | 25 +++++++++++++-- tests/examples/test_warehouse_model.py | 20 +++++++++++- 6 files changed, 80 insertions(+), 7 deletions(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index f8ebca7f97a..16fc03051bd 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -2,7 +2,7 @@ The `/benchmarks` directory contains tools for benchmarking the Mesa library performance on the included example models. This allows to track and compare model initialisation time and runtime between different Mesa versions. -MESA uses several example base models for benchmarking performance (BoltzmannWealth, Schelling, BoidFlockers, and WolfSheep) by calculating the initialization time and run time for each of these models. These example models can be found in the `/mesa/examples` directory. +MESA uses several example base models for benchmarking performance (BoltzmannWealth, Schelling, BoidFlockers, WolfSheep, and the meta-agents examples) by calculating the initialization time and run time for each of these models. These example models can be found in the `/mesa/examples` directory. ## Available Files @@ -39,6 +39,8 @@ This will: - Save results to a pickle file named `timings_X.pickle` (where X is an incremental number) - Display a summary of results in the terminal +For meta-agents branch comparisons, run the benchmark once on the `meta-agents` baseline branch, switch to your feature branch, and run it again before comparing the two pickle files. + >**Noteworthy :** the pickle file created by the benchmark is not under git control. So you can run the benchmark on the master branch first, switch to your development branch, and run the benchmarks again. diff --git a/benchmarks/configurations.py b/benchmarks/configurations.py index 6dcad2a0567..a687cf65787 100644 --- a/benchmarks/configurations.py +++ b/benchmarks/configurations.py @@ -3,11 +3,15 @@ from mesa.examples import ( BoidFlockers, BoltzmannWealth, + MultiLevelAllianceModel, Schelling, SugarscapeG1mt, + WarehouseModel, WolfSheep, ) +from mesa.examples.advanced.alliance_formation.model import AllianceScenario from mesa.examples.advanced.sugarscape_g1mt.model import SugarScapeScenario +from mesa.examples.advanced.warehouse.model import WarehouseScenario from mesa.examples.advanced.wolf_sheep.model import WolfSheepScenario from mesa.examples.basic.boid_flockers.model import BoidsScenario from mesa.examples.basic.boltzmann_wealth_model.model import BoltzmannScenario @@ -126,4 +130,32 @@ ), }, }, + MultiLevelAllianceModel: { + "small": { + "replications": 25, + "iterations": 3, + "steps": 10, + "scenario": AllianceScenario(n=50, mean=0.5, std_dev=0.1, rng=42), + }, + "large": { + "replications": 10, + "iterations": 3, + "steps": 20, + "scenario": AllianceScenario(n=120, mean=0.5, std_dev=0.1, rng=42), + }, + }, + WarehouseModel: { + "small": { + "replications": 25, + "iterations": 3, + "steps": 10, + "scenario": WarehouseScenario(rows=8, cols=8, height=2, rng=42), + }, + "large": { + "replications": 10, + "iterations": 3, + "steps": 20, + "scenario": WarehouseScenario(rows=16, cols=16, height=2, rng=42), + }, + }, } diff --git a/mesa/examples/README.md b/mesa/examples/README.md index e62eb01ff7d..78e897d2c6d 100644 --- a/mesa/examples/README.md +++ b/mesa/examples/README.md @@ -42,3 +42,5 @@ This is Epstein & Axtell's Sugarscape model with Traders, a detailed description ### [Wolf-Sheep Predation Model](examples/advanced/wolf_sheep) Implementation of an ecological model of predation and reproduction, based on the NetLogo [Wolf Sheep Predation](http://ccl.northwestern.edu/netlogo/models/WolfSheepPredation) model. + +The advanced meta-agents examples, especially alliance formation and warehouse, are also used in the benchmark comparison workflow when you want to compare the `meta-agents` branch against a feature branch. diff --git a/mesa/examples/advanced/warehouse/__init__.py b/mesa/examples/advanced/warehouse/__init__.py index c42277d0a4f..f335e78905f 100644 --- a/mesa/examples/advanced/warehouse/__init__.py +++ b/mesa/examples/advanced/warehouse/__init__.py @@ -1,5 +1,5 @@ """Warehouse meta-agent example.""" -from mesa.examples.advanced.warehouse.model import WarehouseModel +from mesa.examples.advanced.warehouse.model import WarehouseModel, WarehouseScenario -__all__ = ["WarehouseModel"] +__all__ = ["WarehouseModel", "WarehouseScenario"] diff --git a/mesa/examples/advanced/warehouse/model.py b/mesa/examples/advanced/warehouse/model.py index 5a8daf4f89f..ededa850efa 100644 --- a/mesa/examples/advanced/warehouse/model.py +++ b/mesa/examples/advanced/warehouse/model.py @@ -16,20 +16,39 @@ LOADING_DOCK_COORDS, make_warehouse, ) +from mesa.experimental import meta_agents from mesa.experimental.meta_agents.backend import MembershipBackend from mesa.experimental.meta_agents.meta_agent import MetaAgent, create_meta_agent +from mesa.experimental.scenarios import Scenario + + +class WarehouseScenario(Scenario): + """Scenario parameters for the warehouse meta-agent example.""" + + rows: int = 8 + cols: int = 8 + height: int = 2 class WarehouseModel(mesa.Model): """Model for simulating warehouse robots assembled from sub-agents.""" - def __init__(self, rng=42): + def __init__(self, scenario: WarehouseScenario = WarehouseScenario, rng=42): """Create the warehouse, inventory, and robot meta-agents.""" - super().__init__(rng=rng) + if isinstance(scenario, Scenario): + super().__init__(scenario=scenario) + self.model = MetaAgent(backend=facade) + else: + super().__init__(scenario=scenario, rng=rng) self.inventory = {} self.membership_backend = MembershipBackend() - layout = make_warehouse(rng=self.random) + layout = make_warehouse( + rows=self.scenario.rows, + cols=self.scenario.cols, + height=self.scenario.height, + rng=self.random, + ) self.warehouse = OrthogonalMooreGrid( (layout.shape[0], layout.shape[1], layout.shape[2]), torus=False, diff --git a/tests/examples/test_warehouse_model.py b/tests/examples/test_warehouse_model.py index 269d867b9f6..80469280275 100644 --- a/tests/examples/test_warehouse_model.py +++ b/tests/examples/test_warehouse_model.py @@ -13,11 +13,12 @@ WorkerAgent, ) from mesa.examples.advanced.warehouse.make_warehouse import LOADING_DOCK_COORDS +from mesa.examples.advanced.warehouse.model import WarehouseScenario def test_warehouse_model_uses_membership_backend(): """Robot memberships should be mirrored into the backend and cleaned up.""" - model = WarehouseModel(rng=42) + model = WarehouseModel(scenario=WarehouseScenario(rng=42)) backend = model.membership_backend robot_type = model.robot_agent_type @@ -67,6 +68,23 @@ def test_warehouse_model_uses_membership_backend(): assert ref() is None +def test_warehouse_model_supports_scenario_initialization(): + """The warehouse model should accept scenario-driven benchmark inputs.""" + scenario = WarehouseScenario(rows=10, cols=12, height=2, rng=42) + model = WarehouseModel(scenario=scenario) + + assert model.scenario.rows == 10 + assert model.scenario.cols == 12 + assert model.scenario.height == 2 + expected_inventory = sum( + 1 + for row in range(2, scenario.rows - 1, 3) + for col in range(1, scenario.cols, 3) + for _ in range(scenario.height) + ) + assert len(model.agents_by_type[InventoryAgent]) == expected_inventory + + def test_warehouse_robot_completes_inventory_cycle(): """A robot should complete the full inventory and loading workflow.""" model = WarehouseModel(rng=42) From 651860b78f01ceaf453b78b769c9dee24e7c874a Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Fri, 10 Jul 2026 19:44:26 +0530 Subject: [PATCH 18/22] remove one breaking facade reference from the warehouse model --- mesa/examples/advanced/warehouse/model.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/mesa/examples/advanced/warehouse/model.py b/mesa/examples/advanced/warehouse/model.py index ededa850efa..24074abacc4 100644 --- a/mesa/examples/advanced/warehouse/model.py +++ b/mesa/examples/advanced/warehouse/model.py @@ -16,7 +16,6 @@ LOADING_DOCK_COORDS, make_warehouse, ) -from mesa.experimental import meta_agents from mesa.experimental.meta_agents.backend import MembershipBackend from mesa.experimental.meta_agents.meta_agent import MetaAgent, create_meta_agent from mesa.experimental.scenarios import Scenario @@ -37,7 +36,6 @@ def __init__(self, scenario: WarehouseScenario = WarehouseScenario, rng=42): """Create the warehouse, inventory, and robot meta-agents.""" if isinstance(scenario, Scenario): super().__init__(scenario=scenario) - self.model = MetaAgent(backend=facade) else: super().__init__(scenario=scenario, rng=rng) self.inventory = {} From bd178c5b77e194f52a5f6349bd7168bbb31ffb04 Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Mon, 20 Jul 2026 09:14:54 +0530 Subject: [PATCH 19/22] rewrite meta_agent.py --- mesa/experimental/meta_agents/meta_agent.py | 616 ++++++++---------- .../meta_agents/meta_agents_api.py | 4 +- 2 files changed, 278 insertions(+), 342 deletions(-) diff --git a/mesa/experimental/meta_agents/meta_agent.py b/mesa/experimental/meta_agents/meta_agent.py index b5b0df7181f..b31c95f02e5 100644 --- a/mesa/experimental/meta_agents/meta_agent.py +++ b/mesa/experimental/meta_agents/meta_agent.py @@ -1,43 +1,19 @@ -"""Implementation of Mesa's meta agent capability. - -Overview: Complex systems often have multiple levels of components. An -organization is not one entity, but is made of departments, sub-departments, -and people. A person is not a single entity, but it is made of micro biomes, -organs and cells. A city is not a single entity, but it is made of districts, -neighborhoods, buildings, and people. A forest comprises an ecosystem of -trees, plants, animals, and microorganisms. - -This reality is the motivation for meta-agents. It allows users to represent -these multiple levels, where each level can have agents with constituting_agents. - -To demonstrate meta-agents capability there are two examples: -1 - Alliance formation which shows emergent meta-agent formation in -advanced examples: -https://github.com/mesa/mesa/tree/main/mesa/examples/advanced/alliance_formation -2 - Warehouse model in the Mesa example's repository -https://github.com/mesa/mesa-examples/tree/main/examples/warehouse - -To accomplish this the MetaAgent module is as follows: - -This contains four helper functions and a MetaAgent class that can be used to -create agents that contain other agents as components. - -Helper methods: -1 - find_combinations: Find combinations of agents to create a meta-agent -constituting_set. -2- evaluate_combination: Evaluate combinations of agents by some user based -criteria to determine if it should be a constituting_set of agents. -3- extract_class: Helper function for create_meta-agent. Extracts the types of -agent being created to create a new instance of that agent type. -4- create_meta_agent: Create a new meta-agent class and instantiate -agents in that class. - -Meta-Agent class (MetaAgent): An agent that contains other agents -as components. - -. +"""Core meta-agent object and legacy construction helpers. + +Meta-agents represent agents that are composed of other agents. The current +experimental rewrite supports overlapping memberships: one agent can belong to +multiple meta-agents at the same time, and canonical membership bookkeeping is +handled by :class:`mesa.experimental.meta_agents.backend.MembershipBackend`. + +This module keeps the historical ``MetaAgent`` class and ``create_meta_agent`` +function available for existing user code. Their object-level references +(``agent.meta_agents`` and ``agent.meta_agent``) are compatibility mirrors for +older models; new code should use the public ``MetaAgents`` facade and typed +membership backend for authoritative membership state. """ +from __future__ import annotations + import itertools from collections.abc import Callable, Iterable from types import MethodType @@ -45,16 +21,63 @@ from mesa.agent import Agent, AgentSet +_RESERVED_META_ATTRIBUTE_NAMES = { + "unique_id", + "model", + "pos", + "name", + "random", + "rng", + "meta_agents", + "meta_agent", + "_constituting_set", +} -def _unique_id_sort_key(agent: Agent) -> tuple[bool, Any]: - """Return a deterministic, type-stable key for ordering agents by ``unique_id``. - Agents are ordered by ``unique_id``, with a ``None`` id sorting first so the key - never compares ``None`` against a real id. Unlike ``unique_id or 0``, this does not - inject an ``int`` into the key, so it also works for non-integer ids such as ``str`` - or ``UUID`` without raising ``TypeError``. - """ - return (agent.unique_id is not None, agent.unique_id) +def _unique_id_sort_key(agent: Agent) -> tuple[bool, str]: + """Return a deterministic, type-stable key for ordering agents by ID.""" + unique_id = getattr(agent, "unique_id", None) + return (unique_id is not None, "" if unique_id is None else str(unique_id)) + + +def _deduplicate_preserving_order(agents: Iterable[Any]) -> list[Any]: + """Return unique agents while preserving caller order.""" + return list(dict.fromkeys(agents)) + + +def _normalize_agent_bases( + mesa_agent_type: type[Agent] | tuple[type[Agent], ...] | None, +) -> tuple[type[Agent], ...]: + """Normalize user-provided Mesa base classes for dynamic meta-agent classes.""" + if mesa_agent_type is None: + return (Agent,) + if isinstance(mesa_agent_type, tuple): + return mesa_agent_type + return (mesa_agent_type,) + + +def _update_primary_meta_agent(agent: Any) -> None: + """Refresh the legacy ``agent.meta_agent`` pointer from ``agent.meta_agents``.""" + meta_agents = getattr(agent, "meta_agents", set()) + agent.meta_agent = ( + sorted(meta_agents, key=_unique_id_sort_key)[0] if meta_agents else None + ) + + +def _attach_meta_agent(agent: Any, meta_agent: MetaAgent) -> None: + """Attach one compatibility mirror membership.""" + if not hasattr(agent, "meta_agents"): + agent.meta_agents = set() + agent.meta_agents.add(meta_agent) + _update_primary_meta_agent(agent) + + +def _detach_meta_agent(agent: Any, meta_agent: MetaAgent) -> None: + """Detach one compatibility mirror membership.""" + if not hasattr(agent, "meta_agents"): + return + agent.meta_agents.discard(meta_agent) + _update_primary_meta_agent(agent) def evaluate_combination( @@ -62,20 +85,10 @@ def evaluate_combination( model, evaluation_func: Callable[[tuple[Agent, ...]], float] | None, ) -> tuple[tuple[Agent, ...], float] | None: - """Evaluate a combination of agents. - - Args: - candidate_group: The group of agents to evaluate. - model: The model instance. - evaluation_func: The function to evaluate the group. - - Returns: - Optional: The evaluated group and its value, or None. - """ - if evaluation_func: - value = evaluation_func(candidate_group) - return candidate_group, value - return None + """Evaluate a candidate meta-agent group with a user-supplied function.""" + if evaluation_func is None: + return None + return candidate_group, evaluation_func(candidate_group) def find_combinations( @@ -88,29 +101,19 @@ def find_combinations( ] | None = None, ) -> list[tuple[tuple[Agent, ...], float]]: - """Find valuable combinations of agents in this set. - - Args: - model: The model instance. - group: The set of agents to find combinations in. - size: The size or range of sizes for combinations. Defaults to (2, 5). - evaluation_func: The function to evaluate combinations. Defaults to None. - filter_func: Allows the user to specify how agents are filtered to form groups. - Defaults to None. - List: The function to filter combinations. Defaults to None. - - Returns: - List: The list of valuable combinations, in a tuple first agentset of valuable combination and then the value of - the combination. + """Find candidate agent groups and score them with ``evaluation_func``. + + The helper is retained for existing examples that discover potential + meta-agents before creating them. It deliberately does not mutate model or + membership state. """ - combinations = [] - # Allow one size or range of sizes to be passed if isinstance(size, int): size_range = range(size, size + 1) else: min_size, max_size = size size_range = range(min_size, max_size + 1) + combinations = [] for candidate_group in itertools.chain.from_iterable( itertools.combinations(group, combination_size) for combination_size in size_range @@ -119,226 +122,207 @@ def find_combinations( candidate_group, model, evaluation_func ) if evaluation_result is not None: - evaluated_group, result = evaluation_result + _evaluated_group, result = evaluation_result if result is not None: - combinations.append((evaluated_group, result)) - - if len(combinations) > 0 and filter_func: - filtered_combinations = filter_func(combinations) - return filtered_combinations + combinations.append(evaluation_result) + if combinations and filter_func is not None: + return filter_func(combinations) return combinations def extract_class(agents_by_type: dict, new_agent_class: object) -> type[Agent] | None: - """Helper function for create_meta_agents extracts the types of agents. + """Return the existing model agent class named ``new_agent_class`` if present.""" + agent_type_names = { + agent_type.__name__: agent_type for agent_type in agents_by_type + } + agent_type = agent_type_names.get(new_agent_class) + if agent_type is None: + return None + return type(next(iter(agents_by_type[agent_type]))) - Args: - agents_by_type (dict): The dictionary of agents by type. - new_agent_class (str): The name of the agent class to be created - Returns: - type(Agent) if agent type exists - None otherwise - """ - agent_type_names = {} - for agent in agents_by_type: - agent_type_names[agent.__name__] = agent +def _collect_inferred_attributes( + agents: Iterable[Any], + meta_attributes: dict[str, Any] | None, + assume_constituting_agent_attributes: bool, +) -> dict[str, Any]: + """Merge explicit and inferred attributes for a new meta-agent instance.""" + resolved_attributes = dict(meta_attributes or {}) + if not assume_constituting_agent_attributes: + return resolved_attributes + + for agent in agents: + for name, value in agent.__dict__.items(): + if ( + not callable(value) + and name not in _RESERVED_META_ATTRIBUTE_NAMES + and not name.startswith("_") + ): + resolved_attributes[name] = value + return resolved_attributes + + +def _apply_meta_attributes( + meta_agent: Any, + meta_attributes: dict[str, Any] | None, +) -> None: + """Set resolved meta-agent attributes on an instance.""" + for key, value in (meta_attributes or {}).items(): + setattr(meta_agent, key, value) + + +def _collect_meta_methods( + agents: Iterable[Any], + meta_methods: dict[str, Callable] | None, + assume_constituting_agent_methods: bool, +) -> dict[str, Callable]: + """Merge explicit and inferred methods for a meta-agent instance.""" + resolved_meta_methods = dict(meta_methods or {}) + if not assume_constituting_agent_methods: + return resolved_meta_methods + + for agent_class in dict.fromkeys(type(agent) for agent in agents): + for name, value in agent_class.__dict__.items(): + if callable(value) and not name.startswith("__"): + resolved_meta_methods.setdefault(name, value) + return resolved_meta_methods + + +def _apply_meta_methods( + meta_agent: Any, + meta_methods: dict[str, Callable] | None, +) -> None: + """Bind resolved meta-agent methods to an instance.""" + for name, method in (meta_methods or {}).items(): + setattr(meta_agent, name, MethodType(method, meta_agent)) + + +def _find_existing_meta_agent( + agents: Iterable[Any], + new_agent_class: str, +) -> Any | None: + """Find a compatible existing meta-agent among legacy mirrors.""" + existing_meta_agents = [] + for agent in agents: + for meta_agent in sorted( + getattr(agent, "meta_agents", set()), key=_unique_id_sort_key + ): + if ( + meta_agent.__class__.__name__ == new_agent_class + and meta_agent not in existing_meta_agents + ): + existing_meta_agents.append(meta_agent) + + if not existing_meta_agents: + return None + return sorted(existing_meta_agents, key=_unique_id_sort_key)[0] + + +def _build_meta_agent_class( + new_agent_class: str, + mesa_agent_type: tuple[type[Agent], ...], +) -> type[Agent]: + """Create a dynamic meta-agent class with the requested Mesa base types.""" + return type( + new_agent_class, + (MetaAgent, *mesa_agent_type), + { + "unique_id": None, + "_constituting_set": None, + }, + ) + + +def _create_meta_agent_instance( + model: Any, + new_agent_class: str, + agents: Iterable[Any], + mesa_agent_type: type[Agent] | tuple[type[Agent], ...] | None, + meta_attributes: dict[str, Any] | None = None, + meta_methods: dict[str, Callable] | None = None, + assume_constituting_agent_methods: bool = False, + assume_constituting_agent_attributes: bool = False, +) -> Any | None: + """Create or reuse a meta-agent instance without recording backend edges.""" + agents = _deduplicate_preserving_order(agents) + agent_bases = _normalize_agent_bases(mesa_agent_type) + resolved_attributes = _collect_inferred_attributes( + agents, + meta_attributes, + assume_constituting_agent_attributes, + ) + resolved_methods = _collect_meta_methods( + agents, + meta_methods, + assume_constituting_agent_methods, + ) + + meta_agent = _find_existing_meta_agent(agents, new_agent_class) + if meta_agent is not None: + _apply_meta_attributes(meta_agent, resolved_attributes) + _apply_meta_methods(meta_agent, resolved_methods) + meta_agent.add_constituting_agents(agents) + return meta_agent - if new_agent_class in agent_type_names: - return type(next(iter(agents_by_type[agent_type_names[new_agent_class]]))) - return None + agent_class = extract_class(model.agents_by_type, new_agent_class) + if agent_class is None: + agent_class = _build_meta_agent_class(new_agent_class, agent_bases) + + meta_agent = agent_class( + model, + agents, + initial_attributes=resolved_attributes, + ) + _apply_meta_attributes(meta_agent, resolved_attributes) + _apply_meta_methods(meta_agent, resolved_methods) + return meta_agent def create_meta_agent( model: Any, new_agent_class: str, agents: Iterable[Any], - mesa_agent_type: type[Agent] | None, + mesa_agent_type: type[Agent] | tuple[type[Agent], ...] | None, meta_attributes: dict[str, Any] | None = None, meta_methods: dict[str, Callable] | None = None, assume_constituting_agent_methods: bool = False, assume_constituting_agent_attributes: bool = False, ) -> Any | None: - """Create a new meta-agent class and instantiate agents. - - Parameters: - model (Any): The model instance. - new_agent_class (str): The name of the new meta-agent class. - agents (Iterable[Any]): The agents to be included in the meta-agent. - meta_attributes (Dict[str, Any]): Attributes to be added to the meta-agent. - meta_methods (Dict[str, Callable]): Methods to be added to the meta-agent. - assume_constituting_agent_methods (bool): Whether to assume methods from - constituting_-agents as meta_agent methods. - assume_constituting_agent_attributes (bool): Whether to retain attributes - from constituting_-agents. - - Returns: - - MetaAgent Instance - """ - # Convert agents to dict, to ensure uniqueness, - # we need a dict, not a set to keep stuff deterministic - agents = list(dict.fromkeys(agents).keys()) - - # Ensure there is at least one agent base class - if not mesa_agent_type: - mesa_agent_type = (Agent,) - elif not isinstance(mesa_agent_type, tuple): - mesa_agent_type = (mesa_agent_type,) - - def add_methods( - meta_agent_instance: Any, - agents: Iterable[Any], - meta_methods: dict[str, Callable] | None, - ) -> None: - """Add methods to the meta-agent instance. - - Parameters: - meta_agent_instance (Any): The meta-agent instance. - agents (Iterable[Any]): The agents to derive methods from. - meta_methods (Dict[str, Callable]): methods to be added to the meta-agent. - """ - resolved_meta_methods = dict(meta_methods or {}) - if assume_constituting_agent_methods: - agent_classes = dict.fromkeys(type(agent) for agent in agents) - for agent_class in agent_classes: - for name in agent_class.__dict__: - if callable(getattr(agent_class, name)) and not name.startswith( - "__" - ): - original_method = getattr(agent_class, name) - resolved_meta_methods.setdefault(name, original_method) - - for name, meth in resolved_meta_methods.items(): - bound_method = MethodType(meth, meta_agent_instance) - setattr(meta_agent_instance, name, bound_method) - - def add_attributes( - meta_agent_instance: Any, - agents: Iterable[Any], - meta_attributes: dict[str, Any], - ) -> None: - """Add attributes to the meta-agent instance. - - Parameters: - meta_agent_instance (Any): The meta-agent instance. - agents (Iterable[Any]): The agents to derive attributes from. - meta_attributes (Dict[str, Any]): Attributes to be added to the - meta-agent. - """ - # Prevent collision of attributes with meta-agent instantiation - mesa_primitives = [ - "unique_id", - "model", - "pos", - "name", - "random", - "rng", - "meta_agents", - "meta_agent", - "_constituting_set", - ] - - if assume_constituting_agent_attributes: - if meta_attributes is None: - # Initialize meta_attributes if not provided - meta_attributes = {} - for agent in agents: - for name, value in agent.__dict__.items(): - if ( - not callable(value) - and name not in mesa_primitives - and not name.startswith("_") - ): - meta_attributes[name] = value - - if meta_attributes is not None: - for key, value in meta_attributes.items(): - setattr(meta_agent_instance, key, value) - - # Path 1 - Add agents to existing meta-agent of the SAME CLASS if any exist - # This preserves the "singleton/unique group per class" behavior while allowing overlap between different classes - existing_meta_agents = [] - for a in agents: - if hasattr(a, "meta_agents"): - for ma in sorted(a.meta_agents, key=_unique_id_sort_key): - if ( - ma.__class__.__name__ == new_agent_class - and ma not in existing_meta_agents - ): - existing_meta_agents.append(ma) - - if len(existing_meta_agents) > 0: - # TODO: Add way for user to specify how agents join meta-agent - # instead of random choice if there are multiple meta-agents of the same class - meta_agent = ( - sorted(existing_meta_agents, key=_unique_id_sort_key)[0] - if len(existing_meta_agents) > 1 - else existing_meta_agents[0] - ) - add_attributes(meta_agent, agents, meta_attributes) - add_methods(meta_agent, agents, meta_methods) - meta_agent.add_constituting_agents(agents) - return meta_agent + """Legacy helper for creating a meta-agent instance. - else: - # Path 2 - Create a new instance of an existing meta-agent class - agent_class = extract_class(model.agents_by_type, new_agent_class) - - if agent_class: - # Pass initial_attributes to __init__ to handle CellAgent and other - # descriptor-based parent classes correctly (initialize before super().__init__()) - meta_agent_instance = agent_class( - model, agents, initial_attributes=meta_attributes - ) - # add_attributes() will handle inferred attributes if needed - add_attributes(meta_agent_instance, agents, meta_attributes) - add_methods(meta_agent_instance, agents, meta_methods) - return meta_agent_instance - else: - # Path 3 - Create a new meta-agent class - meta_agent_class = type( - new_agent_class, - (MetaAgent, *mesa_agent_type), # Inherit Mesa Agent Classes - { - "unique_id": None, - "_constituting_set": None, - }, - ) - # Pass initial_attributes to __init__ to handle CellAgent and other - # descriptor-based parent classes correctly (initialize before super().__init__()) - meta_agent_instance = meta_agent_class( - model, agents, initial_attributes=meta_attributes - ) - # add_attributes() will handle inferred attributes if needed - add_attributes(meta_agent_instance, agents, meta_attributes) - add_methods(meta_agent_instance, agents, meta_methods) - return meta_agent_instance + This function preserves the historical API and object-level compatibility + mirrors. It does not own canonical membership bookkeeping; use + ``MetaAgents.create`` when memberships should be recorded in a + ``MembershipBackend``. + """ + return _create_meta_agent_instance( + model, + new_agent_class, + agents, + mesa_agent_type, + meta_attributes=meta_attributes, + meta_methods=meta_methods, + assume_constituting_agent_methods=assume_constituting_agent_methods, + assume_constituting_agent_attributes=assume_constituting_agent_attributes, + ) class MetaAgent(Agent): - """A MetaAgent is an agent that contains other agents as components.""" + """An agent composed of other agents. + + ``MetaAgent`` keeps the live object relationship needed by existing models. + Canonical typed membership storage lives in the backend/facade layer. + """ def __init__( self, model, - agents: set[Agent] | None = None, + agents: Iterable[Agent] | None = None, name: str = "MetaAgent", initial_attributes: dict[str, Any] | None = None, ): - """Create a new MetaAgent. - - Args: - model: The model instance. - agents (Optional[set[Agent]], optional): The set of agents to - include in the MetaAgent. Defaults to None. - name (str, optional): The name of the MetaAgent. Defaults to "MetaAgent". - initial_attributes (Optional[dict], optional): Attributes to set before - calling super().__init__(). This is important for CellAgent and other - descriptor-based parent classes. Defaults to None. - """ - # Apply initial attributes BEFORE calling super().__init__() - # This is important for CellAgent and other descriptors + """Create a meta-agent from an optional iterable of component agents.""" if initial_attributes: for key, value in initial_attributes.items(): object.__setattr__(self, key, value) @@ -347,67 +331,41 @@ def __init__( self._constituting_set = AgentSet(agents or [], random=model.random) self.name = name - # Add ref to meta_agent in constituting_agents for agent in self._constituting_set: - if not hasattr(agent, "meta_agents"): - agent.meta_agents = set() - agent.meta_agents.add(self) - # Maintain backward compatibility — always pick lowest unique_id - agent.meta_agent = sorted(agent.meta_agents, key=_unique_id_sort_key)[0] + _attach_meta_agent(agent, self) def __len__(self) -> int: - """Return the number of components.""" + """Return the number of component agents.""" return len(self._constituting_set) def __iter__(self): - """Iterate over components.""" + """Iterate over component agents.""" return iter(self._constituting_set) def __contains__(self, agent: Agent) -> bool: - """Check if an agent is a component.""" + """Return whether ``agent`` is a component of this meta-agent.""" return agent in self._constituting_set @property def agents(self) -> AgentSet: - """Get list of Meta-Agent constituting_agents.""" + """Return the component agents.""" return self._constituting_set @property def constituting_agents_by_type(self) -> dict[type, list[Agent]]: - """Get the constituting_agents grouped by type. - - Returns: - dict[type, list[Agent]]: A dictionary of constituting_agents grouped by type. - """ + """Return component agents grouped by their concrete Python type.""" constituting_agents_by_type = {} for agent in self._constituting_set: - agent_type = type(agent) - if agent_type not in constituting_agents_by_type: - constituting_agents_by_type[agent_type] = [] - constituting_agents_by_type[agent_type].append(agent) + constituting_agents_by_type.setdefault(type(agent), []).append(agent) return constituting_agents_by_type @property def constituting_agent_types(self) -> set[type]: - """Get the types of all constituting_agents. - - Returns: - set[type]: A set of unique types of the constituting_agents. - """ + """Return the set of component agent types.""" return {type(agent) for agent in self._constituting_set} def get_constituting_agent_instance(self, agent_type) -> Agent: - """Get the instance of a constituting_agent of the specified type. - - Args: - agent_type: The type of the constituting_agent to retrieve. - - Returns: - The first instance of the specified constituting_agent type. - - Raises: - ValueError: If no constituting_agent of the specified type is found. - """ + """Return the first component agent of ``agent_type``.""" try: return self.constituting_agents_by_type[agent_type][0] except KeyError: @@ -415,53 +373,31 @@ def get_constituting_agent_instance(self, agent_type) -> Agent: f"No constituting_agent of type {agent_type} found." ) from None - def add_constituting_agents( - self, - new_agents: set[Agent], - ): - """Add agents as components. - - Args: - new_agents (set[Agent]): The agents to add to MetaAgent constituting_set. - """ + def add_constituting_agents(self, new_agents: Iterable[Agent]) -> None: + """Add component agents and update legacy compatibility mirrors.""" for agent in new_agents: self._constituting_set.add(agent) - if not hasattr(agent, "meta_agents"): - agent.meta_agents = set() - agent.meta_agents.add(self) - # Maintain backward compatibility — always pick lowest unique_id - agent.meta_agent = sorted(agent.meta_agents, key=_unique_id_sort_key)[0] - - def remove_constituting_agents(self, remove_agents: set[Agent]): - """Remove agents as components. - - Args: - remove_agents (set[Agent]): The agents to remove. - """ + _attach_meta_agent(agent, self) + + def remove_constituting_agents(self, remove_agents: Iterable[Agent]) -> None: + """Remove component agents and update legacy compatibility mirrors.""" for agent in remove_agents: self._constituting_set.discard(agent) - if hasattr(agent, "meta_agents"): - agent.meta_agents.discard(self) - # Update backward compatibility attribute deterministically - if len(agent.meta_agents) > 0: - agent.meta_agent = sorted( - agent.meta_agents, key=_unique_id_sort_key - )[0] - else: - agent.meta_agent = None + _detach_meta_agent(agent, self) def remove(self) -> None: - """Remove the MetaAgent from the model and clean up constituent references. - - Clears ``meta_agents`` and ``meta_agent`` on every constituent agent - before deregistering so no stale references remain. - """ + """Remove this meta-agent and clear live references from components.""" self.remove_constituting_agents(set(self._constituting_set)) super().remove() - def step(self): - """Perform the agent's step. + def step(self) -> None: + """Default meta-agent behavior.""" + - Override this method to define the meta agent's behavior. - By default, does nothing. - """ +__all__ = [ + "MetaAgent", + "create_meta_agent", + "evaluate_combination", + "extract_class", + "find_combinations", +] diff --git a/mesa/experimental/meta_agents/meta_agents_api.py b/mesa/experimental/meta_agents/meta_agents_api.py index 2844c50308a..1926a154249 100644 --- a/mesa/experimental/meta_agents/meta_agents_api.py +++ b/mesa/experimental/meta_agents/meta_agents_api.py @@ -9,7 +9,7 @@ from mesa.agent import Agent from .backend import MembershipBackend, RelationKey, Triplet -from .meta_agent import create_meta_agent +from .meta_agent import _create_meta_agent_instance @dataclass(frozen=True, slots=True) @@ -146,7 +146,7 @@ def create( if member_relations is not None and not agents: agents = [member for member, _ in member_relations] - meta_agent = create_meta_agent( + meta_agent = _create_meta_agent_instance( self.model, new_agent_class, agents, From 99c43b99b64d2b28456e6b8b3d0b44c096c2dac9 Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Fri, 24 Jul 2026 09:42:08 +0530 Subject: [PATCH 20/22] remove facade --- mesa/experimental/meta_agents/__init__.py | 3 +-- mesa/experimental/meta_agents/facade.py | 12 ------------ mesa/experimental/meta_agents/meta_agents_api.py | 5 ----- tests/experimental/test_meta_agents_api.py | 14 -------------- 4 files changed, 1 insertion(+), 33 deletions(-) delete mode 100644 mesa/experimental/meta_agents/facade.py diff --git a/mesa/experimental/meta_agents/__init__.py b/mesa/experimental/meta_agents/__init__.py index 6736f9ef890..3f9d0c8b33f 100644 --- a/mesa/experimental/meta_agents/__init__.py +++ b/mesa/experimental/meta_agents/__init__.py @@ -7,14 +7,13 @@ evaluate_combination, find_combinations, ) -from .meta_agents_api import MembershipEdge, MembershipView, MetaAgentFacade, MetaAgents +from .meta_agents_api import MembershipEdge, MembershipView, MetaAgents __all__ = [ "MembershipBackend", "MembershipEdge", "MembershipView", "MetaAgent", - "MetaAgentFacade", "MetaAgents", "create_meta_agent", "evaluate_combination", diff --git a/mesa/experimental/meta_agents/facade.py b/mesa/experimental/meta_agents/facade.py deleted file mode 100644 index d46b24ccfc2..00000000000 --- a/mesa/experimental/meta_agents/facade.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Legacy compatibility shim for the experimental meta-agents API.""" - -from .meta_agents_api import MembershipEdge, MembershipView, MetaAgents - -MetaAgentFacade = MetaAgents - -__all__ = [ - "MembershipEdge", - "MembershipView", - "MetaAgentFacade", - "MetaAgents", -] diff --git a/mesa/experimental/meta_agents/meta_agents_api.py b/mesa/experimental/meta_agents/meta_agents_api.py index 2844c50308a..af0d162ee4d 100644 --- a/mesa/experimental/meta_agents/meta_agents_api.py +++ b/mesa/experimental/meta_agents/meta_agents_api.py @@ -226,13 +226,8 @@ def deactivate(self, entity: Hashable) -> MembershipView: return self._detach_entity(entity) -# Legacy compatibility alias for the transition period. -MetaAgentFacade = MetaAgents - - __all__ = [ "MembershipEdge", "MembershipView", - "MetaAgentFacade", "MetaAgents", ] diff --git a/tests/experimental/test_meta_agents_api.py b/tests/experimental/test_meta_agents_api.py index 2b568c30589..863d3abae74 100644 --- a/tests/experimental/test_meta_agents_api.py +++ b/tests/experimental/test_meta_agents_api.py @@ -4,7 +4,6 @@ from mesa.experimental.meta_agents import ( MembershipEdge, MembershipView, - MetaAgentFacade, MetaAgents, ) @@ -35,19 +34,6 @@ def test_meta_agents_create_records_backend_memberships(): assert view.memberships[0].group is meta_agent -def test_meta_agents_compatibility_alias_preserves_old_name(): - """The old class name should still resolve to the new API.""" - assert MetaAgentFacade is MetaAgents - - model = Model() - alias = MetaAgentFacade(model) - agent = Agent(model) - group = alias.create("Group", [agent], Agent) - - assert group is not None - assert alias.backend.as_triplets() == {(agent.unique_id, group.unique_id, "member")} - - def test_meta_agents_remove_member_preserves_overlapping_memberships(): """Removing one relation should keep unrelated memberships intact.""" model = Model() From 020e4db64e7053100bf16bd766959d26d96bd3f0 Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Fri, 31 Jul 2026 20:01:08 +0530 Subject: [PATCH 21/22] fix alliance model agent stacking --- mesa/examples/advanced/alliance_formation/app.py | 13 +++++++++++-- mesa/examples/advanced/alliance_formation/model.py | 13 ++++++++----- tests/examples/test_alliance_formation_model.py | 13 +++++++++++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/mesa/examples/advanced/alliance_formation/app.py b/mesa/examples/advanced/alliance_formation/app.py index 147efb672ad..3dd25c47a9b 100644 --- a/mesa/examples/advanced/alliance_formation/app.py +++ b/mesa/examples/advanced/alliance_formation/app.py @@ -24,6 +24,14 @@ "max": 100, "step": 1, }, + "std_dev": { + "type": "SliderFloat", + "value": 0.1, + "label": "Attribute variation:", + "min": 0.01, + "max": 0.4, + "step": 0.01, + }, } # Create visualization elements. The visualization elements are solara components @@ -37,12 +45,12 @@ def plot_network(model): update_counter.get() g = model.network - pos = nx.fruchterman_reingold_layout(g) + pos = nx.multipartite_layout(g, subset_key="level", align="horizontal") fig = Figure() ax = fig.subplots() labels = {agent.unique_id: agent.unique_id for agent in model.agents} node_sizes = [g.nodes[node]["size"] for node in g.nodes] - node_colors = [g.nodes[node]["size"] for node in g.nodes()] + node_colors = [g.nodes[node]["level"] for node in g.nodes()] nx.draw( g, @@ -53,6 +61,7 @@ def plot_network(model): labels=labels, ax=ax, ) + ax.set_axis_off() solara.FigureMatplotlib(fig) diff --git a/mesa/examples/advanced/alliance_formation/model.py b/mesa/examples/advanced/alliance_formation/model.py index b9defe2d576..bfa1d5ff144 100644 --- a/mesa/examples/advanced/alliance_formation/model.py +++ b/mesa/examples/advanced/alliance_formation/model.py @@ -162,11 +162,14 @@ def step(self): """ Execute one step of the model. """ - # Get all other agents of the same type - agent_types = list(self.agents_by_type.keys()) - - for agent_type in agent_types: - similar_agents = self.agents_by_type[agent_type] + # Agents at the same hierarchy level can form an alliance. Meta-agents + # use dynamically generated classes, so grouping by concrete type would + # isolate every meta-agent and prevent higher-level alliances. + agents_by_level = {} + for agent in self.agents: + agents_by_level.setdefault(agent.level, []).append(agent) + + for similar_agents in agents_by_level.values(): # Find the best combinations using find_combinations if ( diff --git a/tests/examples/test_alliance_formation_model.py b/tests/examples/test_alliance_formation_model.py index 3b49269db4c..06860eb881b 100644 --- a/tests/examples/test_alliance_formation_model.py +++ b/tests/examples/test_alliance_formation_model.py @@ -43,3 +43,16 @@ def fake_find_combinations(*args, **kwargs): assert backend.as_triplets() == expected_triplets backend.assert_invariants() + + +def test_alliance_model_forms_higher_level_alliances(): + """Meta-agents at one level can form an alliance at the next level.""" + model = MultiLevelAllianceModel( + scenario=AllianceScenario(n=4, mean=0.5, std_dev=0.0, rng=42) + ) + + model.step() + assert sum(agent.level == 1 for agent in model.agents) == 2 + + model.step() + assert sum(agent.level == 2 for agent in model.agents) == 1 From d1d2294cb317b9292ecdc54f97e0ae09d9682025 Mon Sep 17 00:00:00 2001 From: Abhishek Shinde Date: Fri, 31 Jul 2026 20:05:09 +0530 Subject: [PATCH 22/22] ruff --- mesa/examples/advanced/alliance_formation/model.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mesa/examples/advanced/alliance_formation/model.py b/mesa/examples/advanced/alliance_formation/model.py index bfa1d5ff144..8d0caa655cd 100644 --- a/mesa/examples/advanced/alliance_formation/model.py +++ b/mesa/examples/advanced/alliance_formation/model.py @@ -170,7 +170,6 @@ def step(self): agents_by_level.setdefault(agent.level, []).append(agent) for similar_agents in agents_by_level.values(): - # Find the best combinations using find_combinations if ( len(similar_agents) > 1