Skip to content

Linear Temporal Logic (LTL) Safety Constraint

Monitor API

masa.common.constraints.ltl_safety.LTLSafety

LTLSafety(dfa: DFA)

Bases: Constraint

DFA-based safety monitor.

This monitor uses masa.common.ltl.dfa_to_costfn to obtain a stateful cost function that:

  • tracks the current DFA state,
  • returns a scalar cost indicating safety violation.

A common convention is binary step cost:

\[ c_t \in \{0, 1\}, \quad c_t = 1 \iff \text{DFA enters/indicates an unsafe accepting condition}. \]

The episode is considered satisfied iff no unsafe event occurs:

\[ \text{satisfied} \iff \sum_t \mathbf{1}[c_t \ge 0.5] = 0. \]

Parameters:

Name Type Description Default
dfa DFA

DFA describing the safety property.

required

Attributes:

Name Type Description
cost_fn

Stateful cost object derived from the DFA (exposes DFA state).

safe

Boolean flag tracking whether any violation has occurred.

step_cost

Most recent step cost.

total_unsafe

Count of unsafe steps (as floats, per current code).

Source code in masa/common/constraints/ltl_safety.py
def __init__(self, dfa: DFA):
    self.cost_fn = dfa_to_costfn(dfa)

cost_fn instance-attribute

cost_fn = dfa_to_costfn(dfa)

constraint_type property

constraint_type: str

Stable identifier string: "LTL_SAFETY".

reset

reset()

Reset the safety monitor and underlying DFA-cost state.

Source code in masa/common/constraints/ltl_safety.py
def reset(self):
    """Reset the safety monitor and underlying DFA-cost state."""
    self.safe = True
    self.step_cost = 0.0
    self.total_unsafe = 0.0
    self.cost_fn.reset()

update

update(labels: Iterable[str])

Update the DFA-cost state and safety flags.

Parameters:

Name Type Description Default
labels Iterable[str]

Iterable of atomic propositions true at the current step.

required
Source code in masa/common/constraints/ltl_safety.py
def update(self, labels: Iterable[str]):
    """Update the DFA-cost state and safety flags.

    Args:
        labels: Iterable of atomic propositions true at the current step.
    """
    self.step_cost = self.cost_fn(labels)
    self.total_unsafe += float(self.step_cost >= 0.5)
    self.safe = self.safe and (not self.total_unsafe)

get_automaton_state

get_automaton_state()

Return the current DFA state from the underlying DFA-cost object.

Source code in masa/common/constraints/ltl_safety.py
def get_automaton_state(self):
    """Return the current DFA state from the underlying DFA-cost object."""
    return self.cost_fn.automaton_state

get_dfa

get_dfa()

Return the DFA used by the underlying DFA-cost object.

Source code in masa/common/constraints/ltl_safety.py
def get_dfa(self):
    """Return the DFA used by the underlying DFA-cost object."""
    return self.cost_fn.dfa

satisfied

satisfied() -> bool

Whether the episode remains safe so far.

Source code in masa/common/constraints/ltl_safety.py
def satisfied(self) -> bool:
    """Whether the episode remains safe so far."""
    return self.safe

episode_metric

episode_metric() -> Dict[str, float]

End-of-episode metrics.

Returns:

Type Description
Dict[str, float]

Dict containing:

Dict[str, float]
  • "cum_unsafe": count of unsafe steps,
Dict[str, float]
  • "satisfied": 1.0 if safe else 0.0.
Source code in masa/common/constraints/ltl_safety.py
def episode_metric(self) -> Dict[str, float]:
    """End-of-episode metrics.

    Returns:
        Dict containing:

        - ``"cum_unsafe"``: count of unsafe steps,
        - ``"satisfied"``: 1.0 if safe else 0.0.
    """
    return {"cum_unsafe": float(self.total_unsafe), "satisfied": float(self.satisfied())}

step_metric

step_metric() -> Dict[str, float]

Per-step metrics.

Returns:

Type Description
Dict[str, float]

Dict containing:

Dict[str, float]
  • "cost": current step cost,
Dict[str, float]
  • "violation": 1.0 if cost >= 0.5 else 0.0.
