UP | HOME

# The bdd_dict interface (advanced topic)

## Table of Contents

- The purpose of `bdd_dict`
- Prolonging the association between a BDD variable and an atomic proposition
- Anonymous BDD variables

Spot uses BDD for multiple purposes.

The most common one is for labeling edges of automata: each edge stores a BDD representing its guard (i.e., a Boolean function over atomic propositions). Note that the automaton is still represented as a graph (with a vector of states and a vector of edges) and the BDD is only used for the guard. This differs from symbolic representations where the entire transition structure is represented as one large BDD.

There are other algorithms where BDDs are used from different tasks. For instance, our simulation-based reduction function computes a signature of each state as a BDD that is essentially the disjunction of all outgoing edges, represented by their guard, their acceptance sets, and their destination classes. Also, the translation of LTL formulas to transition-based generalized Büchi automata is using an intermediate representation of states that is similar to the aforementioned signatures, excepts that classes are replaced by subformulas.

From the point of view of the BDD library, BDDs are just DAGs with nodes labeled by BDD variables (numbered from 0). From the point of view of Spot's algorithm, these BDD variables have a meaning. For instance if we want to synchronize two automata that have guards over the atomic propositions \(a\) and \(b\), we need to make sure that both automata agree on the BDD variables used to represent \(a\) and \(b\).

## The purpose of `bdd_dict`

The `spot::bdd_dict` object is in charge of allocating BDD variables, and ensuring that multiple users reuse the same variables for similar purpose. When a `twa_graph` automaton is constructed, it takes a `bdd_dict` as argument. Every time an atomic proposition is registered through the `twa::register_ap()` method, the `bdd_dict` is queried.

As an example, the following two automata share their `bdd_dict`, and although they do not declare their atomic propositions in the same order, they get compatible variable numbers.

    #include <spot/twa/twagraph.hh>

    int main()
    {
      spot::bdd_dict_ptr dict = spot::make_bdd_dict();
      spot::twa_graph_ptr aut1 = spot::make_twa_graph(dict);
      int ap1a = aut1->register_ap("a");
      int ap1b = aut1->register_ap("b");
      std::cout << "aut1: a=" << ap1a << " b=" << ap1b << '\n';

      spot::twa_graph_ptr aut2 = spot::make_twa_graph(dict);
      int ap2c = aut2->register_ap("c");
      int ap2b = aut2->register_ap("b");
      int ap2a = aut2->register_ap("a");
      std::cout << "aut2: a=" << ap2a << " b=" << ap2b << " c=" << ap2c << '\n';
    }

    aut1: a=0 b=1
    aut2: a=0 b=1 c=2

Contrast the above result with the following example, where the two automata use different `bdd_dict`:

    #include <spot/twa/twagraph.hh>

    int main()
    {
      spot::bdd_dict_ptr dict1 = spot::make_bdd_dict();
      spot::twa_graph_ptr aut1 = spot::make_twa_graph(dict1);
      int ap1a = aut1->register_ap("a");
      int ap1b = aut1->register_ap("b");
      std::cout << "aut1: a=" << ap1a << " b=" << ap1b << '\n';

      spot::bdd_dict_ptr dict2 = spot::make_bdd_dict();
      spot::twa_graph_ptr aut2 = spot::make_twa_graph(dict2);
      int ap2c = aut2->register_ap("c");
      int ap2b = aut2->register_ap("b");
      int ap2a = aut2->register_ap("a");
      std::cout << "aut2: a=" << ap2a << " b=" << ap2b << " c=" << ap2c << '\n';
    }

    aut1: a=0 b=1
    aut2: a=2 b=1 c=0

For this reason, operations like `spot::product(aut1, aut2)` will require that `aut1->get_dict() == aut2->get_dict()`.

In Python, many functions that would take an explicit `bdd_dict` in C++ will default to some global `bdd_dict` instead. So we can do:

    import spot
    aut1 = spot.make_twa_graph()
    ap1a = aut1.register_ap("a")
    ap1b = aut1.register_ap("b")
    print(f"aut1: a={ap1a} b={ap1b}")
    aut2 = spot.make_twa_graph()
    ap2c = aut2.register_ap("c")
    ap2b = aut2.register_ap("b")
    ap2a = aut2.register_ap("a")
    print(f"aut1: a={ap2a} b={ap2b} c={ap2c}")

    aut1: a=0 b=1
    aut1: a=0 b=1 c=2

In that case we did not mention any `bdd_dict`, but there is one that is implicitly used in both cases. Similarly, when we call `spot.translate()` the same global `bdd_dict` is used by default.

What really confuses people, is that the association between an atomic proposition (`a`, `b`, …) and a BDD variable (`0`, `1`, …) will only be held by the `bdd_dict` for the lifetime of the objects (here the automata) that registered this association to the `bdd_dict`.

