UP | HOME

Working with Multi-Terminal BDDs (MTBDDs)

Table of Contents

This tutorial describes the Multi-Terminal BDD (MTBDD) support added to BuDDy (Spot's BDD library). MTBDDs extend traditional BDDs by allowing leaves to be arbitrary integer values (called terminals) instead of just bddtrue and bddfalse.

Introduction to MTBDDs

A traditional BDD has two terminal nodes: bddtrue (1) and bddfalse (0). An MTBDD generalizes this by allowing any number of terminal values. Each terminal is identified by an integer value, and terminal nodes are created using bdd_terminal(value).

MTBDDs are useful for representing functions that map Boolean inputs to integer outputs. For instance, they can represent:

  • State numbers in deterministic automata (the main use-case for Spot)
  • Cost functions
  • Multi-valued logic

Important: MTBDDs use the same bdd class as regular BDDs, but the presence of new terminals breaks many standard BDD operations (bdd_apply, bdd_exist, etc.). Except in a few exceptions where you know that terminals won't need to be combined, you must use the bdd_mt_* family of functions, which take callbacks to specify how terminals should be combined.

Creating and Inspecting Terminals

Basic Terminal Operations

Three functions are provided to work with terminals:

Function
Description
bdd_terminal(int v) Create a terminal node with value v
bdd_is_terminal(bdd b) Return non-zero if b is a terminal
bdd_get_terminal(bdd b) Return the integer value of a terminal

Note that bddtrue and bddfalse are not terminals. They are the traditional BDD constants and bdd_is_terminal() returns 0 for them.

Example: Building a Simple MTBDD

#include <iostream>
#include "bddx.h"

void example()
{
  bdd a = bdd_ithvar(0);  // variable a
  bdd b = bdd_ithvar(1);  // variable b

  // Create terminals with values 0, 1, 2, 3
  bdd t0 = bdd_terminal(0);
  bdd t1 = bdd_terminal(1);
  bdd t2 = bdd_terminal(2);
  bdd t3 = bdd_terminal(3);

  // Build an MTBDD.  bdd_ite() is safe to use if it follows the shape
  // of the MTBDD that we want to construct: in this case, the terminals
  // will not need to be combined.
  bdd mtbdd = bdd_ite(a,
                      bdd_ite(b, t1, t2),
                      bdd_ite(b, t3, t0));

  // Check terminal properties
  std::cout << "mtbdd: " << mtbdd << '\n';
  std::cout << "t3 is a terminal: " << bdd_is_terminal(t3) << '\n';
  std::cout << "t3 value: " << bdd_get_terminal(t3) << '\n';
  std::cout << "bddtrue is not a terminal: " << bdd_is_terminal(bddtrue) << '\n';
}

int main()
{
  bdd_init(1000, 100);
  bdd_setvarnum(2);
  example();
  bdd_done();
  return 0;
}
mtbdd: <0:0, 1:0, Terminal(0)><0:0, 1:1, Terminal(3)><0:1, 1:0, Terminal(2)><0:1, 1:1, Terminal(1)>
t3 is a terminal: 1
t3 value: 3
bddtrue is not a terminal: 0

bdd_done() should not be called before all bdd have been destroyed, this is why we run our examples from a function separate from main(). In what follows, we will only show the contents of this function, and assume the same main().

Here is another example that shows that we can build an MTBDD that mixes terminals with bddfalse and bddtrue, and that uses bdd_printdot to obtain a GraphViz description that we can render graphically:

bdd a = bdd_ithvar(0);
bdd b = bdd_ithvar(1);
bdd l0 = bddfalse;
bdd l1 = bddtrue;
bdd l2 = bdd_terminal(2);
bdd l3 = bdd_terminal(3);
bdd mtbdd = bdd_ite(a,
                    bdd_ite(b, l1, l2),
                    bdd_ite(b, l3, l0));
bdd_printdot(mtbdd);

Sorry, your browser does not support SVG.

This representation uses gray squares for constants, gray pentagons for terminals, and ellipses for internal nodes. The numbers in parentheses are the BDD node numbers (for the constants 0 and 1, the node numbers are not repeated because they are 0 and 1 too). The numbers displayed before the parentheses are the variable numbers (for internal nodes) or the terminal value (for terminals).

External Caches for MTBDD Operations

MTBDD operations take user-defined callback functions to specify how terminals should be combined. Because these callbacks may depend on external data, the operation cache cannot be managed internally by the library. Instead, users must create and manage external caches.

Cache Management Functions

Function
Description
bdd_extcache_init(c, n, e) Initialize cache c with n entries
bdd_extcache_reset(c) Clear all entries in cache c
bdd_extcache_reserve(c, n) Ensure cache c has at least n entries
bdd_extcache_done(c) Free cache c

The bddExtCache structure holds the cache. The third argument to bdd_extcache_init (erase_on_gc) specifies whether the cache should be automatically cleared during garbage collection. Set this to 1 (true) by default. Only set this to 0 (false) if you are sure that the cache cannot be used to reference nodes that might be garbage collected.

If the n argument to bdd_extcache_init is negative, it is interpreted as a ratio: the cache size will be bddnodesize / (-n). If n is 0, it defaults to the same size as the standard BDD caches allocated by BuDDy.

Cache Entries, and Their Use by Multiple Operations

Each cache entry contains four integers usually interpreted as (left,right,op,res): given MTBDDs left and right, the result of operation op is res. Functions that implement MTBDD operations will usually take op as an argument. If the callback implementing the operation is using some global data to perform its computation, you may want to update op each time the global data is changing.

Binary Operations: bdd_mt_apply2 and bdd_mt_apply2_leaves

bdd_mt_apply2

BDD bdd_mt_apply2(bdd l, bdd r,
                  int (*termop)(int, int),
                  bddExtCache* cache, int ophash,
                  int applyop_shortcut);

This function applies a binary operation to two MTBDDs. The termop callback receives the integer values of two terminals and returns the integer value of the result terminal.

Parameters:

  • l, r: The two input MTBDDs
  • termop: Callback function int f(int left_val, int right_val)
  • cache: External cache for memoization
  • ophash: Unique identifier for this operation (for cache keying)
  • applyop_shortcut: If ≥0, use BDD shortcuts (e.g., bddop_and)

Without shortcuts, the operation will perform a recursive traversal of l and r until it reaches two terminals to combine with termop. MTBDD should not contain bddtrue or bddfalse in this case.

If applyop_shortcut is set to an operation like bddop_and or bddop_or that knows how to deal with both constants (bddtrue and bddfalse), then the MTBDD may use those constants. The recursion will terminate earlier each time one side is a constant. In that case the result is computed using the semantics of the operation passed to applyop_shortcut.

bdd_mt_apply2_leaves

bdd bdd_mt_apply2_leaves(bdd l, bdd r,
                         int (*termop)(int l_bdd, int l_val,
                                       int r_bdd, int r_val),
                         bddExtCache* cache, int ophash,
                         int applyop_shortcut);

Similar to bdd_mt_apply2, but the callback receives both the BDD node number and the terminal value. This allows distinguishing between bddtrue=/=bddfalse (they have BDD number 1 and 0 respectively) and actual terminals.

You should use this variant when the MTBDD mixes terminals and constants, and when applyop_shortcut will not always be able to remove all constants. For instance, if applyop_shortcut is set to work like an implication (bddop_imp), only the following short-cuts are used: \(\top \rightarrow b = b\), \(\bot \rightarrow b = \top\), \(b \rightarrow b = \top\), \(b \rightarrow \top = \top\). What isn't implemented is a reduction for \(b \rightarrow \bot\) because how to complement \(b\) actually depends on the semantics of the terminals it contains.

Example: Adding Terminal Values

This example uses paths_mt_of(), described later, to iterate over all the paths in the MTBDDs.

bdd a = bdd_ithvar(0);
bdd b = bdd_ithvar(1);
bdd t1 = bdd_terminal(1);
bdd t2 = bdd_terminal(2);
bdd t3 = bdd_terminal(3);

bdd x = bdd_ite(a, t1, t2);
bdd y = bdd_ite(a, t3, bdd_ite(b, t1, t3));

std::cout << "MTBDD for x:\n";
for (auto [path, term]: paths_mt_of(x))
  std::cout << "  " << path << " -> " << bdd_get_terminal(term) << '\n';

std::cout << "MTBDD for y:\n";
for (auto [path, term]: paths_mt_of(y))
  std::cout << "  " << path << " -> " << bdd_get_terminal(term) << '\n';

bddExtCache cache;
bdd_extcache_init(&cache, 100, true);

// Add terminal values
bdd sum = bdd_mt_apply2(x, y,
                        [](int a, int b) { return a + b; },
                        &cache, 1, -1);

std::cout << "MTBDD for sum:\n";
for (auto [path, term]: paths_mt_of(sum))
  std::cout << "  " << path << " -> " << bdd_get_terminal(term) << '\n';

bdd_extcache_done(&cache);
MTBDD for x:
  <0:0> -> 2
  <0:1> -> 1
MTBDD for y:
  <0:0, 1:0> -> 3
  <0:0, 1:1> -> 1
  <0:1> -> 3
MTBDD for sum:
  <0:0, 1:0> -> 5
  <0:0, 1:1> -> 3
  <0:1> -> 4

Unary Operations: bdd_mt_apply1 and bdd_mt_apply1_leaves

Unary operations are easier to describe, as they do not have any notion of shortcut.

bdd_mt_apply1

bdd bdd_mt_apply1(bdd r, int (*termop)(int),
                  bdd replace_false, bdd replace_true,
                  bddExtCache* cache, int ophash);

Apply a unary operation to all terminals of an MTBDD. The termop callback receives a terminal value and returns the new terminal value.

Parameters:

  • r: Input MTBDD
  • termop: Callback function int f(int val)
  • replace_false: BDD to substitute for bddfalse leaves
  • replace_true: BDD to substitute for bddtrue leaves
  • cache: External cache
  • ophash: Unique operation identifier

bdd_mt_apply1_leaves

bdd bdd_mt_apply1_leaves(bdd r, int (*termop)(int bdd, int val),
                         bddExtCache* cache, int ophash);

Similar to bdd_mt_apply1, but the callback receives both the BDD node number and the terminal value, allowing it to handle constants specially. The callback can return any BDD (not just a terminal).

Example: Incrementing All Terminals

bdd a = bdd_ithvar(0);
bdd t1 = bdd_terminal(1);
bdd t2 = bdd_terminal(2);
bdd x = bdd_ite(a, t1, t2);

std::cout << "before:  " << x << '\n';

bddExtCache cache;
bdd_extcache_init(&cache, 100, true);

// Increment all terminals by 10
bdd y = bdd_mt_apply1(x,
                      [](int v) { return v + 10; },
                      bddfalse, bddtrue,
                      &cache, 1);

bdd_extcache_done(&cache);

std::cout << "after:  " << y << '\n';
before:  <0:0, Terminal(2)><0:1, Terminal(1)>
after:  <0:0, Terminal(12)><0:1, Terminal(11)>

Converting Between Constants and Terminals

Two functions convert between BDD constants and terminals:

Function
Description
bdd_const_to_terminal Replace bddfalse and bddtrue with given BDDs
bdd_terminal_to_const Replace specified terminals with constants
bdd bdd_const_to_terminal(bdd r,
                          bdd for_false,  // replaces bddfalse
                          bdd for_true,   // replaces bddtrue
                          bddExtCache* cache, int ophash);

bdd bdd_terminal_to_const(bdd r,
                          bdd map_to_false,  // terminal to map to bddfalse
                          bdd map_to_true,   // terminal to map to bddtrue
                          bddExtCache* cache, int ophash);

These functions can be seen as specialized variants of bdd_mt_apply1.

bdd a = bdd_ithvar(0);
bdd t1 = bdd_terminal(1);
bdd t2 = bdd_terminal(2);
bdd x = bdd_ite(a, t1, t2);

std::cout << "step 1: " << x << '\n';

bddExtCache cache;
bdd_extcache_init(&cache, 100, true);

// 1->false, 2->true
x = bdd_terminal_to_const(x, t1, t2, &cache, 1);
std::cout << "step 2: " << x << '\n';

// false->3, true->4
x = bdd_const_to_terminal(x, bdd_terminal(3), bdd_terminal(4), &cache, 1);
std::cout << "step 3: " << x << '\n';

bdd_extcache_done(&cache);
step 1: <0:0, Terminal(2)><0:1, Terminal(1)>
step 2: <0:0>
step 3: <0:0, Terminal(4)><0:1, Terminal(3)>

Quantification on MTBDDs

MTBDD quantification allows existential or universal quantification with a user-defined terminal evaluation function.

Unlike BuDDy's bdd_exist(x, s) and bdd_forall(x, s) that each quantify the variables of s that appear in x, the quantification over MTBDD is implemented as two separate functions. First, you use bdd_mt_quantify_prepare(s) to set the variables that should be quantified away, and then you perform the quantification using bdd_mt_quantify() or some variant.

bdd_mt_quantify_prepare

void bdd_mt_quantify_prepare(bdd varset);
void bdd_mt_quantify_prepare(bdd output_vars, bdd quant_vars);
void bdd_mt_quantify_prepare(bdd output_vars, bdd quant1_vars, bdd quant2_vars);

The one-argument version prepares for quantification by specifying which variables should be quantified.

The two-argument overload also marks quant_vars as variables to be quantified, but it additionally marks output_vars as a set of output variables. This is typically used for synthesis with partial observation: in this context quant_vars are input variables that will be universally quantified, but the game solving algorithm also needs to distinguish output variables from input variables, even if they are not quantified in the MTBDD.

The three-argument overload is used with bdd_mt_quantify2 to allow two types of quantifications to be performed in the same pass over the MTBDD. Variables in quant1_vars will be quantified using a first type of quantification, variables in quant2_vars will use a second type. Those quantifications will be performed bottom-up following variable order. Again, output_vars is not used for quantification, but it is used to distinguish output variables from input variables during synthesis.

bdd_mt_quantify_to_bool

int bdd_mt_quantify_to_bool(bdd r, int (*termop)(int),
                            bddExtCache* cache, int ophash);

Quantify all variables in r and return a Boolean result (0 or 1). The termop callback evaluates each terminal to 0 or 1.

The variables passed to any argument of bdd_mt_quantify_prepare are considered as existential variables, so their quantification will be the OR of their children. Other variables will be quantified universally, so their quantification will be the AND of their children.

Example: A Very Small Two-Player Game

In this example, the MTBDD encodes a game between two players a and b (two BDD variables). Each path leads to an integer terminal. The question is: regardless of the choice made by player a, can player b make a choice that reaches an odd number?

This is expressed as \(\forall a, \exists b, \mathrm{odd}(\mathit{game}(a,b))\): player a is universal, player b is existential.

bdd a = bdd_ithvar(0);  // player a (universal)
bdd b = bdd_ithvar(1);  // player b (existential)

// Game outcomes:
//   a=0, b=0 -> 4 (even)
//   a=0, b=1 -> 7 (odd)
//   a=1, b=0 -> 3 (odd)
//   a=1, b=1 -> 2 (even)
bdd game = bdd_ite(a,
                   bdd_ite(b, bdd_terminal(2), bdd_terminal(3)),
                   bdd_ite(b, bdd_terminal(7), bdd_terminal(4)));

std::cout << "Game MTBDD:\n";
for (auto [path, term]: paths_mt_of(game))
  std::cout << "  " << path << " -> " << bdd_get_terminal(term)
            << (bdd_get_terminal(term) % 2 ? " (odd)" : " (even)") << '\n';

bddExtCache cache;
bdd_extcache_init(&cache, 100, true);

// Player b is existential, player a is universal
bdd_mt_quantify_prepare(b);

// termop returns 1 if terminal is odd, 0 otherwise
int result = bdd_mt_quantify_to_bool(game,
                                     [](int v) { return v & 1; },
                                     &cache, 1);

std::cout << "\nQuestion: forall a, exists b, odd(game(a,b))?\n";
std::cout << "Answer: " << (result ? "YES" : "NO") << '\n';

// Let's swap the roles of the players
bdd_mt_quantify_prepare(a);

result = bdd_mt_quantify_to_bool(game,
                                 [](int v) { return v & 1; },
                                 &cache, 2);

std::cout << "\nQuestion: exists a, forall b, odd(game(a,b))?\n";
std::cout << "Answer: " << (result ? "YES" : "NO") << '\n';

bdd_extcache_done(&cache);
Game MTBDD:
  <0:0, 1:0> -> 4 (even)
  <0:0, 1:1> -> 7 (odd)
  <0:1, 1:0> -> 3 (odd)
  <0:1, 1:1> -> 2 (even)

Question: forall a, exists b, odd(game(a,b))?
Answer: YES

Question: exists a, forall b, odd(game(a,b))?
Answer: NO

The nature of the quantifiers is indicated by bdd_mt_quantify_prepare, but their order is the order of the BDD variables.

bdd_mt_quantify_to_trival

int bdd_mt_quantify_to_trival(bdd r, int (*termop)(int),
                              bddExtCache* cache, int ophash,
                              int iteration);

Like bdd_mt_quantify_to_bool, but termop returns one of three values:

  • 0: false
  • 2: unknown
  • 3: true

The iteration parameter is used for cache management: "unknown" results are only reused within the same iteration. This function is typically used to compute attractors in two-player games, where false and true are stable values that indicate that the vertex is in the attractor of player 0 or 1, and "unknown" values will be changed after some iterations.

bdd_mt_quantify

bdd bdd_mt_quantify(bdd r,
                    int (*termop)(int),
                    int (*leavesop)(int, int, int, int),
                    bddExtCache* cache, int quanthash,
                    int applyhash, int applyop);

Quantify variables in r and return the resulting BDD. Call bdd_mt_quantify_prepare first to specify which variables should be quantified. If the two-argument version of bdd_mt_quantify_prepare is used, only the second argument is used.

Parameters:

  • r: Input MTBDD
  • termop: Callback applied to each terminal, allowing optional renaming. Receives a terminal value and returns the (possibly new) terminal value.
  • leavesop: Callback passed to bdd_mt_apply2_leaves for combining the low and high children at quantified variables (use a conjunction to implement universal quantification, a disjunction to implement existential quantification, or xor to implement unique quantification).
  • cache: External cache for memoization
  • quanthash: Unique identifier for the quantification operation (for cache keying)
  • applyhash: Unique identifier for the apply operation (for cache keying)
  • applyop: If ≥0, use BDD shortcuts (e.g., bddop_or for existential quantification)

Unlike bdd_mt_quantify_to_bool which returns an integer result after quantifying all variables, this function returns a BDD. When used with the two-argument version of bdd_mt_quantify_prepare, only the variables in quant_vars are quantified away; the output_vars are preserved as existential variables in game-solving contexts.

Example: Bit Set Operations via Quantification

In this example, we interpret the 32-bit terminal values as bit sets. Using quantification with OR computes unions, while quantification with AND computes intersections.

bdd a = bdd_ithvar(0);
bdd b = bdd_ithvar(1);

bdd x = bdd_ite(a,
                bdd_ite(b, bdd_terminal(0b0011), bdd_terminal(0b0101)),
                bdd_ite(b, bdd_terminal(0b1001), bdd_terminal(0b1100)));

std::cout << "MTBDD x (bit sets):\n";
for (auto [path, term]: paths_mt_of(x))
  std::cout << "  " << path << " -> 0b"
            << std::bitset<4>(bdd_get_terminal(term)) << '\n';

bddExtCache cache;
bdd_extcache_init(&cache, 100, true);

// leavesop for combining terminals: these receive bdd numbers and
// values, but in this example the MTBDD does not contain bddtrue or
// bddfalse, so we do not need the bdd numbers.
auto combine_or = [](int, int lval, int, int rval) {
  return bdd_terminal_as_int(lval | rval);
};
auto combine_and = [](int, int lval, int, int rval) {
  return bdd_terminal_as_int(lval & rval);
};
auto combine_xor = [](int, int lval, int, int rval) {
  return bdd_terminal_as_int(lval ^ rval);
};
// We won't change the leaves.
auto term_id = [](int v) { return v; };

// Quantify variable 'b', building the union of its leaves
bdd_mt_quantify_prepare(b);
bdd union_b = bdd_mt_quantify(x, term_id, combine_or,
                              &cache, 1, 2, bddop_or);

std::cout << "\nAfter existential quantification of b (union):\n";
for (auto [path, term]: paths_mt_of(union_b))
  std::cout << "  " << path << " -> 0b"
            << std::bitset<4>(bdd_get_terminal(term)) << '\n';

// Quantify variable 'b' building the intersection of its leaves
bdd_mt_quantify_prepare(b);
bdd inter_b = bdd_mt_quantify(x, term_id, combine_and,
                              &cache, 3, 4, bddop_and);

std::cout << "\nAfter universal quantification of b (intersection):\n";
for (auto [path, term]: paths_mt_of(inter_b))
  std::cout << "  " << path << " -> 0b"
            << std::bitset<4>(bdd_get_terminal(term)) << '\n';

// Quantify variable 'b' building the xor of its leaves
bdd_mt_quantify_prepare(b);
bdd xor_b = bdd_mt_quantify(x, term_id, combine_xor,
                              &cache, 5, 6, bddop_xor);

std::cout << "\nAfter unique quantification of b (xor):\n";
for (auto [path, term]: paths_mt_of(xor_b))
  std::cout << "  " << path << " -> 0b"
            << std::bitset<4>(bdd_get_terminal(term)) << '\n';

bdd_extcache_done(&cache);
MTBDD x (bit sets):
  <0:0, 1:0> -> 0b1100
  <0:0, 1:1> -> 0b1001
  <0:1, 1:0> -> 0b0101
  <0:1, 1:1> -> 0b0011

After existential quantification of b (union):
  <0:0> -> 0b1101
  <0:1> -> 0b0111

After universal quantification of b (intersection):
  <0:0> -> 0b1000
  <0:1> -> 0b0001

After unique quantification of b (xor):
  <0:0> -> 0b0101
  <0:1> -> 0b0110

bdd_mt_quantify2

bdd bdd_mt_quantify2(bdd r,
                     int (*termop)(int),
                     int (*leavesop1)(int, int, int, int),
                     int (*leavesop2)(int, int, int, int),
                     bddExtCache* cache, int quanthash,
                     int applyhash1, int applyop1,
                     int applyhash2, int applyop2);

Like bdd_mt_quantify, but performs two different quantifications depending on the variable. Call the three-argument version of bdd_mt_quantify_prepare first to classify variables into output variables, quant1_vars, and quant2_vars. Variables in quant1_vars will be quantified using leavesop1 / applyop1, and variables in quant2_vars will use leavesop2 / applyop2.

Parameters:

  • r: Input MTBDD
  • termop: Callback applied to each terminal, allowing optional renaming
  • leavesop1: Callback for combining children at quant1_vars variables
  • leavesop2: Callback for combining children at quant2_vars variables
  • cache: External cache for memoization
  • quanthash: Unique identifier for the quantification (for cache keying)
  • applyhash1, applyop1: Cache key and BDD shortcut for leavesop1
  • applyhash2, applyop2: Cache key and BDD shortcut for leavesop2

Example: Dual Quantification

In this example, we have three BDD variables: a (output), b (quantified existentially via OR), and c (quantified universally via AND). The terminal values are bit sets, so the existential quantification computes the union while the universal quantification computes the intersection.

We show how the ∃b∀c quantification can be done using two calls to bdd_mt_quantify(), or how it can be done with a single call to bdd_mt_quantify2().

bdd a = bdd_ithvar(0);  // output variable
bdd b = bdd_ithvar(1);  // quant1: existential (OR)
bdd c = bdd_ithvar(2);  // quant2: universal (AND)

bdd x = bdd_ite(a,
                bdd_ite(b,
                        bdd_ite(c, bdd_terminal(0b0011),
                                   bdd_terminal(0b0101)),
                        bdd_ite(c, bdd_terminal(0b1001),
                                   bdd_terminal(0b1100))),
                bdd_ite(b,
                        bdd_ite(c, bdd_terminal(0b1010),
                                   bdd_terminal(0b0110)),
                        bdd_ite(c, bdd_terminal(0b1111),
                                   bdd_terminal(0b0001))));

std::cout << "MTBDD x (bit sets):\n";
for (auto [path, term]: paths_mt_of(x))
  std::cout << "  " << path << " -> 0b"
            << std::bitset<4>(bdd_get_terminal(term)) << '\n';

bddExtCache cache;
bdd_extcache_init(&cache, 100, true);

auto combine_or = [](int, int lval, int, int rval) {
  return bdd_terminal_as_int(lval | rval);
};
auto combine_and = [](int, int lval, int, int rval) {
  return bdd_terminal_as_int(lval & rval);
};
auto term_id = [](int v) { return v; };

// First, we demonstrate quantification in two steps
std::cout << "\n-- two calls to bdd_mt_quantify()\n\n";
// Quantify c universally (AND)
bdd_mt_quantify_prepare(c);
bdd result = bdd_mt_quantify(x, term_id, combine_and,
                             &cache, 100, 1, bddop_and);
std::cout << "After ∀c:\n";
for (auto [path, term]: paths_mt_of(result))
  std::cout << "  " << path << " -> 0b"
            << std::bitset<4>(bdd_get_terminal(term)) << '\n';

// Quantify b existentially (OR)
bdd_mt_quantify_prepare(b);
result = bdd_mt_quantify(result, term_id, combine_or,
                         &cache, 101, 2, bddop_or);
std::cout << "\nAfter ∃b ∀c:\n";
for (auto [path, term]: paths_mt_of(result))
  std::cout << "  " << path << " -> 0b"
            << std::bitset<4>(bdd_get_terminal(term)) << '\n';

// Now, combine both in a single step.  (This only works
// because the quantifications are following the variable order.)
std::cout << "\n-- one call to bdd_mt_quantify2()\n\n";
// 'a' is marked as 'output', but it is simply ignored by
// bdd_mt_quantify_prepare
bdd_mt_quantify_prepare(a, b, c);
result = bdd_mt_quantify2(x, term_id,
                          combine_or, combine_and,
                          &cache, 102,
                          2, bddop_or,
                          1, bddop_and);

std::cout << "After ∃b ∀c:\n";
for (auto [path, term]: paths_mt_of(result))
  std::cout << "  " << path << " -> 0b"
            << std::bitset<4>(bdd_get_terminal(term)) << '\n';

bdd_extcache_done(&cache);
MTBDD x (bit sets):
  <0:0, 1:0, 2:0> -> 0b0001
  <0:0, 1:0, 2:1> -> 0b1111
  <0:0, 1:1, 2:0> -> 0b0110
  <0:0, 1:1, 2:1> -> 0b1010
  <0:1, 1:0, 2:0> -> 0b1100
  <0:1, 1:0, 2:1> -> 0b1001
  <0:1, 1:1, 2:0> -> 0b0101
  <0:1, 1:1, 2:1> -> 0b0011

-- two calls to bdd_mt_quantify()

After ∀c:
  <0:0, 1:0> -> 0b0001
  <0:0, 1:1> -> 0b0010
  <0:1, 1:0> -> 0b1000
  <0:1, 1:1> -> 0b0001

After ∃b ∀c:
  <0:0> -> 0b0011
  <0:1> -> 0b1001

-- one call to bdd_mt_quantify2()

After ∃b ∀c:
  <0:0> -> 0b0011
  <0:1> -> 0b1001

The quantification is bottom-up, so inner variables (c, variable 2) are processed first (AND), then outer variables (b, variable 1) are processed second (OR).

Since quant1_vars and quant2_vars can contain multiple variables that need not be adjacent, bdd_mt_quantify2() allows executing arbitrary nesting of quantifiers as long as they follow the variable order.

Also, please note how the hash keys 1, 2, 100, 101, and 102 are used in this code: the combine_and=/=apply_and operation is using hash key 1, while combine_or=/=apply_or is using hash key 2. When these operations are performed inside bdd_mt_quantify2(), they can reuse results that were cached when they were applied during previous calls to bdd_mt_quantify(). The top-level operations, i.e., the two calls to bdd_mt_quantify() and bdd_mt_quantify2() are distinct operations with different hash keys (100, 101, 102) so that they do not share their cached results.

bdd_mt_quantified_low_high

std::tuple<bool, int, int> bdd_mt_quantified_low_high(int bdd);

For a node in an MTBDD, return whether the node's variable appears among the arguments of bdd_mt_quantify_prepare, as well as their low/high children.

In MTBDD-based synthesis, this is used to interpret the MTBDD as a game. bdd_mt_quantify_prepare's first argument is used to mark output variables. Then bdd_mt_quantified_low_high will return the type of the current node as well as its successors.

The successors can also be obtained with bdd_low() and bdd_high(), however calling functions across shared libraries is expensive. So this interface groups three results behind a single call.

Synthesis Functions

Two specialized functions support reactive synthesis workflows:

bdd_mt_apply1_synthesis

int bdd_mt_apply1_synthesis(bdd& r,
                            int (*opleaf)(int* bdd, int term),
                            bddExtCache* cache, int ophash);

Perform synthesis traversal on an MTBDD. The opleaf callback is called for each terminal; it may modify the BDD node (via *bdd) and should return a Boolean indicating realizability.

Call bdd_mt_quantify_prepare first to indicate which variables are existential. When using the two-argument version of this function, only the first argument will be considered.

Returns 1 if there is a strategy for the existential player to force the MTBDD to reach a realizable terminal, regardless of the choices performed by the universal player.

The MTBDD r is modified in place to contain the winning strategy (if it exists) or just some partial simplification. A strategy is an MTBDD where each existential node has exactly one child that is bddfalse (indicating that the strategy is to take the other child).

Additionally, branches where the universal player can reach bddfalse will be simplified to bddfalse, since those branches are losing from the point of view of the existential player.

bdd_mt_apply1_synthesis_with_choice

int bdd_mt_apply1_synthesis_with_choice(bdd* ptr_r,
                                        int (*choice)(int),
                                        int (*opleaf)(int* bdd, int term),
                                        bddExtCache* cache, int ophash);

Like bdd_mt_apply1_synthesis, but with an additional choice callback that selects which branch to explore at existential nodes. This allows for implementing specific strategy selection policies.

Function choice() is called on existential nodes, and should return the BDD number of the child to select.

Iterating Over MTBDDs

Several iterator classes allow traversing MTBDDs in C++:

paths_mt_of

Iterate over all paths that lead to a non-false leaf, returning pairs of (path condition, terminal).

for (auto [path, terminal]: paths_mt_of(mtbdd))
  if (terminal == bddtrue)
    std::cout << path << " -> bddtrue\n";
  else
    std::cout << path << " -> " << bdd_get_terminal(terminal) << '\n';

all_paths_mt_of

Like paths_mt_of, but also includes paths leading to bddfalse.

silent_paths_mt_of

Iterate over terminals only (without computing path conditions). Useful when you only need the set of terminals. However, the terminals may be listed twice if they appear on multiple paths.

for (bdd terminal: silent_paths_mt_of(mtbdd))
  if (terminal == bddtrue)
    std::cout << "bddtrue\n";
  else
    std::cout << bdd_get_terminal(terminal) << '\n';

leaves_of

Collect all distinct leaves (terminals and constants) of an MTBDD, or of a vector of MTBDDs. These functions are linear in the (collective) size of the MTBDD(s), and will return each terminal only once, bddfalse included.

std::vector<bdd> leaves_of(const bdd& mtbdd);
std::vector<bdd> leaves_of(const std::vector<bdd>& mtbdds);

Computing SCCs: bdd_mt_sccs

std::vector<int> bdd_mt_sccs(const std::vector<bdd>& states,
                             int (*term_succ)(int),
                             std::unordered_map<int, int>* nodes = nullptr);

Compute strongly connected components of a graph represented as MTBDDs. Each BDD in states represents the successors of a state. The term_succ callback maps terminal values to state indices.

Returns a vector where entry i contains the SCC number of state i.

If nodes is given, this is filled with pairs (BDD number, SCC number) for all nodes in the MTBDD representation of states.