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. Thefore 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 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 Brzozowsk'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 singled pass independant on 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 B8 a S0->B8 B0 0 B8->B0 B6 b B8->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 B11 a S0->B11 B5 b B11->B5 B4 b B11->B4 B1 1 B5->B1 B0 0 B5->B0 B4->B1 B4->B0
mtdfa S0 Xa B6 a S0->B6
mtdfa S0 X[!]a B12 a S0->B12
mtdfa S0 Fa B14 a S0->B14 B1 1 B14->B1 B13 Fa B14->B13
mtdfa S0 Ga B16 a S0->B16 B0 0 B16->B0 B15 Ga B16->B15
mtdfa S0 a U b B19 a S0->B19 B4 b B19->B4 B18 b B19->B18 B0 0 B4->B0 B1 1 B4->B1 B18->B1 B13 a U b B18->B13
mtdfa S0 a W b B21 a S0->B21 B4 b B21->B4 B20 b B21->B20 B0 0 B4->B0 B1 1 B4->B1 B20->B1 B15 a W b B20->B15
mtdfa S0 a R b B24 a S0->B24 B23 b B24->B23 B4 b B24->B4 B0 0 B23->B0 B15 a R b B23->B15 B4->B0 B1 1 B4->B1
mtdfa S0 a M b B26 a S0->B26 B25 b B26->B25 B4 b B26->B4 B0 0 B25->B0 B13 a M b B25->B13 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 B16 a S0->B16 B0 0 B16->B0 B15 Ga B16->B15
mtdfa S0 !Ga B28 a S0->B28 B1 1 B28->B1 B13 !Ga B28->B13
mtdfa S0 F!a B29 a S0->B29 B1 1 B29->B1 B13 F!a B29->B13
mtdfa S0 Fb B14 b S0->B14 B1 1 B14->B1 B13 Fb B14->B13
mtdfa S0 Ga -> Fb B37 a S0->B37 B36 b B37->B36 B1 1 B37->B1 B36->B1 B13 Ga -> Fb B36->B13
mtdfa S0 F!a | Fb B37 a S0->B37 B36 b B37->B36 B1 1 B37->B1 B36->B1 B13 F!a | Fb B36->B13
mtdfa S0 Ga <-> Fb B47 a S0->B47 B44 b B47->B44 B46 b B47->B46 B0 0 B44->B0 B6 !Fb B44->B6 B13 Ga <-> Fb B46->B13 B45 Ga B46->B45
mtdfa S0 Fa xor Gb B57 a S0->B57 B52 b B57->B52 B56 b B57->B56 B12 Fa B52->B12 B15 Fa xor Gb B52->B15 B1 1 B56->B1 B32 !Gb B56->B32

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 B66 a S0->B66 S1 b U c B59 b S1->B59 S2 (b U c) | (a U (b U c)) S2->B66 B66->B59 B65 b B66->B65 B9 c B59->B9 B50 c B59->B50 B60 c B65->B60 B64 c B65->B64 B1 1 B9->B1 B0 0 B9->B0 B60->B1 B13 a U (b U c) B60->B13 B64->B1 B32 (b U c) | (a U (b U c)) B64->B32 B50->B1 B12 b U c B50->B12

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 B67 a S0->B67 S1 b U c B59 b S1->B59 B67->B59 B60 c B67->B60 B9 c B59->B9 B50 c B59->B50 B1 1 B60->B1 B13 a U (b U c) B60->B13 B9->B1 B0 0 B9->B0 B50->B1 B12 b U c B50->B12

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 B71 a S0->B71 S1 Fa & GFa S1->B71 B12 Fa & GFa B71->B12 B15 GFa B71->B15
mtdfa S0 GFa I->S0 B72 a S0->B72 B13 GFa B72->B13 B15 GFa B72->B15

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 B183 a S0->B183 S1 Fa & GFa & Fb & GFb & Fc & GFc S1->B183 S2 Fa & GFa & Fb & GFb & GFc S2->B183 S3 Fa & GFa & GFb & Fc & GFc S3->B183 S4 Fa & GFa & GFb & GFc S4->B183 S5 GFa & Fb & GFb & Fc & GFc S5->B183 S6 GFa & Fb & GFb & GFc S6->B183 S7 GFa & GFb & Fc & GFc S7->B183 B179 b B183->B179 B182 b B183->B182 B176 c B179->B176 B178 c B179->B178 B180 c B182->B180 B181 c B182->B181 B32 Fa & GFa & Fb & GFb & GFc B176->B32 B12 Fa & GFa & Fb & GFb & Fc & GFc B176->B12 B90 GFa & Fb & GFb & GFc B180->B90 B85 GFa & Fb & GFb & Fc & GFc B180->B85 B96 GFa & GFb & Fc & GFc B181->B96 B15 GFa & GFb & GFc B181->B15 B53 Fa & GFa & GFb & Fc & GFc B178->B53 B177 Fa & GFa & GFb & GFc B178->B177

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 B195 a S0->B195 B194 b B195->B194 B13 GFa & GFb & GFc B195->B13 B193 c B194->B193 B194->B13 B193->B13 B15 GFa & GFb & GFc B193->B15

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 B634 i1 S0->B634 S1 1 B640 i1 S1->B640 S2 2 B643 i1 S2->B643 S3 3 B648 i1 S3->B648 S4 4 B653 i1 S4->B653 S5 5 B654 i1 S5->B654 S6 6 B657 i1 S6->B657 S7 7 B658 i1 S7->B658 S8 8 B659 i1 S8->B659 S9 9 B660 i1 S9->B660 S10 10 B671 i1 S10->B671 S11 11 B672 i1 S11->B672 S12 12 B673 i1 S12->B673 S13 13 B675 i1 S13->B675 S14 14 B677 i1 S14->B677 B674 o1 B677->B674 B676 o1 B677->B676 B656 o1 B657->B656 B642 o1 B657->B642 B643->B642 B0 0 B643->B0 B675->B674 B675->B0 B633 o1 B634->B633 B634->B0 B666 o1 B673->B666 B673->B0 B654->B633 B639 o1 B654->B639 B670 o1 B672->B670 B672->B0 B671->B666 B671->B670 B652 o1 B653->B652 B653->B0 B640->B639 B640->B0 B641 o2 B660->B641 B655 o2 B660->B655 B659->B655 B659->B0 B647 o1 B648->B647 B648->B0 B658->B641 B658->B0 B665 o2 B674->B665 B674->B641 B638 o2 B656->B638 B656->B655 B646 o2 B647->B646 B647->B638 B642->B641 B632 o2 B642->B632 B629 o2 B633->B629 B633->B632 B639->B638 B635 o2 B639->B635 B666->B665 B662 o2 B666->B662 B669 o2 B670->B669 B651 o2 B670->B651 B652->B651 B652->B638 B676->B669 B676->B655 B664 o0 B665->B664 B663 o0 B665->B663 B667 o0 B669->B667 B668 o0 B669->B668 B649 o0 B651->B649 B650 o0 B651->B650 B644 o0 B646->B644 B645 o0 B646->B645 B637 o0 B638->B637 B90 6 B638->B90 B99 8 B641->B99 B96 7 B641->B96 B12 1 B629->B12 B13 0 B629->B13 B631 o0 B632->B631 B32 2 B632->B32 B103 9 B655->B103 B655->B99 B662->B650 B661 o0 B662->B661 B85 5 B635->B85 B635->B12 B275 11 B664->B275 B53 3 B664->B53 B649->B85 B273 10 B649->B273 B644->B103 B644->B85 B650->B275 B650->B12 B122 14 B667->B122 B667->B90 B636 i0 B668->B636 B668->B275 B663->B32 B113 13 B663->B113 B645->B99 B645->B12 B637->B636 B630 i0 B637->B630 B108 12 B661->B108 B661->B13 B631->B630 B631->B53 B636->B53 B636->B12 B177 4 B630->B177 B630->B12

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 explicitely, 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 straightfoward: 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 B706 a S0->B706 S1 c R d B701 c S1->B701 S2 a U b B708 a S2->B708 B4 b B708->B4 B707 b B708->B707 B702 b B706->B702 B705 b B706->B705 B1 1 B4->B1 B0 0 B4->B0 B707->B1 B32 a U b B707->B32 B702->B701 B702->B0 B705->B701 B704 c B705->B704 B73 d B701->B73 B700 d B701->B700 B695 d B704->B695 B703 d B704->B703 B73->B1 B73->B0 B695->B0 B13 (a U b) & (c R d) B695->B13 B703->B0 B703->B32 B700->B0 B6 c R d B700->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 B738 a S0->B738 S1 Fa & Gc B739 a S1->B739 S2 Fb & Gc B737 b S2->B737 S3 Gc B736 c S3->B736 B733 c B739->B733 B739->B736 B738->B737 B734 b B738->B734 B735 c B737->B735 B737->B736 B734->B733 B726 c B734->B726 B0 0 B733->B0 B12 Fa & Gc B733->B12 B735->B0 B32 Fb & Gc B735->B32 B726->B0 B13 Fa & Fb & Gc B726->B13 B736->B0 B40 Gc B736->B40
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 B746 a S0->B746 S1 Fa & G(!r | Fa) B71 a S1->B71 B15 G(!r | Fa) B71->B15 B12 Fa & G(!r | Fa) B71->B12 B745 r B746->B745 B746->B15 B745->B15 B745->B12
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) B45 X(0) S1->B45 S2 X(0) B40 0 S2->B40 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 B12 X[!]1 S0->B12 S1 X[!]1 B32 1 S1->B32 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 B72 a S0->B72 B13 F(a & X(0)) B72->B13 B15 F(a & X(0)) B72->B15
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 B72 a S0->B72 B13 GFa | F(a & X(0)) | FGa B72->B13 B15 GFa | F(a & X(0)) | FGa B72->B15
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 B800 o0 S0->B800 S1 1 B802 o0 S1->B802 S2 2 B803 o0 S2->B803 S3 3 B799 i0 S3->B799 B798 i0 B803->B798 B45 2 B803->B45 B800->B799 B800->B798 B802->B798 B801 i0 B802->B801 B40 3 B799->B40 B53 3 B799->B53 B32 2 B798->B32 B6 1 B798->B6 B801->B45 B801->B40
0 0 I->0 1 1 0->1 !i0 & !o0 ⓿ 2 2 0->2 i0 & !o0 3 3 0->3 i0 & o0 0->3 !i0 & o0 ⓿ 1->1 !i0 & !o0 ⓿ 1->2 i0 & !o0 1->2 i0 & o0 ⓿ 1->3 !i0 & o0 ⓿ 2->1 !i0 & !o0 ⓿ 2->2 i0 & !o0 2->2 o0 ⓿ 3->3 i0 3->3 !i0 ⓿
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->1 !i0 & !o0 2->2 i0 & !o0 2->5 o0 3->3 !i0 3->4 i0 4->3 !i0 4->4 i0 5->1 !i0 & !o0 5->2 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 B940 o0 S0->B940 B938 i0 B940->B938 B939 o1 B940->B939 B935 o1 B938->B935 B937 o1 B938->B937 B934 o2 B939->B934 B0 0 B939->B0 B935->B934 B933 o2 B935->B933 B937->B934 B936 o2 B937->B936 B932 o3 B934->B932 B934->B0 B933->B932 B931 o3 B933->B931 B936->B932 B13 phi B936->B13 B932->B0 B932->B13 B930 i1 B931->B930 B931->B13 B929 i2 B930->B929 B930->B13 B929->B13 B15 phi B929->B15
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 B1140 o0 S0->B1140 S1 1 B1146 o0 S1->B1146 S2 2 B1149 o0 S2->B1149 S3 3 B1152 o0 S3->B1152 S4 4 B1138 i1 S4->B1138 B1145 o1 B1149->B1145 B1148 o1 B1149->B1148 B1137 i0 B1140->B1137 B1139 o1 B1140->B1139 B1151 o1 B1152->B1151 B1152->B1139 B1144 i0 B1146->B1144 B1146->B1145 B1134 o1 B1137->B1134 B1136 o1 B1137->B1136 B1143 o1 B1144->B1143 B1142 o1 B1144->B1142 B1135 i1 B1151->B1135 B1150 i1 B1151->B1150 B1133 i1 B1134->B1133 B15 0 B1134->B15 B1145->B1138 B1141 i1 B1145->B1141 B1143->B1135 B1143->B1141 B1148->B1135 B1147 i1 B1148->B1147 B1139->B1138 B1139->B15 B1136->B1135 B1136->B15 B1142->B1133 B1142->B1141 B84 4 B1138->B84 B177 4 B1138->B177 B1133->B15 B6 1 B1133->B6 B53 3 B1135->B53 B45 2 B1135->B45 B1141->B84 B1141->B15 B1147->B84 B1147->B53 B40 3 B1150->B40 B1150->B53
0 0 I->0 0->0 !o1 | (!i0 & i1 & !o0) ⓿ 1 1 0->1 !i0 & !i1 & !o0 & o1 ⓿ 2 2 0->2 i0 & !i1 & !o0 & o1 ⓿ 3 3 0->3 i0 & i1 & !o0 & o1 4 4 0->4 i1 & o0 & o1 0->4 !i1 & o0 & o1 ⓿ 1->0 (!i0 & i1 & !o0) | (i1 & !o1) ⓿ 1->1 !i0 & !i1 & !o0 & o1 ⓿ 1->2 i0 & !i1 & !o0 & o1 ⓿ 1->3 i0 & i1 & !o0 & o1 1->4 i1 & o0 & o1 1->4 (!i1 & o0) | (!i1 & !o1) ⓿ 2->0 i1 & o0 & !o1 ⓿ 2->2 !i1 & !o0 & o1 ⓿ 2->3 i1 & !o0 2->4 i1 & o0 & o1 2->4 (!i1 & o0) | (!i1 & !o1) ⓿ 3->0 o0 & !o1 ⓿ 3->2 !i1 & !o0 & o1 ⓿ 3->3 i1 & !o0 3->3 !i1 & !o0 & !o1 ⓿ 3->4 i1 & o0 & o1 3->4 !i1 & o0 & o1 ⓿ 4->4 i1 4->4 !i1 ⓿

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]:
0 0 I->0 1 1 0->1 i0 & i1 & !o0 & o1 2 2 0->2 i1 & o0 & o1 3 3 0->3 !i1 & o0 & o1 4 4 0->4 !i0 & !i1 & !o0 & o1 5 5 0->5 i0 & !i1 & !o0 & o1 7 7 0->7 !o1 | (!i0 & i1 & !o0) 1->1 i1 & !o0 1->2 i1 & o0 & o1 1->3 !i1 & o0 & o1 1->5 !i1 & !o0 & o1 1->7 o0 & !o1 6 6 1->6 !i1 & !o0 & !o1 2->2 i1 2->3 !i1 3->2 i1 3->3 !i1 4->1 i0 & i1 & !o0 & o1 4->2 i1 & o0 & o1 4->3 (!i1 & o0) | (!i1 & !o1) 4->4 !i0 & !i1 & !o0 & o1 4->5 i0 & !i1 & !o0 & o1 4->7 (!i0 & i1 & !o0) | (i1 & !o1) 5->1 i1 & !o0 5->2 i1 & o0 & o1 5->3 (!i1 & o0) | (!i1 & !o1) 5->5 !i1 & !o0 & o1 5->7 i1 & o0 & !o1 7->1 i0 & i1 & !o0 & o1 7->2 i1 & o0 & o1 7->3 !i1 & o0 & o1 7->4 !i0 & !i1 & !o0 & o1 7->5 i0 & !i1 & !o0 & o1 7->7 !o1 | (!i0 & i1 & !o0) 6->1 i1 & !o0 6->2 i1 & o0 & o1 6->3 !i1 & o0 & o1 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 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.26ms   0.28ms <   0.58ms
lily2    9    9   12     0.15ms   0.17ms <   1.13ms
lily3   13   13   14     0.14ms   0.17ms <   1.37ms
lily4   21   21   23     0.18ms   0.30ms <   1.64ms
lily5   16   16   19     0.16ms   0.20ms <   1.55ms
lily6   25   25   30     0.19ms   0.24ms <   2.42ms
lily7    8    8    9     0.16ms   0.19ms <   0.79ms
lily8    1    1    2     0.09ms   0.11ms <   0.23ms
lily9    4    4    6     0.08ms   0.09ms <   0.29ms
lily10   4    4    7     0.10ms   0.11ms <   0.58ms
lily11   4    4    4     0.09ms   0.10ms <   0.38ms
lily12   8 >  6    7     0.09ms   0.11ms <   0.49ms
lily13   2    2    2     0.06ms   0.07ms <   0.22ms
lily14   1    1    2     0.06ms   0.07ms <   1.20ms
lily15   8    8    9     0.10ms   0.12ms <   0.89ms
lily16  22   22   23     0.19ms   0.24ms <   4.20ms
lily17   1    1    2     0.13ms   0.14ms <   1.93ms
lily18   1    1    2     0.10ms   0.11ms <  13.52ms
lily19   5    5    8     0.10ms   0.13ms <   0.62ms
lily20  16   16   22     0.17ms   0.21ms <   3.43ms
lily21  75   75   76     0.87ms   1.05ms < 142.76ms
lily22  19   19   20     0.64ms   0.72ms <   1.52ms
lily23   7    7    7     0.12ms   0.14ms <   0.31ms

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 B16338 o0 S0->B16338 S1 1 B16342 o0 S1->B16342 S2 2 B16348 o0 S2->B16348 S3 3 B16351 o0 S3->B16351 S4 4 B16352 o0 S4->B16352 S5 5 B16353 o0 S5->B16353 S6 6 B16354 o0 S6->B16354 S7 7 B16355 o0 S7->B16355 B16346 o1 B16354->B16346 B16344 o1 B16354->B16344 B16347 i0 B16348->B16347 B16345 i0 B16348->B16345 B16337 i0 B16338->B16337 B16334 i0 B16338->B16334 B16351->B16347 B16350 i0 B16351->B16350 B16340 o1 B16353->B16340 B16336 o1 B16353->B16336 B16333 o1 B16352->B16333 B16352->B16336 B16342->B16337 B16341 i0 B16342->B16341 B16355->B16346 B16349 o1 B16355->B16349 B16347->B16346 B45 2 B16347->B45 B16335 o1 B16337->B16335 B16337->B16336 B16341->B16340 B16339 o1 B16341->B16339 B16331 o1 B16334->B16331 B16334->B16333 B16350->B16349 B40 3 B16350->B40 B16345->B16344 B15995 i1 B16345->B15995 B16346->B45 B190 6 B16346->B190 B16349->B40 B96 7 B16349->B96 B187 5 B16340->B187 B16340->B40 B16335->B45 B15 0 B16335->B15 B16331->B15995 B16330 i1 B16331->B16330 B16333->B15995 B16332 i1 B16333->B16332 B16336->B45 B84 4 B16336->B84 B6 1 B16339->B6 B16339->B40 B16344->B15995 B16343 i1 B16344->B16343 B15995->B45 B15995->B40 B16330->B6 B16330->B15 B16343->B96 B16343->B190 B16332->B187 B16332->B84
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 B16376 o0 S0->B16376 S1 1 B16377 o0 S1->B16377 S2 2 B16382 o0 S2->B16382 S3 3 B16385 o0 S3->B16385 S4 4 B16386 o0 S4->B16386 S5 5 B16387 o0 S5->B16387 B16383 o1 B16387->B16383 B16336 o1 B16387->B16336 B16380 i0 B16382->B16380 B16381 i0 B16382->B16381 B16335 o1 B16376->B16335 B16331 o1 B16376->B16331 B16386->B16336 B16379 o1 B16386->B16379 B16339 o1 B16377->B16339 B16377->B16335 B16385->B16381 B16384 i0 B16385->B16384 B16380->B16379 B15995 i1 B16380->B15995 B16381->B16336 B45 2 B16381->B45 B16384->B16383 B40 3 B16384->B40 B16383->B40 B85 5 B16383->B85 B16339->B40 B6 1 B16339->B6 B16336->B45 B84 4 B16336->B84 B16379->B15995 B16378 i1 B16379->B16378 B16335->B45 B15 0 B16335->B15 B16331->B15995 B16330 i1 B16331->B16330 B15995->B45 B15995->B40 B16378->B85 B16378->B84 B16330->B6 B16330->B15
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
2 2 I->2 0 0 2->0 i0 & !o0 1 1 2->1 i0 & o0 5 5 2->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 3 3 1->3 !i0 & i1 & o0 4 4 1->4 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->3 !i0 & i1 7->4 i0 & i1 7->8 !i1 3->3 !i0 3->4 i0 4->7 !i1 4->3 !i0 & i1 4->4 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 B127256 o0 S0->B127256 S1 1 B127266 o0 S1->B127266 S2 2 B127272 o0 S2->B127272 S3 3 B127277 o0 S3->B127277 S4 4 B127282 o0 S4->B127282 S5 5 B127297 o0 S5->B127297 S6 6 B127306 o0 S6->B127306 S7 7 B127267 o2 S7->B127267 S8 8 B127273 o2 S8->B127273 S9 9 B127299 o2 S9->B127299 S10 10 B127311 o0 S10->B127311 S11 11 B127314 o0 S11->B127314 S12 12 B127318 o0 S12->B127318 S13 13 B127320 o0 S13->B127320 S14 14 B127322 o0 S14->B127322 B127302 i0 B127322->B127302 B127321 o1 B127322->B127321 B127306->B127302 B127305 i0 B127306->B127305 B127271 i0 B127272->B127271 B127268 o1 B127272->B127268 B127319 o1 B127320->B127319 B127320->B127268 B127255 i0 B127256->B127255 B127250 o1 B127256->B127250 B127317 o1 B127318->B127317 B127318->B127250 B127296 i0 B127297->B127296 B127291 i0 B127297->B127291 B127262 i0 B127314->B127262 B127313 o1 B127314->B127313 B127311->B127291 B127310 o1 B127311->B127310 B127281 i0 B127282->B127281 B127282->B127262 B127265 i0 B127266->B127265 B127266->B127262 B127276 i0 B127277->B127276 B127277->B127265 B127301 o1 B127302->B127301 B127300 o1 B127302->B127300 B127270 o1 B127271->B127270 B127269 o1 B127271->B127269 B127274 o1 B127276->B127274 B127275 o1 B127276->B127275 B127293 o1 B127296->B127293 B127295 o1 B127296->B127295 B127279 o1 B127281->B127279 B127280 o1 B127281->B127280 B127264 o1 B127265->B127264 B127261 o1 B127265->B127261 B127252 o1 B127255->B127252 B127254 o1 B127255->B127254 B127262->B127261 B127259 o1 B127262->B127259 B127290 o1 B127291->B127290 B127287 o1 B127291->B127287 B127303 o1 B127305->B127303 B127304 o1 B127305->B127304 B127321->B127299 B127309 o2 B127321->B127309 B127315 o2 B127317->B127315 B127316 o2 B127317->B127316 B127289 o2 B127301->B127289 B127301->B127299 B127290->B127289 B127284 o2 B127290->B127284 B127303->B127299 B127292 o2 B127303->B127292 B127294 o2 B127304->B127294 B127304->B127299 B127319->B127267 B127319->B127316 B127257 o2 B127264->B127257 B127263 o2 B127264->B127263 B127287->B127284 B127286 o2 B127287->B127286 B127248 o2 B127252->B127248 B127251 o2 B127252->B127251 B127278 o2 B127313->B127278 B127312 o2 B127313->B127312 B127261->B127257 B127260 o2 B127261->B127260 B127279->B127278 B127279->B127260 B127270->B127267 B127253 o2 B127270->B127253 B127258 o2 B127274->B127258 B127274->B127273 B127293->B127284 B127293->B127292 B127259->B127257 B127259->B127258 B127275->B127260 B127275->B127273 B127269->B127267 B127269->B127251 B127300->B127299 B127300->B127286 B127310->B127309 B127308 o2 B127310->B127308 B127280->B127278 B127280->B127263 B127254->B127253 B127254->B127248 B127295->B127284 B127295->B127294 B127249 o2 B127250->B127249 B127250->B127248 B127268->B127267 B127268->B127249 B47146 i1 B127267->B47146 B15026 i1 B127267->B15026 B127288 i1 B127289->B127288 B127285 i1 B127289->B127285 B8695 i1 B127278->B8695 B8690 i1 B127278->B8690 B47302 i1 B127249->B47302 B6701 i1 B127249->B6701 B12159 i1 B127257->B12159 B8555 i1 B127257->B8555 B12127 i1 B127315->B12127 B6690 i1 B127315->B6690 B127283 i1 B127284->B127283 B12 1 B127284->B12 B127294->B127285 B177 4 B127294->B177 B127316->B6690 B12132 i1 B127316->B12132 B127260->B8555 B12163 i1 B127260->B12163 B127253->B6701 B18855 i1 B127253->B18855 B127312->B8695 B27436 i1 B127312->B27436 B127298 i1 B127299->B127298 B99 8 B127299->B99 B16243 i1 B127248->B16243 B14548 i1 B127248->B14548 B127258->B12163 B15032 i1 B127258->B15032 B8868 i1 B127309->B8868 B275 11 B127309->B275 B127292->B127285 B127292->B12 B127263->B12163 B1069 i1 B127263->B1069 B127251->B6701 B127251->B14548 B1083 i1 B127273->B1083 B1080 i1 B127273->B1080 B127286->B127285 B53 3 B127286->B53 B127307 i1 B127308->B127307 B127308->B275 B0 0 B47146->B0 B47146->B99 B8695->B0 B8695->B275 B16243->B0 B13 0 B16243->B13 B1083->B0 B103 9 B1083->B103 B108 12 B127307->B108 B273 10 B127307->B273 B127288->B53 B127288->B12 B96 7 B127298->B96 B127298->B103 B127283->B13 B85 5 B127283->B85 B47302->B0 B47302->B53 B12127->B0 B12127->B108 B6690->B0 B6690->B275 B12159->B0 B12159->B85 B6701->B0 B32 2 B6701->B32 B8555->B0 B8555->B12 B8690->B0 B8690->B273 B127285->B32 B90 6 B127285->B90 B15026->B0 B15026->B96 B27436->B0 B122 14 B27436->B122 B1080->B0 B1080->B99 B12132->B0 B113 13 B12132->B113 B18855->B0 B18855->B177 B12163->B0 B12163->B90 B14548->B0 B14548->B12 B15032->B0 B15032->B53 B8868->B122 B8868->B113 B1069->B0 B1069->B177
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 B68 a S0->B68 S1 1 B1 1 S1->B1 B68->B1 B12 1 B68->B12
mtdfa S0 X[!]1 | Ga I->S0 B68 a S0->B68 S1 1 B1 1 S1->B1 B68->B1 B12 1 B68->B12
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 B127347 a S0->B127347 S1 (b & X[!]1) | (c & X[!]X(0)) B127349 b S1->B127349 S2 Ga | (b & X[!]1) | (c & X[!]X(0)) B127352 a S2->B127352 S3 X(0) B254 0 S3->B254 S4 1 B1 1 S4->B1 S5 Ga B127353 a S5->B127353 S6 X(0) | Ga B127354 a S6->B127354 S7 0 B0 0 S7->B0 B127354->B254 B187 Ga B127354->B187 B127352->B127349 B127351 b B127352->B127351 B12 (b & X[!]1) | (c & X[!]X(0)) B127347->B12 B45 Ga | (b & X[!]1) | (c & X[!]X(0)) B127347->B45 B127353->B0 B127353->B187 B127348 c B127349->B127348 B177 1 B127349->B177 B127350 c B127351->B127350 B127351->B1 B127348->B0 B53 X(0) B127348->B53 B127350->B187 B190 X(0) | Ga B127350->B190
mtdfa S0 Ga | X[!](b & X[!]1) | X[!](c & X[!]X(0)) I->S0 B127347 a S0->B127347 S1 (b & X[!]1) | (c & X[!]X(0)) B127349 b S1->B127349 S2 Ga | (b & X[!]1) | (c & X[!]X(0)) B127352 a S2->B127352 S3 X(0) B254 0 S3->B254 S4 1 B1 1 S4->B1 S5 Ga B127353 a S5->B127353 S6 X(0) | Ga B127354 a S6->B127354 S7 0 B0 0 S7->B0 B127354->B254 B187 Ga B127354->B187 B127352->B127349 B127351 b B127352->B127351 B12 (b & X[!]1) | (c & X[!]X(0)) B127347->B12 B45 Ga | (b & X[!]1) | (c & X[!]X(0)) B127347->B45 B127353->B0 B127353->B187 B127348 c B127349->B127348 B177 1 B127349->B177 B127350 c B127351->B127350 B127351->B1 B127348->B0 B53 X(0) B127348->B53 B127350->B187 B190 X(0) | Ga B127350->B190
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 B127373 a S0->B127373 S1 0 B0 0 S1->B0 S2 Ga B127323 a S2->B127323 B127323->B0 B45 Ga B127323->B45 B127373->B45 B6 0 B127373->B6
mtdfa S0 X(0) | Ga I->S0 B127373 a S0->B127373 S1 0 B0 0 S1->B0 S2 Ga B127323 a S2->B127323 B127323->B0 B45 Ga B127323->B45 B127373->B45 B6 0 B127373->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 B72 a S0->B72 B13 GFa B72->B13 B15 GFa B72->B15
mtdfa S0 a U b I->S0 B19 a S0->B19 B4 b B19->B4 B18 b B19->B18 B0 0 B4->B0 B1 1 B4->B1 B18->B1 B13 a U b B18->B13
mtdfa S0 XXc I->S0 B6 Xc S0->B6 S1 Xc B45 c S1->B45 S2 c B9 c S2->B9 B0 0 B9->B0 B1 1 B9->B1
In [40]:
display_inline(spot.product(gfa, aub), spot.product_or(gfa, aub))
mtdfa S0 GFa & (a U b) I->S0 B127377 a S0->B127377 S1 GFa B184 a S1->B184 B12 GFa B184->B12 B6 GFa B184->B6 B58 b B127377->B58 B127376 b B127377->B127376 B0 0 B58->B0 B58->B12 B127376->B6 B13 GFa & (a U b) B127376->B13
mtdfa S0 GFa | (a U b) I->S0 B127378 a S0->B127378 S1 GFa B184 a S1->B184 B12 GFa B184->B12 B6 GFa B184->B6 B689 b B127378->B689 B20 b B127378->B20 B1 1 B689->B1 B689->B12 B20->B1 B15 GFa | (a U b) B20->B15
In [41]:
spot.product_xor(gfa, aub)
Out[41]:
mtdfa S0 GFa xor (a U b) I->S0 B127381 a S0->B127381 S1 GFa B184 a S1->B184 S2 !GFa B127382 a S2->B127382 B32 !GFa B127382->B32 B45 !GFa B127382->B45 B6 GFa B184->B6 B12 GFa B184->B12 B127379 b B127381->B127379 B127380 b B127381->B127380 B127379->B45 B127379->B12 B127380->B32 B15 GFa xor (a U b) B127380->B15
In [42]:
spot.product_xnor(gfa, aub)
Out[42]:
mtdfa S0 GFa <-> (a U b) I->S0 B127385 a S0->B127385 S1 !GFa B127386 a S1->B127386 S2 GFa B127324 a S2->B127324 B45 GFa B127324->B45 B32 GFa B127324->B32 B12 !GFa B127386->B12 B6 !GFa B127386->B6 B127383 b B127385->B127383 B127384 b B127385->B127384 B127383->B32 B127383->B6 B127384->B45 B13 GFa <-> (a U b) B127384->B13
In [43]:
spot.product_implies(gfa, aub)
Out[43]:
mtdfa S0 GFa -> (a U b) I->S0 B127387 a S0->B127387 S1 !GFa B127386 a S1->B127386 B6 !GFa B127386->B6 B12 !GFa B127386->B12 B127341 b B127387->B127341 B18 b B127387->B18 B1 1 B127341->B1 B127341->B6 B18->B1 B13 GFa -> (a U b) B18->B13
In [44]:
spot.product(aub, xxc)
Out[44]:
mtdfa S0 (a U b) & XXc I->S0 B127389 a S0->B127389 S1 Xc B40 c S1->B40 S2 (a U b) & Xc B127392 a S2->B127392 S3 c B9 c S3->B9 S4 c & (a U b) B127394 a S4->B127394 S5 a U b B127395 a S5->B127395 B4 b B127395->B4 B86 b B127395->B86 B127390 b B127392->B127390 B127391 b B127392->B127391 B127388 b B127389->B127388 B27 b B127389->B27 B127393 b B127394->B127393 B678 b B127394->B678 B1 1 B4->B1 B0 0 B4->B0 B127390->B0 B127390->B40 B32 (a U b) & Xc B127388->B32 B6 Xc B127388->B6 B86->B1 B85 a U b B86->B85 B177 c & (a U b) B127391->B177 B127391->B40 B727 c B127393->B727 B127393->B9 B27->B0 B27->B6 B678->B9 B678->B0 B727->B0 B727->B85 B9->B1 B9->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 B127389 a S0->B127389 S1 Xc B40 c S1->B40 S2 (a U b) & Xc B127392 a S2->B127392 S3 c B9 c S3->B9 S4 c & (a U b) B127394 a S4->B127394 S5 a U b B127395 a S5->B127395 B4 b B127395->B4 B86 b B127395->B86 B127390 b B127392->B127390 B127391 b B127392->B127391 B127388 b B127389->B127388 B27 b B127389->B27 B127393 b B127394->B127393 B678 b B127394->B678 B1 1 B4->B1 B0 0 B4->B0 B127390->B0 B127390->B40 B32 (a U b) & Xc B127388->B32 B6 Xc B127388->B6 B86->B1 B85 a U b B86->B85 B177 c & (a U b) B127391->B177 B127391->B40 B727 c B127393->B727 B127393->B9 B27->B0 B27->B6 B678->B9 B678->B0 B727->B0 B727->B85 B9->B1 B9->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 B19 a S0->B19 B4 b B19->B4 B18 b B19->B18 B0 0 B4->B0 B1 1 B4->B1 B18->B1 B13 a U b B18->B13
mtdfa S0 !(a U b) I->S0 B127406 a S0->B127406 B5 b B127406->B5 B127405 b B127406->B127405 B1 1 B5->B1 B0 0 B5->B0 B127405->B0 B15 !(a U b) B127405->B15

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 inpt 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 B127699 a S0->B127699 S1 1 B9 c S1->B9 S2 2 B127705 a S2->B127705 S3 3 B127709 a S3->B127709 B127692 b B127705->B127692 B127704 b B127705->B127704 B127698 b B127699->B127698 B127341 b B127699->B127341 B4 b B127709->B4 B127708 b B127709->B127708 B1 1 B127698->B1 B45 2 B127698->B45 B4->B1 B0 0 B4->B0 B127708->B1 B53 3 B127708->B53 B127692->B9 B127692->B1 B127341->B1 B6 1 B127341->B6 B127703 c B127704->B127703 B127704->B1 B9->B1 B9->B0 B127703->B1 B127703->B53
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 B127699 a S0->B127699 S1 1 B9 c S1->B9 S2 2 B127705 a S2->B127705 S3 3 B127709 a S3->B127709 B127692 b B127705->B127692 B127704 b B127705->B127704 B127698 b B127699->B127698 B127341 b B127699->B127341 B4 b B127709->B4 B127708 b B127709->B127708 B1 1 B127698->B1 B45 2 B127698->B45 B4->B1 B0 0 B4->B0 B127708->B1 B53 3 B127708->B53 B127692->B9 B127692->B1 B127341->B1 B6 1 B127341->B6 B127703 c B127704->B127703 B127704->B1 B9->B1 B9->B0 B127703->B1 B127703->B53
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 B127715 a S0->B127715 S1 1 B0 0 S1->B0 S2 2 B127702 a S2->B127702 S3 3 B127707 a S3->B127707 B127701 b B127702->B127701 B127702->B0 B127713 b B127715->B127713 B127714 b B127715->B127714 B127706 b B127707->B127706 B127707->B0 B127700 c B127701->B127700 B127701->B0 B127713->B0 B12 1 B127713->B12 B127714->B0 B32 2 B127714->B32 B127706->B0 B53 3 B127706->B53 B127700->B0 B127700->B53
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 B127739 o S0->B127739 S1 !G(i1 -> Fo) & !(Fo & G(i2 -> Fo)) B127741 o S1->B127741 S2 !G(i2 -> Fo) & !(Fo & G(i1 -> Fo)) B127742 o S2->B127742 S3 !(Fo & G(i1 -> Fo)) & !(Fo & G(i2 -> Fo)) B127743 o S3->B127743 B127737 i2 B127742->B127737 B13 !G(i1 -> Fo) & !G(i2 -> Fo) B127742->B13 B127738 i1 B127739->B127738 B127739->B13 B127740 i1 B127741->B127740 B127741->B13 B127743->B13 B40 !(Fo & G(i1 -> Fo)) & !(Fo & G(i2 -> Fo)) B127743->B40 B127738->B127737 B127736 i2 B127738->B127736 B127740->B40 B12 !G(i1 -> Fo) & !(Fo & G(i2 -> Fo)) B127740->B12 B32 !G(i2 -> Fo) & !(Fo & G(i1 -> Fo)) B127737->B32 B127737->B40 B127736->B13 B127736->B12

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 B127739 o S0->B127739 S1 !G(i1 -> Fo) & !(Fo & G(i2 -> Fo)) B127741 o S1->B127741 S2 !G(i2 -> Fo) & !(Fo & G(i1 -> Fo)) B127742 o S2->B127742 S3 !(Fo & G(i1 -> Fo)) & !(Fo & G(i2 -> Fo)) B127743 o S3->B127743 B127737 i2 B127742->B127737 B13 !G(i1 -> Fo) & !G(i2 -> Fo) B127742->B13 B127738 i1 B127739->B127738 B127739->B13 B127740 i1 B127741->B127740 B127741->B13 B127743->B13 B40 !(Fo & G(i1 -> Fo)) & !(Fo & G(i2 -> Fo)) B127743->B40 B127738->B127737 B127736 i2 B127738->B127736 B127740->B40 B12 !G(i1 -> Fo) & !(Fo & G(i2 -> Fo)) B127740->B12 B32 !G(i2 -> Fo) & !(Fo & G(i1 -> Fo)) B127737->B32 B127737->B40 B127736->B13 B127736->B12

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 B127797 o S0->B127797 S1 !G(i1 -> Fo) & !(Fo & G(i2 -> Fo)) B127799 o S1->B127799 S2 !G(i2 -> Fo) & !(Fo & G(i1 -> Fo)) B127800 o S2->B127800 S3 !(Fo & G(i1 -> Fo)) & !(Fo & G(i2 -> Fo)) B127801 o S3->B127801 B127795 i2 B127800->B127795 B13 !G(i1 -> Fo) & !G(i2 -> Fo) B127800->B13 B127796 i1 B127797->B127796 B127797->B13 B127798 i1 B127799->B127798 B127799->B13 B127801->B13 B40 !(Fo & G(i1 -> Fo)) & !(Fo & G(i2 -> Fo)) B127801->B40 B127796->B127795 B127794 i2 B127796->B127794 B127798->B40 B12 !G(i1 -> Fo) & !(Fo & G(i2 -> Fo)) B127798->B12 B32 !G(i2 -> Fo) & !(Fo & G(i1 -> Fo)) B127795->B32 B127795->B40 B127794->B13 B127794->B12
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 B127825 i1 S0->B127825 S1 !G(i1 -> Fo) & !(Fo & G(i2 -> Fo)) B127826 i1 S1->B127826 S2 !G(i2 -> Fo) & !(Fo & G(i1 -> Fo)) B127824 i2 S2->B127824 S3 !(Fo & G(i1 -> Fo)) & !(Fo & G(i2 -> Fo)) B127823 o S3->B127823 B127820 o B127826->B127820 B127826->B127823 B127825->B127824 B127821 i2 B127825->B127821 B127822 o B127824->B127822 B127824->B127823 B127821->B127820 B13 !G(i1 -> Fo) & !G(i2 -> Fo) B127821->B13 B127820->B13 B12 !G(i1 -> Fo) & !(Fo & G(i2 -> Fo)) B127820->B12 B127822->B13 B32 !G(i2 -> Fo) & !(Fo & G(i1 -> Fo)) B127822->B32 B127823->B13 B40 !(Fo & G(i1 -> Fo)) & !(Fo & G(i2 -> Fo)) B127823->B40

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 duplicataing the graph structure to keep track of the predecessor 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 keep 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 unecessary 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 B127951 o8 S0->B127951 S1 G(i8 <-> Fo8) & !Fo8 B127843 o8 S1->B127843 S2 Fo8 & G(i8 <-> Fo8) B127952 o8 S2->B127952 S3 !i9 & Fo8 & G(i8 <-> Fo8) & G(o8 <-> Xi9) B127956 o8 S3->B127956 S4 G(i8 <-> Fo8) B127957 o8 S4->B127957 S5 i9 & G(i8 <-> Fo8) & G(o8 <-> Xi9) B127961 o8 S5->B127961 B127958 i8 B127961->B127958 B127960 i8 B127961->B127960 B735 i8 B127952->B735 B725 i8 B127952->B725 B127949 o9 B127951->B127949 B127950 o9 B127951->B127950 B127957->B725 B127840 i8 B127957->B127840 B127842 i8 B127843->B127842 B0 0 B127843->B0 B127953 i8 B127956->B127953 B127955 i8 B127956->B127955 B127348 i8 B127949->B127348 B127949->B127840 B127884 i8 B127950->B127884 B127950->B725 B127859 i9 B127958->B127859 B127958->B0 B735->B0 B32 Fo8 & G(i8 <-> Fo8) B735->B32 B127348->B0 B53 !i9 & Fo8 & G(i8 <-> Fo8) & G(o8 <-> Xi9) B127348->B53 B127884->B0 B187 i9 & G(i8 <-> Fo8) & G(o8 <-> Xi9) B127884->B187 B127959 i9 B127960->B127959 B127960->B0 B725->B0 B84 G(i8 <-> Fo8) B725->B84 B127856 i9 B127953->B127856 B127953->B0 B127954 i9 B127955->B127954 B127955->B0 B127842->B0 B6 G(i8 <-> Fo8) & !Fo8 B127842->B6 B127840->B32 B127840->B6 B127859->B0 B127859->B53 B127959->B0 B127959->B187 B127954->B0 B127954->B187 B127856->B0 B127856->B53

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 B128026 i8 S0->B128026 S1 G(i8 <-> Fo8) & !Fo8 B127843 i8 S1->B127843 S2 Fo8 & G(i8 <-> Fo8) B128028 i8 S2->B128028 S3 !i9 & Fo8 & G(i8 <-> Fo8) & G(o8 <-> Xi9) B128030 i8 S3->B128030 S4 G(i8 <-> Fo8) B128031 i8 S4->B128031 S5 i9 & G(i8 <-> Fo8) & G(o8 <-> Xi9) B128033 i8 S5->B128033 B128032 i9 B128033->B128032 B0 0 B128033->B0 B128027 o8 B128028->B128027 B128028->B0 B128017 o8 B128026->B128017 B128025 o8 B128026->B128025 B128031->B128027 B127842 o8 B128031->B127842 B127843->B127842 B127843->B0 B128029 i9 B128030->B128029 B128030->B0 B127993 o8 B128032->B127993 B128032->B0 B128029->B127993 B128029->B0 B44 o9 B128017->B44 B128017->B0 B53 !i9 & Fo8 & G(i8 <-> Fo8) & G(o8 <-> Xi9) B127993->B53 B187 i9 & G(i8 <-> Fo8) & G(o8 <-> Xi9) B127993->B187 B32 Fo8 & G(i8 <-> Fo8) B128027->B32 B84 G(i8 <-> Fo8) B128027->B84 B127842->B0 B6 G(i8 <-> Fo8) & !Fo8 B127842->B6 B128023 o9 B128025->B128023 B128024 o9 B128025->B128024 B44->B0 B44->B6 B128023->B32 B128023->B53 B128024->B84 B128024->B187
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 B128036 i8 S0->B128036 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 B128034 o8 B128036->B128034 B128035 o8 B128036->B128035 B34 o9 B128034->B34 B128034->B0 B128035->B34 B128035->B0 B34->B0 B1 1 B34->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 B127797 o S0->B127797 S1 !G(i1 -> Fo) & !(Fo & G(i2 -> Fo)) B127799 o S1->B127799 S2 !G(i2 -> Fo) & !(Fo & G(i1 -> Fo)) B127800 o S2->B127800 S3 !(Fo & G(i1 -> Fo)) & !(Fo & G(i2 -> Fo)) B127801 o S3->B127801 B127795 i2 B127800->B127795 B13 !G(i1 -> Fo) & !G(i2 -> Fo) B127800->B13 B127796 i1 B127797->B127796 B127797->B13 B127798 i1 B127799->B127798 B127799->B13 B127801->B13 B40 !(Fo & G(i1 -> Fo)) & !(Fo & G(i2 -> Fo)) B127801->B40 B127796->B127795 B127794 i2 B127796->B127794 B127798->B40 B12 !G(i1 -> Fo) & !(Fo & G(i2 -> Fo)) B127798->B12 B32 !G(i2 -> Fo) & !(Fo & G(i1 -> Fo)) B127795->B32 B127795->B40 B127794->B13 B127794->B12
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 B127951 o8 S0->B127951 S1 1 B127843 o8 S1->B127843 S2 2 B127952 o8 S2->B127952 S3 3 B127956 o8 S3->B127956 S4 4 B127957 o8 S4->B127957 S5 5 B127961 o8 S5->B127961 B127958 i8 B127961->B127958 B127960 i8 B127961->B127960 B735 i8 B127952->B735 B725 i8 B127952->B725 B127949 o9 B127951->B127949 B127950 o9 B127951->B127950 B127957->B725 B127840 i8 B127957->B127840 B127842 i8 B127843->B127842 B0 0 B127843->B0 B127953 i8 B127956->B127953 B127955 i8 B127956->B127955 B127348 i8 B127949->B127348 B127949->B127840 B127884 i8 B127950->B127884 B127950->B725 B127859 i9 B127958->B127859 B127958->B0 B735->B0 B32 2 B735->B32 B127348->B0 B53 3 B127348->B53 B127884->B0 B187 5 B127884->B187 B127959 i9 B127960->B127959 B127960->B0 B725->B0 B84 4 B725->B84 B127856 i9 B127953->B127856 B127953->B0 B127954 i9 B127955->B127954 B127955->B0 B127842->B0 B6 1 B127842->B6 B127840->B32 B127840->B6 B127859->B0 B127859->B53 B127959->B0 B127959->B187 B127954->B0 B127954->B187 B127856->B0 B127856->B53
(False, False, False, False, False, False)

