Skip to content

Multi-Agent Constraints

Here, we consider constraints and model interfaces applicable to multi-agent environments.

Labelled parallel environments

LabelledParallelEnv attaches proposition labels to each agent's info mapping without changing observations, rewards, or actions.

masa.common.multi_agent.LabelledParallelEnv

LabelledParallelEnv(env: ParallelEnv, label_fn: Dict[str, LabelFn] | LabelFn)

Bases: ParallelEnv

PettingZoo parallel API wrapper that attaches the per-agent labelling function.

Source code in masa/common/multi_agent/labelled_pz_env.py
def __init__(self, env: ParallelEnv, label_fn: Dict[str, LabelFn] | LabelFn):
    self.env = env
    self.metadata = getattr(env, "metadata", self.metadata)
    self.agents = list(getattr(env, "agents", env.possible_agents))
    self.label_fn = label_fn
    self.cost_fn = getattr(env, "cost_fn", None)

metadata class-attribute instance-attribute

metadata = getattr(env, 'metadata', self.metadata)

env instance-attribute

env = env

agents instance-attribute

agents = list(getattr(env, 'agents', env.possible_agents))

label_fn instance-attribute

label_fn = label_fn

cost_fn instance-attribute

cost_fn = getattr(env, 'cost_fn', None)

possible_agents property

possible_agents

__getattr__

__getattr__(name: str)
Source code in masa/common/multi_agent/labelled_pz_env.py
def __getattr__(self, name: str):
    if name == "env":
        raise AttributeError(name)
    return getattr(self.env, name)

reset

reset(seed: int | None = None, options: Dict[str, Any] | None = None)
Source code in masa/common/multi_agent/labelled_pz_env.py
def reset(self, seed: int | None = None, options: Dict[str, Any] | None = None):
    obs, info = self.env.reset(seed=seed, options=options)
    self.agents = list(getattr(self.env, "agents", self.possible_agents))
    for a in obs:
        lf = self.label_fn[a] if isinstance(self.label_fn, dict) else self.label_fn
        agent_info = info.setdefault(a, {})
        if agent_info is None:
            agent_info = {}
            info[a] = agent_info
        agent_info["labels"] = set(lf(obs[a]))
    return obs, info

step

step(actions)
Source code in masa/common/multi_agent/labelled_pz_env.py
def step(self, actions):
    obs, rewards, term, trunc, infos = self.env.step(actions)
    self.agents = list(getattr(self.env, "agents", self.possible_agents))
    for a in obs:
        lf = self.label_fn[a] if isinstance(self.label_fn, dict) else self.label_fn
        labels = set(lf(obs[a]))
        agent_info = infos.setdefault(a, {})
        if agent_info is None:
            agent_info = {}
            infos[a] = agent_info
        agent_info["labels"] = labels

    return obs, rewards, term, trunc, infos

observation_space

observation_space(agent)
Source code in masa/common/multi_agent/labelled_pz_env.py
def observation_space(self, agent):
    return self.env.observation_space(agent)

action_space

action_space(agent)
Source code in masa/common/multi_agent/labelled_pz_env.py
def action_space(self, agent):
    return self.env.action_space(agent)

state

state()
Source code in masa/common/multi_agent/labelled_pz_env.py
def state(self):
    return self.env.state()

render

render()
Source code in masa/common/multi_agent/labelled_pz_env.py
def render(self):
    return self.env.render()

close

close()
Source code in masa/common/multi_agent/labelled_pz_env.py
def close(self):
    return self.env.close()

Coalitions

A Coalition is an order-independent set of focal agents. Algorithms obtain the canonical joint-action order from the wrapped environment's possible_agents.

masa.common.multi_agent.Coalition dataclass

Coalition(agents: tuple[str, ...], name: str | None = None)

A non-empty set of agents with an optional display name.

Coalition membership is order-independent. Algorithms should call ordered with an environment's possible_agents to obtain the canonical tuple used for joint-action encoding.

agents instance-attribute

agents: tuple[str, ...]

name class-attribute instance-attribute

name: str | None = field(default=None, compare=False)

__post_init__

__post_init__() -> None
Source code in masa/common/multi_agent/coalition.py
def __post_init__(self) -> None:
    if isinstance(self.agents, (str, bytes)):
        raise TypeError("Coalition agents must be a sequence of agent names.")
    agents = tuple(self.agents)
    if not agents:
        raise ValueError("A coalition must contain at least one agent.")
    if any(not isinstance(agent, str) or not agent for agent in agents):
        raise TypeError("Coalition agents must be non-empty strings.")
    if len(set(agents)) != len(agents):
        raise ValueError("Coalition agents must be unique.")
    if self.name is not None and (
        not isinstance(self.name, str) or not self.name
    ):
        raise TypeError("Coalition name must be a non-empty string or None.")
    # Store a canonical order so equality and hashing follow set membership;
    # the optional display name is not part of coalition identity.
    object.__setattr__(self, "agents", tuple(sorted(agents)))

