In [1]:
import spot
spot.setup(show_default=".A")  # hide acceptance conditions
from spot.jupyter import display_inline
  • MTBDDs
  • LTLf semantics
  • Conversion from LTLf to MTBDD
  • Examples of outputs for tr
  • Building a MTBDD-based DFA
  • Propositional equivalence
  • Simplifying terminals
  • Detecting empty or universal translations
  • Conversion from MTDFA to Twa
  • More examples
  • DFA Minimization
  • Minimal MTDFA does not imply minimal state-based DFA
  • Examples that caused problems to the minimization at some point
  • Boolean operations on MTDFAs
  • Converting from TwA to MTDFA
  • Compositional translation
  • Game interpretation
  • Different ways to solve such a game
  • Building a restricted MTBDD-based DFA for synthesis
  • Strategy to AIG

MTBDDs¶

(If you are familiar with Mona's MTBDDs, skip directly to the last paragraph of this section.)

A Binary Decision Diagram represents a Boolean formula $f(a_1,a_2,\ldots,a_n): \mathbb{B}^n\to\mathbb{B}$ as a DAG in which each internal node is labeled by one of the $a_i$s (always in the same order) and has two children corresponding to $a_i=\bot$ and $a_i=\top$. The leaves of the DAG represent the values of $f$ when its arguments are assigned to the values represented on the path leading to that leave. This is typically implemented by storing all nodes in an array of triplets of integers $(i,low,high)$ where $i$ denotes variable $a_i$, $low$ is the index of the triplet for the $a_i=\bot$ branch, and $high$ is the index of the triplet for the $a_i=\top$ branch.
Special triplets $(n+1,0,0)$ and $(n+1,1,1)$ are stored at index 0 and 1 and represent the output values $\bot$ and $\top$. (The fact that they are stored at indices 0 and 1 makes it easy to recognize those constants from their indices without having to look at the triplets.)

Multi-Terminal BDDs generalize the co-domain of the representable functions to $\mathbb{N}$. So the DAG now stores functions of the form $f(a_1,a_2,\ldots,a_n): \mathbb{B}^n\to\mathbb{N}$. In this context, each leaf of the DAG should store a value $v\in\mathbb{N}$. Mona represents this by a triplet of the form $(\mathrm{MAXINT}, 1, v)$. (The value 1 does not mean anything, but both Mona and BuDDy use $low=-1$ to denote a unused triplet, so that makes it dangerous to repurpose the $low$ field.)

In both cases, a typical binary operation takes two BDDs, recursively descends in both BDDs in a way to explore all combinations of variables, combine the leaves once they are reached on both sides, and rebuild the resulting BDD from the bottom up while backtracking. The combinations of leaves correspond to Boolean operations in the $\mathbb{B}$ case. They can be arbitrary $\mathbb{N}\times\mathbb{N}\to\mathbb{N}$ functions in the MTBDD case. However, even of the two cases look very similar at a high-level, they are quite different in practice:

  • In the Boolean case, the set of Boolean operations that can be used for combinations is small. Therefore a BDD library can implement them all, share a cache for all these operations, and implement shortcuts (for instance $\top\land f$ can return $f$ right away without having to recurse all the way down to the leaves of $f$ to rebuild $f$ from the bottom).

  • In the Multi-Terminal case, an arbitrary $\mathbb{N}\times\mathbb{N}\to\mathbb{N}$ operation has to be supplied by the user as a function pointer. The facts that (1) the combination function works only on the terminals, and that (2) the apply function does not know what it does, makes it impossible for apply to "shortcut" the recursion. It also makes it difficult to cache multiple operations in the same cache because the combination function will likely use some global state (the function pointer is therefore not a good key for a hash table). Mona solves the cache issue by letting users manage the cache.

The version of BuDDy that ships with Spot has already been modified in many ways. It now supports integer-valued terminals in addition to the usual false and true nodes. You can build pure MTBDDs that do not use false and true, like in Mona. You can build standard BDDs that you only false and true, like BuDDy has always supported. And you may also build MTBDDs that mix integer-terminals and Boolean leaves. So in effect you can represent functions of $\mathbb{B}^n\to\mathbb{N}\cup\mathbb{B}$. The latter mix makes sense when some of the terminals would have semantics equivalent to false and true, and the applied operations also behave like Boolean operations: in that case we can specify the Boolean operation performed (in addition to passing the combination function that performs it on terminal) so that apply() knows how to shortcut its recursion when it reaches the false or true nodes on for one of its arguments.

LTLf semantics¶

This describes the standard LTLf semantics over non-empty words.

Given a set of atomic propositions $AP$, we consider LTLf formulas built over $AP$, using constants $\bot$, $\top$, unary operators $\mathsf{X}$, $\mathsf{X[!]}$, $\mathsf{F}$, $\mathsf{G}$, $\lnot$ and binary operators $\mathsf{U}$, $\mathsf{R}$, $\mathsf{W}$, $\mathsf{M}$, $\land$, $\lor$, $\rightarrow$, $\leftrightarrow$, $\oplus$.
Let $\sigma\in\Sigma^n$ be a non-empty word of $n>0$ letters of $\Sigma=2^{AP}$, let $i\in\{0,\ldots,n-1\}$ be some position on $\sigma$, and let $\varphi$ be an LTLf formula. The satisfaction of $\sigma$ by an LTLf formula at position $i$, denoted $\sigma,i\models \varphi$, is defined as follows:

$$ \begin{aligned} \sigma,i &\models \bot && \iff i=n \\ \sigma,i &\models \top && \iff i<n \\ \text{for }p\in\mathit{AP}\quad \sigma,i &\models p && \iff p\in\sigma(i) \\ \sigma,i &\models \lnot \varphi && \iff \lnot(\sigma,i \models \varphi)\\ \text{for }\odot\in\{\land,\lor,\rightarrow,\leftrightarrow,\oplus\}\quad\sigma,i&\models \varphi_1\odot\varphi_2 &&\iff (\sigma,i\models \varphi_1)\odot(\sigma,i\models \varphi_2) \\ \sigma,i &\models \mathop{\mathsf{X}}\varphi &&\iff (i+1=n)\lor(\sigma, i+1\models \varphi)\\ \sigma,i &\models \mathop{\mathsf{X[!]}}\varphi &&\iff (i+1<n)\land(\sigma, i+1\models \varphi)\\ \sigma,i &\models \varphi_1\mathbin{\mathsf{U}}\varphi_2 &&\iff \exists j\in \{i,\ldots,n-1\}, (\sigma,j\models \varphi_2) \land (\forall k\in\{i,\ldots,j-1\}, \sigma,k\models \varphi_1)\\ \sigma,i &\models \varphi_1\mathbin{\mathsf{R}}\varphi_2 &&\iff \forall j\in \{i,\ldots,n-1\}, (\sigma,j\models \varphi_2) \lor (\exists k\in\{i,\ldots,j-1\}, \sigma,k\models \varphi_1)\\ \sigma,i &\models \varphi_1\mathbin{\mathsf{M}}\varphi_2 &&\iff \exists j\in \{i,\ldots,n-1\}, (\sigma,j\models \varphi_1) \land (\forall k\in\{i,\ldots,j\},\phantom{{}-1} \sigma,k\models \varphi_2)\\ \sigma,i &\models \varphi_1\mathbin{\mathsf{W}}\varphi_2 &&\iff \forall j\in \{i,\ldots,n-1\}, (\sigma,j\models \varphi_1) \lor (\exists k\in\{i,\ldots,j\},\phantom{{}-1} \sigma,k\models \varphi_2)\\ \sigma,i &\models \mathop{\mathsf{F}}\varphi &&\iff \exists j\in[i,n), \sigma,j\models \varphi\\ \sigma,i &\models \mathop{\mathsf{G}}\varphi &&\iff \forall j\in[i,n), \sigma,j\models \varphi\\ \end{aligned} $$

A word $\sigma$ is accepted by $\varphi$ if $\sigma,0\models \varphi$.

Note that there exists an alternative definition of LTLf that considers the empty word. However one then needs two different negation operators to distinguish $\bar p$ (meaning $|\sigma|>0 \land p\not\in\sigma(0)$) from $\lnot p$ (meaning $|\sigma|=0 \lor p\in\sigma(0)$). Giacomo et al. avoid that problem by using only LTLf formulas in negative normal form. In what follows, we ignore the empty word and support arbitrary formulas that are not necessarily in negative normal form.

Conversion from LTLf to MTBDD¶

Given an LTLf formula labeling one automaton state, we want to construct an MTBDD that represents the set of its outgoing transitions: terminals will represent LTL formulas labeling the destination states, and one path in such an MTBDD will represent one outgoing transition. The $tr$ function defined below constructs such an MTBDD. (A full automaton will eventually be represented by an array of MTBDDs, one per state.)

In this context, we will use the terminals to represent pairs $(f,b)$ where $f$ is an LTLf formula, and $b$ is a Boolean. For simplicity we write $term(f,b)$ but in practice the terminal stores an integer, and this integer allows to find $f$ and $b$. The terminal $term(f,b)$ can be understood as representing a destination state labeled by $f$, and that state should be accepting iff $b=\top$. Or equivalently, you could think of $term(f,\top)$ as meaning $f\lor \varepsilon$; i.e., $b$ indicates whether the empty word is meant to be accepted after reaching that destination state.

We can apply a binary operator $\odot\in\{\land,\lor,\rightarrow,\leftrightarrow,\oplus\}$ to two MTBDDs using the aforementioned apply() algorithm, by using the convention that $term(f_1,b_1)\odot term(f_2,b_2) = term(f_1\odot f_2,b_1\odot b_2)$. The negation operator is handled similarly: $\lnot term(f,b) = term(\lnot f,\lnot b)$.

The translation of an LTLf formula to MTBDD is done using the following rules:

  • $tr(\top) = term(\top,\top)\qquad$ (See optimization below)
  • $tr(\bot) = term(\bot,\bot)\qquad$ (See optimization below)
  • $tr(p) = p\land term(\top, \top)\quad$ for any atomic proposition $p\in AP$
  • $tr(\lnot \alpha) = \lnot tr(\alpha)$
  • $tr(\alpha \odot \beta)= tr(\alpha)\odot tr(\beta)$ for any $\odot\in\{\land,\lor,\rightarrow,\leftrightarrow,\oplus\}$
  • $tr(\mathop{\mathsf{X}}\alpha) = term(\alpha,\top)$
  • $tr(\mathop{\mathsf{X[!]}}\alpha) = term(\alpha,\bot)$
  • $tr(\alpha \mathbin{\mathsf{U}} \beta) = tr(\beta) \lor (tr(\alpha)\land term(\alpha \mathbin{\mathsf{U}} \beta, \bot))$
  • $tr(\alpha \mathbin{\mathsf{W}} \beta) = tr(\beta) \lor (tr(\alpha)\land term(\alpha \mathbin{\mathsf{W}} \beta, \top))$
  • $tr(\alpha \mathbin{\mathsf{M}} \beta) = tr(\beta) \land (tr(\alpha)\lor term(\alpha \mathbin{\mathsf{M}} \beta, \bot))$
  • $tr(\alpha \mathbin{\mathsf{R}} \beta) = tr(\beta) \land (tr(\alpha)\lor term(\alpha \mathbin{\mathsf{R}} \beta, \top))$
  • $tr(\mathop{\mathsf{F}}\alpha) = tr(\alpha)\lor term(\mathop{\mathsf{F}}\alpha,\bot)$
  • $tr(\mathop{\mathsf{G}}\alpha) = tr(\alpha)\land term(\mathop{\mathsf{G}}\alpha,\top)$

Note that the argument of $tr$ does not have to be in negative normal form.

Optimization: The above definition of $tr$ creates a pure MTBDD in which all leaves are terminal nodes. This unfortunately implies that whenever two MTBDD are combined, apply() has to recurse to the bottom of both operands to perform operations on terms. To help apply() shortcut some operations, we replace $term(\top,\top)$ and $term(\bot,\bot)$ by the true and false BDD constants respectively. A consequence of that is that for any Boolean formula $b$, $tr(b)$ is the traditional BDD representing the formula $b$, not involving any terminal.

Implementation details: Assuming there exists a mapping $M$ from LTLf formulas to integers, we actually represent $term(f,b)$ as a terminal with integer value $2M(f)+b$. In other words the lowest significant bit of the terminal value is used to remember whether the associated formula is allowed to satisfy the empty word.

Related work

This draws inspiration from several sources.

  • Brzozowski's derivatives is a way to construct a DFA from a regular expression. The automaton is labeled by regular expressions, successors are obtained using a function $\delta_\ell(r)$ that computes the regular expression that one should go to after reading $\ell$ from a state labeled by $r$. The technique support regular expressions using any Boolean operator, not just disjunction. However for each state, it requires iterating over $\ell\in\Sigma$, so this can become quite slow for a large $\Sigma$.
  • Giacomo et al.'s formula progressions is the equivalent of Brzozowski's derivatives, but applied to LTLf with semantics that consider the empty word. It requires the formula to be in negative normal form (that appears to be a consequence of allowing the empty word but not willing to complement it). Since LTLf is defined over a propositional alphabet, we have $\Sigma=2^{AP}$, and for each state we unfortunately have to loop over all letters $\ell\in\Sigma$ to compute the progressions, as in Brzozowski's derivatives.
  • Antimirov's linear forms is a way compute all successors of a state labeld by a regular expression $r$ in a single pass. The linear form $\mathit{lf}(r)$ is a set $\{(\ell_1,r_1),...,(\ell_n,r_n)\}$ where $l_i$ are letters and $r_i$s are regular expressions, such that $L(r)\setminus\{\varepsilon\}=\bigvee_i \ell_i\cdot L(r_i)$. One can use that to construct an NFA (when multiple $\ell_i$ can be equal) or a DFA (after merging the pairs with identical $\ell_i$: $\{...,(\ell,r),(\ell,s),...\} \to \{...,(\ell,r\lor s),...\}$). Altought Antimirov only worked with classical regular expressions where disjunction is the only Boolean operator, deteriministic linear forms are easy to adapt to arbitrary Boolean operators. The linear form $\mathit{lf}(r)$ can be seen as a way to compute all derivatives in a single pass (the set of formulas obtained are not always syntactically equal to the set of derivatives, but they are equivalent).
  • Martin et al.'s linear forms is an adaptation of Antimirov's linear forms to the semi-extended regular expressions (SERE) that appear in PSL or SVA. These expressions are defined over a propositional alphabet, so that makes the use of linear-forms (that can be computed in a single pass independent of the size of $\Sigma$) pertinent. This work also shows that two states that can have the same linear form can be merged when using finite automata with transition-based acceptance.
  • Couvreur's LTL translation creates transition-based generalized Büchi automata from LTL. It uses BDDs to encode the outgoing transitions of a state labeled by a LTL formula. Extra BDD variables are used to store destination state as well as promises that have yet to be fulfilled. That BDD is computed recursively from the LTL label, using a set of rules similar to those of $tr$. Transitions can be recovered from the BDD by computing its prime implicants. Couvreur also suggests merging state represented by identical BDDs, and that transition naturally lends itself to transition-based acceptance. This algorithm is actually the core LTL translation in Spot, and computing those prime implicants is a costly part of the process.
  • Mona uses MTBDDs to represent DFAs. My understanding is that their translation of WS1S to DFA is implemented as DFA operations over that representation. They do not really build their DFA state-by-state as in the previous techniques.

An MTBDD is actually very good data structure to represent a deterministic linear form over a propositional alphabet. The MTBDD computed by $tr(\alpha)$ above can be seen as a symbolic linear form for $tr(\alpha)$. This can equivalently be seen as a way to compute all formula progressions at once (modulo the semantics differences for the empty word). As done in Martin at al. and Couvreur's papers, we can merge two states that have the same MTBDD and benefit from transition-based acceptance (as will be shown below).

Examples of outputs for $tr$¶

Recall that $tr(\alpha)$ computes an MTBDD representing the outgoing edges of a single state labeled by $\alpha$.

In [2]:
# The C++ version of the above tr() function isn't user-accesible, but we can
# fake it by displaying only the first state of the automaton.  The function
# ltlf_to_mtdfa is dicussed in the next section.
def tr(f):
    return spot.ltlf_to_mtdfa(f, False).show(state=0)

The following example shows how the two MTBDDs for $a$ and $\mathop{\mathsf{X}}a$ can be combined in an MTBDD for $a\land \mathop{\mathsf{X}}$

The cyan rounded rectangles at the top of the DAGs are not part of the MTBDD, they are just giving a name to that MTBDD root to help us read the picture. A terminal $term(f,b)$ is shown as a magental rounded rectangle labeled by $f$, and that leaf has a double enclosure iff $b=\top$. The BDD constants false and true are shown in orange squares, but since true is actually a replacement for $term(\top,\top)$, it is also displayed with a double enclosure. White round nodes are labeled by atomic propositions. Below white nodes, dotted edges represent low links, and plain edges are high link. Hovering over the pictures with the mouse will display additional informations like bdd node numbers.

Below we learn that from a state labeled by $a\land \mathop{\mathsf{X}}$, we should not read $\lnot a$ (because that path would go to false, which is not accepting), and that if we read $a$, we can either accept the word if that was the last letter (because the terminal reached is marked as accepting), or go to a state labeled by formula $b$ to read the suffix of the word.

In [3]:
display_inline(tr("a"), tr("Xb"), tr("a & Xb"))
mtdfa S0 a B2 a S0->B2 B0 0 B2->B0 B1 1 B2->B1
mtdfa S0 Xb B6 b S0->B6
mtdfa S0 a & Xb B7 a S0->B7 B0 0 B7->B0 B6 b B7->B6

Here are the outputs of $tr$ on some basic operators, just to double-check their definitions.

In [4]:
display_inline(tr("0"), tr("1"), tr("a <-> b"))
display_inline(tr("Xa"), tr("X[!]a"), tr("Fa"), tr("Ga"))
display_inline(tr("a U b"), tr("a W b"), tr("a R b"), tr("a M b"))
mtdfa S0 0 B0 0 S0->B0
mtdfa S0 1 B1 1 S0->B1
mtdfa S0 a <-> b B8 a S0->B8 B5 b B8->B5 B4 b B8->B4 B1 1 B5->B1 B0 0 B5->B0 B4->B1 B4->B0
mtdfa S0 Xa B6 a S0->B6
mtdfa S0 X[!]a B9 a S0->B9
mtdfa S0 Fa B11 a S0->B11 B1 1 B11->B1 B10 Fa B11->B10
mtdfa S0 Ga B13 a S0->B13 B0 0 B13->B0 B12 Ga B13->B12
mtdfa S0 a U b B16 a S0->B16 B4 b B16->B4 B15 b B16->B15 B0 0 B4->B0 B1 1 B4->B1 B15->B1 B10 a U b B15->B10
mtdfa S0 a W b B18 a S0->B18 B4 b B18->B4 B17 b B18->B17 B0 0 B4->B0 B1 1 B4->B1 B17->B1 B12 a W b B17->B12
mtdfa S0 a R b B21 a S0->B21 B20 b B21->B20 B4 b B21->B4 B0 0 B20->B0 B12 a R b B20->B12 B4->B0 B1 1 B4->B1
mtdfa S0 a M b B23 a S0->B23 B22 b B23->B22 B4 b B23->B4 B0 0 B22->B0 B10 a M b B22->B10 B4->B0 B1 1 B4->B1

The following plays with Boolean operators that are not used when forulas are in negative normal forms. However this translation does not require the input to be in negative normal form.

In [5]:
display_inline(tr("Ga"), tr("!Ga"), tr("F!a"), tr("Fb"))
display_inline(tr("Ga -> Fb"), tr("F!a | Fb"))
display_inline(tr("Ga <-> Fb"), tr("Fa xor Gb"))
mtdfa S0 Ga B13 a S0->B13 B0 0 B13->B0 B12 Ga B13->B12
mtdfa S0 !Ga B24 a S0->B24 B1 1 B24->B1 B10 !Ga B24->B10
mtdfa S0 F!a B24 a S0->B24 B1 1 B24->B1 B10 F!a B24->B10
mtdfa S0 Fb B11 b S0->B11 B1 1 B11->B1 B10 Fb B11->B10
mtdfa S0 Ga -> Fb B32 a S0->B32 B15 b B32->B15 B1 1 B32->B1 B15->B1 B10 Ga -> Fb B15->B10
mtdfa S0 F!a | Fb B32 a S0->B32 B15 b B32->B15 B1 1 B32->B1 B15->B1 B10 F!a | Fb B15->B10
mtdfa S0 Ga <-> Fb B43 a S0->B43 B40 b B43->B40 B42 b B43->B42 B0 0 B40->B0 B6 !Fb B40->B6 B10 Ga <-> Fb B42->B10 B41 Ga B42->B41
mtdfa S0 Fa xor Gb B53 a S0->B53 B48 b B53->B48 B52 b B53->B52 B9 Fa B48->B9 B12 Fa xor Gb B48->B12 B1 1 B52->B1 B25 !Gb B52->B25

Building a MTBDD-based DFA¶

Iterating the use of tr() over all formulas appearing on terminals, we can build a MTBDD-based DFA. This is an array of MTBDDs, as in Mona. We call those MTDFA. The initial MTBDD (or initial state if you prefer) is indicated with a down arrow. The different MTBDD denoting the states can of course share part of their BDDs.

In [6]:
spot.ltlf_to_mtdfa("a U b U c", False, False)
Out[6]:
mtdfa S0 a U (b U c) I->S0 B63 a S0->B63 S1 b U c B56 b S1->B56 S2 (b U c) | (a U (b U c)) S2->B63 B63->B56 B62 b B63->B62 B27 c B56->B27 B55 c B56->B55 B57 c B62->B57 B61 c B62->B61 B1 1 B27->B1 B0 0 B27->B0 B57->B1 B10 a U (b U c) B57->B10 B61->B1 B25 (b U c) | (a U (b U c)) B61->B25 B55->B1 B9 b U c B55->B9

Different LTLf formulas can have the same MTBDD representation. Above, we can see that $tr(a \mathbin{\mathsf{U}} (b \mathbin{\mathsf{U}} c))=tr((b \mathbin{\mathsf{U}} c) \lor a \mathbin{\mathsf{U}} (b \mathbin{\mathsf{U}} c))$.

Since the successors of these states are identical, it makes sense to fuse them. This is actually what ltlf_to_mtdfa() does by default.

In [7]:
aut1 = spot.ltlf_to_mtdfa("a U b U c")
aut1
Out[7]:
mtdfa S0 a U (b U c) I->S0 B64 a S0->B64 S1 b U c B56 b S1->B56 B64->B56 B57 c B64->B57 B27 c B56->B27 B55 c B56->B55 B1 1 B57->B1 B10 a U (b U c) B57->B10 B27->B1 B0 0 B27->B0 B55->B1 B9 b U c B55->B9

When fusing states, we must be cautious about the acceptance of these states.

Here is an example where this is clearly visible:

In [8]:
display_inline(spot.ltlf_to_mtdfa("GFa", False, False), spot.ltlf_to_mtdfa("GFa"))
mtdfa S0 GFa I->S0 B67 a S0->B67 S1 Fa & GFa S1->B67 B9 Fa & GFa B67->B9 B12 GFa B67->B12
mtdfa S0 GFa I->S0 B68 a S0->B68 B10 GFa B68->B10 B12 GFa B68->B12

Here, $(\mathop{\mathsf{F}}a)\land (\mathop{\mathsf{G}}\mathop{\mathsf{F}}a)$ and $\mathop{\mathsf{G}}\mathop{\mathsf{F}}a$ are equivalent, so the algorithm simply remembers the first formula it has seen. Distinguishing the acceptance of the terminal nodes is not a problem.

Implementation details: A MTDFA is represented using two arrays:

  • formula names[n];
  • bdd states[n];

The MTBDD stored in states[i] encodes the outgoing edges of state i. State 0 is always the initial state. The terminal in those MTBDDs store integers of the form 2j+a were j is the index of the destination state, and a is a boolean indicating whether that destination is accepting. The names array is purely cosmetic: names[i] is the LTLf formula associated to state i, it can be used for rendering (names can be empty if one does not want to label the states).

Automaton aut1 above automaton has n=2 roots for a U b U c and b U c, but one will usually want to consider 1 as a state. The state for formula 1 is not stored, because it is always encoded as true. (People who prefer to work with complete DFAs, might want to consider 0 as a state too.) The mtdfa class has two methods to return its size: num_roots() counts the number of roots stored in the DFA (i.e., the size of the states and names array), and num_states() adds one to num_roots() if any root can reach true.

In [9]:
print(aut1.num_roots(), aut1.num_states())
2 3

Propositional equivalence¶

The approach for building a MTDFA described above does not always terminate.

For instance consider $\varphi_1=(\mathop{\mathsf{G}}a) \mathbin{\mathsf{W}} (\mathop{\mathsf{G}}b)$. The MTBDD constructed by $tr(\varphi)$ will contain a path labeled $a\land b$ with destination $term(\varphi_2, \top)$ where $\varphi_2=(\mathop{\mathsf{G}}b) \lor ((\mathop{\mathsf{G}}a) \land \varphi_1)$. The formula MTBDD for $tr(\varphi_2)$ will in turn contain a path labeled by $a\land b$ with destination $term(\varphi_3, \top)$ where $\varphi_3=(\mathop{\mathsf{G}}b) \lor ((\mathop{\mathsf{G}}a) \land ((\mathop{\mathsf{G}}b) \lor ((\mathop{\mathsf{G}}a) \land \varphi_1)))$. The series could continue indefinitely like this adding new terms as it progress.

To avoid this, we say that two LTLf formulas are propositionally equivalent iff, when we interpret their maximal subformulas starting with a temporal operator as if they were propositions, the two formulas are equivalent. For instance formulas $\varphi_2$ and $\varphi_3$ are propositionally equivalent, because if we replace $\mathop{\mathsf{G}}a$, $\mathop{\mathsf{G}}b$, and $\varphi_1$ by the propositions $p_1$, $p_2$, and $p_3$ (respectively), we have:

$$ \begin{aligned} \varphi_2 &= p_2 \lor (p_1 \land p_3) \\ \varphi_3 &= p_2 \lor (p_1 \land (p_2 \lor (p_1 \land p_3))) = p_2 \lor (p_1 \land p_3) \end{aligned} $$

The equivalence between these two Boolean formulas can easily be established by encoding them in BDDs.

The implementation keeps track of one BDD representing the "propositional representation" of each LTLf used in a terminal, and everytime it attempts to create $term(g,b)$, it will replace it by $term(f,b)$ if a formula $f$ propositionally equivalent to $g$ has already been seen.

Doing so ensure termination, because the formula we translate has a finite number $m$ of subformulas, and the set of Boolean combinations of those formulas has only $2^{2^m}$ propositionally-equivalent classes.

Aside: Implementing this wasn't fun at all, because new terminals are created by a callback function called deep into the apply recursion. Propositional equivalence has to be checked inside that recursion, but it also require running other BDD operations and creating new BDD variables... So BuDDy had to learn how to run a BDD operation from within another BDD operation, and how to introduce new variables during a BDD operation.

In [10]:
# The `ltlf_translator` class (which provide the machinery used by the
# ltlf_to_mtdfa() function) has a propeq_representative().  That function
# keep tracks of all function on which it is applied, and will return
# the first such function that is equivalent to its argument.
tmp = spot.ltlf_translator(spot._bdd_dict)
display(tmp.propeq_representative("Ga W Gb"))
display(tmp.propeq_representative("Gb | (Ga & (Ga W Gb))"))
display(tmp.propeq_representative("Gb | (Ga & (Gb | (Ga & (Ga W Gb))))"))
del tmp
$\mathsf{G} a \mathbin{\mathsf{W}} \mathsf{G} b$
$\mathsf{G} b \lor (\mathsf{G} a \land (\mathsf{G} a \mathbin{\mathsf{W}} \mathsf{G} b))$
$\mathsf{G} b \lor (\mathsf{G} a \land (\mathsf{G} a \mathbin{\mathsf{W}} \mathsf{G} b))$

Simplifying terminals¶

In an earlier example, we have seen that the translation of $\mathop{\mathsf{G}}\mathop{\mathsf{F}}a$ produces two different states labeled by $\mathop{\mathsf{G}}\mathop{\mathsf{F}}a$ and $\mathop{\mathsf{F}}a\land\mathop{\mathsf{G}}\mathop{\mathsf{a}}$. These two states have exactly the same MTBDD encoding, so that can be detected and fixed in a second pass after the translation. The only problem is that we did have to translate both formulas into MTBDDs.

The situation can get a lot worse: $\bigwedge_{i=1}^n \mathop{\mathsf{G}}\mathop{\mathsf{F}}p_i$ will create $2^n$ different terminals, but all these terminal are labeled by formulas that translate identically.

In [11]:
spot.ltlf_to_mtdfa("GFa & GFb & GFc", False, False)
Out[11]:
mtdfa S0 GFa & GFb & GFc I->S0 B161 a S0->B161 S1 Fa & GFa & Fb & GFb & Fc & GFc S1->B161 S2 Fa & GFa & Fb & GFb & GFc S2->B161 S3 Fa & GFa & GFb & Fc & GFc S3->B161 S4 Fa & GFa & GFb & GFc S4->B161 S5 GFa & Fb & GFb & Fc & GFc S5->B161 S6 GFa & Fb & GFb & GFc S6->B161 S7 GFa & GFb & Fc & GFc S7->B161 B157 b B161->B157 B160 b B161->B160 B154 c B157->B154 B156 c B157->B156 B158 c B160->B158 B159 c B160->B159 B25 Fa & GFa & Fb & GFb & GFc B154->B25 B9 Fa & GFa & Fb & GFb & Fc & GFc B154->B9 B82 GFa & Fb & GFb & GFc B158->B82 B77 GFa & Fb & GFb & Fc & GFc B158->B77 B87 GFa & GFb & Fc & GFc B159->B87 B12 GFa & GFb & GFc B159->B12 B49 Fa & GFa & GFb & Fc & GFc B156->B49 B155 Fa & GFa & GFb & GFc B156->B155

To avoid that, the propeq_representative function, that is already applied to each terminal formula to detect if it is propositionally equivalent to a previous formula, also implements the following cheap simplifications:

$$ \begin{aligned} (\alpha \mathbin{\mathsf{U}} \beta)\lor \beta &\equiv \alpha \mathbin{\mathsf{U}} \beta \\ (\alpha \mathbin{\mathsf{W}} \beta)\lor \beta &\equiv \alpha \mathbin{\mathsf{W}} \beta \\ (\alpha \mathbin{\mathsf{M}} \beta)\land \beta &\equiv \alpha \mathbin{\mathsf{M}} \beta \\ (\alpha \mathbin{\mathsf{R}} \beta)\land \beta &\equiv \alpha \mathbin{\mathsf{R}} \beta \\ (\mathop{\mathsf{F}}\beta) \lor \beta &\equiv \mathop{\mathsf{F}}\beta \\ (\mathop{\mathsf{G}}\beta) \land \beta &\equiv \mathop{\mathsf{G}}\beta \end{aligned} $$

There is one such rule for each temporal operator, for a pattern likely to occur in the output of $tr(\cdot)$.

In [12]:
spot.ltlf_translator(spot._bdd_dict).propeq_representative("GFa & GFb & GFc & Fa & Fb")
Out[12]:
$\mathsf{G} \mathsf{F} a \land \mathsf{G} \mathsf{F} b \land \mathsf{G} \mathsf{F} c$

In practice, for the $\bigwedge_{i=1}^n \mathop{\mathsf{G}}\mathop{\mathsf{F}}p_i$ formula mentionned above, those rules reduce all terminals to the input formula, causing a single state to be created. And there are no identical state to merges aferwards.

In [13]:
spot.ltlf_to_mtdfa("GFa & GFb & GFc",
                   False,   # fusing states with identical MTBDD is disabled
                   True)    # simplification of terminal formulas is enabled
Out[13]:
mtdfa S0 GFa & GFb & GFc I->S0 B173 a S0->B173 B172 b B173->B172 B10 GFa & GFb & GFc B173->B10 B171 c B172->B171 B172->B10 B171->B10 B12 GFa & GFb & GFc B171->B12

Detecting empty or universal translations¶

If the result of the translation has no accepting terminal (including true), or if it has no rejecting terminal (including false), then the formula is equivalent to true or false, and the automaton can be simplified to have a single state.

This simplification is enabled by default. Here is an example when we disable it:

In [14]:
f = "!i1 & F(o1 & X[!]!o1 & (!o2 | (!o0 & !o1) | Go1 | (o0 & !o2 & X[!]!o0) | (!o0 & (!i0 | !i1) & X[!]!o0) | (!o1 & (i0 | !i1) & X[!]!o1) | (i0 & Go0))) & G(i1 | o2 | X[!]!i1) & G(!i1 | F!i1) & G(!o2 | X[!]i1)"
spot.ltlf_to_mtdfa(f, True, True, False).show(labels=False)
Out[14]:
mtdfa S0 0 I->S0 B570 i1 S0->B570 S1 1 B576 i1 S1->B576 S2 2 B579 i1 S2->B579 S3 3 B584 i1 S3->B584 S4 4 B589 i1 S4->B589 S5 5 B590 i1 S5->B590 S6 6 B593 i1 S6->B593 S7 7 B594 i1 S7->B594 S8 8 B595 i1 S8->B595 S9 9 B596 i1 S9->B596 S10 10 B607 i1 S10->B607 S11 11 B608 i1 S11->B608 S12 12 B609 i1 S12->B609 S13 13 B611 i1 S13->B611 S14 14 B613 i1 S14->B613 B610 o1 B613->B610 B612 o1 B613->B612 B592 o1 B593->B592 B578 o1 B593->B578 B579->B578 B0 0 B579->B0 B611->B610 B611->B0 B569 o1 B570->B569 B570->B0 B602 o1 B609->B602 B609->B0 B590->B569 B575 o1 B590->B575 B606 o1 B608->B606 B608->B0 B607->B602 B607->B606 B588 o1 B589->B588 B589->B0 B576->B575 B576->B0 B577 o2 B596->B577 B591 o2 B596->B591 B595->B591 B595->B0 B583 o1 B584->B583 B584->B0 B594->B577 B594->B0 B601 o2 B610->B601 B610->B577 B574 o2 B592->B574 B592->B591 B582 o2 B583->B582 B583->B574 B578->B577 B568 o2 B578->B568 B565 o2 B569->B565 B569->B568 B575->B574 B571 o2 B575->B571 B602->B601 B598 o2 B602->B598 B605 o2 B606->B605 B587 o2 B606->B587 B588->B587 B588->B574 B612->B605 B612->B591 B600 o0 B601->B600 B599 o0 B601->B599 B603 o0 B605->B603 B604 o0 B605->B604 B585 o0 B587->B585 B586 o0 B587->B586 B580 o0 B582->B580 B581 o0 B582->B581 B573 o0 B574->B573 B82 6 B574->B82 B90 8 B577->B90 B87 7 B577->B87 B9 1 B565->B9 B10 0 B565->B10 B567 o0 B568->B567 B25 2 B568->B25 B92 9 B591->B92 B591->B90 B598->B586 B597 o0 B598->B597 B77 5 B571->B77 B571->B9 B241 11 B600->B241 B49 3 B600->B49 B585->B77 B239 10 B585->B239 B580->B92 B580->B77 B586->B241 B586->B9 B114 14 B603->B114 B603->B82 B572 i0 B604->B572 B604->B241 B599->B25 B104 13 B599->B104 B581->B90 B581->B9 B573->B572 B566 i0 B573->B566 B97 12 B597->B97 B597->B10 B567->B566 B567->B49 B572->B49 B572->B9 B155 4 B566->B155 B566->B9

By default, the translation will build the above, realize it has no accepting terminal, and therefore return a "false" automaton instead.

In [15]:
spot.ltlf_to_mtdfa(f)
Out[15]:
mtdfa S0 0 I->S0 B0 0 S0->B0

Conversion from MTDFA to TwA¶

TwA is the standard data-structure of Spot used to represent ω-automata.
Since Spot has no class for representing DFA explicitly, this conversion is just a hack to allow a more traditional representation where we just ignore the acceptance condition.

The conversion to transition-based acceptance is straightforward: each root of the MTBDD corresponds to a state, and each path from a root to a terminal corresponds to a transition, and that transition is accepting iff the terminal is odd. Paths going to the true BDD constant become transitions leading to an accepting sink.

With transition-based DFA, a word is accepted if its last letter was read by a transition marked with a ⓿.

The conversion to state-based acceptance is not more difficult: the acceptance is simply pushed onto the destination state.

Below, the as_twa() returns a transition-based automaton by default. as_twa(True) asks for a state-based output.

In [16]:
display_inline(aut1.as_twa(), aut1.as_twa(True))
0 a U (b U c) I->0 0->0 a & !c 1 b U c 0->1 !a & b & !c 2 1 0->2 c ⓿ 1->1 b & !c 1->2 c ⓿ 2->2 1 ⓿
0 a U (b U c) I->0 0->0 a & !c 1 1 0->1 c 2 b U c 0->2 !a & b & !c 1->1 1 2->1 c 2->2 b & !c
In [17]:
aut2 = spot.ltlf_to_mtdfa("GFa")
display_inline(aut2.as_twa(), aut2.as_twa(True))
0 GFa I->0 0->0 !a 0->0 a ⓿
0 GFa I->0 0->0 !a 1 GFa 0->1 a 1->0 !a 1->1 a

More examples¶

In [18]:
a1 = spot.ltlf_to_mtdfa("(a U b) & (c R d)")
display(a1, a1.as_twa())
mtdfa S0 (a U b) & (c R d) I->S0 B643 a S0->B643 S1 c R d B638 c S1->B638 S2 a U b B644 a S2->B644 B4 b B644->B4 B26 b B644->B26 B639 b B643->B639 B642 b B643->B642 B1 1 B4->B1 B0 0 B4->B0 B26->B1 B25 a U b B26->B25 B639->B638 B639->B0 B642->B638 B641 c B642->B641 B29 d B638->B29 B637 d B638->B637 B632 d B641->B632 B640 d B641->B640 B29->B1 B29->B0 B632->B0 B10 (a U b) & (c R d) B632->B10 B640->B0 B640->B25 B637->B0 B6 c R d B637->B6
0 (a U b) & (c R d) I->0 0->0 a & !b & !c & d 1 c R d 0->1 b & !c & d ⓿ 2 a U b 0->2 a & !b & c & d 3 1 0->3 b & c & d ⓿ 1->1 !c & d ⓿ 1->3 c & d ⓿ 2->2 a & !b 2->3 b ⓿ 3->3 1 ⓿
In [19]:
a2 = spot.ltlf_to_mtdfa("Fa & Fb & Gc")
display(a2, a2.as_twa())
mtdfa S0 Fa & Fb & Gc I->S0 B674 a S0->B674 S1 Fa & Gc B675 a S1->B675 S2 Fb & Gc B673 b S2->B673 S3 Gc B672 c S3->B672 B669 c B675->B669 B675->B672 B674->B673 B670 b B674->B670 B671 c B673->B671 B673->B672 B670->B669 B662 c B670->B662 B0 0 B669->B0 B9 Fa & Gc B669->B9 B671->B0 B25 Fb & Gc B671->B25 B662->B0 B10 Fa & Fb & Gc B662->B10 B672->B0 B35 Gc B672->B35
0 Fa & Fb & Gc I->0 0->0 !a & !b & c 1 Fa & Gc 0->1 !a & b & c 2 Fb & Gc 0->2 a & !b & c 3 Gc 0->3 a & b & c ⓿ 1->1 !a & c 1->3 a & c ⓿ 2->2 !b & c 2->3 b & c ⓿ 3->3 c ⓿
In [20]:
a3 = spot.ltlf_to_mtdfa("G(!r | Fa)")
display(a3, a3.as_twa())
mtdfa S0 G(!r | Fa) I->S0 B682 a S0->B682 S1 Fa & G(!r | Fa) B67 a S1->B67 B12 G(!r | Fa) B67->B12 B9 Fa & G(!r | Fa) B67->B9 B681 r B682->B681 B682->B12 B681->B12 B681->B9
0 G(!r | Fa) I->0 0->0 a | !r ⓿ 1 Fa & G(!r | Fa) 0->1 !a & r 1->0 a ⓿ 1->1 !a
In [21]:
a4 = spot.ltlf_to_mtdfa("XXX(0)")
display(a4, a4.as_twa())
mtdfa S0 XXX(0) I->S0 B6 XX(0) S0->B6 S1 XX(0) B41 X(0) S1->B41 S2 X(0) B35 0 S2->B35 S3 0 B0 0 S3->B0
0 XXX(0) I->0 1 XX(0) 0->1 1 ⓿ 2 X(0) 1->2 1 ⓿ 3 0 2->3 1 ⓿
In [22]:
# because Spot only support transition-based acceptance internally,
# it has to add a false self-loop whenever he needs to emulate
# state-based acceptance on a state without successor.
display(a4.as_twa(True))
0 XXX(0) I->0 1 XX(0) 0->1 1 2 X(0) 1->2 1 3 0 2->3 1 3->3 0
In [23]:
a5 = spot.ltlf_to_mtdfa("X[!]X[!]1")
display(a5, a5.as_twa())
mtdfa S0 X[!]X[!]1 I->S0 B9 X[!]1 S0->B9 S1 X[!]1 B25 1 S1->B25 S2 1 B1 1 S2->B1
0 X[!]X[!]1 I->0 1 X[!]1 0->1 1 2 1 1->2 1 3 1 2->3 1 ⓿ 3->3 1 ⓿
In [24]:
# The last step has to match "a".  This is equivalent to GFa or FGa in LTLf.
a6 = spot.ltlf_to_mtdfa("F(a & X(0))")
display(a6, a6.as_twa())
mtdfa S0 F(a & X(0)) I->S0 B68 a S0->B68 B10 F(a & X(0)) B68->B10 B12 F(a & X(0)) B68->B12
0 F(a & X(0)) I->0 0->0 !a 0->0 a ⓿
In [25]:
# same as above
a7 = spot.ltlf_to_mtdfa("F(a & X(0)) | GFa | FGa")
display(a7, a7.as_twa())
mtdfa S0 GFa | F(a & X(0)) | FGa I->S0 B68 a S0->B68 B10 GFa | F(a & X(0)) | FGa B68->B10 B12 GFa | F(a & X(0)) | FGa B68->B12
0 GFa | F(a & X(0)) | FGa I->0 0->0 !a 0->0 a ⓿
In [26]:
# lily09 -- without labels because formulas are too long (hover over the nodes to see them)
a8 = spot.ltlf_to_mtdfa("GFi0 -> (!o0 & G(!o0 -> ((!o0 U i0) & (i0 -> Fo0))) & GFo0)")
display(a8.show(labels=False), a8.as_twa(False, False), a8.as_twa(True, False))
mtdfa S0 0 I->S0 B736 i0 S0->B736 S1 1 B738 i0 S1->B738 S2 2 B739 i0 S2->B739 S3 3 B741 i0 S3->B741 B25 2 B739->B25 B41 2 B739->B41 B735 o0 B736->B735 B734 o0 B736->B734 B737 o0 B738->B737 B738->B734 B741->B737 B740 o0 B741->B740 B49 3 B735->B49 B735->B25 B737->B49 B35 3 B737->B35 B6 1 B740->B6 B740->B35 B734->B6 B734->B41
0 0 I->0 1 1 0->1 !i0 & !o0 ⓿ 2 2 0->2 i0 & o0 0->2 !i0 & o0 ⓿ 3 3 0->3 i0 & !o0 1->1 !i0 & !o0 ⓿ 1->2 !i0 & o0 ⓿ 1->3 i0 & !o0 1->3 i0 & o0 ⓿ 2->2 i0 2->2 !i0 ⓿ 3->1 !i0 & !o0 ⓿ 3->3 i0 & !o0 3->3 o0 ⓿
0 0 I->0 1 1 0->1 !i0 & !o0 2 2 0->2 !i0 & o0 3 3 0->3 i0 & !o0 4 4 0->4 i0 & o0 1->1 !i0 & !o0 1->2 !i0 & o0 1->3 i0 & !o0 5 5 1->5 i0 & o0 2->2 !i0 2->4 i0 3->1 !i0 & !o0 3->3 i0 & !o0 3->5 o0 4->2 !i0 4->4 i0 5->1 !i0 & !o0 5->3 i0 & !o0 5->5 o0
In [27]:
# lily18 -- without labels because formulas are too long
phi = "G(!(o0 & o1) & !(o0 & o2) & !(o0 & o3) & !(o1 & o2) & !(o1 & o3) & !(o2 & o3)) & (GFi0 -> GFo0) & (GFi1 -> GFo1) & (GFi2 -> GFo2) & GFo3"
a9 = spot.ltlf_to_mtdfa(phi)
a9.names[0] = spot.formula("phi") # Rename the formula for cosmetics
display(a9, a9.as_twa(), a9.as_twa(True))
mtdfa S0 phi I->S0 B877 i0 S0->B877 B873 o0 B877->B873 B876 o0 B877->B876 B871 o1 B873->B871 B872 o1 B873->B872 B876->B872 B875 o1 B876->B875 B869 o2 B871->B869 B870 o2 B871->B870 B872->B870 B0 0 B872->B0 B875->B870 B874 o2 B875->B874 B867 o3 B869->B867 B868 o3 B869->B868 B870->B868 B870->B0 B874->B868 B10 phi B874->B10 B866 i1 B867->B866 B867->B10 B868->B0 B868->B10 B865 i2 B866->B865 B866->B10 B865->B10 B12 phi B865->B12
0 phi I->0 0->0 (i0 & !o0 & !o1 & !o2) | (i1 & !o0 & !o1 & !o2) | (i2 & !o0 & !o1 & !o2) | (!o0 & !o1 & !o3) | (!o0 & !o2 & !o3) | (!o1 & !o2 & !o3) 0->0 !i0 & !i1 & !i2 & !o0 & !o1 & !o2 & o3 ⓿
0 phi I->0 0->0 (i0 & !o0 & !o1 & !o2) | (i1 & !o0 & !o1 & !o2) | (i2 & !o0 & !o1 & !o2) | (!o0 & !o1 & !o3) | (!o0 & !o2 & !o3) | (!o1 & !o2 & !o3) 1 phi 0->1 !i0 & !i1 & !i2 & !o0 & !o1 & !o2 & o3 1->0 (i0 & !o0 & !o1 & !o2) | (i1 & !o0 & !o1 & !o2) | (i2 & !o0 & !o1 & !o2) | (!o0 & !o1 & !o3) | (!o0 & !o2 & !o3) | (!o1 & !o2 & !o3) 1->1 !i0 & !i1 & !i2 & !o0 & !o1 & !o2 & o3
In [28]:
# lily19 -- without labels because formulas are too long  (hover on the states to read them)
f = "GFi1 -> G(o1 -> (!(o0 & o1) & (o1 U i1) & (o0 -> (o0 U i1)) & (i0 -> Fo0) & Fo1))"
a11 = spot.ltlf_to_mtdfa(f)
display(a11.show(labels=False), a11.as_twa(False, False))
mtdfa S0 0 I->S0 B1083 i0 S0->B1083 S1 1 B1090 i0 S1->B1090 S2 2 B1077 i1 S2->B1077 S3 3 B1093 o0 S3->B1093 S4 4 B1096 o0 S4->B1096 B1089 o0 B1090->B1089 B1087 o0 B1090->B1087 B1079 o0 B1083->B1079 B1082 o0 B1083->B1082 B1088 o1 B1089->B1088 B1086 o1 B1089->B1086 B1078 o1 B1079->B1078 B1076 o1 B1079->B1076 B1082->B1078 B1081 o1 B1082->B1081 B1093->B1086 B1092 o1 B1093->B1092 B1087->B1086 B1085 o1 B1087->B1085 B1095 o1 B1096->B1095 B1096->B1078 B1080 i1 B1088->B1080 B1084 i1 B1088->B1084 B1086->B1084 B1086->B1077 B1085->B1084 B1075 i1 B1085->B1075 B1095->B1080 B1094 i1 B1095->B1094 B1078->B1077 B12 0 B1078->B12 B1092->B1080 B1091 i1 B1092->B1091 B1076->B1075 B1076->B12 B1081->B1080 B1081->B12 B35 3 B1080->B35 B155 4 B1080->B155 B1084->B12 B41 2 B1084->B41 B1077->B41 B25 2 B1077->B25 B1075->B12 B6 1 B1075->B6 B1091->B155 B1091->B41 B1094->B155 B76 4 B1094->B76
0 0 I->0 0->0 !o1 | (!i0 & i1 & !o0) ⓿ 1 1 0->1 !i0 & !i1 & !o0 & o1 ⓿ 2 2 0->2 i1 & o0 & o1 0->2 !i1 & o0 & o1 ⓿ 3 3 0->3 i0 & !i1 & !o0 & o1 ⓿ 4 4 0->4 i0 & i1 & !o0 & o1 1->0 (!i0 & i1 & !o0) | (i1 & !o1) ⓿ 1->1 !i0 & !i1 & !o0 & o1 ⓿ 1->2 i1 & o0 & o1 1->2 (!i1 & o0) | (!i1 & !o1) ⓿ 1->3 i0 & !i1 & !o0 & o1 ⓿ 1->4 i0 & i1 & !o0 & o1 2->2 i1 2->2 !i1 ⓿ 3->0 i1 & o0 & !o1 ⓿ 3->2 i1 & o0 & o1 3->2 (!i1 & o0) | (!i1 & !o1) ⓿ 3->3 !i1 & !o0 & o1 ⓿ 3->4 i1 & !o0 4->0 o0 & !o1 ⓿ 4->2 i1 & o0 & o1 4->2 !i1 & o0 & o1 ⓿ 4->3 !i1 & !o0 & o1 ⓿ 4->4 i1 & !o0 4->4 !i1 & !o0 & !o1 ⓿

On the above example, we can see how transition-based acceptance can save three states (copies of 0, 2, and 4). Here is the state-based version for comparison.

In [29]:
spot.translate(f, "finite", "deterministic")
Out[29]:
1 1 I->1 0 0 1->0 i1 & o0 & o1 3 3 1->3 !i1 & o0 & o1 2 2 1->2 i0 & i1 & !o0 & o1 4 4 1->4 !i0 & !i1 & !o0 & o1 5 5 1->5 i0 & !i1 & !o0 & o1 7 7 1->7 !o1 | (!i0 & i1 & !o0) 0->0 i1 0->3 !i1 3->0 i1 3->3 !i1 2->0 i1 & o0 & o1 2->3 !i1 & o0 & o1 2->2 i1 & !o0 2->5 !i1 & !o0 & o1 2->7 o0 & !o1 6 6 2->6 !i1 & !o0 & !o1 4->0 i1 & o0 & o1 4->3 (!i1 & o0) | (!i1 & !o1) 4->2 i0 & i1 & !o0 & o1 4->4 !i0 & !i1 & !o0 & o1 4->5 i0 & !i1 & !o0 & o1 4->7 (!i0 & i1 & !o0) | (i1 & !o1) 5->0 i1 & o0 & o1 5->3 (!i1 & o0) | (!i1 & !o1) 5->2 i1 & !o0 5->5 !i1 & !o0 & o1 5->7 i1 & o0 & !o1 7->0 i1 & o0 & o1 7->3 !i1 & o0 & o1 7->2 i0 & i1 & !o0 & o1 7->4 !i0 & !i1 & !o0 & o1 7->5 i0 & !i1 & !o0 & o1 7->7 !o1 | (!i0 & i1 & !o0) 6->0 i1 & o0 & o1 6->3 !i1 & o0 & o1 6->2 i1 & !o0 6->5 !i1 & !o0 & o1 6->7 o0 & !o1 6->6 !i1 & !o0 & !o1

DFA Minimization¶

The MTBDD-based DFA allows a very simple implementation of minimization by partition refinement, similar to what is used in Mona.

  1. Assume all terminal belong to the same class, numbered 0.
  2. Rewrite all MTBDDs by relabeling each terminal by its class number (preserving the acceptance bit)
  3. Group all states in new classes according to their new BDD representation, giving each class a new number.
  4. Loop to 2 until the number of classes is stable.

For the purpose of the above algorithm, false, and true should treated as terminals (i.e., assigned a class and rewritten). The actual implementation then performs a final pass on the result to put false and true back.

Let's find some formulas on which this minimization is effective by trying the examples from Lily. (These are LTL examples, but we interpret them as LTLf here.)

In [30]:
import spot.gen as sg
from timeit import default_timer as timer
In [31]:
print("           states                    time          ")
print("       -------------     --------------------------")
print("       DFA  min  ref        DFA  DFA+min        ref")
for i, f in enumerate(sg.ltl_patterns(sg.LTL_LILY_PATTERNS)):
    t1 = timer()
    dfa = spot.ltlf_to_mtdfa(f)
    t1b = timer()
    mindfa = spot.minimize_mtdfa(dfa)
    t2 = timer()
    n1, n2 = dfa.num_states(), mindfa.num_states()
    mark = ">" if n1 > n2 else " "
    t3 = timer()
    ref = spot.translate(f, "finite", "deterministic", xargs="tls-impl=0")
    t4 = timer()
    refsz = ref.num_states()
    t1b -= t1
    t2 -= t1
    t4 -= t3
    tmark = '='
    if t4 > t2: tmark = "<"
    elif t4 < t2: tmark = ">"

    # While we are there, let's control that mindfa ≡ ref.
    # We can do that by checking the emptiness of mindfa XOR ref.
    assert spot.product_xor(mindfa, spot.twadfa_to_mtdfa(ref)).is_empty()

    print(f"lily{i+1:<3} {n1:2} {mark} {n2:2}   {refsz:2}   {t1b * 1000:6.2f}ms {t2 * 1000:6.2f}ms {tmark} {t4 * 1000:6.2f}ms")
           states                    time          
       -------------     --------------------------
       DFA  min  ref        DFA  DFA+min        ref
lily1    6    6    7     0.31ms   0.34ms <   0.53ms
lily2    9    9   12     0.14ms   0.17ms <   0.94ms
lily3   13   13   14     0.22ms   0.26ms <   1.37ms
lily4   21   21   23     0.21ms   0.25ms <   1.55ms
lily5   16   16   19     0.16ms   0.19ms <   1.12ms
lily6   25   25   30     0.15ms   0.19ms <   2.11ms
lily7    8    8    9     0.08ms   0.10ms <   0.58ms
lily8    1    1    2     0.05ms   0.06ms <   0.20ms
lily9    4    4    6     0.05ms   0.06ms <   0.24ms
lily10   4    4    7     0.05ms   0.06ms <   0.45ms
lily11   4    4    4     0.07ms   0.08ms <   0.30ms
lily12   8 >  6    7     0.07ms   0.08ms <   0.38ms
lily13   2    2    2     0.04ms   0.05ms <   0.17ms
lily14   1    1    2     0.05ms   0.06ms <   0.91ms
lily15   8    8    9     0.08ms   0.09ms <   0.51ms
lily16  22   22   23     0.20ms   0.23ms <   1.41ms
lily17   1    1    2     0.06ms   0.07ms <   1.66ms
lily18   1    1    2     0.07ms   0.08ms <  11.81ms
lily19   5    5    8     0.21ms   0.23ms <   0.60ms
lily20  16   16   22     0.18ms   0.21ms <   2.81ms
lily21  75   75   76     0.65ms   0.80ms <   2.71ms
lily22  19   19   20     0.16ms   0.21ms <   1.11ms
lily23   7    7    7     0.08ms   0.10ms <   0.26ms

The columns labeled DFA refer to the MTDFA construction, and show the number of states and time of the construction.

The columns labeled min refer to the minimized MTDFA; the time includes the time of the translation.

The "ref" column refers to the historical LTLf->LTL->DBA->DFA pipeline, that produces a state-based minimal DFA. (Note that this pipeline performs some LTL simplification on the LTL formula prior to translation to DBA. The LTLf->MTDBA construction does not do any such simplification. The tls-impl=0 option disables implication-based simplifications, to save some time.)

Both min and ref are minimal, but the former uses transition-based acceptance while the latter uses state-based acceptance.

Here is the only instance where minimization is useful:

In [32]:
def showlily(n, labels=False):
    f = sg.ltl_pattern(sg.LTL_LILY_PATTERNS, n)
    print("-- before minimization --\n")
    aut = spot.ltlf_to_mtdfa(f)
    display(aut.show(labels=labels), aut.as_twa(False, labels))
    print("-- after minimization --\n")
    minaut = spot.minimize_mtdfa(aut)
    display(minaut.show(labels=labels), minaut.as_twa(False, labels))
In [33]:
showlily(12)
-- before minimization --

mtdfa S0 0 I->S0 B15651 i0 S0->B15651 S1 1 B15655 i0 S1->B15655 S2 2 B15661 i0 S2->B15661 S3 3 B15665 i0 S3->B15665 S4 4 B15650 o0 S4->B15650 S5 5 B15654 o0 S5->B15654 S6 6 B15660 o0 S6->B15660 S7 7 B15664 o0 S7->B15664 B15661->B15660 B15656 o0 B15661->B15656 B15651->B15650 B15646 o0 B15651->B15646 B15665->B15664 B15662 o0 B15665->B15662 B15653 o0 B15655->B15653 B15655->B15654 B15659 o1 B15660->B15659 B15658 o1 B15660->B15658 B15664->B15659 B15663 o1 B15664->B15663 B41 2 B15662->B41 B35 3 B15662->B35 B15652 o1 B15653->B15652 B15645 o1 B15653->B15645 B195 o1 B15654->B195 B15649 o1 B15654->B15649 B15648 o1 B15650->B15648 B15650->B15649 B15644 o1 B15646->B15644 B15646->B15645 B15339 i1 B15656->B15339 B15656->B41 B15659->B41 B168 6 B15659->B168 B87 7 B15663->B87 B15663->B35 B15644->B15339 B15643 i1 B15644->B15643 B6 1 B15652->B6 B15652->B35 B165 5 B195->B165 B195->B35 B15648->B15339 B15647 i1 B15648->B15647 B15645->B41 B12 0 B15645->B12 B15658->B15339 B15657 i1 B15658->B15657 B15649->B41 B76 4 B15649->B76 B15339->B41 B15339->B35 B15643->B6 B15643->B12 B15657->B168 B15657->B87 B15647->B76 B15647->B165
0 0 I->0 0->0 (!i0 & o0 & !o1) | (!i0 & !i1 & !o1) ⓿ 1 1 0->1 !i0 & i1 & !o0 & !o1 ⓿ 2 2 0->2 (o0 & o1) | (!i1 & o1) ⓿ 3 3 0->3 i1 & !o0 & o1 ⓿ 4 4 0->4 (i0 & o0 & !o1) | (i0 & !i1 & !o1) ⓿ 5 5 0->5 i0 & i1 & !o0 & !o1 ⓿ 1->0 !i0 & o0 & !o1 ⓿ 1->1 !i0 & !o0 & !o1 ⓿ 1->2 o0 & o1 ⓿ 1->3 !o0 & o1 ⓿ 1->4 i0 & o0 & !o1 ⓿ 1->5 i0 & !o0 & !o1 ⓿ 2->2 (o0 & o1) | (!i0 & o0) | (!i0 & !i1) | (!i1 & o1) ⓿ 2->3 (!i0 & i1 & !o0) | (i1 & !o0 & o1) ⓿ 6 6 2->6 (i0 & o0 & !o1) | (i0 & !i1 & !o1) ⓿ 7 7 2->7 i0 & i1 & !o0 & !o1 3->2 (o0 & o1) | (!i0 & o0) ⓿ 3->3 (!i0 & !o0) | (!o0 & o1) ⓿ 3->6 i0 & o0 & !o1 ⓿ 3->7 i0 & !o0 & !o1 4->2 (o0 & o1) | (!i1 & o1) ⓿ 4->3 i1 & !o0 & o1 ⓿ 4->4 (o0 & !o1) | (!i1 & !o1) ⓿ 4->5 i1 & !o0 & !o1 ⓿ 5->2 o0 & o1 ⓿ 5->3 !o0 & o1 ⓿ 5->4 o0 & !o1 ⓿ 5->5 !o0 & !o1 ⓿ 6->2 (o0 & o1) | (!i1 & o1) ⓿ 6->3 i1 & !o0 & o1 ⓿ 6->6 (o0 & !o1) | (!i1 & !o1) ⓿ 6->7 i1 & !o0 & !o1 7->2 o0 & o1 ⓿ 7->3 !o0 & o1 ⓿ 7->6 o0 & !o1 ⓿ 7->7 !o0 & !o1
-- after minimization --

mtdfa S0 0 I->S0 B15646 o0 S0->B15646 S1 1 B15653 o0 S1->B15653 S2 2 B15685 i0 S2->B15685 S3 3 B15688 i0 S3->B15688 S4 4 B15684 o0 S4->B15684 S5 5 B15687 o0 S5->B15687 B15656 o0 B15685->B15656 B15685->B15684 B15688->B15687 B15662 o0 B15688->B15662 B15686 o1 B15687->B15686 B15649 o1 B15687->B15649 B15339 i1 B15656->B15339 B41 2 B15656->B41 B35 3 B15662->B35 B15662->B41 B15684->B15649 B15683 o1 B15684->B15683 B15645 o1 B15646->B15645 B15644 o1 B15646->B15644 B15653->B15645 B15652 o1 B15653->B15652 B77 5 B15686->B77 B15686->B35 B76 4 B15649->B76 B15649->B41 B15645->B41 B12 0 B15645->B12 B15652->B35 B6 1 B15652->B6 B15683->B15339 B15682 i1 B15683->B15682 B15644->B15339 B15643 i1 B15644->B15643 B15339->B35 B15339->B41 B15682->B76 B15682->B77 B15643->B6 B15643->B12
0 0 I->0 0->0 (o0 & !o1) | (!i1 & !o1) ⓿ 1 1 0->1 i1 & !o0 & !o1 ⓿ 2 2 0->2 (o0 & o1) | (!i1 & o1) ⓿ 3 3 0->3 i1 & !o0 & o1 ⓿ 1->0 o0 & !o1 ⓿ 1->1 !o0 & !o1 ⓿ 1->2 o0 & o1 ⓿ 1->3 !o0 & o1 ⓿ 2->2 (o0 & o1) | (!i0 & o0) | (!i0 & !i1) | (!i1 & o1) ⓿ 2->3 (!i0 & i1 & !o0) | (i1 & !o0 & o1) ⓿ 4 4 2->4 (i0 & o0 & !o1) | (i0 & !i1 & !o1) ⓿ 5 5 2->5 i0 & i1 & !o0 & !o1 3->2 (o0 & o1) | (!i0 & o0) ⓿ 3->3 (!i0 & !o0) | (!o0 & o1) ⓿ 3->4 i0 & o0 & !o1 ⓿ 3->5 i0 & !o0 & !o1 4->2 (o0 & o1) | (!i1 & o1) ⓿ 4->3 i1 & !o0 & o1 ⓿ 4->4 (o0 & !o1) | (!i1 & !o1) ⓿ 4->5 i1 & !o0 & !o1 5->2 o0 & o1 ⓿ 5->3 !o0 & o1 ⓿ 5->4 o0 & !o1 ⓿ 5->5 !o0 & !o1

Minimal MTDFA does not imply minimal state-based DFA¶

An MTDFA is transition-based in nature, because the acceptance can be different on each path from a root.

Although there is an easy interpretation of the MTDFA as a state-based DFA, the following shows that the state-based automaton one obtains from a minimial MTDFA is not necessarily minimal itself. Here, the minimial transition-based DFA autA, obtained via this approach, has 8 states. The state-based automaton obtained from that automata, autB, has 12 states. But the minimial state-based DFA computed using the historical LTLf->LTL->DBA->DFA pipeline has 9 states.

In [34]:
lily07 = "G(i0->X(i1|Xi1))->G(i0->(X(!o0 U i1)&(o0->X!o0)&(i2->(i0|o0|X(i0|o0|X(i0|o0|X(i0|o0)))))))"
l07 = spot.ltlf_to_mtdfa(lily07)
l07 = spot.minimize_mtdfa(l07)
# Get rids of formula labels, as they are too large
l07.names.clear()
autA = l07.as_twa(False)
autB = l07.as_twa(True)
autC = spot.translate(lily07, "finite", "deterministic")
# Ensure equivalence between l07 and aut.
assert spot.product_xor(l07, spot.twadfa_to_mtdfa(autC)).is_empty()
display(autA, autB, autC)
0 0 I->0 0->0 !i0 ⓿ 1 1 0->1 i0 & !o0 ⓿ 2 2 0->2 i0 & o0 ⓿ 1->0 !i0 & i1 ⓿ 1->1 i0 & i1 & !o0 ⓿ 1->2 i0 & i1 & o0 ⓿ 3 3 1->3 !i1 & !o0 4 4 1->4 !i1 & o0 2->0 !i0 & i1 & !o0 ⓿ 2->1 i0 & i1 & !o0 ⓿ 2->3 !i1 & !o0 2->4 !i1 & o0 5 5 2->5 !i0 & i1 & o0 6 6 2->6 i0 & i1 & o0 3->0 !i0 & i1 ⓿ 3->1 i0 & i1 & !o0 ⓿ 3->2 i0 & i1 & o0 ⓿ 7 7 3->7 !i1 ⓿ 4->5 !i0 & i1 4->6 i0 & i1 4->7 !i1 ⓿ 5->5 !i0 5->6 i0 6->4 !i1 6->5 !i0 & i1 6->6 i0 & i1 7->7 1 ⓿
0 0 I->0 1 1 0->1 !i0 2 2 0->2 i0 & !o0 3 3 0->3 i0 & o0 1->1 !i0 1->2 i0 & !o0 1->3 i0 & o0 2->1 !i0 & i1 2->2 i0 & i1 & !o0 2->3 i0 & i1 & o0 4 4 2->4 !i1 & !o0 5 5 2->5 !i1 & o0 3->1 !i0 & i1 & !o0 3->2 i0 & i1 & !o0 3->4 !i1 & !o0 3->5 !i1 & o0 6 6 3->6 !i0 & i1 & o0 7 7 3->7 i0 & i1 & o0 4->1 !i0 & i1 4->2 i0 & i1 & !o0 4->3 i0 & i1 & o0 8 8 4->8 !i1 5->6 !i0 & i1 5->7 i0 & i1 5->8 !i1 6->6 !i0 6->7 i0 7->5 !i1 7->6 !i0 & i1 7->7 i0 & i1 8->8 1
4 4 I->4 0 0 4->0 i0 & !o0 1 1 4->1 i0 & o0 5 5 4->5 !i0 0->0 i0 & i1 & !o0 0->1 i0 & i1 & o0 0->5 !i0 & i1 6 6 0->6 !i1 & !o0 7 7 0->7 !i1 & o0 1->0 i0 & i1 & !o0 1->5 !i0 & i1 & !o0 1->6 !i1 & !o0 1->7 !i1 & o0 2 2 1->2 !i0 & i1 & o0 3 3 1->3 i0 & i1 & o0 5->0 i0 & !o0 5->1 i0 & o0 5->5 !i0 6->0 i0 & i1 & !o0 6->1 i0 & i1 & o0 6->5 !i0 & i1 8 8 6->8 !i1 7->2 !i0 & i1 7->3 i0 & i1 7->8 !i1 2->2 !i0 2->3 i0 3->7 !i1 3->2 !i0 & i1 3->3 i0 & i1 8->8 1

Examples that caused problems to the minimization at some point¶

Let's keep these examples so that the notebook can serve as a test suite.

This large automaton reduces to false because it has no accepting terminals. ltlf_to_mtdfa() will detect that and return false already (unless we disable the optimization as done below), but the minimization should also achieve that.

In [35]:
f = "!i1 & F(o1 & X[!]!o1 & (!o2 | (!o0 & !o1) | Go1 | (o0 & !o2 & X[!]!o0) | (!o0 & (!i0 | !i1) & X[!]!o0) | (!o1 & (i0 | !i1) & X[!]!o1) | (i0 & Go0))) & G(i1 | o2 | X[!]!i1) & G(!i1 | F!i1) & G(!o2 | X[!]i1)"
dfa = spot.ltlf_to_mtdfa(f, True, True, False)
display(dfa.show(labels=False))
spot.minimize_mtdfa(dfa).show(labels=False)
mtdfa S0 0 I->S0 B78181 i0 S0->B78181 S1 1 B78191 i0 S1->B78191 S2 2 B78198 i0 S2->B78198 S3 3 B78204 i0 S3->B78204 S4 4 B78210 i0 S4->B78210 S5 5 B78224 i0 S5->B78224 S6 6 B78233 i0 S6->B78233 S7 7 B78192 o2 S7->B78192 S8 8 B78199 o2 S8->B78199 S9 9 B78226 o2 S9->B78226 S10 10 B78240 i0 S10->B78240 S11 11 B78245 i0 S11->B78245 S12 12 B78249 o0 S12->B78249 S13 13 B78251 o0 S13->B78251 S14 14 B78255 i0 S14->B78255 B78253 o0 B78255->B78253 B78254 o0 B78255->B78254 B78232 o0 B78233->B78232 B78229 o0 B78233->B78229 B78197 o0 B78198->B78197 B78195 o0 B78198->B78195 B78180 o0 B78181->B78180 B78177 o0 B78181->B78177 B78244 o0 B78245->B78244 B78243 o0 B78245->B78243 B78218 o0 B78224->B78218 B78223 o0 B78224->B78223 B78239 o0 B78240->B78239 B78238 o0 B78240->B78238 B78207 o0 B78210->B78207 B78209 o0 B78210->B78209 B78190 o0 B78191->B78190 B78187 o0 B78191->B78187 B78203 o0 B78204->B78203 B78201 o0 B78204->B78201 B78252 o1 B78253->B78252 B78227 o1 B78253->B78227 B78250 o1 B78251->B78250 B78193 o1 B78251->B78193 B78179 o1 B78180->B78179 B78174 o1 B78180->B78174 B78186 o1 B78190->B78186 B78189 o1 B78190->B78189 B78202 o1 B78203->B78202 B78203->B78189 B78248 o1 B78249->B78248 B78249->B78174 B78231 o1 B78232->B78231 B78230 o1 B78232->B78230 B78201->B78186 B78200 o1 B78201->B78200 B78244->B78186 B78242 o1 B78244->B78242 B78237 o1 B78239->B78237 B78220 o1 B78239->B78220 B78184 o1 B78207->B78184 B78206 o1 B78207->B78206 B78187->B78186 B78187->B78184 B78209->B78186 B78208 o1 B78209->B78208 B78217 o1 B78218->B78217 B78215 o1 B78218->B78215 B78238->B78237 B78238->B78215 B78222 o1 B78223->B78222 B78223->B78220 B78197->B78193 B78196 o1 B78197->B78196 B78243->B78242 B78243->B78184 B78177->B78174 B78176 o1 B78177->B78176 B78254->B78252 B78254->B78230 B78194 o1 B78195->B78194 B78195->B78193 B78228 o1 B78229->B78228 B78229->B78227 B78247 o2 B78250->B78247 B78250->B78192 B78172 o2 B78179->B78172 B78178 o2 B78179->B78178 B78221 o2 B78231->B78221 B78231->B78226 B78175 o2 B78194->B78175 B78194->B78192 B78216 o2 B78228->B78216 B78228->B78226 B78182 o2 B78186->B78182 B78185 o2 B78186->B78185 B78248->B78247 B78246 o2 B78248->B78246 B78236 o2 B78252->B78236 B78252->B78226 B78174->B78172 B78173 o2 B78174->B78173 B78202->B78199 B78202->B78185 B78193->B78192 B78193->B78173 B78214 o2 B78227->B78214 B78227->B78226 B78188 o2 B78189->B78188 B78189->B78182 B78200->B78199 B78183 o2 B78200->B78183 B78205 o2 B78242->B78205 B78241 o2 B78242->B78241 B78208->B78188 B78208->B78205 B78219 o2 B78230->B78219 B78230->B78226 B78237->B78236 B78235 o2 B78237->B78235 B78184->B78183 B78184->B78182 B78206->B78205 B78206->B78185 B78217->B78216 B78212 o2 B78217->B78212 B78215->B78214 B78215->B78212 B78176->B78175 B78176->B78172 B78222->B78221 B78222->B78212 B78220->B78219 B78220->B78212 B78196->B78192 B78196->B78178 B6269 i1 B78247->B6269 B11615 i1 B78247->B11615 B13947 i1 B78175->B13947 B6292 i1 B78175->B6292 B78213 i1 B78221->B78213 B155 4 B78221->B155 B1007 i1 B78199->B1007 B1012 i1 B78199->B1012 B78214->B78213 B49 3 B78214->B49 B78172->B13947 B15572 i1 B78172->B15572 B78216->B78213 B9 1 B78216->B9 B78246->B6269 B11610 i1 B78246->B11610 B77778 i1 B78236->B77778 B241 11 B78236->B241 B47845 i1 B78192->B47845 B14399 i1 B78192->B14399 B11632 i1 B78188->B11632 B996 i1 B78188->B996 B78219->B78213 B41458 i1 B78219->B41458 B78183->B11632 B14406 i1 B78183->B14406 B8340 i1 B78205->B8340 B8334 i1 B78205->B8334 B78241->B8340 B23426 i1 B78241->B23426 B78225 i1 B78226->B78225 B90 8 B78226->B90 B78178->B6292 B18105 i1 B78178->B18105 B78173->B6292 B48015 i1 B78173->B48015 B78234 i1 B78235->B78234 B78235->B241 B11628 i1 B78182->B11628 B8171 i1 B78182->B8171 B78185->B11632 B78185->B8171 B78211 i1 B78212->B78211 B78212->B9 B0 0 B6269->B0 B6269->B241 B13947->B0 B13947->B9 B8340->B0 B8340->B241 B87 7 B78225->B87 B92 9 B78225->B92 B11628->B0 B77 5 B11628->B77 B10 0 B78211->B10 B78211->B77 B47845->B0 B47845->B90 B11632->B0 B82 6 B11632->B82 B8334->B0 B239 10 B8334->B239 B1007->B0 B1007->B90 B15572->B0 B15572->B10 B14399->B0 B14399->B87 B114 14 B77778->B114 B104 13 B77778->B104 B1012->B0 B1012->B92 B78213->B82 B25 2 B78213->B25 B6292->B0 B6292->B25 B11610->B0 B97 12 B11610->B97 B41458->B9 B41458->B49 B8171->B0 B8171->B9 B11615->B0 B11615->B104 B996->B0 B996->B155 B14406->B0 B14406->B49 B23426->B0 B23426->B114 B78234->B97 B78234->B239 B18105->B0 B18105->B155 B48015->B0 B48015->B49
Out[35]:
mtdfa S0 0 I->S0 B0 0 S0->B0

Because the minimization has to turn constants false and true to terminals, it is a bit tricky to turn the terminal back into false/true especially in automata were "accepting false" or "rejecting true" are used, because these two have to be kept as terms. The following translations, where minimization should have no effect, used to highlight issues with previous implementations of the minimization.

In [36]:
a = spot.ltlf_to_mtdfa("X[!](1) | Ga")
display_inline(a, spot.minimize_mtdfa(a))
mtdfa S0 X[!]1 | Ga I->S0 B45 a S0->B45 S1 1 B1 1 S1->B1 B45->B1 B9 1 B45->B9
mtdfa S0 X[!]1 | Ga I->S0 B45 a S0->B45 S1 1 B1 1 S1->B1 B45->B1 B9 1 B45->B9
In [37]:
a = spot.ltlf_to_mtdfa("X[!](b & X[!](1)) | Ga | X[!](c & X[!]X(0))")
display_inline(a.show(labels=True), spot.minimize_mtdfa(a))
mtdfa S0 Ga | X[!](b & X[!]1) | X[!](c & X[!]X(0)) I->S0 B78277 a S0->B78277 S1 (b & X[!]1) | (c & X[!]X(0)) B78279 b S1->B78279 S2 Ga | (b & X[!]1) | (c & X[!]X(0)) B78282 a S2->B78282 S3 X(0) B220 0 S3->B220 S4 1 B1 1 S4->B1 S5 Ga B78283 a S5->B78283 S6 X(0) | Ga B78284 a S6->B78284 S7 0 B0 0 S7->B0 B78284->B220 B165 Ga B78284->B165 B78282->B78279 B78281 b B78282->B78281 B9 (b & X[!]1) | (c & X[!]X(0)) B78277->B9 B41 Ga | (b & X[!]1) | (c & X[!]X(0)) B78277->B41 B78283->B0 B78283->B165 B78278 c B78279->B78278 B155 1 B78279->B155 B78280 c B78281->B78280 B78281->B1 B78278->B0 B49 X(0) B78278->B49 B78280->B165 B168 X(0) | Ga B78280->B168
mtdfa S0 Ga | X[!](b & X[!]1) | X[!](c & X[!]X(0)) I->S0 B78277 a S0->B78277 S1 (b & X[!]1) | (c & X[!]X(0)) B78279 b S1->B78279 S2 Ga | (b & X[!]1) | (c & X[!]X(0)) B78282 a S2->B78282 S3 X(0) B220 0 S3->B220 S4 1 B1 1 S4->B1 S5 Ga B78283 a S5->B78283 S6 X(0) | Ga B78284 a S6->B78284 S7 0 B0 0 S7->B0 B78284->B220 B165 Ga B78284->B165 B78282->B78279 B78281 b B78282->B78281 B9 (b & X[!]1) | (c & X[!]X(0)) B78277->B9 B41 Ga | (b & X[!]1) | (c & X[!]X(0)) B78277->B41 B78283->B0 B78283->B165 B78278 c B78279->B78278 B155 1 B78279->B155 B78280 c B78281->B78280 B78281->B1 B78278->B0 B49 X(0) B78278->B49 B78280->B165 B168 X(0) | Ga B78280->B168
In [38]:
a = spot.ltlf_to_mtdfa("X(0) | Ga")
display_inline(a.show(labels=True), spot.minimize_mtdfa(a))
mtdfa S0 X(0) | Ga I->S0 B78303 a S0->B78303 S1 0 B0 0 S1->B0 S2 Ga B44 a S2->B44 B44->B0 B41 Ga B44->B41 B78303->B41 B6 0 B78303->B6
mtdfa S0 X(0) | Ga I->S0 B78303 a S0->B78303 S1 0 B0 0 S1->B0 S2 Ga B44 a S2->B44 B44->B0 B41 Ga B44->B41 B78303->B41 B6 0 B78303->B6

Boolean operations on MTDFAs¶

The cross-product of two MTDFAs can be done combining the initial MTBDDs of the two MTDFAs in such a way that the terminal of the product represent pairs of states of the original formulas.

The combination function used can implement any Boolean operation. In practice the Boolean operation just changes the way the acceptence of the new terminal is defined. Using $term(s_\ell,b_\ell)$ represent a terminal for state $s_\ell$ in the left automaton, and $term(s_r,b_r)$ is a terminal in the right automaton, then for a Boolean operator $\odot$ we combine MTBDD using the rule $term(s_\ell,b_\ell)\odot term(s_r,b_r) = term((s_\ell,s_r),b_\ell \odot b_r)$.

Once we have computed MTBDDs for all pairs $(s_\ell,s_r)$ appearing in terminals that can be transitively reached this way, we can compute the names of all the states associated to those pairs.

The following Boolean functions are available:

  • product() implements $\land$
  • product_or() implements $\lor$
  • product_xor() implements $\oplus$
  • product_xnor() implements $\leftrightarrow$
  • product_implies() implements $\rightarrow$

Except for product_implies, the other functions are already defined for the twa class.

Let's build three base MTDFAs so that we can combine them.

In [39]:
gfa = spot.ltlf_to_mtdfa("GFa")
aub = spot.ltlf_to_mtdfa("a U b")
xxc = spot.ltlf_to_mtdfa("XXc")
display_inline(gfa, aub, xxc)
mtdfa S0 GFa I->S0 B68 a S0->B68 B10 GFa B68->B10 B12 GFa B68->B12
mtdfa S0 a U b I->S0 B16 a S0->B16 B4 b B16->B4 B15 b B16->B15 B0 0 B4->B0 B1 1 B4->B1 B15->B1 B10 a U b B15->B10
mtdfa S0 XXc I->S0 B6 Xc S0->B6 S1 Xc B41 c S1->B41 S2 c B27 c S2->B27 B0 0 B27->B0 B1 1 B27->B1
In [40]:
display_inline(spot.product(gfa, aub), spot.product_or(gfa, aub))
mtdfa S0 GFa & (a U b) I->S0 B78306 a S0->B78306 S1 GFa B162 a S1->B162 B9 GFa B162->B9 B6 GFa B162->B6 B54 b B78306->B54 B38 b B78306->B38 B0 0 B54->B0 B54->B9 B38->B6 B10 GFa & (a U b) B38->B10
mtdfa S0 GFa | (a U b) I->S0 B78307 a S0->B78307 S1 GFa B162 a S1->B162 B9 GFa B162->B9 B6 GFa B162->B6 B626 b B78307->B626 B17 b B78307->B17 B1 1 B626->B1 B626->B9 B17->B1 B12 GFa | (a U b) B17->B12
In [41]:
spot.product_xor(gfa, aub)
Out[41]:
mtdfa S0 GFa xor (a U b) I->S0 B78310 a S0->B78310 S1 GFa B162 a S1->B162 S2 !GFa B78311 a S2->B78311 B25 !GFa B78311->B25 B41 !GFa B78311->B41 B6 GFa B162->B6 B9 GFa B162->B9 B78308 b B78310->B78308 B78309 b B78310->B78309 B78308->B41 B78308->B9 B78309->B25 B12 GFa xor (a U b) B78309->B12
In [42]:
spot.product_xnor(gfa, aub)
Out[42]:
mtdfa S0 GFa <-> (a U b) I->S0 B78313 a S0->B78313 S1 !GFa B78314 a S1->B78314 S2 GFa B78256 a S2->B78256 B41 GFa B78256->B41 B25 GFa B78256->B25 B9 !GFa B78314->B9 B6 !GFa B78314->B6 B78312 b B78313->B78312 B42 b B78313->B42 B78312->B25 B78312->B6 B42->B41 B10 GFa <-> (a U b) B42->B10
In [43]:
spot.product_implies(gfa, aub)
Out[43]:
mtdfa S0 GFa -> (a U b) I->S0 B78315 a S0->B78315 S1 !GFa B78314 a S1->B78314 B6 !GFa B78314->B6 B9 !GFa B78314->B9 B78270 b B78315->B78270 B15 b B78315->B15 B1 1 B78270->B1 B78270->B6 B15->B1 B10 GFa -> (a U b) B15->B10
In [44]:
spot.product(aub, xxc)
Out[44]:
mtdfa S0 (a U b) & XXc I->S0 B78318 a S0->B78318 S1 Xc B35 c S1->B35 S2 (a U b) & Xc B78321 a S2->B78321 S3 c B27 c S3->B27 S4 c & (a U b) B78323 a S4->B78323 S5 a U b B78324 a S5->B78324 B4 b B78324->B4 B78 b B78324->B78 B78319 b B78321->B78319 B78320 b B78321->B78320 B78317 b B78318->B78317 B78316 b B78318->B78316 B78322 b B78323->B78322 B615 b B78323->B615 B1 1 B4->B1 B0 0 B4->B0 B78319->B0 B78319->B35 B25 (a U b) & Xc B78317->B25 B6 Xc B78317->B6 B78->B1 B77 a U b B78->B77 B155 c & (a U b) B78320->B155 B78320->B35 B663 c B78322->B663 B78322->B27 B78316->B0 B78316->B6 B615->B27 B615->B0 B663->B0 B663->B77 B27->B1 B27->B0
In [45]:
# The above (obtained by product) is similar to what we get by direct translation
spot.ltlf_to_mtdfa("(a U b) & XXc")
Out[45]:
mtdfa S0 (a U b) & XXc I->S0 B78318 a S0->B78318 S1 Xc B35 c S1->B35 S2 (a U b) & Xc B78321 a S2->B78321 S3 c B27 c S3->B27 S4 c & (a U b) B78323 a S4->B78323 S5 a U b B78324 a S5->B78324 B4 b B78324->B4 B78 b B78324->B78 B78319 b B78321->B78319 B78320 b B78321->B78320 B78317 b B78318->B78317 B78316 b B78318->B78316 B78322 b B78323->B78322 B615 b B78323->B615 B1 1 B4->B1 B0 0 B4->B0 B78319->B0 B78319->B35 B25 (a U b) & Xc B78317->B25 B6 Xc B78317->B6 B78->B1 B77 a U b B78->B77 B155 c & (a U b) B78320->B155 B78320->B35 B663 c B78322->B663 B78322->B27 B78316->B0 B78316->B6 B615->B27 B615->B0 B663->B0 B663->B77 B27->B1 B27->B0

Additionally, complement() does what's expected: accepting terminals become non-accepting and vice-versa. Formula labels are also negated for looks.

In [46]:
display_inline(aub, spot.complement(aub))
mtdfa S0 a U b I->S0 B16 a S0->B16 B4 b B16->B4 B15 b B16->B15 B0 0 B4->B0 B1 1 B4->B1 B15->B1 B10 a U b B15->B10
mtdfa S0 !(a U b) I->S0 B78334 a S0->B78334 B5 b B78334->B5 B78333 b B78334->B78333 B1 1 B5->B1 B0 0 B5->B0 B78333->B0 B12 !(a U b) B78333->B12

Converting from TwA to MTDFA¶

The helper function spot.twadfa_to_mtdfa() can be used to convert a TwA representing a DFA into MTDFA. It should work if the input TwA is state-based (as in next cell), or transition-based (three cells down).

In [47]:
a = spot.translate("(a U b) | Xc", "finite", "deterministic")
a
Out[47]:
0 0 I->0 1 1 0->1 b 2 2 0->2 !a & !b 3 3 0->3 a & !b 1->1 1 2->1 c 3->1 b | c 4 4 3->4 a & !b & !c 4->1 b 4->4 a & !b
In [48]:
a2 = spot.twadfa_to_mtdfa(a)
a2
Out[48]:
mtdfa S0 0 I->S0 B78573 a S0->B78573 S1 1 B27 c S1->B27 S2 2 B78579 a S2->B78579 S3 3 B78583 a S3->B78583 B78566 b B78579->B78566 B78578 b B78579->B78578 B78572 b B78573->B78572 B78270 b B78573->B78270 B4 b B78583->B4 B78582 b B78583->B78582 B1 1 B78572->B1 B41 2 B78572->B41 B4->B1 B0 0 B4->B0 B78582->B1 B49 3 B78582->B49 B78566->B27 B78566->B1 B78270->B1 B6 1 B78270->B6 B78577 c B78578->B78577 B78578->B1 B27->B1 B27->B0 B78577->B1 B78577->B49
In [49]:
a3 = a2.as_twa(False, False)
a3
Out[49]:
0 0 I->0 1 1 0->1 !a & !b ⓿ 2 2 0->2 a & !b ⓿ 4 4 0->4 b ⓿ 1->4 c ⓿ 2->4 b | c ⓿ 3 3 2->3 a & !b & !c 4->4 1 ⓿ 3->4 b ⓿ 3->3 a & !b
In [50]:
a4 = spot.twadfa_to_mtdfa(a3)
a4
Out[50]:
mtdfa S0 0 I->S0 B78573 a S0->B78573 S1 1 B27 c S1->B27 S2 2 B78579 a S2->B78579 S3 3 B78583 a S3->B78583 B78566 b B78579->B78566 B78578 b B78579->B78578 B78572 b B78573->B78572 B78270 b B78573->B78270 B4 b B78583->B4 B78582 b B78583->B78582 B1 1 B78572->B1 B41 2 B78572->B41 B4->B1 B0 0 B4->B0 B78582->B1 B49 3 B78582->B49 B78566->B27 B78566->B1 B78270->B1 B6 1 B78270->B6 B78577 c B78578->B78577 B78578->B1 B27->B1 B27->B0 B78577->B1 B78577->B49
In [51]:
# We can check that a2 and a4 are equivalent by checking the emptiness of a2 XOR a4.
p = spot.product_xor(a2, a4)
display(p)
print(p.is_empty())
mtdfa S0 0 I->S0 B78589 a S0->B78589 S1 1 B0 0 S1->B0 S2 2 B78576 a S2->B78576 S3 3 B78581 a S3->B78581 B78575 b B78576->B78575 B78576->B0 B78587 b B78589->B78587 B78588 b B78589->B78588 B78580 b B78581->B78580 B78581->B0 B78574 c B78575->B78574 B78575->B0 B78587->B0 B9 1 B78587->B9 B78588->B0 B25 2 B78588->B25 B78580->B0 B49 3 B78580->B49 B78574->B0 B78574->B49
True

Compositional translation¶

ltlf_to_mtdfa() translate an entire formula at once, using the $tr(\cdot)$ function defined above. The resulting MTDFA can then be minimized if desired.

ltlf_to_mtdfa_compose() implements a compositional approach in which maximal subformulas that starts with a temporal operator are first translated and minimized separately, and the resulting minimial MTDFA are then combined recursively according to the Boolean operators that connect those formula.

For instance in the formula $\lnot\mathop{\mathsf{G}}(i_1\rightarrow \mathop{\mathsf{F}}o)\land \lnot\mathop{\mathsf{G}}(i_2\rightarrow \mathop{\mathsf{F}}o)$, ltlf_to_mtdfa_compose() will first call ltlf_to_mtdfa() to translate $\mathop{\mathsf{G}}(i_1\rightarrow \mathop{\mathsf{F}}o)$ and $\mathop{\mathsf{G}}(i_2\rightarrow \mathop{\mathsf{F}}o)$ separately. Then those two automata will be minimized. Then they will be both complemented and combined with product(), and finially the result of the product will be minimized again. Minimizing at every step like this can help keep the automaton small, and avoid the quadratic cost of running minimization on a very large automaton at the end of the translation.

In practice, the compositional approach works very well on benchmarks that have been built in a compositional way, for instance conjunctions of random formulas.

In [52]:
f = spot.formula("!G(i1 -> Fo) & !G(i2 -> Fo)")
a = spot.ltlf_to_mtdfa_compose(f); a
Out[52]:
mtdfa S0 !G(i1 -> Fo) & !G(i2 -> Fo) I->S0 B78611 o S0->B78611 S1 !G(i1 -> Fo) & !(Fo & G(i2 -> Fo)) B78613 o S1->B78613 S2 !G(i2 -> Fo) & !(Fo & G(i1 -> Fo)) B78614 o S2->B78614 S3 !(Fo & G(i1 -> Fo)) & !(Fo & G(i2 -> Fo)) B78615 o S3->B78615 B53052 i2 B78614->B53052 B10 !G(i1 -> Fo) & !G(i2 -> Fo) B78614->B10 B78610 i1 B78611->B78610 B78611->B10 B78612 i1 B78613->B78612 B78613->B10 B78615->B10 B35 !(Fo & G(i1 -> Fo)) & !(Fo & G(i2 -> Fo)) B78615->B35 B78610->B53052 B59053 i2 B78610->B59053 B78612->B35 B9 !G(i1 -> Fo) & !(Fo & G(i2 -> Fo)) B78612->B9 B25 !G(i2 -> Fo) & !(Fo & G(i1 -> Fo)) B53052->B25 B53052->B35 B59053->B10 B59053->B9

In this case, the direct translation produces the same result:

In [53]:
spot.ltlf_to_mtdfa(f)
Out[53]:
mtdfa S0 !G(i1 -> Fo) & !G(i2 -> Fo) I->S0 B78611 o S0->B78611 S1 !G(i1 -> Fo) & !(Fo & G(i2 -> Fo)) B78613 o S1->B78613 S2 !G(i2 -> Fo) & !(Fo & G(i1 -> Fo)) B78614 o S2->B78614 S3 !(Fo & G(i1 -> Fo)) & !(Fo & G(i2 -> Fo)) B78615 o S3->B78615 B53052 i2 B78614->B53052 B10 !G(i1 -> Fo) & !G(i2 -> Fo) B78614->B10 B78610 i1 B78611->B78610 B78611->B10 B78612 i1 B78613->B78612 B78613->B10 B78615->B10 B35 !(Fo & G(i1 -> Fo)) & !(Fo & G(i2 -> Fo)) B78615->B35 B78610->B53052 B59053 i2 B78610->B59053 B78612->B35 B9 !G(i1 -> Fo) & !(Fo & G(i2 -> Fo)) B78612->B9 B25 !G(i2 -> Fo) & !(Fo & G(i1 -> Fo)) B53052->B25 B53052->B35 B59053->B10 B59053->B9

Game interpretation¶

Let's now assume that a subset $C$ of the atomic proposition represent controllable variables. The complement of these variables are the uncontrollable variables. We assume that the environement is setting the uncontrollable variables for the current step, and that we are looking for a controller that will set the controllable variables in such a way that the automaton eventually reaches an accepting terminal.

There are two semantics we can work with:

  • with Mealy's semantics, the controllable variables can be set depending on the uncontrollable variables. In other words, the environment sets the uncontrollable variables, and the controller reacts to that by setting the output variables. In a game set up, the environment plays before the controller.
  • with Moore's semantics, the controller sets the controllable variables based only on the current state, and the environment can decide about the uncontrolled variables based on the value of the controlled one. In a game set up, the environment plays after the controller.

To interpret our MTDFA as a game, we will simply order the variables of the first player above those of the second player (according to the selected semantics).

We can do so by using a fresh bdd_dict in which we pre-register the atomic propositions we want to see at the top of the MTDFA. For cosmetics, calling set_controllable_variables on the automaton will render controllable variables as diamond nodes.

In [54]:
with spot.bdd_dict_preorder("o") as d:
    a_moore = spot.ltlf_to_mtdfa_compose("!G(i1 -> Fo) & !G(i2 -> Fo)", dict=d)
    a_moore.set_controllable_variables(["o"])
display(a_moore)
mtdfa S0 !G(i1 -> Fo) & !G(i2 -> Fo) I->S0 B78669 o S0->B78669 S1 !G(i1 -> Fo) & !(Fo & G(i2 -> Fo)) B78671 o S1->B78671 S2 !G(i2 -> Fo) & !(Fo & G(i1 -> Fo)) B78672 o S2->B78672 S3 !(Fo & G(i1 -> Fo)) & !(Fo & G(i2 -> Fo)) B78673 o S3->B78673 B78667 i2 B78672->B78667 B10 !G(i1 -> Fo) & !G(i2 -> Fo) B78672->B10 B78668 i1 B78669->B78668 B78669->B10 B78670 i1 B78671->B78670 B78671->B10 B78673->B10 B35 !(Fo & G(i1 -> Fo)) & !(Fo & G(i2 -> Fo)) B78673->B35 B78668->B78667 B78666 i2 B78668->B78666 B78670->B35 B9 !G(i1 -> Fo) & !(Fo & G(i2 -> Fo)) B78670->B9 B25 !G(i2 -> Fo) & !(Fo & G(i1 -> Fo)) B78667->B25 B78667->B35 B78666->B10 B78666->B9
In [55]:
with spot.bdd_dict_preorder("i1", "i2") as d:
    a_mealy = spot.ltlf_to_mtdfa_compose("!G(i1 -> Fo) & !G(i2 -> Fo)", dict=d)
    a_mealy.set_controllable_variables(["o"])
display(a_mealy)
mtdfa S0 !G(i1 -> Fo) & !G(i2 -> Fo) I->S0 B78697 i1 S0->B78697 S1 !G(i1 -> Fo) & !(Fo & G(i2 -> Fo)) B78698 i1 S1->B78698 S2 !G(i2 -> Fo) & !(Fo & G(i1 -> Fo)) B78696 i2 S2->B78696 S3 !(Fo & G(i1 -> Fo)) & !(Fo & G(i2 -> Fo)) B78695 o S3->B78695 B78692 o B78698->B78692 B78698->B78695 B78697->B78696 B78693 i2 B78697->B78693 B78694 o B78696->B78694 B78696->B78695 B78693->B78692 B10 !G(i1 -> Fo) & !G(i2 -> Fo) B78693->B10 B78692->B10 B9 !G(i1 -> Fo) & !(Fo & G(i2 -> Fo)) B78692->B9 B78694->B10 B25 !G(i2 -> Fo) & !(Fo & G(i1 -> Fo)) B78694->B25 B78695->B10 B35 !(Fo & G(i1 -> Fo)) & !(Fo & G(i2 -> Fo)) B78695->B35

The game interpretations of those MTDFA is that the controller plays the controllable variables and wins if it manages to steer the automaton towards an accepting terminal. The environments plays the uncontrollable variables and wins if he manages to avoid accepting terminals. We solve those games from the point of view of the controller, i.e., we say that the game is winning if the controller has a strategy to force the automaton to reach an accepting terminal, regardless of what the environment plays.

We will say that a terminal is winning if it is accepting, or if it represents a winning state. A state is winning if it has an assignment of controllable variables to force the automaton to eventually reach a winning terminal regardless of the value of uncontrollable variables. Please read the previous sentence twice, because this actually hides some transition-based semantics: reaching an accepting terminal is winning for the controller, but it does not imply that the state corresponding to that terminal is also winning for the controller.

(An alternative way to look at the game without transition-based semantics is to assume that accepting terminals correspond to game vertices without successors. The game can only continue after non-accepting terminals.)

In any case, what we described is a reachability game.

Different ways to solve such a game¶

We have two ways to solve the game node-based, or state-based. We illustrate both here, but in practice the node-based approach is better: it uses slightly more memory, but always runs in linear time.

Node-based approach¶

The node-based approach is probably the more natural, since this is how the game is presented to us: the nodes of the MTBDDs used to represent the MTDFA are the vertices of the game arena. We assume that rejecting terminal have the corresponding MTBDD root as successor, and that accepting terminals are winning nodes for the output player. If a diamond node N can has a winning node as successor, N can be marked as winning too. If a round node has only winning successors, then that node is winning as well. If, after some iteration of those rules, we manage to determinate that the initial state is winning, then the LTLf formula was realizable.

Such a backpropagation algorithm can be executed in linear time, but it requires duplicating the graph structure to keep track of the predecessors of each node. We can use Spot's backprop_graph to solve this, but it will help to read the backprop notebook first, to better understand what follows. In particular, the backprop_graph only keeps track of edges that will be used for backtracking. For instance, if you connect a diamond node to a winning node, the diamond node can be marked as winning already, which means that the edge between the two nodes does not have to be kept; the backprop_graph will therefore ignore such unnecessary edges to save memory. This dropping of edges might make it a bit difficult to compare the backprop_graph with the MTDFA it was created from.

Here is a an example with Moore semantics:

In [56]:
f = spot.formula("G(i8<->Fo8) & (o9 -> G(Xi9<->o8))")
C = ["o8", "o9"]
UC = ["i8", "i9"]

with spot.bdd_dict_preorder(*C) as d:
    a_moore = spot.ltlf_to_mtdfa_compose(f, dict=d)
    a_moore.set_controllable_variables(C)
display(a_moore)
mtdfa S0 G(i8 <-> Fo8) & (o9 -> G(o8 <-> Xi9)) I->S0 B78825 o8 S0->B78825 S1 G(i8 <-> Fo8) & !Fo8 B78716 o8 S1->B78716 S2 Fo8 & G(i8 <-> Fo8) B78826 o8 S2->B78826 S3 !i9 & Fo8 & G(i8 <-> Fo8) & G(o8 <-> Xi9) B78830 o8 S3->B78830 S4 G(i8 <-> Fo8) B78831 o8 S4->B78831 S5 i9 & G(i8 <-> Fo8) & G(o8 <-> Xi9) B78835 o8 S5->B78835 B78832 i8 B78835->B78832 B78834 i8 B78835->B78834 B671 i8 B78826->B671 B661 i8 B78826->B661 B78823 o9 B78825->B78823 B78824 o9 B78825->B78824 B78831->B661 B78713 i8 B78831->B78713 B78715 i8 B78716->B78715 B0 0 B78716->B0 B78827 i8 B78830->B78827 B78829 i8 B78830->B78829 B78278 i8 B78823->B78278 B78823->B78713 B78758 i8 B78824->B78758 B78824->B661 B78732 i9 B78832->B78732 B78832->B0 B671->B0 B25 Fo8 & G(i8 <-> Fo8) B671->B25 B78278->B0 B49 !i9 & Fo8 & G(i8 <-> Fo8) & G(o8 <-> Xi9) B78278->B49 B78758->B0 B165 i9 & G(i8 <-> Fo8) & G(o8 <-> Xi9) B78758->B165 B78833 i9 B78834->B78833 B78834->B0 B661->B0 B76 G(i8 <-> Fo8) B661->B76 B78729 i9 B78827->B78729 B78827->B0 B78828 i9 B78829->B78828 B78829->B0 B78715->B0 B6 G(i8 <-> Fo8) & !Fo8 B78715->B6 B78713->B25 B78713->B6 B78732->B0 B78732->B49 B78833->B0 B78833->B165 B78828->B0 B78828->B165 B78729->B0 B78729->B49

It what follows, the nodes of the MTBDD representing each state of the MTDFA are encoded into the backprop graphs, one state (of the MTDFA) at a time. The bddfalse node is mapped to a losing game vertex labeled by false, and all accepting terminals are mapped into a winning vertex labeled by true. The winning status of each game vertex is backpropapagted during the construction, and unnecessary edges are dropped.

In [57]:
b = spot.mtdfa_to_backprop(a_moore, False, True)
b
Out[57]:
mtdfa 0 G(i8 <-> Fo8) & (o9 -> G(o8 <-> Xi9)) 1 1 0->1 2 2 1->2 3 3 1->3 4 4 2->4 5 5 2->5 6 6 3->6 7 7 3->7 9 Fo8 & G(i8 <-> Fo8) 4->9 10 false 5->10 6->10 7->10 8 true 14 14 9->14 11 G(i8 <-> Fo8) & !Fo8 12 12 11->12 13 13 12->13 13->10 15 15 14->15 15->10 16 !i9 & Fo8 & G(i8 <-> Fo8) & G(o8 <-> Xi9) 17 17 16->17 18 18 17->18 19 19 17->19 18->10 19->10 20 G(i8 <-> Fo8) 21 21 20->21 22 i9 & G(i8 <-> Fo8) & G(o8 <-> Xi9) 23 23 22->23 24 24 23->24 25 25 23->25 24->10 25->10

The status of each state is encoded using two bits: b.is_determined(s) indicate whether a winner is known of state s. Then b.winner(s) gives the winning player for state s. When the state is underitermined, the winner default to False.

determined winner meaning
False False Player True (output) cannot force the game to reach an accepting terminal, and player False (input) cannot for the game to reach a rejecting sink
True False Player False (input) can force the game to reach a rejecting sink
True True Player True (output) can force the game to reach an accepting terminal

Therefore the formula is realizable iff winner(0) == True.

In [58]:
b.is_determined(0), b.winner(0)
Out[58]:
(True, False)

The second argument of mtdfa_to_backprop() requests that the algorithms aborts as soon as the winning status of the initial state is determined. If we enable it, you can see that the backprop graph has fewer vertices. (The third argument of mtdfa_to_backprop() asks to preserves the formulas that label states.)

In [59]:
b = spot.mtdfa_to_backprop(a_moore, True, True)  # abort early
b
Out[59]:
mtdfa 0 G(i8 <-> Fo8) & (o9 -> G(o8 <-> Xi9)) 1 1 0->1 2 2 1->2 3 3 1->3 4 4 2->4 5 5 2->5 6 6 3->6 7 7 3->7 9 Fo8 & G(i8 <-> Fo8) 4->9 10 false 5->10 6->10 7->10 8 true 14 14 9->14 11 G(i8 <-> Fo8) & !Fo8 12 12 11->12 13 13 12->13 13->10 15 15 14->15 15->10

Let's do the same with Mealy semantics:

In [60]:
with spot.bdd_dict_preorder(*UC) as d:
    a_mealy = spot.ltlf_to_mtdfa_compose(f, dict=d)
    a_mealy.set_controllable_variables(C)
display(a_mealy)
mtdfa S0 G(i8 <-> Fo8) & (o9 -> G(o8 <-> Xi9)) I->S0 B78904 i8 S0->B78904 S1 G(i8 <-> Fo8) & !Fo8 B78716 i8 S1->B78716 S2 Fo8 & G(i8 <-> Fo8) B78906 i8 S2->B78906 S3 !i9 & Fo8 & G(i8 <-> Fo8) & G(o8 <-> Xi9) B78908 i8 S3->B78908 S4 G(i8 <-> Fo8) B78909 i8 S4->B78909 S5 i9 & G(i8 <-> Fo8) & G(o8 <-> Xi9) B78911 i8 S5->B78911 B78910 i9 B78911->B78910 B0 0 B78911->B0 B78905 o8 B78906->B78905 B78906->B0 B78895 o8 B78904->B78895 B78903 o8 B78904->B78903 B78909->B78905 B78715 o8 B78909->B78715 B78716->B78715 B78716->B0 B78907 i9 B78908->B78907 B78908->B0 B78870 o8 B78910->B78870 B78910->B0 B78907->B78870 B78907->B0 B78894 o9 B78895->B78894 B78895->B0 B49 !i9 & Fo8 & G(i8 <-> Fo8) & G(o8 <-> Xi9) B78870->B49 B165 i9 & G(i8 <-> Fo8) & G(o8 <-> Xi9) B78870->B165 B25 Fo8 & G(i8 <-> Fo8) B78905->B25 B76 G(i8 <-> Fo8) B78905->B76 B78715->B0 B6 G(i8 <-> Fo8) & !Fo8 B78715->B6 B78901 o9 B78903->B78901 B78902 o9 B78903->B78902 B78894->B0 B78894->B6 B78901->B25 B78901->B49 B78902->B76 B78902->B165
In [61]:
b = spot.mtdfa_to_backprop(a_mealy, True, True)  # abort early
b
Out[61]:
mtdfa 0 G(i8 <-> Fo8) & (o9 -> G(o8 <-> Xi9)) 1 1 0->1 2 2 1->2 3 3 1->3 4 4 2->4 6 6 3->6 7 7 3->7 8 true 4->8 5 false 9 9 6->9 10 10 6->10 7->8
In [62]:
b.is_determined(0), b.winner(0)
Out[62]:
(True, True)

The process of interpreting an MTDFA as a game, solving the game, and then extracting a strategy is implemented by mtdfa_winning_strategy. A strategy is just an MTDFA where each diamond node has exactly one successor that is bddfalse, indicating that the strategy it always to take the other successor. (Arrow between diamond nodes and bddfalse are colored in gray so we remember they should not be used.)

The second argument of True requests the node-based approach. mtdfa_winning_strategy will "refine" the MTBDD for each states while it is solving the game by backpropagation. It does not waste time cleaning up the result MTDFA by removing unnecessary states. Below you can see that the formula is realizable in one step. By following the path from the initial state, if the environment sets i8=0, then the controller should set o8=o9=0, and if the envirnment sets i8=1, the controller should set o8=1 and o9=0.

In [63]:
spot.mtdfa_winning_strategy(a_mealy, True)
Out[63]:
mtdfa S0 G(i8 <-> Fo8) & (o9 -> G(o8 <-> Xi9)) I->S0 B78914 i8 S0->B78914 S1 G(i8 <-> Fo8) & !Fo8 B0 0 S1->B0 S2 Fo8 & G(i8 <-> Fo8) S2->B0 S3 !i9 & Fo8 & G(i8 <-> Fo8) & G(o8 <-> Xi9) S3->B0 S4 G(i8 <-> Fo8) S4->B0 S5 i9 & G(i8 <-> Fo8) & G(o8 <-> Xi9) S5->B0 B78912 o8 B78914->B78912 B78913 o8 B78914->B78913 B30 o9 B78912->B30 B78912->B0 B78913->B30 B78913->B0 B30->B0 B1 1 B30->B1

When no winning strategy exists, mtdfa_winning_strategy returns an MTDFA with an initial state equal to bddfalse:

In [64]:
spot.mtdfa_winning_strategy(a_moore, True)
Out[64]:
mtdfa S0 0 I->S0 B0 0 S0->B0

The state-based approach¶

In the state-based approach to game solving, we do not keep track of the winning status of the nodes of the MTBDDs. We just keep track of the winning status of states.

We find the set of winning states using a fixpoint algorithm. Initially, all states are considered as losing. Among those losing states we look for those for which there is an assignment of controllable variables that allow them to forcibly reach a winning terminal. This can be done by quantifying all variables of the MTBDD $b$ representing a state: controllable variables (in $C$) are existentially quantified, other variables are universaly quantified, and terminals are replaced by true if the are known to be winning or false otherwise.

The following pseudo-code shows how this quantification is performed on the MTBDD for a single state, caching the results in cache since multiple branches of the MTBDD can share the same node.

def is_winning_state(b, C, winterm, cache):
    # leaves
    if b == bddtrue: return True
    if b == bddfalse: return False
    if is_terminal(b): return winterm(b)  # is this terminal known to be winning?

    # internal nodes
    if b in cache: return cache[b]
    wlow = is_winning_state(b.low, C, winterm, cache)
    whigh = is_winning_state(b.high, C, winterm, cache)
    if b.var in C: 
        res = wlow | whigh  # ∃-quantification
    else:
        res = wlow & whigh  # ∀-quantification
    cache[b] = res
    return res

The function winterm returns True if the terminal is accepting, or if it is going to a state that has already been found to be winning.

Since is_winning_state only returns True or False, it does not have to modify the BDD b. Caching makes the function linear in the size of the b.

In general, the above quantification has to be performed on the entire MTDFA, sharing the cache for all quantifications of the same iteration. The set of the winning states determined after $n$ iterations will include all states for which the controller can force the automaton to reach an accepting terminal in $n$ edges (by "edge" we mean going from a root to a terminal). We can stop once a fixpoint is reached. (An obvious optimization would be to stop once it is known that state 0 is winning, but hold on to that.)

For instance:

In [65]:
with spot.bdd_dict_preorder("o") as d:
    a = spot.ltlf_to_mtdfa_compose("!G(i1 -> Fo) & !G(i2 -> Fo)", dict=d)
    a.set_controllable_variables(["o"])
display(a)
mtdfa S0 !G(i1 -> Fo) & !G(i2 -> Fo) I->S0 B78669 o S0->B78669 S1 !G(i1 -> Fo) & !(Fo & G(i2 -> Fo)) B78671 o S1->B78671 S2 !G(i2 -> Fo) & !(Fo & G(i1 -> Fo)) B78672 o S2->B78672 S3 !(Fo & G(i1 -> Fo)) & !(Fo & G(i2 -> Fo)) B78673 o S3->B78673 B78667 i2 B78672->B78667 B10 !G(i1 -> Fo) & !G(i2 -> Fo) B78672->B10 B78668 i1 B78669->B78668 B78669->B10 B78670 i1 B78671->B78670 B78671->B10 B78673->B10 B35 !(Fo & G(i1 -> Fo)) & !(Fo & G(i2 -> Fo)) B78673->B35 B78668->B78667 B78666 i2 B78668->B78666 B78670->B35 B9 !G(i1 -> Fo) & !(Fo & G(i2 -> Fo)) B78670->B9 B25 !G(i2 -> Fo) & !(Fo & G(i1 -> Fo)) B78667->B25 B78667->B35 B78666->B10 B78666->B9
In [66]:
spot.mtdfa_winning_region(a)
Out[66]:
(False, False, False, True)

The above function returns a vector of Boolean indicating for each state whether it wins or not.

Above, the fixpoint decides that only state 3 is winning, because from [3], we can force $o=\top$ to reach an accepting terminal. The other three states are not winning, because regardless of the value of $o$, some values of $i_1$ or $i_2$ will prevent the automaton from reaching state 3.

Let's look at something a bit more complicated, and compare Moore's and Mealy's semantics.

In [67]:
f = spot.formula("G(i8<->Fo8) & (o9 -> G(Xi9<->o8))")
C = ["o8", "o9"]
UC = ["i8", "i9"]

print("Moore semantics:")
with spot.bdd_dict_preorder(*C) as d:
    a_moore = spot.ltlf_to_mtdfa_compose(f, dict=d)
    a_moore.set_controllable_variables(C)
    display(a_moore.show(labels=False))
    print(spot.mtdfa_winning_region(a_moore))

print("\nMealy semantics:")
with spot.bdd_dict_preorder(*UC) as d:
    a_mealy = spot.ltlf_to_mtdfa_compose(f, dict=d)
    a_mealy.set_controllable_variables(C)
    display(a_mealy.show(labels=False))
    print(spot.mtdfa_winning_region(a_mealy))
Moore semantics:
mtdfa S0 0 I->S0 B78825 o8 S0->B78825 S1 1 B78716 o8 S1->B78716 S2 2 B78826 o8 S2->B78826 S3 3 B78830 o8 S3->B78830 S4 4 B78831 o8 S4->B78831 S5 5 B78835 o8 S5->B78835 B78832 i8 B78835->B78832 B78834 i8 B78835->B78834 B671 i8 B78826->B671 B661 i8 B78826->B661 B78823 o9 B78825->B78823 B78824 o9 B78825->B78824 B78831->B661 B78713 i8 B78831->B78713 B78715 i8 B78716->B78715 B0 0 B78716->B0 B78827 i8 B78830->B78827 B78829 i8 B78830->B78829 B78278 i8 B78823->B78278 B78823->B78713 B78758 i8 B78824->B78758 B78824->B661 B78732 i9 B78832->B78732 B78832->B0 B671->B0 B25 2 B671->B25 B78278->B0 B49 3 B78278->B49 B78758->B0 B165 5 B78758->B165 B78833 i9 B78834->B78833 B78834->B0 B661->B0 B76 4 B661->B76 B78729 i9 B78827->B78729 B78827->B0 B78828 i9 B78829->B78828 B78829->B0 B78715->B0 B6 1 B78715->B6 B78713->B25 B78713->B6 B78732->B0 B78732->B49 B78833->B0 B78833->B165 B78828->B0 B78828->B165 B78729->B0 B78729->B49
(False, False, False, False, False, False)

Mealy semantics:
mtdfa S0 0 I->S0 B78904 i8 S0->B78904 S1 1 B78716 i8 S1->B78716 S2 2 B78906 i8 S2->B78906 S3 3 B78908 i8 S3->B78908 S4 4 B78909 i8 S4->B78909 S5 5 B78911 i8 S5->B78911 B78910 i9 B78911->B78910 B0 0 B78911->B0 B78905 o8 B78906->B78905 B78906->B0 B78895 o8 B78904->B78895 B78903 o8 B78904->B78903 B78909->B78905 B78715 o8 B78909->B78715 B78716->B78715 B78716->B0 B78907 i9 B78908->B78907 B78908->B0 B78870 o8 B78910->B78870 B78910->B0 B78907->B78870 B78907->B0 B78894 o9 B78895->B78894 B78895->B0 B49 3 B78870->B49 B165 5 B78870->B165 B25 2 B78905->B25 B76 4 B78905->B76 B78715->B0 B6 1 B78715->B6 B78901 o9 B78903->B78901 B78902 o9 B78903->B78902 B78894->B0 B78894->B6 B78901->B25 B78901->B49 B78902->B76 B78902->B165
(True, False, False, False, True, False)

Here, the game cannot be won (the specification is unrealizable) with Moore semantics, and only states 0 and 4 are winning with Mealy semantics.

Note that while the above spot.mtdfa_winning_region is checking whether each state is winning, it does too much work our purpose:

  • We want to know if state 0 is winning.
  • We only need to check the winning status of states that are reachable from state 0 via non-accepting terminals
  • When a state is found to be winning, only its predecessors needs to be checked again at the next iteration.

One way to interpret the first two points is to pretend that each accepting terminal has been replaced by an accepting true sink. The function mtdfa_restrict_as_game() will do that rewriting and remove all non-accessible states so we can visualize it. (Beware that this procedure is renumbering states: you can still look at the LTLf formulas of each state by hovering above their number with your mouse if you have trouble matching those with the previous automata.)

In [68]:
a_moore2 = spot.mtdfa_restrict_as_game(a_moore)
display(a_moore2.show(labels=False))
print(spot.mtdfa_winning_region(a_moore2))
mtdfa S0 0 I->S0 B78916 o8 S0->B78916 S1 1 B78917 o8 S1->B78917 S2 2 B78920 o8 S2->B78920 B78913 i8 B78920->B78913 B78919 i8 B78920->B78919 B27 i8 B78917->B27 B669 i8 B78917->B669 B78915 o9 B78916->B78915 B78916->B27 B671 i8 B78915->B671 B78656 i8 B78915->B78656 B1 1 B27->B1 B0 0 B27->B0 B30 i9 B78913->B30 B78913->B0 B671->B0 B25 2 B671->B25 B78918 i9 B78919->B78918 B78919->B0 B78656->B1 B9 1 B78656->B9 B669->B0 B669->B9 B30->B1 B30->B0 B78918->B0 B78918->B25
(False, False, False)
In [69]:
a_mealy2 = spot.mtdfa_restrict_as_game(a_mealy)
display(a_mealy2.show(labels=False))
print(spot.mtdfa_winning_region(a_mealy2))
mtdfa S0 0 I->S0 B78923 i8 S0->B78923 S1 1 B78924 i8 S1->B78924 S2 2 B78926 i8 S2->B78926 B78925 i9 B78926->B78925 B0 0 B78926->B0 B55 o8 B78924->B55 B78924->B0 B78912 o8 B78923->B78912 B78922 o8 B78923->B78922 B61 o8 B78925->B61 B78925->B0 B1 1 B55->B1 B9 1 B55->B9 B30 o9 B78912->B30 B78912->B0 B78921 o9 B78922->B78921 B78922->B1 B61->B1 B25 2 B61->B25 B30->B1 B30->B0 B78921->B9 B78921->B25
(True, False, False)

The nice thing about this reduced MTDFA game is that it has fewer states, since only non-accepting states need to be represented. However, we do not really have to build that reduced DFAs to benefit from that, we can simply explore the non-accepting part of the original MTDFA. This is what mtdfa_winning_region_lazy does: instead of computing the winning status of every state, it only computes the winning status for the states that are reachable from state 0 without going through a winning terminal. (This can be done by hooking the winterm() function used in the is_winning_state() function shown above to record the non-winning terminals it encounters.) Additionally, this version will stop as soon is it determinates that state 0 is winning (if state 0 is not winning, or if the algorithm stops when it reaches a fixpoint). Finally, this lazy version also updates only the predecessors of the states that have been found winning at the previous iteration.

Compare mtdfa_winning_region and mtdfa_winning_region_lazy on the following:

In [70]:
display(a_mealy.show(labels=False))
print(spot.mtdfa_winning_region(a_mealy))
print(spot.mtdfa_winning_region_lazy(a_mealy))
mtdfa S0 0 I->S0 B78904 i8 S0->B78904 S1 1 B78716 i8 S1->B78716 S2 2 B78906 i8 S2->B78906 S3 3 B78908 i8 S3->B78908 S4 4 B78909 i8 S4->B78909 S5 5 B78911 i8 S5->B78911 B78910 i9 B78911->B78910 B0 0 B78911->B0 B78905 o8 B78906->B78905 B78906->B0 B78895 o8 B78904->B78895 B78903 o8 B78904->B78903 B78909->B78905 B78715 o8 B78909->B78715 B78716->B78715 B78716->B0 B78907 i9 B78908->B78907 B78908->B0 B78870 o8 B78910->B78870 B78910->B0 B78907->B78870 B78907->B0 B78894 o9 B78895->B78894 B78895->B0 B49 3 B78870->B49 B165 5 B78870->B165 B25 2 B78905->B25 B76 4 B78905->B76 B78715->B0 B6 1 B78715->B6 B78901 o9 B78903->B78901 B78902 o9 B78903->B78902 B78894->B0 B78894->B6 B78901->B25 B78901->B49 B78902->B76 B78902->B165
(True, False, False, False, True, False)
(True, False, False, False, False, False)

While state 4 is actually winning, mtdfa_winning_region_lazy does not bother because state 4 cannot be reached from by a sequence of only non-accepting terminals. Here mtdfa_winning_region_lazy first calls is_winning_state on state 0. is_winning_state records (as a side-effect of calling winterm()) that the only non-winning terminals state 0 can reach correspond to state 2 and 3. is_winning_state also returns that after ∀∃-quantification, state 0 is already winning, so states 2 and 3 do not even have to be explored.

There is also a variant called mtdfa_winning_region_lazy3 that works similarly to mtdfa_winning_region_lazy but using 3-valued logic. In that case, the returned vector uses False/True/maybe: False means that the input player has a way to force the game to reach the bddfalse node from that state, True means that the output player has a way to force the game to reach an accepting node from that state, and maybe means that either no player can reach their targets or the lazy algorithm aborted before being able to make a decision for this state.

In [71]:
print(spot.mtdfa_winning_region_lazy3(a_mealy))
(spot.trival(True), spot.trival_maybe(), spot.trival(False), spot.trival(False), spot.trival_maybe(), spot.trival_maybe())

Knowing the winning region actually allows us to reduce the game represented by the MTDFA a bit more: in addition to changing accepting terminal into true, we can also change losing (i.e., non-winning) terminals into false.

In [72]:
winning_states = spot.mtdfa_winning_region_lazy(a_mealy)
spot.mtdfa_restrict_as_game(a_mealy, winning_states)
Out[72]:
mtdfa S0 G(i8 <-> Fo8) & (o9 -> G(o8 <-> Xi9)) I->S0 B78927 i8 S0->B78927 B78912 o8 B78927->B78912 B27 o8 B78927->B27 B30 o9 B78912->B30 B0 0 B78912->B0 B27->B0 B1 1 B27->B1 B30->B0 B30->B1

This also works with the three-valued variant:

In [73]:
winning_states3 = spot.mtdfa_winning_region_lazy3(a_mealy)
spot.mtdfa_restrict_as_game(a_mealy, winning_states3)
Out[73]:
mtdfa S0 G(i8 <-> Fo8) & (o9 -> G(o8 <-> Xi9)) I->S0 B78927 i8 S0->B78927 B78912 o8 B78927->B78912 B27 o8 B78927->B27 B30 o9 B78912->B30 B0 0 B78912->B0 B27->B0 B1 1 B27->B1 B30->B0 B30->B1

By chance, the above is so simple that it is a winning strategy: we can tell because every controllable node has one black arrow (that should be taken) and one gray arrow (that should not be taken).

Let's look at a larger example using Moore semantics to show that this reduction of the game does not always lead to a strategy:

In [74]:
f2 = spot.formula('GF(!btn) -> (G(btn -> Flit) & G(lit -> F!lit) & Flit)')
C2 = ["lit"]
with spot.bdd_dict_preorder(*C2) as d:
    a_moore = spot.ltlf_to_mtdfa_compose(f2, dict=d)
    a_moore.set_controllable_variables(C2)
    display(a_moore.show(labels=True))
    print("winning region:", spot.mtdfa_winning_region(a_moore))
    ws = spot.mtdfa_winning_region_lazy(a_moore)
    print("          lazy:", ws)
    display(spot.mtdfa_restrict_as_game(a_moore, ws))
mtdfa S0 GF!btn -> (Flit & G(btn -> Flit) & G(lit -> F!lit)) I->S0 B78945 lit S0->B78945 S1 GF!btn -> (G(btn -> Flit) & G(lit -> F!lit)) B78947 lit S1->B78947 B78946 btn B78947->B78946 B78944 btn B78947->B78944 B78945->B78944 B78675 btn B78945->B78675 B6 GF!btn -> (G(btn -> Flit) & G(lit -> F!lit)) B78946->B6 B12 GF!btn -> (Flit & G(btn -> Flit) & G(lit -> F!lit)) B78946->B12 B78944->B6 B9 GF!btn -> (G(btn -> Flit) & G(lit -> F!lit)) B78944->B9 B78675->B12 B10 GF!btn -> (Flit & G(btn -> Flit) & G(lit -> F!lit)) B78675->B10
winning region: (True, True)
          lazy: (True, True)
mtdfa S0 GF!btn -> (Flit & G(btn -> Flit) & G(lit -> F!lit)) I->S0 B78949 lit S0->B78949 S1 GF!btn -> (G(btn -> Flit) & G(lit -> F!lit)) B78950 lit S1->B78950 B626 btn B78950->B626 B1 1 B78950->B1 B78949->B626 B15 btn B78949->B15 B626->B1 B9 GF!btn -> (G(btn -> Flit) & G(lit -> F!lit)) B626->B9 B15->B1 B10 GF!btn -> (Flit & G(btn -> Flit) & G(lit -> F!lit)) B15->B10

To describe a winning strategy, we need to select one successful edge for each controllable variable. We do that by modifying the MTBDD and redirecting the other edge to false. The implementation of this algorithm is actually very similar to the fixpoint algorithm used by mtdfa_winning_region_lazy(). Instead of is_winning_state, the core of the fixpoint loop looks as follows:

# inputs:
#   b: the MTBDD representing the state to refine
#   C: the set of controllable variable
#   winterm: a function that indicates whether b is a known winning
#            terminal, and that schedule the corresponding state for
#            processing if it is not terminal.
# output: (res, val)
#   res: the rewritten MTBDD
#   val: a value indicating if that state is winning for sure 
# if val is true, each controllable variable in res has a single non-false successor.
def refine_state(b, C, winterm, cache):
    # leaves
    if b == bddtrue: return (b, True)
    if b == bddfalse: return (b, False)
    if is_terminal(b):
        if is_accepting_terminal(b):
            return bddtrue, val   # remap any accepting terminal to bddtrue
        else: 
            return b, winterm(b)  # is this terminal known to be winning?

    # internal nodes
    if b in cache: return cache[b] 
    (rlow, vlow) = refine_state(b.low, C, winterm, cache)
    (rhigh, vhigh) = refine_state(b.high, C, winterm, cache)
    if b.var in C: 
        val = vlow | vhigh  # ∃-quantification
        if val:  # winning existential node: cut one of the two branches
            if vlow:
                rhigh = bddfalse
            else:
                rlow = bddfalse
    else:
        val = wlow & whigh  # ∀-quantification
        if !val and (rlow == bddfalse or rhigh == bddfalse):
            # if one branch of a universal node is false,
            # we can reduce the node to false.
            rlow = rhigh = bddfalse
        # if one child a universal node is bddtrue, we
        # can replace the current node by the other child
        if rlow == bddtrue:
            rlow = rhigh
        elif rhigh == bddtrue:
            rhigh = rlow
    res = bdd_makenode(b.bar, rlow, rhigh)
    cache[b] = (res, val)
    return (res, val)

Essentially, this performs the work of is_winning_state() in addition to modifying the MTBDD. There are only four modifications performed: (1) accepting terminals are replaced by bddtrue, (2) internal nodes that are both winning representing a controllable variable get one of their successors redirected to false (we keep a branch that is winning, of course), and (3) if a universal node has bddfalse as a child, that node can be replaced by bddfalse, and (4) if a universal node has bddtrie as child, that node can be replaced by the other child.

TODO? There should be room for improvement in the above algorithm, because the second recursive call to refine_state() can be skipped if vlow == True and b.var in C. Implementing this is trickier than it looks, because the actual implementation is non-recursive and very low-level. Furthermore, on a few examples where I tried, attempting to remove the second recursive call when possible actually slowed the implementation down without reducing the number of calls to bdd_makenode (either the "saved" branch was cached already, or it needed to be computed later anyway). So currently I have this optimization disabled.

After applying the above in a fixpoint until state 0 is accepting, here is what we get for the previous example:

In [75]:
spot.mtdfa_winning_strategy(a_moore, False)
Out[75]:
mtdfa S0 GF!btn -> (Flit & G(btn -> Flit) & G(lit -> F!lit)) I->S0 B625 lit S0->B625 S1 GF!btn -> (G(btn -> Flit) & G(lit -> F!lit)) B3 lit S1->B3 B1 1 B3->B1 B0 0 B3->B0 B625->B0 B9 GF!btn -> (G(btn -> Flit) & G(lit -> F!lit)) B625->B9

The above tells us that, initially, the controller should emit have lit=1, and on the next step it should do lit=0 regardless of what the environment plays (in fact the controller only need to do lit=0 if btn=0; if btn=1 the controller is free to do anything, so this includes doing lit=0).

Here is the output of mtdfa_winning_strategy run on a previous example with Mealy semantics. Note that states that are not winning are mapped to false instead of being removed. Removing states would require renumbering terminals, so it is cheaper to keep them and set their MTBDD to bddfalse.

In [76]:
display(a_mealy2.show(labels=False))
strat = spot.mtdfa_winning_strategy(a_mealy2, False)
display(strat.show(labels=False))
mtdfa S0 0 I->S0 B78923 i8 S0->B78923 S1 1 B78924 i8 S1->B78924 S2 2 B78926 i8 S2->B78926 B78925 i9 B78926->B78925 B0 0 B78926->B0 B55 o8 B78924->B55 B78924->B0 B78912 o8 B78923->B78912 B78922 o8 B78923->B78922 B61 o8 B78925->B61 B78925->B0 B1 1 B55->B1 B9 1 B55->B9 B30 o9 B78912->B30 B78912->B0 B78921 o9 B78922->B78921 B78922->B1 B61->B1 B25 2 B61->B25 B30->B1 B30->B0 B78921->B9 B78921->B25
mtdfa S0 0 I->S0 B78927 i8 S0->B78927 S1 1 B0 0 S1->B0 S2 2 S2->B0 B78912 o8 B78927->B78912 B27 o8 B78927->B27 B30 o9 B78912->B30 B78912->B0 B27->B0 B1 1 B27->B1 B30->B0 B30->B1

Building a restricted MTBDD-based DFA for synthesis¶

Since we do not need to traverse accepting terminals while solving a synthesis propblem, it is actually wasteful to create the full DFA for the original formula. We can instead instruct ltlf_to_mtdfa to replace accepting terminals by bddtrue during the construction of the DFA. This can be done my applying refine_state on the output of tr() during the construction (just assume that winterm(s) is always False at this point). Using that refine_state function actually has the added advantage of simplifying controllable nodes that can reach bddtrue, and simplifying uncontrollable nodes that can reach bddfalse, so this helps a lot in reducing the number of states that have to be explored.

Let's see the effect on the following example. Here is the full translation for the formula $f$ defined earlier.

In [77]:
display(f)
with spot.bdd_dict_preorder(*UC) as d:
    aa = spot.ltlf_to_mtdfa(f, True, True, True, dict=d)
    aa.set_controllable_variables(C)
    display(aa.show(labels=False))
$\mathsf{G} (i_{8} \leftrightarrow \mathsf{F} o_{8}) \land (o_{9} \rightarrow \mathsf{G} (o_{8} \leftrightarrow \mathsf{X} i_{9}))$
mtdfa S0 0 I->S0 B78998 i8 S0->B78998 S1 1 B78716 i8 S1->B78716 S2 2 B79000 i8 S2->B79000 S3 3 B78871 i8 S3->B78871 S4 4 B78898 i8 S4->B78898 S5 5 B78876 i8 S5->B78876 S6 6 B79002 i8 S6->B79002 B79001 i9 B79002->B79001 B78899 i9 B79002->B78899 B78999 i9 B79000->B78999 B0 0 B79000->B0 B78870 o8 B78876->B78870 B78715 o8 B78876->B78715 B78995 o8 B78998->B78995 B78997 o8 B78998->B78997 B78897 i9 B78898->B78897 B78898->B0 B78716->B78715 B78716->B0 B78871->B78870 B78871->B0 B78867 o8 B79001->B78867 B79001->B0 B78872 o8 B78899->B78872 B78899->B0 B78897->B78872 B78897->B0 B78999->B78867 B78999->B0 B49 3 B78870->B49 B165 5 B78870->B165 B78715->B0 B6 1 B78715->B6 B78867->B0 B25 2 B78867->B25 B78994 o9 B78995->B78994 B78995->B0 B155 4 B78872->B155 B168 6 B78872->B168 B14426 o9 B78997->B14426 B78996 o9 B78997->B78996 B78994->B25 B78994->B6 B14426->B49 B14426->B155 B78996->B165 B78996->B168

The way the original (full) translation works is that it first builds the MTBDD for state 0 using the $tr(f)$ function defined at the top of this notebook, as seen below:

In [78]:
aa.show(state=0, labels=False)
Out[78]:
mtdfa S0 0 B78998 i8 S0->B78998 B78995 o8 B78998->B78995 B78997 o8 B78998->B78997 B78994 o9 B78995->B78994 B0 0 B78995->B0 B14426 o9 B78997->B14426 B78996 o9 B78997->B78996 B6 1 B78994->B6 B25 2 B78994->B25 B155 4 B14426->B155 B49 3 B14426->B49 B165 5 B78996->B165 B168 6 B78996->B168

Then it gathers all the states that appear in terminals, i.e., $\{1,2,3,4,5,6\}$, and repeats the process for each state. However, here, we can see that state 1,4,6 are accepting, so for the purpose of synthesis we can simply replace those by bddtrue. Furthermore, $o8$ and $o9$ are controllable variables, so we can force them to reach always that bddtrue leaf. In the end, we just have bddfalse and bddtrue as terminals, and we do not need to explore further states.

We can trigger this restricted translation by using ltlf_to_mtdfa_for_synthesis, that takes a set C of controllable variables. This function has options, and implement different node-based or state-based approaches. Option state_refine requests a restricted translation in which states have been refined as described earlier. The result is not necessarily a strategy: in general, it is a game that must still be solved.

In [79]:
with spot.bdd_dict_preorder(*UC) as d:
    display(spot.ltlf_to_mtdfa_for_synthesis(f, C, approach="state_refine", dict=d).show(labels=False))
mtdfa S0 0 I->S0 B78914 i8 S0->B78914 B78912 o8 B78914->B78912 B78913 o8 B78914->B78913 B30 o9 B78912->B30 B0 0 B78912->B0 B78913->B30 B78913->B0 B30->B0 B1 1 B30->B1

This, by chance, is already a strategy. In general we should just expect fewer states that the complete translation. Also, in this example, the order of o8 and o9 has changed compared to the complete construction. This is just a side-effect of ltlf_to_mtdfa_for_synthesis having to build a table of all output variables before actually starting the translation.

The previous example shows that by refinining output nodes to make them point toward bddtrue, we avoid constructing many states. Dually, refine_state() can replace any input nodes that have a bddfalse child by bddfalse since such a node is already losing. This can save the construction of many states as well. Here is an example. First, the full translation:

In [80]:
f = spot.formula('G(o1 & o2 & (i1 | XFo3) & i2)')
C = ["o1", "o2", "o3"]
with spot.bdd_dict_preorder(*C) as d:
    aa = spot.ltlf_to_mtdfa(f, True, True, True, dict=d)
    aa.set_controllable_variables(C)
    display(aa)
mtdfa S0 G(i2 & o1 & o2 & (i1 | XFo3)) I->S0 B79022 o1 S0->B79022 S1 Fo3 & G(i2 & o1 & o2 & (i1 | XFo3)) B79025 o1 S1->B79025 B79024 o2 B79025->B79024 B0 0 B79025->B0 B79021 o2 B79022->B79021 B79022->B0 B79023 o3 B79024->B79023 B79024->B0 B79020 i2 B79021->B79020 B79021->B0 B79023->B79020 B633 i2 B79023->B633 B79019 i1 B79020->B79019 B79020->B0 B633->B0 B9 Fo3 & G(i2 & o1 & o2 & (i1 | XFo3)) B633->B9 B6 Fo3 & G(i2 & o1 & o2 & (i1 | XFo3)) B79019->B6 B12 G(i2 & o1 & o2 & (i1 | XFo3)) B79019->B12

However, the output nodes labeled by i2 both have bddfalse as successor, so if we do that during the translation, the initial states simplifies to bddfalse and we do not have to construct additional nodes.

In [81]:
with spot.bdd_dict_preorder(*C) as d:
    aa = spot.ltlf_to_mtdfa_for_synthesis(f, C, approach="state_refine", dict=d)
    display(aa)
mtdfa S0 0 I->S0 B0 0 S0->B0

ltlf_to_mtdfa_for_synthesis implements different approaches. If you replace state_refine by dfs_node_backprop or bfs_node_backprop, this will build the MTDFA and the backprop graph on-the-fly while solving the game, and it will return a strategy. Using dfs_node_backprop gives the best results, so this is the default.

Strategy to AIG¶

We can convert the above strategies into Mealy machines, which in Spot are just TwAs with true acceptance, decorated with a property indicating which are the output propositions. Then, such a Mealy machine can simply be converted to an And Inverter Graph using Spot's AIG encoder (spot.mealy_machine_to_aig).

Spot has no representation for Moore machines. However, a Moore machine can always be seen as a Mealy machine in which the output signals are only dependent on the input state. Therefore, this conversion is also OK when the strategy was built with Moore semantics.

In [82]:
print("Mealy strategy")  # the one computed a few cells above
twa1 = spot.mtdfa_strategy_to_mealy(strat)
aig1 = spot.mealy_machine_to_aig(twa1, "isop")
display_inline(strat.show(labels=False),
               twa1.show('.1v'), aig1, per_row=3)
Mealy strategy
mtdfa S0 0 I->S0 B78927 i8 S0->B78927 S1 1 B0 0 S1->B0 S2 2 S2->B0 B78912 o8 B78927->B78912 B27 o8 B78927->B27 B30 o9 B78912->B30 B78912->B0 B27->B0 B1 1 B27->B1 B30->B0 B30->B1
0 0 I->0 1 1 0->1 !i8 / !o8 & !o9 0->1 i8 / o8 1->1 1 / 1
6 L0_out 8 8 6->8 o0 o8 8->o0:s L0 L0_in o1 o9 2 i8 2->8 4 i9 0 False 0->L0 0->o1:s
In [83]:
print("Moore strategy")
strat2 = spot.mtdfa_winning_strategy(a_moore, True)
twa2 = spot.mtdfa_strategy_to_mealy(strat2)
aig2 = spot.mealy_machine_to_aig(twa2, "isop")
display_inline(strat2.show(labels=False), twa2.show('.1v'), aig2, per_row=3)
Moore strategy
mtdfa S0 0 I->S0 B625 lit S0->B625 S1 1 B3 lit S1->B3 B1 1 B3->B1 B0 0 B3->B0 B625->B0 B9 1 B625->B9
0 0 I->0 1 1 0->1 1 / lit 2 2 1->2 1 / !lit 2->2 1 / 1
4 L0_out 8 8 4->8 10 10 4->10 12 12 4->12 6 L1_out 6->8 6->10 6->12 L0 L0_in 8->L0 o0 lit 8->o0:s 14 14 10->14 12->14 L1 L1_in 14->L1 2 btn

In an AIG computed for a Moore strategy, the output signals only depend on latches, they cannot depend on input signals. However latches can be updated from input signals.

spot.mtdfa_strategy_to_mealy has an option to stutter on acceptance. I.e., instead of jumping to an accepting sink as shown above (to reflect the fact that after the specification is fulfilled any behavior is ok), it will jump back to he previous state (that's one possible behavior). Doing so saves one state:

In [84]:
print("Mealy strategy")  # the one computed a few cells above
twa1 = spot.mtdfa_strategy_to_mealy(strat, False, True)
aig1 = spot.mealy_machine_to_aig(twa1, "isop")
display_inline(strat.show(labels=False),
               twa1.show('.1v'), aig1, per_row=3)
Mealy strategy
mtdfa S0 0 I->S0 B78927 i8 S0->B78927 S1 1 B0 0 S1->B0 S2 2 S2->B0 B78912 o8 B78927->B78912 B27 o8 B78927->B27 B30 o9 B78912->B30 B78912->B0 B27->B0 B1 1 B27->B1 B30->B0 B30->B1
0 0 I->0 0->0 !i8 / !o8 & !o9 0->0 i8 / o8
o0 o8 o1 o9 2 i8 2->o0:s 4 i9 0 False 0->o1:s
In [85]:
print("Moore strategy")
strat2 = spot.mtdfa_winning_strategy(a_moore, True)
twa2 = spot.mtdfa_strategy_to_mealy(strat2, False, True)
aig2 = spot.mealy_machine_to_aig(twa2, "isop")
display_inline(strat2.show(labels=False), twa2.show('.1v'), aig2, per_row=3)
Moore strategy
mtdfa S0 0 I->S0 B625 lit S0->B625 S1 1 B3 lit S1->B3 B1 1 B3->B1 B0 0 B3->B0 B625->B0 B9 1 B625->B9
0 0 I->0 1 1 0->1 1 / lit 1->1 1 / !lit
4 L0_out o0 lit 4->o0:s L0 L0_in 2 btn 0 False 0->L0
In [ ]: