Skip to content

Multi-agent coalition safety-game shielding

CoalitionLTLShield extends MASA's winning-region shielding to finite PettingZoo Parallel environments. It protects a selected coalition against every legal action of agents outside the coalition and every supported stochastic outcome.

The environment stack is:

TabularParallelEnv -> LabelledParallelEnv -> CoalitionLTLShield

TabularParallelEnv owns the finite game dynamics. LabelledParallelEnv supplies proposition labels. The shield owns the DFA product, safety-game solution, and runtime enforcement. No separate game-model object is required.

The wrapper supports two independent choices:

Setting Options Meaning
mode preemptive, postposed Reject an unsafe proposal before stepping, or replace it.
execution centralised, decentralised Select one coalition joint action, or let members choose independently from certified local masks.

Both execution modes use the same bad-prefix safety DFA and robust coalition winning region.

Concurrent safety game

Let \(C\) be the focal coalition and \(-C\) its complement. At product state \(x=(q,s)\), coalition controllability is

\[ \operatorname{CPre}_C(W)= \left\{ x: \exists a_C\; \forall a_{-C}\; \forall x'\in\operatorname{Succ}(x,a_C,a_{-C}), \quad x'\in W \right\}. \]

The winning region is the greatest fixed point

\[ W_C=\nu W.\left(\operatorname{Safe}\cap\operatorname{CPre}_C(W)\right). \]

MASA implements the universal complement quantifier by taking the union of all successor supports induced by a fixed coalition action and every legal complement action. The existing winning_region() solver can then be reused: its controllable "action" is a coalition tuple, and its support already contains every outsider action and every environment outcome.

The quantifier order is important. The coalition chooses one action that works against every simultaneous complement action. It cannot observe an outsider's current action and choose a response afterwards.

Required environment interface

The base environment must subclass TabularParallelEnv. Like the existing single-agent TabularEnv, it exposes either a dense transition matrix or sparse successor/probability dictionaries.

A subclass sets self._n_states and one of:

# Dense model
self._transition_matrix[next_state, state, joint_action_index]

# Sparse model
self._successor_states[state]
self._transition_probs[state, joint_action_index]

Joint-action indices follow the Cartesian-product order induced by possible_agents and the agents' zero-based Discrete action spaces. The class provides encode_joint_action() and decode_joint_action().

The environment also maintains its exact current finite ID in self._state, or overrides get_state_id(). Environments with structured or local observations override observations_from_state(state) so the labelled wrapper's existing labelling functions can be evaluated for every hypothetical model state.

State-dependent action availability is represented by overriding legal_actions(state, agent). Missing transition support for a legal full joint action is an error; it cannot silently remove an adversarial action.

from masa.common.multi_agent import Coalition, LabelledParallelEnv
from masa.deterministic_shield import CoalitionLTLShield
from masa.envs.multiagent.matrix.chicken import ChickenMatrix, label_fn
from masa.examples.chicken_safety_game import make_never_crash_dfa

env = CoalitionLTLShield(
    LabelledParallelEnv(ChickenMatrix(), label_fn),
    coalition=Coalition(("player_0",)),
    dfa=make_never_crash_dfa(),
    mode="preemptive",
    execution="centralised",
)

By default, the shield unions labels produced for every possible agent. Supply a label_combiner when the shared DFA uses a different alphabet—for example, agent-namespaced propositions.

Centralised execution

One controller selects the complete coalition action. The permitted relation is

\[ A_C^{\mathrm{safe}}(x)= \left\{ a_C: \forall a_{-C}, \operatorname{Succ}(x,a_C,a_{-C})\subseteq W_C \right\}. \]

A central relation need not be Cartesian. It may permit (left, left) and (right, right) while excluding both mismatched pairs because one controller selects the entire tuple.

from masa.deterministic_shield import random_safe

env = CoalitionLTLShield(
    LabelledParallelEnv(ChickenMatrix(), label_fn),
    coalition=("player_0", "player_1"),
    dfa=make_never_crash_dfa(),
    mode="postposed",
    execution="centralised",
    replacement=random_safe(seed=7),
)

observations, infos = env.reset(seed=0)
mask = env.coalition_action_mask()
# mask[i] corresponds to env.coalition_actions[i].

A centralised replacement callback receives a coalition-action index, not a primitive action ID. decode_coalition_action() and encode_coalition_action() convert between indices and per-agent mappings. Actions of agents outside the coalition are never changed.

Decentralised execution

Coalition members choose simultaneously and cannot condition on teammates' current choices. Teammates are not treated as adversaries: each member may rely on the others obeying their certified local masks. Agents outside the coalition remain unrestricted.

For local masks \(M_i(x)\), MASA certifies

\[ \varnothing\neq M_i(x) \]