Here is a new C++ example where we use the `bdd_dict::dump()` method to display the contents of the `bdd_dict` (this method is only meant for debugging, please do not rely on its output).

    #include <spot/twa/twagraph.hh>

    int main()
    {
      spot::bdd_dict_ptr dict = spot::make_bdd_dict();

      spot::twa_graph_ptr aut1 = spot::make_twa_graph(dict);
      int ap1a = aut1->register_ap("a");
      int ap1b = aut1->register_ap("b");
      std::cout << "aut1@" << aut1 << ": a=" << ap1a << " b=" << ap1b << '\n';
      dict->dump(std::cout) << "---\n";

      spot::twa_graph_ptr aut2 = spot::make_twa_graph(dict);
      int ap2c = aut2->register_ap("c");
      int ap2b = aut2->register_ap("b");
      std::cout << "aut2@" << aut2 << ": b=" << ap2b << " c=" << ap2c << '\n';
      dict->dump(std::cout) << "---\n";

      aut1 = nullptr;
      std::cout << "aut1 destroyed\n";
      dict->dump(std::cout) << "---\n";

      aut2 = nullptr;
      std::cout << "aut2 destroyed\n";
      dict->dump(std::cout);
    }

    aut1@0x55f5380d0780: a=0 b=1
    Variable Map:
     0 Var[a] x1 { 0x55f5380d0780 }
     1 Var[b] x1 { 0x55f5380d0780 }
    Anonymous lists:
      [0]
    Free list:

    ---
    aut2@0x55f5380d1d10: b=1 c=2
    Variable Map:
     0 Var[a] x1 { 0x55f5380d0780 }
     1 Var[b] x2 { 0x55f5380d0780 0x55f5380d1d10 }
     2 Var[c] x1 { 0x55f5380d1d10 }
    Anonymous lists:
      [0]
    Free list:

    ---
    aut1 destroyed
    Variable Map:
     0 Free
     1 Var[b] x1 { 0x55f5380d1d10 }
     2 Var[c] x1 { 0x55f5380d1d10 }
    Anonymous lists:
      [0]
    Free list:
      (0, 1)
    ---
    aut2 destroyed
    Variable Map:
     0 Free
     1 Free
     2 Free
    Anonymous lists:
      [0]
    Free list:
      (0, 3)

For each BDD variable registered to the `bdd_dict`, we have one line that gives: the variable number, its meaning (e.g. `Var[b]`), its registration count (`x2`), and a list of pointers to the objects that registered the association.

Every time `twa::register_ap()` is called, it calls a similar function in the `bdd_dict` to check for an existing association or register a new one. When `aut1` is deleted, it unregisters all its variables, causing variable `0` to become free. The free list is actually a list of pairs representing ranges of free variables that can be reassigned by the BDD dict when needed. (The anonymous list serves when anonymous BDD variables are used.)

Such a low-level registration is usually handled by the following interface:

    // return a BDD variable number for f
    int bdd_dict::register_proposition(formula f, const void* for_me);
    // release the BDD variable
    void bdd_dict::unregister_variable(int var, const void* me);
    // release all BDD variables registered by me
    void bdd_dict::unregister_all_my_variables(const void* me);
    // register the same variables as another object
    void bdd_dict::register_all_variables_of(const void* from_other,
                                             const void* for_me);

The last function may be a bit tricky to use, because we need to be sure that another object has registered some variables. You can rely on the fact that each `twa` automaton register its variables this way.

Now, in most cases, there is no need to worry about the `bdd_dict`. Automata will register and unregister variables as needed. Other objects like `spot::twa_word` will do the same.

There are at least two situations where one may need to deal with the `bdd_dict`:

1. One case is when creating a derived object that store some BDD representing a formula over atomic proposition (but without reference to their original automaton).
2. Another case is when more BDD variables (maybe unrelated to atomic propositions) are needed.

These two cases are discussed in the next sections.

## Prolonging the association between a BDD variable and an atomic proposition

Let us implement an object representing a set of transitions of the form \((src, guard, dest)\). This can for instance be used to store all transition that belong to a certain acceptance set.

    import spot

    class trans_set:
        def __init__(self, dict):
            self.set = set()
            self.dict = dict
        def add_trans(self, src, guard, dst):
            self.set.add((src, guard, dst))
        def str_trans(self, src, guard, dst):
            f = spot.bdd_format_formula(self.dict, guard)
            return f"({src},{f},{dst})"
        def __str__(self):
            return '{' + ",".join([ self.str_trans(*t) for t in self.set ]) + '}'

    def accepting_set(aut, num):
        ts = trans_set(aut.get_dict())
        for e in aut.edges():
            if e.acc.has(num):
                ts.add_trans(e.src, e.cond, e.dst)
        return ts

The above code has two definitions.

1. The `trans_set` class is a set of transitions that can be printed. It stores a `bdd_dict` so that it can print the guard of the transition.
2. The `accepting_set` function iterates over an automaton, and saves all transitions that belong to a given acceptance set number.