ordered

ordered(possible_agents: Sequence[str]) -> tuple[str, ...]

Return members in the environment's canonical agent order.

Source code in masa/common/multi_agent/coalition.py
def ordered(self, possible_agents: Sequence[str]) -> tuple[str, ...]:
    """Return members in the environment's canonical agent order."""
    possible = tuple(possible_agents)
    if len(set(possible)) != len(possible):
        raise ValueError("possible_agents must be unique.")
    members = set(self.agents)
    unknown = members - set(possible)
    if unknown:
        raise ValueError(
            f"Coalition contains unknown agents: {sorted(unknown)}."
        )
    return tuple(agent for agent in possible if agent in members)

__contains__

__contains__(agent: str) -> bool
Source code in masa/common/multi_agent/coalition.py
def __contains__(self, agent: str) -> bool:
    return agent in self.agents

Finite parallel environments

TabularParallelEnv is the multi-agent counterpart of the single-agent TabularEnv. It adds an enumerable finite state and joint-transition-support contract to PettingZoo's Parallel API. Labels remain the responsibility of LabelledParallelEnv.

masa.envs.multiagent.TabularParallelEnv

TabularParallelEnv()

Bases: ParallelEnv

Parallel environment with an enumerable finite-state transition model.

Subclasses set _n_states and expose either:

  • _transition_matrix[next_state, state, joint_action_index]; or
  • _successor_states[state] together with _transition_probs[state, joint_action_index].

Joint-action indices use the Cartesian-product order induced by possible_agents and their zero-based discrete action spaces. For example, two binary agents use (0, 0), (0, 1), (1, 0), (1, 1).

get_state_id() returns the current tabular state. By default this reads self._state. observations_from_state() reconstructs the per-agent observations used by LabelledParallelEnv when synthesising an LTL product. The default implementation supports the common case where every agent directly observes the same discrete state ID.

The class deliberately does not define rewards, labels, reset, or step. It only standardises the finite game model needed by planning and shielding.

Source code in masa/envs/multiagent/tabular_env.py
def __init__(self) -> None:
    super().__init__()
    self._n_states: int | None = None
    self._transition_matrix: np.ndarray | None = None
    self._successor_states: Mapping[int, Sequence[int]] | None = None
    self._transition_probs: Mapping[tuple[int, int], Sequence[float]] | None = None
    self._state: int | None = None

_n_states instance-attribute

_n_states: int | None = None

_transition_matrix instance-attribute

_transition_matrix: ndarray | None = None

_successor_states instance-attribute

_successor_states: Mapping[int, Sequence[int]] | None = None

_transition_probs instance-attribute

_transition_probs: Mapping[tuple[int, int], Sequence[float]] | None = None

_state instance-attribute

_state: int | None = None

n_states property

n_states: int

Number of finite model states.

action_sizes property

action_sizes: dict[str, int]

Primitive action count for each agent in possible_agents order.

n_joint_actions property

n_joint_actions: int

Size of the full Cartesian joint-action space.

has_transition_matrix property

has_transition_matrix: bool

has_successor_states_dict property

has_successor_states_dict: bool

get_transition_matrix

get_transition_matrix() -> np.ndarray | None

Return P[next_state, state, joint_action_index], when available.

Source code in masa/envs/multiagent/tabular_env.py
def get_transition_matrix(self) -> np.ndarray | None:
    """Return ``P[next_state, state, joint_action_index]``, when available."""
    return self._transition_matrix

get_successor_states_dict

get_successor_states_dict() -> tuple[Mapping[int, Sequence[int]], Mapping[tuple[int, int], Sequence[float]]] | None

Return the sparse state-successor/probability representation.

Probability vectors are aligned with successor_states[state] and keyed by (state, joint_action_index).

Source code in masa/envs/multiagent/tabular_env.py
def get_successor_states_dict(
    self,
) -> tuple[
    Mapping[int, Sequence[int]],
    Mapping[tuple[int, int], Sequence[float]],
] | None:
    """Return the sparse state-successor/probability representation.

    Probability vectors are aligned with ``successor_states[state]`` and
    keyed by ``(state, joint_action_index)``.
    """
    if not self.has_successor_states_dict:
        return None
    assert self._successor_states is not None
    assert self._transition_probs is not None
    return self._successor_states, self._transition_probs

_check_state