for each coalition member and

\[ \prod_{i\in C}M_i(x) \subseteq A_C^{\mathrm{safe}}(x). \]

Every combination of permitted local actions is therefore safe against every complement action and supported successor. An action that is safe only through runtime coordination is not exposed independently.

Why marginal projection is unsound

Suppose the central relation is

\[ \{(L,L),(R,R)\}. \]

Projecting it onto each agent gives both agents {L, R}, whose Cartesian product also contains unsafe (L, R) and (R, L). A valid decentralised interface must choose a Cartesian subset, such as {L} x {L}.

MASA tries every safe tuple as a singleton seed, greedily expands the local masks while preserving Cartesian closure, and keeps the candidate admitting the most joint profiles. The result is sound and deterministic, but is not claimed to be a globally maximum rectangle. Equally permissive choices may be asymmetric because ties use canonical agent/action order.

env = CoalitionLTLShield(
    LabelledParallelEnv(ChickenMatrix(), label_fn),
    coalition=("player_0", "player_1"),
    dfa=make_never_crash_dfa(),
    mode="preemptive",
    execution="decentralised",
)

observations, infos = env.reset(seed=0)
player_0_mask = env.local_action_mask("player_0")
player_1_mask = env.local_action_mask("player_1")

For postposed decentralised execution, provide a separate replacement callback per coalition member. A callback receives only the shared product state, that agent's proposal, and that agent's local mask. It does not receive teammates' simultaneous proposals.

replacement = {
    "player_0": random_safe(seed=10),
    "player_1": random_safe(seed=11),
}

Temporal and runtime semantics

The reset observation's labels are consumed once. On each environment transition, the DFA consumes labels of the successor state:

\[ (q,s)\xrightarrow{a} \left(\delta(q,L(s')),s'\right). \]

The wrapper validates that:

  • the live finite state is a supported successor of the executed full joint action;
  • reconstructed model observations induce the same labels as live observations;
  • the successor product state remains winning;
  • all agents remain active until a simultaneous termination or truncation.

A runtime mismatch is detected only after the environment has stepped and cannot undo an unsafe transition. The safety guarantee therefore depends on a correct bad-prefix DFA, fixed labels, and transition support containing every possible real outcome.

TabularParallelEnv may represent stochastic dynamics. "Deterministic shielding" means the property is enforced without an allowed violation probability; a postposed selector may still randomly choose among already-safe actions.

Runtime interface

Centralised methods:

coalition_action_mask()
robust_coalition_action_mask()
safe_coalition_actions()
encode_coalition_action(...)
decode_coalition_action(...)

Decentralised methods:

local_action_mask(agent)
local_action_masks()
coalition_action_mask()  # the selected Cartesian subset

Coalition-agent infos include the product state, DFA state, shared labels, mode, execution type, and coalition identity. Centralised infos include a joint-action mask. Decentralised infos include each member's local mask. Postposed steps also report proposed and executed primitive and coalition actions.

Limitations

  • The implementation targets PettingZoo's Parallel API, not AEC timing.
  • State and action spaces must be finite; primitive actions are zero-based Discrete values.
  • The base environment must provide complete transition support.
  • Dynamic agent populations and asynchronous per-agent termination are not supported.
  • Decentralised members must identify the same global tabular and DFA state. This is not a partial-observation synthesis method.
  • As standard with safety-game based shielding, the DFA accepting states must denote bad prefixes; general liveness objectives are outside this shield.
  • Joint-action enumeration grows exponentially with the number of agents.
  • You'd likely want to read further on shield decentralisation techniques if this becomes an issue in practice.

API reference

masa.deterministic_shield.CoalitionLTLShield

CoalitionLTLShield(env: LabelledParallelEnv, *, coalition: Coalition | Sequence[str], dfa: DFA, mode: ShieldMode = 'preemptive', execution: str = 'centralised', replacement: ReplacementSpec = None, label_combiner: LabelCombiner | None = None)

Bases: ParallelEnv

Shield a coalition against every action of its complement.

This wrapper must directly wrap LabelledParallelEnv(TabularParallelEnv). dfa.accepting must contain bad-prefix states. The tabular transition model must include every real successor with non-zero probability.

execution='centralised' exposes or repairs a coalition joint action. execution='decentralised' exposes one local mask per coalition member. Those masks form a certified Cartesian rectangle: coalition members may choose simultaneously without observing one another's current choice. They rely on teammates respecting their masks; agents outside the coalition are treated as unrestricted adversaries and are never modified by the shield.

mode='preemptive' rejects unsafe proposals before stepping. In mode='postposed' unsafe proposals are replaced. A centralised replacement operates on a coalition-action index. Decentralised replacements operate independently per agent and must be supplied as separate callbacks in a mapping when the coalition contains more than one agent.

The wrapper leaves observations unchanged. It publishes product/DFA state and masks through methods and per-coalition-agent infos. Separately deployed coalition members must reconstruct the same global model state and DFA state. This implementation does not solve partial-observation shielding.

Agent membership must remain fixed during an episode, and all agents must end an episode together. The transition model, labels, DFA and action spaces must remain fixed after construction.

Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def __init__(
    self,
    env: LabelledParallelEnv,
    *,
    coalition: Coalition | Sequence[str],
    dfa: DFA,
    mode: ShieldMode = "preemptive",
    execution: str = "centralised",
    replacement: ReplacementSpec = None,
    label_combiner: LabelCombiner | None = None,
) -> None:
    if not isinstance(env, LabelledParallelEnv):
        raise TypeError("CoalitionLTLShield must wrap a LabelledParallelEnv.")
    if not isinstance(dfa, DFA):
        raise TypeError("dfa must be a masa.common.ltl.DFA.")
    if mode not in ("preemptive", "postposed"):
        raise ValueError("mode must be 'preemptive' or 'postposed'.")
    if isinstance(coalition, (str, bytes)):
        raise TypeError("coalition must be a Coalition or sequence of agent names.")
    if not isinstance(coalition, Coalition):
        coalition = Coalition(tuple(coalition))

    self.env = env
    self.metadata = getattr(env, "metadata", self.metadata)
    self.possible_agents = list(env.possible_agents)
    self.agents = list(getattr(env, "agents", self.possible_agents))
    if not isinstance(env.env, TabularParallelEnv):
        raise TypeError(
            "CoalitionLTLShield requires LabelledParallelEnv to wrap a "
            "TabularParallelEnv directly."
        )
    self.tabular_env = env.env
    if tuple(self.tabular_env.possible_agents) != tuple(self.possible_agents):
        raise ValueError(
            "TabularParallelEnv and LabelledParallelEnv possible_agents "
            "must have the same order."
        )
    self._action_sizes = self.tabular_env.action_sizes
    self._agent_index = {
        agent: index for index, agent in enumerate(self.possible_agents)
    }
    self.coalition = coalition
    self.mode: ShieldMode = mode
    self.execution = _normalise_execution(execution)

    self.coalition_agents = coalition.ordered(self.possible_agents)
    coalition_set = set(self.coalition_agents)
    self.complement_agents = tuple(
        agent for agent in self.possible_agents if agent not in coalition_set
    )

    for agent in self.possible_agents:
        space = env.action_space(agent)
        if not isinstance(space, spaces.Discrete) or space.start != 0:
            raise TypeError(
                "Coalition shielding requires zero-based Discrete action spaces."
            )

    self._configure_replacements(replacement)
    self._dfa = dfa
    self._dfa_states = tuple(dfa.states)
    self._q_index = {
        state: index for index, state in enumerate(self._dfa_states)
    }
    if len(self._q_index) != len(self._dfa_states):
        raise ValueError("DFA states must be unique.")
    unknown_accepting = set(dfa.accepting) - set(self._dfa_states)
    if unknown_accepting:
        raise ValueError(
            f"DFA accepting states are unknown: {sorted(unknown_accepting)}."
        )
    if dfa.initial not in self._q_index:
        raise ValueError("DFA initial state must be listed in dfa.states.")
    if dfa.initial in dfa.accepting:
        raise ValueError("The DFA already rejects the empty prefix.")

    if label_combiner is not None and not callable(label_combiner):
        raise TypeError("label_combiner must be callable or None.")
    self._label_combiner = label_combiner or _union_labels
    labels = [
        self._labels_for_state(state)
        for state in range(self.tabular_env.n_states)
    ]
    self._state_labels = tuple(labels)
    next_q = np.empty(
        (len(self._dfa_states), self.tabular_env.n_states), dtype=np.intp
    )
    for q_index, q_state in enumerate(self._dfa_states):
        for state, state_labels in enumerate(labels):
            q_next = dfa.transition(q_state, state_labels)
            if q_next not in self._q_index:
                raise ValueError(
                    f"DFA transition returned unknown state {q_next!r}."
                )
            next_q[q_index, state] = self._q_index[q_next]
    self._next_q = next_q

    rejecting = np.zeros(len(self._dfa_states), dtype=bool)
    for state in dfa.accepting:
        rejecting[self._q_index[state]] = True

    game = build_coalition_support(self.tabular_env, self.coalition_agents)
    self._game: CoalitionSupport = game
    self.coalition_actions = game.coalition_actions
    self._coalition_action_index = {
        action: index for index, action in enumerate(self.coalition_actions)
    }
    self._targets = (
        next_q[:, game.successors] * self.tabular_env.n_states + game.successors
    )
    self.winning_region, self.safe_joint_actions = winning_region(
        self._targets, game.support, rejecting
    )
    self.winning_region.flags.writeable = False
    self.safe_joint_actions.flags.writeable = False
    self._joint_fallback = self.safe_joint_actions.argmax(axis=1)

    self.independent_joint_actions: np.ndarray | None = None
    self.local_safe_actions: Mapping[str, np.ndarray] = MappingProxyType({})
    self._local_fallback: dict[str, np.ndarray] = {}
    if self.execution == "decentralised":
        coalition_sizes = tuple(
            self._action_sizes[agent] for agent in self.coalition_agents
        )
        local_masks, rectangle = rectangular_action_masks(
            self.safe_joint_actions,
            self.coalition_actions,
            coalition_sizes,
        )
        self.independent_joint_actions = rectangle
        local_by_agent = dict(zip(self.coalition_agents, local_masks))
        self.local_safe_actions = MappingProxyType(local_by_agent)
        self._local_fallback = {
            agent: mask.argmax(axis=1)
            for agent, mask in local_by_agent.items()
        }

    self._base_state: int | None = None
    self._q: int | None = None
    self._product_state: int | None = None

metadata class-attribute instance-attribute

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

env instance-attribute

env = env

possible_agents instance-attribute

possible_agents = list(env.possible_agents)

agents instance-attribute

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

tabular_env instance-attribute

tabular_env = env.env

_action_sizes instance-attribute

_action_sizes = self.tabular_env.action_sizes

_agent_index instance-attribute

_agent_index = {agent: index for index, agent in enumerate(self.possible_agents)}

coalition instance-attribute

coalition = coalition

mode instance-attribute

mode: ShieldMode = mode

execution instance-attribute

execution = _normalise_execution(execution)

coalition_agents instance-attribute

coalition_agents = coalition.ordered(self.possible_agents)

complement_agents instance-attribute

complement_agents = tuple(agent for agent in self.possible_agents if agent not in coalition_set)

_dfa instance-attribute

_dfa = dfa

_dfa_states instance-attribute

_dfa_states = tuple(dfa.states)

_q_index instance-attribute

_q_index = {state: index for index, state in enumerate(self._dfa_states)}

_label_combiner instance-attribute

_label_combiner = label_combiner or _union_labels

_state_labels instance-attribute

_state_labels = tuple(labels)

_next_q instance-attribute

_next_q = next_q

_game instance-attribute

_game: CoalitionSupport = game

coalition_actions instance-attribute

coalition_actions = game.coalition_actions

_coalition_action_index instance-attribute

_coalition_action_index = {action: index for index, action in enumerate(self.coalition_actions)}

_targets instance-attribute

_targets = next_q[:, game.successors] * self.tabular_env.n_states + game.successors

_joint_fallback instance-attribute

_joint_fallback = self.safe_joint_actions.argmax(axis=1)

independent_joint_actions instance-attribute

independent_joint_actions: ndarray | None = None

local_safe_actions instance-attribute

local_safe_actions: Mapping[str, ndarray] = MappingProxyType({})

_local_fallback instance-attribute

_local_fallback: dict[str, ndarray] = {}

_base_state instance-attribute

_base_state: int | None = None

_q instance-attribute

_q: int | None = None

_product_state instance-attribute

_product_state: int | None = None

product_state property

product_state: int

Current flattened (DFA state, tabular state) index.

tabular_state property

tabular_state: int

Current finite state ID of the wrapped TabularParallelEnv.

automaton_state property

automaton_state

Current DFA state value, rather than its integer encoding.

constraint_type property

constraint_type: str

_labels_from_observations

_labels_from_observations(observations: Mapping[str, object]) -> frozenset[str]
Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def _labels_from_observations(
    self, observations: Mapping[str, object]
) -> frozenset[str]:
    if not isinstance(observations, Mapping):
        raise TypeError("Tabular observations must be an agent mapping.")
    expected = set(self.possible_agents)
    supplied = set(observations)
    if supplied != expected:
        raise ValueError(
            "State observations must contain exactly possible_agents; "
            f"missing={sorted(expected - supplied)}, "
            f"extra={sorted(supplied - expected)}."
        )

    label_fn = self.env.label_fn
    labels_by_agent: dict[str, frozenset[str]] = {}
    for agent in self.possible_agents:
        if isinstance(label_fn, Mapping):
            try:
                fn = label_fn[agent]
            except KeyError as exc:
                raise ValueError(
                    f"No labelling function was supplied for {agent!r}."
                ) from exc
        else:
            fn = label_fn
        if not callable(fn):
            raise TypeError(f"Labelling function for {agent!r} is not callable.")
        raw = fn(observations[agent])
        if isinstance(raw, (str, bytes)):
            raise TypeError("Labels must be an iterable of proposition names.")
        labels = frozenset(raw)
        if any(not isinstance(label, str) or not label for label in labels):
            raise TypeError("Labels must be non-empty strings.")
        labels_by_agent[agent] = labels

    raw_combined = self._label_combiner(MappingProxyType(labels_by_agent))
    if isinstance(raw_combined, (str, bytes)):
        raise TypeError(
            "label_combiner must return an iterable of proposition names."
        )
    combined = frozenset(raw_combined)
    if any(not isinstance(label, str) or not label for label in combined):
        raise TypeError("Combined labels must be non-empty strings.")
    return combined

_labels_for_state

_labels_for_state(state: int) -> frozenset[str]
Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def _labels_for_state(self, state: int) -> frozenset[str]:
    observations = self.tabular_env.observations_from_state(state)
    return self._labels_from_observations(observations)

_check_runtime_labels

_check_runtime_labels(observations: Mapping[str, object], state: int) -> None
Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def _check_runtime_labels(
    self, observations: Mapping[str, object], state: int
) -> None:
    # Some Parallel environments omit terminal observations. When all are
    # present, verify that the live observation and tabular-state encodings
    # induce the same shared propositions.
    if isinstance(observations, Mapping) and set(observations) == set(
        self.possible_agents
    ):
        actual = self._labels_from_observations(observations)
        expected = self._state_labels[state]
        if actual != expected:
            raise RuntimeError(
                "Runtime labels disagree with observations_from_state() for "
                f"tabular state {state}: expected {set(expected)}, "
                f"got {set(actual)}."
            )
_legal_actions(state: int, agent: str) -> tuple[int, ...]
Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def _legal_actions(self, state: int, agent: str) -> tuple[int, ...]:
    return self._game.legal_actions[state][self._agent_index[agent]]

_configure_replacements

_configure_replacements(replacement: ReplacementSpec) -> None
Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def _configure_replacements(self, replacement: ReplacementSpec) -> None:
    if self.execution == "centralised":
        if isinstance(replacement, Mapping):
            raise TypeError(
                "A centralised replacement is one callback over joint-action indices."
            )
        if replacement is not None and not callable(replacement):
            raise TypeError("replacement must be callable or None.")
        self._joint_replacement = replacement
        self._replacement_by_agent: dict[str, Replacement | None] = {}
        return

    self._joint_replacement = None
    if isinstance(replacement, Mapping):
        unknown = set(replacement) - set(self.coalition_agents)
        if unknown:
            raise ValueError(
                "Replacement mapping contains non-coalition agents: "
                f"{sorted(unknown)}."
            )
        configured: dict[str, Replacement | None] = {}
        for agent in self.coalition_agents:
            callback = replacement.get(agent)
            if callback is not None and not callable(callback):
                raise TypeError(
                    f"Replacement for {agent!r} must be callable or None."
                )
            configured[agent] = callback
        callbacks = [callback for callback in configured.values() if callback is not None]
        if len({id(callback) for callback in callbacks}) != len(callbacks):
            raise ValueError(
                "Decentralised agents must use separate replacement callback "
                "instances; do not share one stateful selector or RNG."
            )
        self._replacement_by_agent = configured
        return

    if replacement is not None and not callable(replacement):
        raise TypeError("replacement must be callable, a mapping, or None.")
    if replacement is not None and len(self.coalition_agents) > 1:
        raise TypeError(
            "For a multi-agent decentralised coalition, provide a mapping "
            "with one replacement callback per agent."
        )
    self._replacement_by_agent = {
        agent: replacement for agent in self.coalition_agents
    }

__getattr__

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

observation_space

observation_space(agent: str)
Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def observation_space(self, agent: str):
    return self.env.observation_space(agent)

action_space

action_space(agent: str)
Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def action_space(self, agent: str):
    return self.env.action_space(agent)

state

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

render

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

close

close()
Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def close(self):
    self._clear_runtime_state()
    return self.env.close()

labels_for_state

labels_for_state(state: int) -> frozenset[str]

Shared DFA propositions precomputed for one finite game state.

Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def labels_for_state(self, state: int) -> frozenset[str]:
    """Shared DFA propositions precomputed for one finite game state."""
    if not isinstance(state, (int, np.integer)) or isinstance(state, bool):
        raise TypeError("Tabular state must be an integer.")
    state = int(state)
    if not 0 <= state < self.tabular_env.n_states:
        raise ValueError(
            f"Tabular state {state} is outside "
            f"[0, {self.tabular_env.n_states})."
        )
    return self._state_labels[state]

encode_coalition_action

encode_coalition_action(action: Mapping[str, int] | Sequence[int]) -> int

Encode a coalition action in canonical environment-agent order.

Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def encode_coalition_action(
    self, action: Mapping[str, int] | Sequence[int]
) -> int:
    """Encode a coalition action in canonical environment-agent order."""
    if isinstance(action, Mapping):
        if set(action) != set(self.coalition_agents):
            raise ValueError(
                "Coalition action mapping must contain exactly the coalition agents."
            )
        key = tuple(int(action[agent]) for agent in self.coalition_agents)
    else:
        if isinstance(action, (str, bytes)):
            raise TypeError("Coalition action must be a mapping or action sequence.")
        key = tuple(int(primitive) for primitive in action)
    try:
        return self._coalition_action_index[key]
    except KeyError as exc:
        raise ValueError(f"Invalid coalition action: {key!r}.") from exc

decode_coalition_action

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

Decode a coalition-action index to an agent-action mapping.

Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def decode_coalition_action(self, index: int) -> dict[str, int]:
    """Decode a coalition-action index to an agent-action mapping."""
    if not isinstance(index, (int, np.integer)) or isinstance(index, bool):
        raise TypeError("Coalition action index must be an integer.")
    if not 0 <= int(index) < len(self.coalition_actions):
        raise ValueError(f"Invalid coalition action index: {index!r}.")
    action = self.coalition_actions[int(index)]
    return dict(zip(self.coalition_agents, action))

robust_coalition_action_mask

robust_coalition_action_mask() -> np.ndarray

Full coalition relation safe against every complement action.

Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def robust_coalition_action_mask(self) -> np.ndarray:
    """Full coalition relation safe against every complement action."""
    return self.safe_joint_actions[self._require_product_state()].copy()

coalition_action_mask

coalition_action_mask() -> np.ndarray

Current executable coalition relation in coalition-action index order.

In centralised execution this is the full robust safe relation. In decentralised execution it is the selected Cartesian subset represented by the local masks.

Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def coalition_action_mask(self) -> np.ndarray:
    """Current executable coalition relation in coalition-action index order.

    In centralised execution this is the full robust safe relation. In
    decentralised execution it is the selected Cartesian subset represented
    by the local masks.
    """
    product_state = self._require_product_state()
    if self.execution == "centralised":
        return self.safe_joint_actions[product_state].copy()
    assert self.independent_joint_actions is not None
    return self.independent_joint_actions[product_state].copy()

safe_coalition_actions

safe_coalition_actions() -> tuple[dict[str, int], ...]

Decode every currently executable coalition joint action.

Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def safe_coalition_actions(self) -> tuple[dict[str, int], ...]:
    """Decode every currently executable coalition joint action."""
    return tuple(
        self.decode_coalition_action(index)
        for index in np.flatnonzero(self.coalition_action_mask())
    )

local_action_mask

local_action_mask(agent: str) -> np.ndarray

Current independent mask for one decentralised coalition member.

Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def local_action_mask(self, agent: str) -> np.ndarray:
    """Current independent mask for one decentralised coalition member."""
    if self.execution != "decentralised":
        raise RuntimeError(
            "Local masks are available only for decentralised execution."
        )
    if agent not in self.local_safe_actions:
        raise ValueError(f"{agent!r} is not in the coalition.")
    return self.local_safe_actions[agent][self._require_product_state()].copy()

local_action_masks

local_action_masks() -> dict[str, np.ndarray]

Copies of all current decentralised local masks.

Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def local_action_masks(self) -> dict[str, np.ndarray]:
    """Copies of all current decentralised local masks."""
    return {
        agent: self.local_action_mask(agent)
        for agent in self.coalition_agents
    }

reset

reset(seed=None, options=None)
Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def reset(self, seed=None, options=None):
    self._clear_runtime_state()
    observations, infos = self.env.reset(seed=seed, options=options)
    self.agents = list(getattr(self.env, "agents", self.possible_agents))
    if tuple(self.agents) != tuple(self.possible_agents):
        raise RuntimeError(
            "CoalitionLTLShield requires every possible agent to be active at reset."
        )

    state = self.tabular_env.get_state_id()
    self._check_runtime_labels(observations, state)
    q = int(self._next_q[self._q_index[self._dfa.initial], state])
    product_state = self._checked_product_state(q, state)
    infos = self._decorate_infos(
        infos,
        product_state=product_state,
        masks_available=True,
        proposed=None,
        executed=None,
    )
    # Publish an active runtime state only after every reset check succeeds.
    self._base_state = state
    self._q = q
    self._product_state = product_state
    return observations, infos

step

step(actions)
Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def step(self, actions):
    previous_product = self._require_product_state()
    previous_state = self._base_state
    previous_q = self._q
    assert previous_state is not None and previous_q is not None
    proposed = self._validate_action_mapping(actions, previous_state)

    if self.execution == "centralised":
        executed = self._centralised_actions(proposed, previous_product)
    else:
        executed = self._decentralised_actions(proposed, previous_product)

    full_action = tuple(
        executed[agent] for agent in self.tabular_env.possible_agents
    )
    full_action_index = self.tabular_env.encode_joint_action(full_action)
    # Any environment failure or post-step model mismatch invalidates the
    # runtime state until reset. Pre-step validation failures leave it intact.
    self._clear_runtime_state()
    observations, rewards, terminations, truncations, infos = self.env.step(
        executed
    )

    state = self.tabular_env.get_state_id()
    if state not in self._game.full_successors[previous_state][full_action_index]:
        raise RuntimeError(
            "Observed transition is absent from the tabular transition model."
        )
    self._check_runtime_labels(observations, state)
    q = int(self._next_q[previous_q, state])
    product_state = self._checked_product_state(q, state)

    expected_agents = tuple(self.possible_agents)
    done_by_agent = {
        agent: bool(terminations.get(agent, False))
        or bool(truncations.get(agent, False))
        for agent in expected_agents
    }
    if any(done_by_agent.values()) and not all(done_by_agent.values()):
        raise RuntimeError(
            "Per-agent removal/termination is not supported; all agents must "
            "finish the modeled game simultaneously."
        )
    done = all(done_by_agent.values())
    self.agents = list(getattr(self.env, "agents", self.possible_agents))
    if not done and tuple(self.agents) != expected_agents:
        raise RuntimeError(
            "Dynamic agent populations are not supported by this shield."
        )

    true_termination = done and any(
        bool(terminations.get(agent, False)) for agent in expected_agents
    )
    infos = self._decorate_infos(
        infos,
        product_state=product_state,
        masks_available=not true_termination,
        proposed=proposed,
        executed=executed,
    )
    # Publish the next active state only after all post-step checks succeed.
    if not done:
        self._base_state = state
        self._q = q
        self._product_state = product_state
    return observations, rewards, terminations, truncations, infos

_validate_action_mapping

_validate_action_mapping(actions, state: int) -> dict[str, int]
Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def _validate_action_mapping(self, actions, state: int) -> dict[str, int]:
    if not isinstance(actions, Mapping):
        raise TypeError("Parallel actions must be a mapping from agent to action.")
    expected = set(self.possible_agents)
    supplied = set(actions)
    if supplied != expected:
        raise ValueError(
            "Action mapping must contain exactly the currently modeled agents; "
            f"missing={sorted(expected - supplied)}, "
            f"extra={sorted(supplied - expected)}."
        )

    result: dict[str, int] = {}
    for agent in self.possible_agents:
        action = actions[agent]
        if not self.action_space(agent).contains(action):
            raise ValueError(f"Invalid action {action!r} for {agent!r}.")
        primitive = int(action)
        if primitive not in self._legal_actions(state, agent):
            raise ValueError(
                f"Action {primitive} is not model-legal for {agent!r} "
                f"in state {state}."
            )
        result[agent] = primitive
    return result

_centralised_actions

_centralised_actions(proposed: dict[str, int], product_state: int) -> dict[str, int]
Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def _centralised_actions(
    self, proposed: dict[str, int], product_state: int
) -> dict[str, int]:
    coalition_action = tuple(
        proposed[agent] for agent in self.coalition_agents
    )
    proposed_index = self._coalition_action_index[coalition_action]
    mask = self.safe_joint_actions[product_state]
    if mask[proposed_index]:
        executed_index = proposed_index
    elif self.mode == "preemptive":
        raise ValueError(
            f"Coalition action {coalition_action} is unsafe in product state "
            f"{product_state}."
        )
    elif self._joint_replacement is None:
        executed_index = int(self._joint_fallback[product_state])
    else:
        executed_index = self._joint_replacement(
            product_state, proposed_index, mask.copy()
        )
    executed_index = self._validate_joint_replacement(
        executed_index, mask, product_state
    )

    executed = dict(proposed)
    for agent, primitive in self.decode_coalition_action(executed_index).items():
        executed[agent] = primitive
    return executed

_decentralised_actions

_decentralised_actions(proposed: dict[str, int], product_state: int) -> dict[str, int]
Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def _decentralised_actions(
    self, proposed: dict[str, int], product_state: int
) -> dict[str, int]:
    executed = dict(proposed)
    unsafe = [
        agent
        for agent in self.coalition_agents
        if not self.local_safe_actions[agent][product_state, proposed[agent]]
    ]
    if unsafe and self.mode == "preemptive":
        raise ValueError(
            "Unsafe decentralised actions for "
            + ", ".join(f"{agent}={proposed[agent]}" for agent in unsafe)
            + f" in product state {product_state}."
        )

    for agent in unsafe:
        mask = self.local_safe_actions[agent][product_state]
        callback = self._replacement_by_agent[agent]
        if callback is None:
            replacement = int(self._local_fallback[agent][product_state])
        else:
            replacement = callback(
                product_state, proposed[agent], mask.copy()
            )
        if (
            not isinstance(replacement, (int, np.integer))
            or isinstance(replacement, bool)
            or not 0 <= int(replacement) < mask.size
            or not mask[int(replacement)]
        ):
            raise ValueError(
                f"Replacement {replacement!r} is not safe for {agent!r} "
                f"in product state {product_state}."
            )
        executed[agent] = int(replacement)

    coalition_action = tuple(
        executed[agent] for agent in self.coalition_agents
    )
    coalition_index = self._coalition_action_index[coalition_action]
    assert self.independent_joint_actions is not None
    if not self.independent_joint_actions[product_state, coalition_index]:
        raise AssertionError(
            "Internal error: local shield outputs left the certified rectangle."
        )
    return executed

_validate_joint_replacement staticmethod

_validate_joint_replacement(replacement, mask: ndarray, product_state: int) -> int
Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
@staticmethod
def _validate_joint_replacement(
    replacement, mask: np.ndarray, product_state: int
) -> int:
    if (
        not isinstance(replacement, (int, np.integer))
        or isinstance(replacement, bool)
        or not 0 <= int(replacement) < mask.size
        or not mask[int(replacement)]
    ):
        raise ValueError(
            f"Replacement coalition-action index {replacement!r} is not safe "
            f"in product state {product_state}."
        )
    return int(replacement)

_checked_product_state

_checked_product_state(q: int, state: int) -> int
Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def _checked_product_state(self, q: int, state: int) -> int:
    product_state = q * self.tabular_env.n_states + state
    if not self.winning_region[product_state]:
        raise RuntimeError(
            f"Product state {product_state} is outside the coalition winning "
            "region; the safety property cannot be guaranteed."
        )
    return product_state

_require_product_state

_require_product_state() -> int
Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def _require_product_state(self) -> int:
    if self._product_state is None:
        raise RuntimeError(
            "Call reset() before acting or requesting masks, including after "
            "episode end or a failed environment/model check."
        )
    return self._product_state

_clear_runtime_state

_clear_runtime_state() -> None
Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def _clear_runtime_state(self) -> None:
    self._base_state = None
    self._q = None
    self._product_state = None

_decorate_infos

_decorate_infos(infos, *, product_state: int, masks_available: bool, proposed: Mapping[str, int] | None, executed: Mapping[str, int] | None)
Source code in masa/deterministic_shield/multi_agent/coalition_shielding.py
def _decorate_infos(
    self,
    infos,
    *,
    product_state: int,
    masks_available: bool,
    proposed: Mapping[str, int] | None,
    executed: Mapping[str, int] | None,
):
    if not isinstance(infos, Mapping):
        raise TypeError("Parallel infos must be a mapping.")
    out = dict(infos)
    q_index, state = divmod(product_state, self.tabular_env.n_states)
    for agent in self.coalition_agents:
        raw = out.get(agent, {})
        if raw is None:
            raw = {}
        if not isinstance(raw, Mapping):
            raise TypeError(f"Info for {agent!r} must be a mapping or None.")
        info = dict(raw)
        info["shield_mode"] = self.mode
        info["shield_execution"] = self.execution
        info["shield_coalition"] = self.coalition.name or self.coalition_agents
        info["shield_product_state"] = product_state
        info["shield_automaton_state"] = self._dfa_states[q_index]
        info["shield_labels"] = set(self._state_labels[state])

        if self.execution == "centralised":
            info["shield_joint_action_mask"] = (
                self.safe_joint_actions[product_state].copy()
                if masks_available
                else np.zeros(len(self.coalition_actions), dtype=bool)
            )
        else:
            info["shield_action_mask"] = (
                self.local_safe_actions[agent][product_state].copy()
                if masks_available
                else np.zeros(self._action_sizes[agent], dtype=bool)
            )

        if proposed is not None and executed is not None:
            info["shield_intervened"] = proposed[agent] != executed[agent]
            info["shield_proposed_action"] = proposed[agent]
            info["shield_executed_action"] = executed[agent]
            info["shield_joint_intervened"] = any(
                proposed[member] != executed[member]
                for member in self.coalition_agents
            )
            info["shield_proposed_coalition_action"] = tuple(
                proposed[member] for member in self.coalition_agents
            )
            info["shield_executed_coalition_action"] = tuple(
                executed[member] for member in self.coalition_agents
            )
        out[agent] = info
    return out

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

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