For instance, we can now translate an automaton, compute its acceptance set 0, and print it as follows:

    aut = spot.translate('GF(a <-> XXa)')
    ts = accepting_set(aut, 0)
    print(ts)

    {(2,!a,2),(0,a,3),(1,a,1),(3,!a,0)}

The code of `trans_set` is in fact bogus. The problem is that it assumes the association between the atomic propositions and the BDD variable is still available when the `str_trans` method is called. However, that might not be the case.

The following call sequence demonstrates the problem:

    try:
      ts = accepting_set(spot.translate('GF(a <-> XXa)'), 0)
      print(ts)
    except RuntimeError as e:
      print("ERROR:", e)

    ERROR: bdd_to_formula() was passed a bdd with a variable that is not in the dictionary

In this case, the temporary automaton constructed by `spot.translate()` and passed to the `accepting_set()` function is destroyed right after the `ts` object has been constructed. When the automaton is destroyed, it removes all its associations from the `bdd_dict`. This means that before the `print(ts)`, the dictionary that was used by the automaton and that is still stored in the `ts` objects is now empty. Consequently, calling `bdd_format_formula()` raises an exception.

This can be fixed in a couple of ways. The easy way is to store the automaton inside the `trans_set` object, to ensure that it will live at least as long as the `trans_set` object. But maybe the automaton is too big, and we really want to get rid of it? In this case `trans_set` should tell the `bdd_dict` that it want to retain the associations. The easiest way in this case is to call the `register_all_variables_of()` method, because we know that each automaton registers its variables.

    import spot

    class trans_set:
        def __init__(self, aut):
            self.set = set()
            self.dict = aut.get_dict()
            self.dict.register_all_variables_of(aut, self)
        def __del__(self):
            self.dict.unregister_all_my_variables(self)
        def add_trans(self, src, guard, dest):
            self.set.add((src, guard, dest))
        def str_trans(self, src, guard, dest):
            f = spot.bdd_format_formula(self.dict, guard)
            return f"({src},{f},{dest})"
        def __str__(self):
            return '{' + ",".join([ self.str_trans(*t) for t in self.set ]) + '}'

    def accepting_set(aut, num):
        ts = trans_set(aut)
        for e in aut.edges():
            if e.acc.has(num):
                ts.add_trans(e.src, e.cond, e.dst)
        return ts

    try:
      ts = accepting_set(spot.translate('GF(a <-> XXa)'), 0)
      print(ts)
    except RuntimeError as e:
      print("ERROR:", e)

    {(2,!a,2),(0,a,3),(1,a,1),(3,!a,0)}

Notice that we have also added a destructor to `trans_set` to unregister all the variables.

## Anonymous BDD variables

Another scenario where working with a `bdd_dict` is needed is when one needs to allocate anonymous BDD variables. These are variables that are not attached to any atomic proposition, and that can be used by one algorithm privately. If multiple algorithms (or objects) register anonymous variables, the `bdd_dict` will reuse anonymous variables allocated to other algorithms. One can allocate multiple anonymous variables with the following `bdd_dict` method:

    int bdd_dict::register_anonymous_variables(int n, const void* for_me);

A range of `n` variables will be allocated starting at the returned index.

For instance, let's say that our `trans_set` should now store a symbolic representation of a transition relation. For simplicity, we assume we just want to store set of pairs `(src,dst)`: each pair will be a conjunction \(v_{src}\land v'_{dst}\) between two BDD variables taken from two ranges (\(v_i\) representing a source state \(i\) and \(v'i\) representing a destination state \(i\)), and the entire set will be a disjunction of all these pairs. If the automaton has \(n\) states, we want to allocate \(2n\) BDD variables for this purpose. We call these variables anonymous because their meaning is unknown to the `bdd_dict`.

    import spot
    from buddy import *

    class trans_set:
        def __init__(self, aut):
            self.dict = d = aut.get_dict()
            self.num_states = n = aut.num_states()
            self.anonbase = b = d.register_anonymous_variables(2*n, self)
            s = bddfalse
            for e in aut.edges():
              s |= self.src(e.src) & self.dst(e.dst)
            self.rel = s
        def src(self, n):
            return bdd_ithvar(self.anonbase + n)
        def dst(self, n):
            return bdd_ithvar(self.anonbase + n + self.num_states)
        def __del__(self):
            self.dict.unregister_all_my_variables(self)
        def __str__(self):
            isop = spot.minato_isop(self.rel)
            i = isop.next()
            res = []
            while i != bddfalse:
              s = bdd_var(i) - self.anonbase
              d = bdd_var(bdd_high(i)) - self.anonbase - self.num_states
              res.append((s, d))
              i = isop.next()
            return str(res)

    ts = trans_set(spot.translate('GF(a <-> XXa)'))
    print(ts)

    [(0, 2), (0, 3), (1, 0), (1, 1), (2, 2), (2, 3), (3, 0), (3, 1)]