_check_state(state: int) -> int
Source code in masa/envs/multiagent/tabular_env.py
def _check_state(self, state: int) -> int:
    if not isinstance(state, Integral) or isinstance(state, bool):
        raise TypeError(f"State ID must be an integer, got {state!r}.")
    state = int(state)
    if not 0 <= state < self.n_states:
        raise ValueError(
            f"State ID {state} is outside [0, {self.n_states})."
        )
    return state

_joint_action_tuple

_joint_action_tuple(actions: Mapping[str, int] | Sequence[int]) -> JointAction
Source code in masa/envs/multiagent/tabular_env.py
def _joint_action_tuple(
    self, actions: Mapping[str, int] | Sequence[int]
) -> JointAction:
    agents = tuple(self.possible_agents)
    if isinstance(actions, Mapping):
        supplied = set(actions)
        expected = set(agents)
        if supplied != expected:
            raise ValueError(
                "Joint action must contain exactly possible_agents; "
                f"missing={sorted(expected - supplied)}, "
                f"extra={sorted(supplied - expected)}."
            )
        raw = tuple(actions[agent] for agent in agents)
    else:
        if isinstance(actions, (str, bytes)):
            raise TypeError("Joint action must be a mapping or action sequence.")
        raw = tuple(actions)
        if len(raw) != len(agents):
            raise ValueError(
                f"Expected {len(agents)} primitive actions, got {len(raw)}."
            )

    sizes = self.action_sizes
    result: list[int] = []
    for agent, primitive in zip(agents, raw):
        if not isinstance(primitive, Integral) or isinstance(primitive, bool):
            raise TypeError(
                f"Action for {agent!r} must be an integer, got {primitive!r}."
            )
        primitive = int(primitive)
        if not 0 <= primitive < sizes[agent]:
            raise ValueError(
                f"Action {primitive} is outside the action space of {agent!r}."
            )
        result.append(primitive)
    return tuple(result)

encode_joint_action

encode_joint_action(actions: Mapping[str, int] | Sequence[int]) -> int

Encode a full joint action in canonical Cartesian-product order.

Source code in masa/envs/multiagent/tabular_env.py
def encode_joint_action(
    self, actions: Mapping[str, int] | Sequence[int]
) -> int:
    """Encode a full joint action in canonical Cartesian-product order."""
    action = self._joint_action_tuple(actions)
    index = 0
    for primitive, size in zip(action, self.action_sizes.values()):
        index = index * size + primitive
    return int(index)

decode_joint_action

decode_joint_action(index: int) -> dict[str, int]

Decode a canonical joint-action index to an agent-action mapping.

Source code in masa/envs/multiagent/tabular_env.py
def decode_joint_action(self, index: int) -> dict[str, int]:
    """Decode a canonical joint-action index to an agent-action mapping."""
    if not isinstance(index, Integral) or isinstance(index, bool):
        raise TypeError("Joint-action index must be an integer.")
    index = int(index)
    if not 0 <= index < self.n_joint_actions:
        raise ValueError(
            f"Joint-action index {index} is outside [0, {self.n_joint_actions})."
        )

    sizes = tuple(self.action_sizes.values())
    primitives = [0] * len(sizes)
    remainder = index
    for position in range(len(sizes) - 1, -1, -1):
        remainder, primitives[position] = divmod(remainder, sizes[position])
    return dict(zip(self.possible_agents, primitives))

legal_actions

legal_actions(state: int, agent: str) -> tuple[int, ...]

Legal primitive actions for an agent in a model state.

Override for state-dependent action availability. The default permits the entire action space.

Source code in masa/envs/multiagent/tabular_env.py
def legal_actions(self, state: int, agent: str) -> tuple[int, ...]:
    """Legal primitive actions for an agent in a model state.

    Override for state-dependent action availability. The default permits the
    entire action space.
    """
    self._check_state(state)
    try:
        size = self.action_sizes[agent]
    except KeyError as exc:
        raise ValueError(f"Unknown agent: {agent!r}.") from exc
    return tuple(range(size))
get_legal_actions(state: int, agent: str) -> tuple[int, ...]

Return a validated, sorted copy of legal_actions.

Source code in masa/envs/multiagent/tabular_env.py
def get_legal_actions(self, state: int, agent: str) -> tuple[int, ...]:
    """Return a validated, sorted copy of :meth:`legal_actions`."""
    state = self._check_state(state)
    try:
        size = self.action_sizes[agent]
    except KeyError as exc:
        raise ValueError(f"Unknown agent: {agent!r}.") from exc
    raw = tuple(self.legal_actions(state, agent))
    if not raw:
        raise ValueError(
            f"Agent {agent!r} has no legal action in model state {state}."
        )
    if any(
        not isinstance(action, Integral)
        or isinstance(action, bool)
        or not 0 <= int(action) < size
        for action in raw
    ):
        raise ValueError(
            f"Invalid legal actions for {agent!r} in state {state}: {raw!r}."
        )
    return tuple(sorted(set(map(int, raw))))