Mealy semantics:
mtdfa S0 0 I->S0 B128026 i8 S0->B128026 S1 1 B127843 i8 S1->B127843 S2 2 B128028 i8 S2->B128028 S3 3 B128030 i8 S3->B128030 S4 4 B128031 i8 S4->B128031 S5 5 B128033 i8 S5->B128033 B128032 i9 B128033->B128032 B0 0 B128033->B0 B128027 o8 B128028->B128027 B128028->B0 B128017 o8 B128026->B128017 B128025 o8 B128026->B128025 B128031->B128027 B127842 o8 B128031->B127842 B127843->B127842 B127843->B0 B128029 i9 B128030->B128029 B128030->B0 B127993 o8 B128032->B127993 B128032->B0 B128029->B127993 B128029->B0 B44 o9 B128017->B44 B128017->B0 B53 3 B127993->B53 B187 5 B127993->B187 B32 2 B128027->B32 B84 4 B128027->B84 B127842->B0 B6 1 B127842->B6 B128023 o9 B128025->B128023 B128024 o9 B128025->B128024 B44->B0 B44->B6 B128023->B32 B128023->B53 B128024->B84 B128024->B187
(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 B128038 o8 S0->B128038 S1 1 B128039 o8 S1->B128039 S2 2 B128042 o8 S2->B128042 B128035 i8 B128042->B128035 B128041 i8 B128042->B128041 B9 i8 B128039->B9 B733 i8 B128039->B733 B128037 o9 B128038->B128037 B128038->B9 B735 i8 B128037->B735 B39 i8 B128037->B39 B1 1 B9->B1 B0 0 B9->B0 B34 i9 B128035->B34 B128035->B0 B735->B0 B32 2 B735->B32 B128040 i9 B128041->B128040 B128041->B0 B39->B1 B12 1 B39->B12 B733->B0 B733->B12 B34->B1 B34->B0 B128040->B0 B128040->B32
(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 B128045 i8 S0->B128045 S1 1 B128046 i8 S1->B128046 S2 2 B128048 i8 S2->B128048 B128047 i9 B128048->B128047 B0 0 B128048->B0 B50 o8 B128046->B50 B128046->B0 B128034 o8 B128045->B128034 B128044 o8 B128045->B128044 B64 o8 B128047->B64 B128047->B0 B1 1 B50->B1 B12 1 B50->B12 B34 o9 B128034->B34 B128034->B0 B128043 o9 B128044->B128043 B128044->B1 B64->B1 B32 2 B64->B32 B34->B1 B34->B0 B128043->B12 B128043->B32
(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 B128026 i8 S0->B128026 S1 1 B127843 i8 S1->B127843 S2 2 B128028 i8 S2->B128028 S3 3 B128030 i8 S3->B128030 S4 4 B128031 i8 S4->B128031 S5 5 B128033 i8 S5->B128033 B128032 i9 B128033->B128032 B0 0 B128033->B0 B128027 o8 B128028->B128027 B128028->B0 B128017 o8 B128026->B128017 B128025 o8 B128026->B128025 B128031->B128027 B127842 o8 B128031->B127842 B127843->B127842 B127843->B0 B128029 i9 B128030->B128029 B128030->B0 B127993 o8 B128032->B127993 B128032->B0 B128029->B127993 B128029->B0 B44 o9 B128017->B44 B128017->B0 B53 3 B127993->B53 B187 5 B127993->B187 B32 2 B128027->B32 B84 4 B128027->B84 B127842->B0 B6 1 B127842->B6 B128023 o9 B128025->B128023 B128024 o9 B128025->B128024 B44->B0 B44->B6 B128023->B32 B128023->B53 B128024->B84 B128024->B187
(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 is uses False/True/maybe: False means that the input player as a way to force the game to reach the bddfalse node from that state, True means that the output player as a way to force the game to reach an accepting node from thate, 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 B128049 i8 S0->B128049 B128034 o8 B128049->B128034 B9 o8 B128049->B9 B34 o9 B128034->B34 B0 0 B128034->B0 B9->B0 B1 1 B9->B1 B34->B0 B34->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 B128049 i8 S0->B128049 B128034 o8 B128049->B128034 B9 o8 B128049->B9 B34 o9 B128034->B34 B0 0 B128034->B0 B9->B0 B1 1 B9->B1 B34->B0 B34->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 B128068 lit S0->B128068 S1 GF!btn -> (G(btn -> Flit) & G(lit -> F!lit)) B128070 lit S1->B128070 B128069 btn B128070->B128069 B128067 btn B128070->B128067 B128068->B128067 B127803 btn B128068->B127803 B6 GF!btn -> (G(btn -> Flit) & G(lit -> F!lit)) B128069->B6 B15 GF!btn -> (Flit & G(btn -> Flit) & G(lit -> F!lit)) B128069->B15 B128067->B6 B12 GF!btn -> (G(btn -> Flit) & G(lit -> F!lit)) B128067->B12 B127803->B15 B13 GF!btn -> (Flit & G(btn -> Flit) & G(lit -> F!lit)) B127803->B13
winning region: (True, True)
          lazy: (True, True)
mtdfa S0 GF!btn -> (Flit & G(btn -> Flit) & G(lit -> F!lit)) I->S0 B128072 lit S0->B128072 S1 GF!btn -> (G(btn -> Flit) & G(lit -> F!lit)) B128073 lit S1->B128073 B689 btn B128073->B689 B1 1 B128073->B1 B128072->B689 B18 btn B128072->B18 B689->B1 B12 GF!btn -> (G(btn -> Flit) & G(lit -> F!lit)) B689->B12 B18->B1 B13 GF!btn -> (Flit & G(btn -> Flit) & G(lit -> F!lit)) B18->B13

To describe a winning strategy, we need to select one succesfull 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
    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 three 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.

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 B128074 lit S0->B128074 S1 GF!btn -> (G(btn -> Flit) & G(lit -> F!lit)) B3 lit S1->B3 B0 0 B3->B0 B1 1 B3->B1 B689 btn B128074->B689 B128074->B0 B689->B1 B12 GF!btn -> (G(btn -> Flit) & G(lit -> F!lit)) B689->B12

The above tells us that, initially, we should have lit=1. Then if btn=1 the controller has won, and if btn=0 the controller should do lit=0 on the next step ensuring a win.

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 B128045 i8 S0->B128045 S1 1 B128046 i8 S1->B128046 S2 2 B128048 i8 S2->B128048 B128047 i9 B128048->B128047 B0 0 B128048->B0 B50 o8 B128046->B50 B128046->B0 B128034 o8 B128045->B128034 B128044 o8 B128045->B128044 B64 o8 B128047->B64 B128047->B0 B1 1 B50->B1 B12 1 B50->B12 B34 o9 B128034->B34 B128034->B0 B128043 o9 B128044->B128043 B128044->B1 B64->B1 B32 2 B64->B32 B34->B1 B34->B0 B128043->B12 B128043->B32
mtdfa S0 0 I->S0 B128049 i8 S0->B128049 S1 1 B0 0 S1->B0 S2 2 S2->B0 B128034 o8 B128049->B128034 B9 o8 B128049->B9 B34 o9 B128034->B34 B128034->B0 B9->B0 B1 1 B9->B1 B34->B0 B34->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 B128123 i8 S0->B128123 S1 1 B128124 i8 S1->B128124 S2 2 B128126 i8 S2->B128126 S3 3 B128127 i8 S3->B128127 S4 4 B128128 i8 S4->B128128 S5 5 B128130 i8 S5->B128130 S6 6 B128133 i8 S6->B128133 B128131 i9 B128133->B128131 B128132 i9 B128133->B128132 B128125 i9 B128126->B128125 B0 0 B128126->B0 B128129 i9 B128130->B128129 B128130->B0 B128119 o9 B128123->B128119 B128122 o9 B128123->B128122 B128120 o8 B128128->B128120 B128117 o8 B128128->B128117 B128124->B128117 B128124->B0 B128127->B128120 B128127->B0 B128118 o8 B128131->B128118 B128131->B0 B128121 o8 B128132->B128121 B128132->B0 B128129->B128121 B128129->B0 B128125->B128118 B128125->B0 B128119->B128117 B128119->B128118 B128122->B128120 B128122->B128121 B53 3 B128120->B53 B84 4 B128120->B84 B85 5 B128121->B85 B190 6 B128121->B190 B128117->B0 B6 1 B128117->B6 B128118->B0 B32 2 B128118->B32

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 B128123 i8 S0->B128123 B128119 o9 B128123->B128119 B128122 o9 B128123->B128122 B128117 o8 B128119->B128117 B128118 o8 B128119->B128118 B128120 o8 B128122->B128120 B128121 o8 B128122->B128121 B0 0 B128117->B0 B6 1 B128117->B6 B84 4 B128120->B84 B53 3 B128120->B53 B85 5 B128121->B85 B190 6 B128121->B190 B128118->B0 B32 2 B128118->B32

Then it gathers all the states that appear in terminals, i.e., $\{1,2,3,4,5,6\}$, and repeat the process is repeated 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, spot.state_refine, dict=d).show(labels=False))
mtdfa S0 0 I->S0 B128036 i8 S0->B128036 B128034 o8 B128036->B128034 B128035 o8 B128036->B128035 B34 o9 B128034->B34 B0 0 B128034->B0 B128035->B34 B128035->B0 B34->B0 B1 1 B34->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 contruction. 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 B128177 o1 S0->B128177 S1 Fo3 & G(i2 & o1 & o2 & (i1 | XFo3)) B128180 o1 S1->B128180 B128179 o2 B128180->B128179 B0 0 B128180->B0 B128176 o2 B128177->B128176 B128177->B0 B128178 o3 B128179->B128178 B128179->B0 B128175 i2 B128176->B128175 B128176->B0 B128178->B128175 B127865 i2 B128178->B127865 B128174 i1 B128175->B128174 B128175->B0 B127865->B0 B12 Fo3 & G(i2 & o1 & o2 & (i1 | XFo3)) B127865->B12 B6 Fo3 & G(i2 & o1 & o2 & (i1 | XFo3)) B128174->B6 B15 G(i2 & o1 & o2 & (i1 | XFo3)) B128174->B15

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, spot.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 B128049 i8 S0->B128049 S1 1 B0 0 S1->B0 S2 2 S2->B0 B128034 o8 B128049->B128034 B9 o8 B128049->B9 B34 o9 B128034->B34 B128034->B0 B9->B0 B1 1 B9->B1 B34->B0 B34->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 B128074 lit S0->B128074 S1 1 B3 lit S1->B3 B0 0 B3->B0 B1 1 B3->B1 B689 btn B128074->B689 B128074->B0 B689->B1 B12 1 B689->B12
0 0 I->0 1 1 0->1 !btn / lit 2 2 0->2 btn / lit 1->2 1 / !lit 2->2 1 / 1
4 L0_out 8 8 4->8 14 14 4->14 16 16 4->16 6 L1_out 6->8 12 12 6->12 6->14 6->16 10 10 8->10 o0 lit 8->o0:s L0 L0_in 10->L0 18 18 12->18 14->18 20 20 16->20 18->20 L1 L1_in 20->L1 2 btn 2->10 2->12

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