Source code in masa/common/constraints/ltl_safety.py
def step_metric(self) -> Dict[str, float]:
    """Per-step metrics.

    Returns:
        Dict containing:

        - ``"cost"``: current step cost,
        - ``"violation"``: 1.0 if ``cost >= 0.5`` else 0.0.
    """
    return {"cost": self.step_cost, "violation": float(self.step_cost >= 0.5), "cost_done": float(self.step_cost >= 0.5)}

masa.common.constraints.ltl_safety.LTLSafetyEnv

LTLSafetyEnv(env: Env, dfa: 'DFA' = make_dummy_dfa(), obs_type: str = 'discrete', **kw: Any)

Bases: BaseConstraintEnv

Gymnasium wrapper that monitors LTL safety and augments observations.

This wrapper attaches LTLSafety to the environment and augments the observation space to include the current DFA state, enabling model-free learning over the product.

The representation of the product observation is controlled by obs_type:

  • obs_type="discrete": Requires the underlying observation space to be gymnasium.spaces.Discrete. The observation becomes a single discrete index encoding both base state and automaton state:

$$ \text{obs}_\otimes = q_\text{idx} \cdot n + s. $$

  • obs_type="box": Produces a gymnasium.spaces.Box observation by concatenating a one-hot encoding of the automaton state.

  • If the base space is Box (1-D only), the result is concat([obs, one_hot(q)]).

  • If the base space is Discrete, the result is concat([one_hot(s), one_hot(q)]).

  • obs_type="dict": Produces a gymnasium.spaces.Dict observation with keys: "orig" and "automaton". "automaton" is always Discrete; "orig" matches the original observation (for Discrete) or the original vector (for 1-D Box). For original Dict, this wrapper adds an "automaton" key to the existing dict.

The wrapper also writes info["automaton_state"] each step/reset.

Parameters:

Name Type Description Default
env Env

Base environment (must be a LabelledEnv).

required
dfa 'DFA'

DFA for safety monitoring. Defaults to a dummy DFA.

make_dfa()
obs_type str

One of {"discrete", "box", "dict"}, controlling the product observation representation.

'discrete'
**kw Any

Extra keyword arguments forwarded to BaseConstraintEnv.

{}

Raises:

Type Description
ValueError

If dfa.num_automaton_states is non-positive.

ValueError

If obs_type is not in {"discrete", "box", "dict"}.

TypeError

If an incompatible configuration is requested (e.g. obs_type="discrete" but the base observation space is not Discrete), or if the base space is unsupported.

TypeError

If obs_type requires a 1-D Box but the Box is not 1-D.

Source code in masa/common/constraints/ltl_safety.py
def __init__(
    self,
    env: gym.Env,
    dfa: "DFA" = make_dummy_dfa(),
    obs_type: str = "discrete",
    **kw: Any,
):
    if obs_type not in ("discrete", "box", "dict"):
        raise ValueError(
            f"obs_type must be one of ['discrete', 'box', 'dict'], got {obs_type!r}"
        )

    super().__init__(env, LTLSafety(dfa=dfa), **kw)
    self._num_automaton_states = int(dfa.num_automaton_states)
    if self._num_automaton_states < 1:
        raise ValueError("dfa.num_automaton_states must be non-zero and positive")

    self._automaton_states_idx = {q: i for i, q in enumerate(dfa.states)}

    self._orig_obs_space = env.observation_space
    self._obs_type = obs_type
    self._box_dtype = np.float32

    self.observation_space = self._make_augmented_obs_space(self._orig_obs_space, self._obs_type)

_num_automaton_states instance-attribute

_num_automaton_states = int(dfa.num_automaton_states)

_automaton_states_idx instance-attribute

_automaton_states_idx = {q: i for i, q in enumerate(dfa.states)}

_orig_obs_space instance-attribute

_orig_obs_space = env.observation_space

_obs_type instance-attribute

_obs_type = obs_type

_box_dtype instance-attribute

_box_dtype = np.float32

observation_space instance-attribute

observation_space = self._make_augmented_obs_space(self._orig_obs_space, self._obs_type)

_make_augmented_obs_space

_make_augmented_obs_space(orig: Space, obs_type: str) -> spaces.Space

Construct the augmented observation space.

Parameters:

Name Type Description Default
orig Space

Original observation space of the wrapped environment.

required

Returns:

Type Description
Space

A new observation space that includes the automaton state.

Raises:

Type Description
TypeError

If the observation space type is unsupported, or if a Box space is not 1-D.

Source code in masa/common/constraints/ltl_safety.py
def _make_augmented_obs_space(self, orig: spaces.Space, obs_type: str) -> spaces.Space:
    """Construct the augmented observation space.

    Args:
        orig: Original observation space of the wrapped environment.

    Returns:
        A new observation space that includes the automaton state.

    Raises:
        TypeError: If the observation space type is unsupported, or if a Box
            space is not 1-D.
    """
    if isinstance(orig, spaces.Discrete):
        n = int(orig.n)
        if obs_type == "discrete":
            return spaces.Discrete(n * self._num_automaton_states)
        if obs_type == "box":
            dim = n + self._num_automaton_states
            return spaces.Box(
                low=0.0,
                high=1.0,
                shape=(dim,),
                dtype=self._box_dtype,
            )
        if obs_type == "dict":
            return spaces.Dict(
                {
                    "orig": spaces.Discrete(n),
                    "automaton": spaces.Discrete(self._num_automaton_states)
                }
            )
        raise RuntimeError(f"Unhandled obs_type: {obs_type!r}")

    if isinstance(orig, spaces.Box):
        if obs_type == "discrete":
            raise TypeError(
                "Incompatible configuration: obs_type='discrete' requires a Discrete "
                "base observation space, but got Box."
            )
        if orig.shape is None or len(orig.shape) != 1:
            raise TypeError(
                f"LTLSafetyEnv only supports 1-D Box for augmentation; got shape {orig.shape}"
            )
        d = int(orig.shape[0])
        if obs_type == "box":
            low = np.concatenate(
                [
                    orig.low.astype(self._box_dtype, copy=False),
                    np.zeros(self._num_automaton_states, dtype=self._box_dtype),
                ]
            )
            high = np.concatenate(
                [
                    orig.high.astype(self._box_dtype, copy=False),
                    np.ones(self._num_automaton_states, dtype=self._box_dtype),
                ]
            )
            return spaces.Box(low=low, high=high, dtype=self._box_dtype)
        if obs_type == "dict":
            return spaces.Dict(
                {
                    "orig": orig,
                    "automaton": spaces.Discrete(self._num_automaton_states)
                }
            )
        raise RuntimeError(f"Unhandled obs_type: {obs_type!r}")

    if isinstance(orig, spaces.Dict):
        if obs_type == "discrete":
            raise TypeError(
                "Incompatible configuration: obs_type='discrete' requires a Discrete "
                "base observation space, but got Dict."
            )
        if obs_type == "box":
            raise TypeError(
                "Incompatible configuration: obs_type='box' is not supported when the "
                "base observation space is Dict (cannot flatten generically). "
                "Use obs_type='dict' instead."
            )
        new_spaces = dict(orig.spaces)
        new_spaces["automaton"] = spaces.Discrete(self._num_automaton_states)
        return spaces.Dict(new_spaces)

    raise TypeError(
        f"LTLSafetyEnv does not support base observation space type {type(orig).__name__}. "
        "Supported base spaces: Discrete, 1-D Box, Dict."
    )

_one_hot

_one_hot(idx: int, dim: int) -> np.ndarray

One-hot encode an index into a vector of length dim.

Parameters:

Name Type Description Default
idx int

index to encomde.

required
dim int

length of one-hot encoding.

required

Returns:

Type Description
ndarray

A 1-D numpy array of shape (dim,) containing a

ndarray

one-hot encoding. If idx is out of range, returns the all-zeros vector.

Source code in masa/common/constraints/ltl_safety.py
def _one_hot(self, idx: int, dim: int) -> np.ndarray:
    """One-hot encode an index into a vector of length ``dim``.

    Args:
        idx: index to encomde.
        dim: length of one-hot encoding.

    Returns:
        A 1-D numpy array of shape ``(dim,)`` containing a
        one-hot encoding. If ``idx`` is out of range, returns the all-zeros vector.
    """
    enc = np.zeros(dim, dtype=self._box_dtype)
    if 0 <= int(idx) < dim:
        enc[int(idx)] = 1.0
    return enc

_augment_obs

_augment_obs(obs: Any) -> Any