successors

successors(state: int, actions: Mapping[str, int] | Sequence[int]) -> tuple[int, ...]

Return every non-zero-probability successor of a legal joint action.

Source code in masa/envs/multiagent/tabular_env.py
def successors(
    self,
    state: int,
    actions: Mapping[str, int] | Sequence[int],
) -> tuple[int, ...]:
    """Return every non-zero-probability successor of a legal joint action."""
    state = self._check_state(state)
    action = self._joint_action_tuple(actions)
    for agent, primitive in zip(self.possible_agents, action):
        if primitive not in self.get_legal_actions(state, agent):
            raise ValueError(
                f"Action {primitive} is not legal for {agent!r} in state {state}."
            )
    action_index = self.encode_joint_action(action)

    if self.has_successor_states_dict:
        sparse = self.get_successor_states_dict()
        assert sparse is not None
        successor_states, transition_probs = sparse
        ids = np.asarray(successor_states.get(state, ()))
        probs = np.asarray(
            transition_probs.get((state, action_index), ()),
            dtype=np.float64,
        )
    elif self.has_transition_matrix:
        matrix = np.asarray(self.get_transition_matrix())
        expected = (self.n_states, self.n_states, self.n_joint_actions)
        if matrix.shape != expected:
            raise ValueError(
                "Expected transition shape "
                f"(next_state, state, joint_action)={expected}, got {matrix.shape}."
            )
        ids = np.arange(self.n_states, dtype=np.intp)
        probs = np.asarray(matrix[:, state, action_index], dtype=np.float64)
    else:
        raise ValueError(
            "TabularParallelEnv must expose a transition matrix or sparse "
            "successor-state dictionaries."
        )

    if ids.ndim != 1 or (ids.size and ids.dtype.kind not in "iu"):
        raise ValueError(f"Successors of state {state} must be integer IDs.")
    ids = ids.astype(np.intp, copy=False)
    if np.any((ids < 0) | (ids >= self.n_states)):
        raise ValueError(f"Successor of state {state} is out of range.")
    if probs.shape != (ids.size,):
        raise ValueError(
            f"Probability vector for state {state}, joint action {action} "
            "has the wrong shape."
        )
    if not np.all(np.isfinite(probs)) or np.any(probs < 0):
        raise ValueError(
            f"Invalid probabilities at state {state}, joint action {action}."
        )
    if not np.isclose(probs.sum(), 1.0, rtol=1e-6, atol=1e-8):
        raise ValueError(
            f"Transition probabilities for state {state}, joint action "
            f"{action} must sum to 1."
        )

    # No probability threshold: every positive-probability outcome matters.
    result = tuple(sorted(set(map(int, ids[probs > 0]))))
    if not result:
        raise ValueError(
            f"Missing transition support for state {state}, joint action {action}."
        )
    return result

get_state_id

get_state_id() -> int

Return the current finite model-state ID.

Subclasses with a non-integer runtime representation may override this, but should still return the exact state indexing the transition model.

Source code in masa/envs/multiagent/tabular_env.py
def get_state_id(self) -> int:
    """Return the current finite model-state ID.

    Subclasses with a non-integer runtime representation may override this,
    but should still return the exact state indexing the transition model.
    """
    if self._state is None:
        raise RuntimeError("The environment has no active tabular state.")
    return self._check_state(self._state)

observations_from_state

observations_from_state(state: int) -> dict[str, Any]

Return each possible agent's observation for a finite model state.

The default supports fully observed environments where every agent's observation is the same zero-based Discrete(n_states) state ID. Environments with structured or local observations should override it.

Source code in masa/envs/multiagent/tabular_env.py
def observations_from_state(self, state: int) -> dict[str, Any]:
    """Return each possible agent's observation for a finite model state.

    The default supports fully observed environments where every agent's
    observation is the same zero-based ``Discrete(n_states)`` state ID.
    Environments with structured or local observations should override it.
    """
    state = self._check_state(state)
    observations: dict[str, Any] = {}
    for agent in self.possible_agents:
        space = self.observation_space(agent)
        if (
            not isinstance(space, spaces.Discrete)
            or int(space.start) != 0
            or int(space.n) != self.n_states
        ):
            raise NotImplementedError(
                f"{type(self).__name__}.observations_from_state() must be "
                "implemented for structured or non-state observations."
            )
        observations[agent] = state
    return observations

The coalition shielding wrapper expects this stack:

TabularParallelEnv -> LabelledParallelEnv -> CoalitionLTLShield

See Multi-agent coalition safety-game shielding for synthesis and execution semantics.