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.
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.
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.
The base environment must subclass TabularParallelEnv. Like the existing
single-agent TabularEnv, it exposes either a dense transition matrix or sparse
successor/probability dictionaries.
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.
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.
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.
frommasa.deterministic_shieldimportrandom_safeenv=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.
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.
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.
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.
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.
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.
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.
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:ifnotisinstance(env,LabelledParallelEnv):raiseTypeError("CoalitionLTLShield must wrap a LabelledParallelEnv.")ifnotisinstance(dfa,DFA):raiseTypeError("dfa must be a masa.common.ltl.DFA.")ifmodenotin("preemptive","postposed"):raiseValueError("mode must be 'preemptive' or 'postposed'.")ifisinstance(coalition,(str,bytes)):raiseTypeError("coalition must be a Coalition or sequence of agent names.")ifnotisinstance(coalition,Coalition):coalition=Coalition(tuple(coalition))self.env=envself.metadata=getattr(env,"metadata",self.metadata)self.possible_agents=list(env.possible_agents)self.agents=list(getattr(env,"agents",self.possible_agents))ifnotisinstance(env.env,TabularParallelEnv):raiseTypeError("CoalitionLTLShield requires LabelledParallelEnv to wrap a ""TabularParallelEnv directly.")self.tabular_env=env.enviftuple(self.tabular_env.possible_agents)!=tuple(self.possible_agents):raiseValueError("TabularParallelEnv and LabelledParallelEnv possible_agents ""must have the same order.")self._action_sizes=self.tabular_env.action_sizesself._agent_index={agent:indexforindex,agentinenumerate(self.possible_agents)}self.coalition=coalitionself.mode:ShieldMode=modeself.execution=_normalise_execution(execution)self.coalition_agents=coalition.ordered(self.possible_agents)coalition_set=set(self.coalition_agents)self.complement_agents=tuple(agentforagentinself.possible_agentsifagentnotincoalition_set)foragentinself.possible_agents:space=env.action_space(agent)ifnotisinstance(space,spaces.Discrete)orspace.start!=0:raiseTypeError("Coalition shielding requires zero-based Discrete action spaces.")self._configure_replacements(replacement)self._dfa=dfaself._dfa_states=tuple(dfa.states)self._q_index={state:indexforindex,stateinenumerate(self._dfa_states)}iflen(self._q_index)!=len(self._dfa_states):raiseValueError("DFA states must be unique.")unknown_accepting=set(dfa.accepting)-set(self._dfa_states)ifunknown_accepting:raiseValueError(f"DFA accepting states are unknown: {sorted(unknown_accepting)}.")ifdfa.initialnotinself._q_index:raiseValueError("DFA initial state must be listed in dfa.states.")ifdfa.initialindfa.accepting:raiseValueError("The DFA already rejects the empty prefix.")iflabel_combinerisnotNoneandnotcallable(label_combiner):raiseTypeError("label_combiner must be callable or None.")self._label_combiner=label_combineror_union_labelslabels=[self._labels_for_state(state)forstateinrange(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)forq_index,q_stateinenumerate(self._dfa_states):forstate,state_labelsinenumerate(labels):q_next=dfa.transition(q_state,state_labels)ifq_nextnotinself._q_index:raiseValueError(f"DFA transition returned unknown state {q_next!r}.")next_q[q_index,state]=self._q_index[q_next]self._next_q=next_qrejecting=np.zeros(len(self._dfa_states),dtype=bool)forstateindfa.accepting:rejecting[self._q_index[state]]=Truegame=build_coalition_support(self.tabular_env,self.coalition_agents)self._game:CoalitionSupport=gameself.coalition_actions=game.coalition_actionsself._coalition_action_index={action:indexforindex,actioninenumerate(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=Falseself.safe_joint_actions.flags.writeable=Falseself._joint_fallback=self.safe_joint_actions.argmax(axis=1)self.independent_joint_actions:np.ndarray|None=Noneself.local_safe_actions:Mapping[str,np.ndarray]=MappingProxyType({})self._local_fallback:dict[str,np.ndarray]={}ifself.execution=="decentralised":coalition_sizes=tuple(self._action_sizes[agent]foragentinself.coalition_agents)local_masks,rectangle=rectangular_action_masks(self.safe_joint_actions,self.coalition_actions,coalition_sizes,)self.independent_joint_actions=rectanglelocal_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)foragent,maskinlocal_by_agent.items()}self._base_state:int|None=Noneself._q:int|None=Noneself._product_state:int|None=None
def_labels_from_observations(self,observations:Mapping[str,object])->frozenset[str]:ifnotisinstance(observations,Mapping):raiseTypeError("Tabular observations must be an agent mapping.")expected=set(self.possible_agents)supplied=set(observations)ifsupplied!=expected:raiseValueError("State observations must contain exactly possible_agents; "f"missing={sorted(expected-supplied)}, "f"extra={sorted(supplied-expected)}.")label_fn=self.env.label_fnlabels_by_agent:dict[str,frozenset[str]]={}foragentinself.possible_agents:ifisinstance(label_fn,Mapping):try:fn=label_fn[agent]exceptKeyErrorasexc:raiseValueError(f"No labelling function was supplied for {agent!r}.")fromexcelse:fn=label_fnifnotcallable(fn):raiseTypeError(f"Labelling function for {agent!r} is not callable.")raw=fn(observations[agent])ifisinstance(raw,(str,bytes)):raiseTypeError("Labels must be an iterable of proposition names.")labels=frozenset(raw)ifany(notisinstance(label,str)ornotlabelforlabelinlabels):raiseTypeError("Labels must be non-empty strings.")labels_by_agent[agent]=labelsraw_combined=self._label_combiner(MappingProxyType(labels_by_agent))ifisinstance(raw_combined,(str,bytes)):raiseTypeError("label_combiner must return an iterable of proposition names.")combined=frozenset(raw_combined)ifany(notisinstance(label,str)ornotlabelforlabelincombined):raiseTypeError("Combined labels must be non-empty strings.")returncombined
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.ifisinstance(observations,Mapping)andset(observations)==set(self.possible_agents):actual=self._labels_from_observations(observations)expected=self._state_labels[state]ifactual!=expected:raiseRuntimeError("Runtime labels disagree with observations_from_state() for "f"tabular state {state}: expected {set(expected)}, "f"got {set(actual)}.")
def_configure_replacements(self,replacement:ReplacementSpec)->None:ifself.execution=="centralised":ifisinstance(replacement,Mapping):raiseTypeError("A centralised replacement is one callback over joint-action indices.")ifreplacementisnotNoneandnotcallable(replacement):raiseTypeError("replacement must be callable or None.")self._joint_replacement=replacementself._replacement_by_agent:dict[str,Replacement|None]={}returnself._joint_replacement=Noneifisinstance(replacement,Mapping):unknown=set(replacement)-set(self.coalition_agents)ifunknown:raiseValueError("Replacement mapping contains non-coalition agents: "f"{sorted(unknown)}.")configured:dict[str,Replacement|None]={}foragentinself.coalition_agents:callback=replacement.get(agent)ifcallbackisnotNoneandnotcallable(callback):raiseTypeError(f"Replacement for {agent!r} must be callable or None.")configured[agent]=callbackcallbacks=[callbackforcallbackinconfigured.values()ifcallbackisnotNone]iflen({id(callback)forcallbackincallbacks})!=len(callbacks):raiseValueError("Decentralised agents must use separate replacement callback ""instances; do not share one stateful selector or RNG.")self._replacement_by_agent=configuredreturnifreplacementisnotNoneandnotcallable(replacement):raiseTypeError("replacement must be callable, a mapping, or None.")ifreplacementisnotNoneandlen(self.coalition_agents)>1:raiseTypeError("For a multi-agent decentralised coalition, provide a mapping ""with one replacement callback per agent.")self._replacement_by_agent={agent:replacementforagentinself.coalition_agents}
deflabels_for_state(self,state:int)->frozenset[str]:"""Shared DFA propositions precomputed for one finite game state."""ifnotisinstance(state,(int,np.integer))orisinstance(state,bool):raiseTypeError("Tabular state must be an integer.")state=int(state)ifnot0<=state<self.tabular_env.n_states:raiseValueError(f"Tabular state {state} is outside "f"[0, {self.tabular_env.n_states}).")returnself._state_labels[state]
defencode_coalition_action(self,action:Mapping[str,int]|Sequence[int])->int:"""Encode a coalition action in canonical environment-agent order."""ifisinstance(action,Mapping):ifset(action)!=set(self.coalition_agents):raiseValueError("Coalition action mapping must contain exactly the coalition agents.")key=tuple(int(action[agent])foragentinself.coalition_agents)else:ifisinstance(action,(str,bytes)):raiseTypeError("Coalition action must be a mapping or action sequence.")key=tuple(int(primitive)forprimitiveinaction)try:returnself._coalition_action_index[key]exceptKeyErrorasexc:raiseValueError(f"Invalid coalition action: {key!r}.")fromexc
defdecode_coalition_action(self,index:int)->dict[str,int]:"""Decode a coalition-action index to an agent-action mapping."""ifnotisinstance(index,(int,np.integer))orisinstance(index,bool):raiseTypeError("Coalition action index must be an integer.")ifnot0<=int(index)<len(self.coalition_actions):raiseValueError(f"Invalid coalition action index: {index!r}.")action=self.coalition_actions[int(index)]returndict(zip(self.coalition_agents,action))
defrobust_coalition_action_mask(self)->np.ndarray:"""Full coalition relation safe against every complement action."""returnself.safe_joint_actions[self._require_product_state()].copy()
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
defcoalition_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()ifself.execution=="centralised":returnself.safe_joint_actions[product_state].copy()assertself.independent_joint_actionsisnotNonereturnself.independent_joint_actions[product_state].copy()
defsafe_coalition_actions(self)->tuple[dict[str,int],...]:"""Decode every currently executable coalition joint action."""returntuple(self.decode_coalition_action(index)forindexinnp.flatnonzero(self.coalition_action_mask()))
deflocal_action_mask(self,agent:str)->np.ndarray:"""Current independent mask for one decentralised coalition member."""ifself.execution!="decentralised":raiseRuntimeError("Local masks are available only for decentralised execution.")ifagentnotinself.local_safe_actions:raiseValueError(f"{agent!r} is not in the coalition.")returnself.local_safe_actions[agent][self._require_product_state()].copy()
deflocal_action_masks(self)->dict[str,np.ndarray]:"""Copies of all current decentralised local masks."""return{agent:self.local_action_mask(agent)foragentinself.coalition_agents}
defreset(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))iftuple(self.agents)!=tuple(self.possible_agents):raiseRuntimeError("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=stateself._q=qself._product_state=product_statereturnobservations,infos
defstep(self,actions):previous_product=self._require_product_state()previous_state=self._base_stateprevious_q=self._qassertprevious_stateisnotNoneandprevious_qisnotNoneproposed=self._validate_action_mapping(actions,previous_state)ifself.execution=="centralised":executed=self._centralised_actions(proposed,previous_product)else:executed=self._decentralised_actions(proposed,previous_product)full_action=tuple(executed[agent]foragentinself.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()ifstatenotinself._game.full_successors[previous_state][full_action_index]:raiseRuntimeError("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))orbool(truncations.get(agent,False))foragentinexpected_agents}ifany(done_by_agent.values())andnotall(done_by_agent.values()):raiseRuntimeError("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))ifnotdoneandtuple(self.agents)!=expected_agents:raiseRuntimeError("Dynamic agent populations are not supported by this shield.")true_termination=doneandany(bool(terminations.get(agent,False))foragentinexpected_agents)infos=self._decorate_infos(infos,product_state=product_state,masks_available=nottrue_termination,proposed=proposed,executed=executed,)# Publish the next active state only after all post-step checks succeed.ifnotdone:self._base_state=stateself._q=qself._product_state=product_statereturnobservations,rewards,terminations,truncations,infos
def_validate_action_mapping(self,actions,state:int)->dict[str,int]:ifnotisinstance(actions,Mapping):raiseTypeError("Parallel actions must be a mapping from agent to action.")expected=set(self.possible_agents)supplied=set(actions)ifsupplied!=expected:raiseValueError("Action mapping must contain exactly the currently modeled agents; "f"missing={sorted(expected-supplied)}, "f"extra={sorted(supplied-expected)}.")result:dict[str,int]={}foragentinself.possible_agents:action=actions[agent]ifnotself.action_space(agent).contains(action):raiseValueError(f"Invalid action {action!r} for {agent!r}.")primitive=int(action)ifprimitivenotinself._legal_actions(state,agent):raiseValueError(f"Action {primitive} is not model-legal for {agent!r} "f"in state {state}.")result[agent]=primitivereturnresult
def_centralised_actions(self,proposed:dict[str,int],product_state:int)->dict[str,int]:coalition_action=tuple(proposed[agent]foragentinself.coalition_agents)proposed_index=self._coalition_action_index[coalition_action]mask=self.safe_joint_actions[product_state]ifmask[proposed_index]:executed_index=proposed_indexelifself.mode=="preemptive":raiseValueError(f"Coalition action {coalition_action} is unsafe in product state "f"{product_state}.")elifself._joint_replacementisNone: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)foragent,primitiveinself.decode_coalition_action(executed_index).items():executed[agent]=primitivereturnexecuted
def_decentralised_actions(self,proposed:dict[str,int],product_state:int)->dict[str,int]:executed=dict(proposed)unsafe=[agentforagentinself.coalition_agentsifnotself.local_safe_actions[agent][product_state,proposed[agent]]]ifunsafeandself.mode=="preemptive":raiseValueError("Unsafe decentralised actions for "+", ".join(f"{agent}={proposed[agent]}"foragentinunsafe)+f" in product state {product_state}.")foragentinunsafe:mask=self.local_safe_actions[agent][product_state]callback=self._replacement_by_agent[agent]ifcallbackisNone:replacement=int(self._local_fallback[agent][product_state])else:replacement=callback(product_state,proposed[agent],mask.copy())if(notisinstance(replacement,(int,np.integer))orisinstance(replacement,bool)ornot0<=int(replacement)<mask.sizeornotmask[int(replacement)]):raiseValueError(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]foragentinself.coalition_agents)coalition_index=self._coalition_action_index[coalition_action]assertself.independent_joint_actionsisnotNoneifnotself.independent_joint_actions[product_state,coalition_index]:raiseAssertionError("Internal error: local shield outputs left the certified rectangle.")returnexecuted
@staticmethoddef_validate_joint_replacement(replacement,mask:np.ndarray,product_state:int)->int:if(notisinstance(replacement,(int,np.integer))orisinstance(replacement,bool)ornot0<=int(replacement)<mask.sizeornotmask[int(replacement)]):raiseValueError(f"Replacement coalition-action index {replacement!r} is not safe "f"in product state {product_state}.")returnint(replacement)
def_checked_product_state(self,q:int,state:int)->int:product_state=q*self.tabular_env.n_states+stateifnotself.winning_region[product_state]:raiseRuntimeError(f"Product state {product_state} is outside the coalition winning ""region; the safety property cannot be guaranteed.")returnproduct_state
def_require_product_state(self)->int:ifself._product_stateisNone:raiseRuntimeError("Call reset() before acting or requesting masks, including after ""episode end or a failed environment/model check.")returnself._product_state
def_decorate_infos(self,infos,*,product_state:int,masks_available:bool,proposed:Mapping[str,int]|None,executed:Mapping[str,int]|None,):ifnotisinstance(infos,Mapping):raiseTypeError("Parallel infos must be a mapping.")out=dict(infos)q_index,state=divmod(product_state,self.tabular_env.n_states)foragentinself.coalition_agents:raw=out.get(agent,{})ifrawisNone:raw={}ifnotisinstance(raw,Mapping):raiseTypeError(f"Info for {agent!r} must be a mapping or None.")info=dict(raw)info["shield_mode"]=self.modeinfo["shield_execution"]=self.executioninfo["shield_coalition"]=self.coalition.nameorself.coalition_agentsinfo["shield_product_state"]=product_stateinfo["shield_automaton_state"]=self._dfa_states[q_index]info["shield_labels"]=set(self._state_labels[state])ifself.execution=="centralised":info["shield_joint_action_mask"]=(self.safe_joint_actions[product_state].copy()ifmasks_availableelsenp.zeros(len(self.coalition_actions),dtype=bool))else:info["shield_action_mask"]=(self.local_safe_actions[agent][product_state].copy()ifmasks_availableelsenp.zeros(self._action_sizes[agent],dtype=bool))ifproposedisnotNoneandexecutedisnotNone: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]formemberinself.coalition_agents)info["shield_proposed_coalition_action"]=tuple(proposed[member]formemberinself.coalition_agents)info["shield_executed_coalition_action"]=tuple(executed[member]formemberinself.coalition_agents)out[agent]=inforeturnout
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.
def__post_init__(self)->None:ifisinstance(self.agents,(str,bytes)):raiseTypeError("Coalition agents must be a sequence of agent names.")agents=tuple(self.agents)ifnotagents:raiseValueError("A coalition must contain at least one agent.")ifany(notisinstance(agent,str)ornotagentforagentinagents):raiseTypeError("Coalition agents must be non-empty strings.")iflen(set(agents))!=len(agents):raiseValueError("Coalition agents must be unique.")ifself.nameisnotNoneand(notisinstance(self.name,str)ornotself.name):raiseTypeError("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)))
defordered(self,possible_agents:Sequence[str])->tuple[str,...]:"""Return members in the environment's canonical agent order."""possible=tuple(possible_agents)iflen(set(possible))!=len(possible):raiseValueError("possible_agents must be unique.")members=set(self.agents)unknown=members-set(possible)ifunknown:raiseValueError(f"Coalition contains unknown agents: {sorted(unknown)}.")returntuple(agentforagentinpossibleifagentinmembers)
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
defget_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)``. """ifnotself.has_successor_states_dict:returnNoneassertself._successor_statesisnotNoneassertself._transition_probsisnotNonereturnself._successor_states,self._transition_probs
def_check_state(self,state:int)->int:ifnotisinstance(state,Integral)orisinstance(state,bool):raiseTypeError(f"State ID must be an integer, got {state!r}.")state=int(state)ifnot0<=state<self.n_states:raiseValueError(f"State ID {state} is outside [0, {self.n_states}).")returnstate
def_joint_action_tuple(self,actions:Mapping[str,int]|Sequence[int])->JointAction:agents=tuple(self.possible_agents)ifisinstance(actions,Mapping):supplied=set(actions)expected=set(agents)ifsupplied!=expected:raiseValueError("Joint action must contain exactly possible_agents; "f"missing={sorted(expected-supplied)}, "f"extra={sorted(supplied-expected)}.")raw=tuple(actions[agent]foragentinagents)else:ifisinstance(actions,(str,bytes)):raiseTypeError("Joint action must be a mapping or action sequence.")raw=tuple(actions)iflen(raw)!=len(agents):raiseValueError(f"Expected {len(agents)} primitive actions, got {len(raw)}.")sizes=self.action_sizesresult:list[int]=[]foragent,primitiveinzip(agents,raw):ifnotisinstance(primitive,Integral)orisinstance(primitive,bool):raiseTypeError(f"Action for {agent!r} must be an integer, got {primitive!r}.")primitive=int(primitive)ifnot0<=primitive<sizes[agent]:raiseValueError(f"Action {primitive} is outside the action space of {agent!r}.")result.append(primitive)returntuple(result)
defencode_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=0forprimitive,sizeinzip(action,self.action_sizes.values()):index=index*size+primitivereturnint(index)
defdecode_joint_action(self,index:int)->dict[str,int]:"""Decode a canonical joint-action index to an agent-action mapping."""ifnotisinstance(index,Integral)orisinstance(index,bool):raiseTypeError("Joint-action index must be an integer.")index=int(index)ifnot0<=index<self.n_joint_actions:raiseValueError(f"Joint-action index {index} is outside [0, {self.n_joint_actions}).")sizes=tuple(self.action_sizes.values())primitives=[0]*len(sizes)remainder=indexforpositioninrange(len(sizes)-1,-1,-1):remainder,primitives[position]=divmod(remainder,sizes[position])returndict(zip(self.possible_agents,primitives))
deflegal_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]exceptKeyErrorasexc:raiseValueError(f"Unknown agent: {agent!r}.")fromexcreturntuple(range(size))
defget_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]exceptKeyErrorasexc:raiseValueError(f"Unknown agent: {agent!r}.")fromexcraw=tuple(self.legal_actions(state,agent))ifnotraw:raiseValueError(f"Agent {agent!r} has no legal action in model state {state}.")ifany(notisinstance(action,Integral)orisinstance(action,bool)ornot0<=int(action)<sizeforactioninraw):raiseValueError(f"Invalid legal actions for {agent!r} in state {state}: {raw!r}.")returntuple(sorted(set(map(int,raw))))
defsuccessors(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)foragent,primitiveinzip(self.possible_agents,action):ifprimitivenotinself.get_legal_actions(state,agent):raiseValueError(f"Action {primitive} is not legal for {agent!r} in state {state}.")action_index=self.encode_joint_action(action)ifself.has_successor_states_dict:sparse=self.get_successor_states_dict()assertsparseisnotNonesuccessor_states,transition_probs=sparseids=np.asarray(successor_states.get(state,()))probs=np.asarray(transition_probs.get((state,action_index),()),dtype=np.float64,)elifself.has_transition_matrix:matrix=np.asarray(self.get_transition_matrix())expected=(self.n_states,self.n_states,self.n_joint_actions)ifmatrix.shape!=expected:raiseValueError("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:raiseValueError("TabularParallelEnv must expose a transition matrix or sparse ""successor-state dictionaries.")ifids.ndim!=1or(ids.sizeandids.dtype.kindnotin"iu"):raiseValueError(f"Successors of state {state} must be integer IDs.")ids=ids.astype(np.intp,copy=False)ifnp.any((ids<0)|(ids>=self.n_states)):raiseValueError(f"Successor of state {state} is out of range.")ifprobs.shape!=(ids.size,):raiseValueError(f"Probability vector for state {state}, joint action {action} ""has the wrong shape.")ifnotnp.all(np.isfinite(probs))ornp.any(probs<0):raiseValueError(f"Invalid probabilities at state {state}, joint action {action}.")ifnotnp.isclose(probs.sum(),1.0,rtol=1e-6,atol=1e-8):raiseValueError(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]))))ifnotresult:raiseValueError(f"Missing transition support for state {state}, joint action {action}.")returnresult
defget_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. """ifself._stateisNone:raiseRuntimeError("The environment has no active tabular state.")returnself._check_state(self._state)
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
defobservations_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]={}foragentinself.possible_agents:space=self.observation_space(agent)if(notisinstance(space,spaces.Discrete)orint(space.start)!=0orint(space.n)!=self.n_states):raiseNotImplementedError(f"{type(self).__name__}.observations_from_state() must be ""implemented for structured or non-state observations.")observations[agent]=statereturnobservations