Augment a base observation with the current automaton state.

Parameters:

Name Type Description Default
obs Any

Base observation returned by the wrapped environment.

required

Returns:

Type Description
Any

Augmented observation matching observation_space.

Raises:

Type Description
TypeError

If the base observation does not match the expected type/shape implied by the observation space.

RuntimeError

If the wrapper is in an unexpected observation-space state.

Source code in masa/common/constraints/ltl_safety.py
def _augment_obs(self, obs: Any) -> Any:
    """Augment a base observation with the current automaton state.

    Args:
        obs: Base observation returned by the wrapped environment.

    Returns:
        Augmented observation matching :attr:`observation_space`.

    Raises:
        TypeError: If the base observation does not match the expected type/shape
            implied by the observation space.
        RuntimeError: If the wrapper is in an unexpected observation-space state.
    """
    q_state = self._constraint.get_automaton_state()
    q_idx = int(self._automaton_states_idx[q_state])

    orig = self._orig_obs_space
    obs_type = self._obs_type

    if isinstance(orig, spaces.Discrete):
        if not isinstance(obs, (int, np.integer)):
            raise TypeError(f"Expected Discrete obs as int, got {type(obs).__name__}")
        s = int(obs)
        if not (0 <= s < int(orig.n)):
            raise TypeError(f"Discrete obs out of range: got {s}, expected [0, {orig.n})")
        if obs_type == "discrete":
            return int(orig.n) * q_idx + s
        if obs_type == "box":
            return np.concatenate(
                [self._one_hot(s, int(orig.n)), self._one_hot(q_idx, self._num_automaton_states)],
                axis=0,
            )
        if obs_type == "dict":
            return {
                "orig": s,
                "automaton": q_idx,
            }
        raise RuntimeError(f"Unhandled obs_type: {obs_type!r}")
    if isinstance(orig, spaces.Box):
        if obs_type == "discrete":
            raise RuntimeError("obs_type='discrete' with Box base should have been rejected.")
        arr = obs if isinstance(obs, np.ndarray) else np.asarray(obs, dtype=self._box_dtype)
        if arr.ndim != 1:
            raise TypeError(
                f"Expected 1-D Box observation, got shape {getattr(arr, 'shape', None)}"
            )
        arr = arr.astype(self._box_dtype, copy=False)
        if obs_type == "box":
            return np.concatenate([arr, self._one_hot(q_idx, self._num_automaton_states)], axis=0)
        if obs_type == "dict":
            return {
                "orig": arr,
                "automaton": q_idx
            }
        raise RuntimeError(f"Unhandled obs_type: {obs_type!r}")
    if isinstance(orig, spaces.Dict):
        if obs_type != "dict":
            raise RuntimeError("Only obs_type='dict' is supported for Dict base spaces.")
        if not isinstance(obs, dict):
            raise TypeError(f"Expected Dict obs as dict, got {type(obs).__name__}")
        out = dict(obs)
        out["automaton"] = q_idx
        return out

    raise RuntimeError(f"Unexpected base observation space type {type(orig).__name__}")

reset

reset(*, seed: int | None = None, options: Dict[str, Any] | None = None)
Source code in masa/common/constraints/ltl_safety.py
def reset(self, *, seed: int | None = None, options: Dict[str, Any] | None = None):
    obs, info = self.env.reset(seed=seed, options=options)
    self._constraint.reset()
    labels = info.get("labels", set())
    self._constraint.update(labels)
    info['automaton_state'] = self._constraint.get_automaton_state()
    return self._augment_obs(obs), info

step

step(action)
Source code in masa/common/constraints/ltl_safety.py
def step(self, action):
    obs, reward, terminated, truncated, info = self.env.step(action)
    labels = info.get("labels", set())
    self._constraint.update(labels)
    info['automaton_state'] = self._constraint.get_automaton_state()
    return self._augment_obs(obs), reward, terminated, truncated, info

Helpers

masa.common.constraints.ltl_safety.create_product_transition_matrix

create_product_transition_matrix(n_states: int, n_actions: int, transition_matrix: ndarray, dfa: DFA, label_fn: LabelFn) -> np.ndarray

Create the dense product transition tensor for (MDP × DFA).

Given a dense base transition tensor of shape (n_states, n_states, n_actions), this constructs the corresponding dense product transition tensor of shape (n_states * n_aut, n_states * n_aut, n_actions).

The DFA transition is computed from labels of the current base state s (i.e., it applies \(q' = \delta(q, L(s))\)), which matches the product formulation used in the code:

\[ P_\otimes((s', q') \mid (s,q), a) = P(s'\mid s,a) \cdot \mathbf{1}\{ q' = \delta(q, L(s)) \}. \]

Parameters:

Name Type Description Default
n_states int

Number of base MDP states.

required
n_actions int

Number of actions.

required
transition_matrix ndarray

Dense base transition tensor with shape (n_states, n_states, n_actions), where transition_matrix[s, s_next, a] = P(s_next | s, a).

required
dfa DFA

Deterministic finite automaton.

required
label_fn LabelFn

Labelling function L(s) -> set[str].

required

Returns:

Type Description
ndarray

Dense product transition tensor with shape

ndarray

(n_states * n_aut, n_states * n_aut, n_actions).

Raises:

Type Description
AssertionError

If the provided transition matrix does not have the expected shape.

Source code in masa/common/constraints/ltl_safety.py
def create_product_transition_matrix(
    n_states: int,
    n_actions: int,
    transition_matrix: np.ndarray, 
    dfa: DFA,
    label_fn: LabelFn,
) -> np.ndarray:
    """Create the dense product transition tensor for (MDP × DFA).

    Given a dense base transition tensor of shape ``(n_states, n_states, n_actions)``,
    this constructs the corresponding dense product transition tensor of shape
    ``(n_states * n_aut, n_states * n_aut, n_actions)``.

    The DFA transition is computed from labels of the *current* base state ``s``
    (i.e., it applies :math:`q' = \\delta(q, L(s))`), which matches the product
    formulation used in the code:

    .. math::

       P_\\otimes((s', q') \\mid (s,q), a)
       = P(s'\\mid s,a) \\cdot \\mathbf{1}\\{ q' = \\delta(q, L(s)) \\}.

    Args:
        n_states: Number of base MDP states.
        n_actions: Number of actions.
        transition_matrix: Dense base transition tensor with shape
            ``(n_states, n_states, n_actions)``, where
            ``transition_matrix[s, s_next, a] = P(s_next | s, a)``.
        dfa: Deterministic finite automaton.
        label_fn: Labelling function ``L(s) -> set[str]``.

    Returns:
        Dense product transition tensor with shape
        ``(n_states * n_aut, n_states * n_aut, n_actions)``.

    Raises:
        AssertionError: If the provided transition matrix does not have the expected shape.

    """

    assert len(transition_matrix.shape) == 3 and transition_matrix.shape[0] == transition_matrix.shape[1], \
    f"Expected transition matrix with shape (n_states, n_states, n_actions), got shape {transition_matrix.shape} instead"

    aut_states = list(dfa.states)
    n_aut = len(aut_states)
    aut_index = {q: i for i, q in enumerate(aut_states)}

    assert n_states == transition_matrix.shape[0], \
    "Something went wrong, the provided n_states does not equal the number of states in transition_matrix"
    f"Got n_states = {n_states} and transition_matrix.shape[0] == {transition_matrix.shape[0]}"

    assert n_actions == transition_matrix.shape[2], \
    "Something went wrong, the provided n_actions does not equal the number of states in transition_matrix"
    f"Got n_actions = {n_actions} and transition_matrix.shape[2] == {transition_matrix.shape[2]}"

    sat = np.zeros((n_aut, n_aut, n_states), dtype=np.float32)

    for i, i_state in enumerate(aut_states):
        for j, j_state in enumerate(aut_states):
            if i == j:
                continue
            if dfa.has_edge(i_state, j_state):
                edge = dfa.edges[i_state][j_state]
                # i_j_sat_relation[s] = edge.sat(label_fn(s))
                i_j_sat_relation = np.array(
                    [edge.sat(label_fn(s)) for s in range(n_states)],
                    dtype=np.float32,
                )
                sat[i, j, :] = i_j_sat_relation

    sat_no_diag = sat.copy()
    idx = np.arange(n_aut)
    sat_no_diag[idx, idx, :] = 0.0

    # Default behvaiour: states with no outgoing edge loop in the automata
    outgoing_any = sat_no_diag.max(axis=1)
    loop_sat = 1.0 - outgoing_any
    sat[idx, idx, :] = loop_sat

    product = np.einsum('ijs,ska->jsika', sat, transition_matrix.astype(np.float32))

    n_prod_states = n_states * n_aut
    product_transition_matrix = product.reshape(
        n_aut * n_states,
        n_aut * n_states,
        n_actions,
    )

    return product_transition_matrix

masa.common.constraints.ltl_safety.create_product_successor_states_and_probabilities

create_product_successor_states_and_probabilities(n_states: int, n_actions: int, successor_states: Dict[State, List[State]], probabilities: Dict[Tuple[State, Action], ndarray], dfa: DFA, label_fn: LabelFn) -> Tuple[Dict[ProdState, List[ProdState]], Dict[Tuple[ProdState, Action], np.ndarray]]

Create a sparse product successor representation (MDP x DFA).

This constructs:

  • prod_successor_states: mapping prod_state -> list[prod_state_next]
  • prod_probabilities: mapping (prod_state, action) -> probs

where probability vectors are copied from the base representation and the automaton transition is determined by the current base state labels.

Product state indexing ~~~~~~~~~~~~~~~~~~~~~~ The code uses the encoding:

\[ (q\_\text{idx}, s) \mapsto \text{prod} = q\_\text{idx} \cdot n\_\text{states} + s. \]

Parameters:

Name Type Description Default
n_states int

Number of base MDP states.

required
n_actions int

Number of actions.

required
successor_states Dict[State, List[State]]

Mapping s -> [s_1, s_2, ...] listing successors of s.

required
probabilities Dict[Tuple[State, Action], ndarray]

Mapping (s, a) -> p where p is a 1-D array aligned with successor_states[s] and sums to 1.

required
dfa DFA

Deterministic finite automaton.

required
label_fn LabelFn

Labelling function L(s) -> set[str].

required

Returns:

Type Description
Dict[ProdState, List[ProdState]]

A tuple (prod_successor_states, prod_probabilities) representing the

Dict[Tuple[ProdState, Action], ndarray]

product dynamics.

Raises:

Type Description
AssertionError

If n_states is inconsistent with the keys in successor_states.

Source code in masa/common/constraints/ltl_safety.py
def create_product_successor_states_and_probabilities(
    n_states: int,
    n_actions: int,
    successor_states: Dict[State, List[State]],
    probabilities: Dict[Tuple[State, Action], np.ndarray],
    dfa: DFA,
    label_fn: LabelFn,
) -> Tuple[Dict[ProdState, List[ProdState]], Dict[Tuple[ProdState, Action], np.ndarray]]:
    """Create a sparse product successor representation (MDP x DFA).

    This constructs:

    - ``prod_successor_states``: mapping ``prod_state -> list[prod_state_next]``
    - ``prod_probabilities``: mapping ``(prod_state, action) -> probs``

    where probability vectors are copied from the base representation and the
    automaton transition is determined by the current base state labels.

    Product state indexing
    ~~~~~~~~~~~~~~~~~~~~~~
    The code uses the encoding:

    .. math::

       (q\\_\\text{idx}, s) \\mapsto \\text{prod} = q\\_\\text{idx} \\cdot n\\_\\text{states} + s.

    Args:
        n_states: Number of base MDP states.
        n_actions: Number of actions.
        successor_states: Mapping ``s -> [s_1, s_2, ...]`` listing successors of ``s``.
        probabilities: Mapping ``(s, a) -> p`` where ``p`` is a 1-D array aligned with ``successor_states[s]`` and sums to 1.
        dfa: Deterministic finite automaton.
        label_fn: Labelling function ``L(s) -> set[str]``.

    Returns:
        A tuple ``(prod_successor_states, prod_probabilities)`` representing the
        product dynamics.

    Raises:
        AssertionError: If ``n_states`` is inconsistent with the keys in ``successor_states``.

    """

    base_states = sorted(successor_states.keys())

    assert n_states == len(base_states), \
    "Something went wrong, the provided n_states does not equal the numebr of states in successor_states "
    f"Got n_states = {n_states} and len(successor_states) = {len(base_states)}"

    state_index = {s: idx for idx, s in enumerate(base_states)}

    aut_states = list(dfa.states)
    n_aut = len(aut_states)
    aut_index = {q: i for i, q in enumerate(aut_states)}

    next_aut = np.zeros((n_aut, n_states), dtype=np.int64)

    for q_idx, q in enumerate(aut_states):
        for s in base_states:
            labels = label_fn(s)
            q_next = dfa.transition(q, labels)
            j_idx = aut_index[q_next]
            next_aut[q_idx, s] = j_idx

    prod_successor_states: Dict[ProdState, List[ProdState]] = {}
    prod_probabilities: Dict[Tuple[ProdState, Action], np.ndarray] = {}

    for q_idx, q in enumerate(aut_states):
        for s in base_states:
            prod_state = q_idx * n_states + s

            succ_s = successor_states.get(s, [])
            if not succ_s:
                continue

            j_idx = next_aut[q_idx, s]

            prod_succ_list = [j_idx * n_states + s_prime for s_prime in succ_s]
            prod_successor_states[prod_state] = prod_succ_list

            for a in range(n_actions):
                probs_sa = probabilities.get((s, a))
                if probs_sa is None:
                    continue

                prod_probabilities[(prod_state, a)] = probs_sa.copy()

    return prod_successor_states, prod_probabilities

masa.common.constraints.ltl_safety.create_product_label_fn

create_product_label_fn(n_states: int, dfa: DFA) -> Callable[[ProdState], Set[str]]

Create a label function on product states indicating DFA acceptance.

The returned labelling function maps a product-state index to {"accepting"} if the embedded DFA state is accepting, and to the empty set otherwise.

Parameters:

Name Type Description Default
n_states int

Number of base states used in product encoding.

required
dfa DFA

DFA defining which automaton indices are accepting.

required

Returns:

Type Description
Callable[[ProdState], Set[str]]

A callable L_prod(prod_state) -> set[str] suitable for cost functions

Callable[[ProdState], Set[str]]

such as::

cost = 1.0 if "accepting" in labels else 0.0

Source code in masa/common/constraints/ltl_safety.py
def create_product_label_fn(
    n_states: int,
    dfa: DFA,
) -> Callable[[ProdState], Set[str]]:
    """Create a label function on product states indicating DFA acceptance.

    The returned labelling function maps a product-state index to ``{"accepting"}``
    if the embedded DFA state is accepting, and to the empty set otherwise.

    Args:
        n_states: Number of base states used in product encoding.
        dfa: DFA defining which automaton indices are accepting.

    Returns:
        A callable ``L_prod(prod_state) -> set[str]`` suitable for cost functions
        such as::

            cost = 1.0 if "accepting" in labels else 0.0

    """

    aut_states = list(dfa.states)
    aut_index = {q: i for i, q in enumerate(aut_states)}
    accepting_indexes = {aut_index[q] for q in dfa.accepting}

    def product_label_fn(obs):
        aut_state_idx = obs // n_states
        if aut_state_idx in accepting_indexes:
            return {"accepting"}
        else:
            return set()

    return product_label_fn

Monitoring versus enforcement

LTLSafetyEnv monitors a safety DFA and augments the observation with the DFA state.

It does not itself restrict the agent's actions.

In MASA's safety interface, accepting DFA states recognize bad prefixes: entering an accepting state means that the safety property has been violated, rather than that a task has been successfully completed.

For enforcement, wrap

LTLSafetyEnv(..., obs_type="discrete")

with either:

PreemptiveLTLShield(...)

or:

PostposedLTLShield(...)

Both wrappers use the same winning region.

  • Preemptive shielding exposes a safe-action mask before the policy chooses.
  • Postposed shielding checks a proposed action and replaces it only when the proposal is unsafe.

See Winning-region safety-game shielding for the safety-game construction, guarantee assumptions, examples, replacement strategies, and API reference.

Monitor timing

The live LTL monitor consumes the initial state's labels during reset().

After each environment transition, it consumes the labels of the newly returned state.

Therefore, if the current product state is \((q,s)\), the next monitor state after a physical transition to \(s'\) is

\[ q' = \delta(q,L(s')). \]

The winning-region shield follows this timing when constructing its product successors.

When using the product-construction helpers below independently, check their documented label timing rather than assuming that every product representation uses the same convention.