{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"This notebook discusses how explicit automata (the `spot::twa_graph_ptr` objects in C++) are stored by Spot. The Python bindings do not expose all of the internals available in C++, however they have some graphical representation that are convenient to present those inner workings."
]
},
{
"cell_type": "code",
"execution_count": 1,
"metadata": {},
"outputs": [],
"source": [
"import buddy\n",
"import spot\n",
"spot.setup(show_default='.n')\n",
"from IPython.display import display"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# The two-vector representation"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Let's consider a small automaton, generated from an LTL formula."
]
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {},
"outputs": [],
"source": [
"aut = spot.translate('GF(a <-> Xa) & FGb', 'det', 'gen')"
]
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
"\n",
"\n",
"\n",
"\n",
"\n"
],
"text/plain": [
" *' at 0x7f616c1788a0> >"
]
},
"execution_count": 3,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"aut"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The graphical representation above is just a convenient representation of that automaton and hides some details. Internally, this automaton is stored as two vectors plus some additional data. All of those can be displayed using the `show_storage()` method. The two vectors are the `states` and `edges` vectors. The additional data gives the initial state, number of acceptance sets, acceptance condition, list of atomic propositions, as well as a bunch of [property flags](https://spot.lrde.epita.fr/concepts.html#property-flags) on the automaton. All those properties default to `maybe`, but some algorithms will turn them to `yes` or `no` whenever that property can be decided at very low cost (usually a side effect of the algorithm). In this example we asked for a deterministic automaton, so the output of the construction is necessarily `universal` (this means no existantial branching, hence deterministic for our purpose), and this property implies `unambiguous` and `semi_deterministic`."
]
},
{
"cell_type": "code",
"execution_count": 4,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
""
],
"text/plain": [
""
]
},
"execution_count": 4,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"aut.show_storage()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Each state is represented by an integer that is a 0-based index into the `states` array. Each edge is also represented by an integer that is a 1-based index into the `edges` array. In the above picture, yellow and cyan denote state and edge indices respectively.\n",
"\n",
"Adding a new edge, for instance, will augment the size of the `edges` array and return the index of the newly added edge:"
]
},
{
"cell_type": "code",
"execution_count": 5,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"9"
]
},
"execution_count": 5,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"s = aut.new_state()\n",
"aut.set_init_state(s)\n",
"aut.new_edge(s, 0, buddy.bddtrue)"
]
},
{
"cell_type": "code",
"execution_count": 6,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
"\n",
"\n",
"\n",
"\n",
"\n"
],
"text/plain": [
" *' at 0x7f616c1788a0> >"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"image/svg+xml": [
""
],
"text/plain": [
""
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"display(aut, aut.show_storage(\"v\")) # \"v\" displays only the states and edges Vectors"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"For each state, the `states` vector stores two edge indices: `succ` is the index of the first outgoing edge, and `succ_tail` is the index of the last outgoing edge. Since there is no edge at index `0`, that value is used to indicate that there is no outgoing edge.\n",
"\n",
"In the `edges` vector, the field `next_succ` is used to organize the outgoing edges of a state as a linked list. For instance to iterate over all successors of state `0`, we would start at the edge `e = states[0].succ` (if it's not `0`),\n",
"then move to the next successor with `e = edges[e].next_succ`, and repeat until `e` becomes `0`. This code cannot be executed in Python because the automaton class won't let us access the `states` vector. However this iteration mechanism is what is used into the `out()` method: `out()` simply provides an iterator over some columns of the `edges` vector, following the `next_succ` links. When we have a reference to a column of `edges` as returned by `out()`, we can convert it into an edge index with the `edge_number()` method."
]
},
{
"cell_type": "code",
"execution_count": 7,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"edges[1].src=0, edges[1].dst=0\n",
"edges[2].src=0, edges[2].dst=0\n",
"edges[3].src=0, edges[3].dst=1\n",
"edges[4].src=0, edges[4].dst=1\n"
]
}
],
"source": [
"for ed in aut.out(0):\n",
" print(\"edges[{e}].src={src}, edges[{e}].dst={dst}\".format(e=aut.edge_number(ed), src=ed.src, dst=ed.dst))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"The other fields of the `edges` vector probably speak for themselves. `cond` is a BDD representing the boolean combination of atomic propositions expected by the edge, `acc` is an instance of `spot::acc_cond::mark_t`, i.e., a bit set representing the set of acceptance sets the edge belongs to, `src` and `dst` are the source and destination of the transition. Of course when iterating over the successors of a state with `aut.out(src)`, the source is well known, but there are other situations where it is convenient to retrieve the source from the edge (e.g., when iterating over all edges of an automaton, or when storing edges indices for later processing).\n",
"\n",
"You can access one column of the `edges` vector using the `edge_storage()` method. For instance let's modify edge 3:"
]
},
{
"cell_type": "code",
"execution_count": 8,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
"\n",
"\n",
"\n",
"\n",
"\n"
],
"text/plain": [
" *' at 0x7f616c1788a0> >"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"image/svg+xml": [
""
],
"text/plain": [
""
]
},
"execution_count": 8,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"aut.edge_storage(3).acc.set(1)\n",
"aut.edge_storage(3).dst = 0\n",
"display(aut)\n",
"aut.show_storage(\"v\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Having the source into the `edges` vector also allows us to easily sort that vector to put all sibling transitions (i.e., transition leaving the same state) together to improve data locality. The `merge_edges()` method will do that and a bit more: edges are first sorted by (`src`, `dst`, `acc`) making possible to merge the `cond` field of edges with identical (`src`, `dst`, `acc`). On Fin-less automata (not our example), the a second pass is perform to sort the edge (`src`, `dst`, `cond`) and then merge the `acc` fields of edges that share the other fields.\n",
"\n",
"In our example, `merge_edges()` will merge edges 1 and 3."
]
},
{
"cell_type": "code",
"execution_count": 9,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
"\n",
"\n",
"\n",
"\n",
"\n"
],
"text/plain": [
" *' at 0x7f616c1788a0> >"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"image/svg+xml": [
""
],
"text/plain": [
""
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"aut.merge_edges()\n",
"display(aut, aut.show_storage(\"v\"))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Note that the `succ_tail` field of the `states` vector is seldom used when reading automata as the linked list of edges ends when `next_succ` (or `succ`) equals `0`. Its main use is during calls to `new_edge()`: new edges are always created at the end of the list (otherwise it would be hard to preserve the order of edges when parsing and automaton and printing it)."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# The property-update issue\n",
"\n",
"Properties like `prop_complete()`, `prop_universal()`, and the like are normally updated by algorithms that modify the automaton in place. They are not updated when we modify the automaton using low-level methods like `new_state()` or `new_edges()` as we have done so far.\n",
"\n",
"For instance we could add a new edge to the automaton to introduce some non-determinism, and the automaton would still pretend it is universal."
]
},
{
"cell_type": "code",
"execution_count": 10,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
"\n",
"\n",
"\n",
"\n",
"\n"
],
"text/plain": [
" *' at 0x7f616c1788a0> >"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"image/svg+xml": [
""
],
"text/plain": [
""
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"aut.new_edge(1, 1, buddy.bddtrue, [1, 0])\n",
"display(aut, aut.show_storage())"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Such an inconsistency will cause many issues when the automaton is passed to algorithm with specialized handling of universal automata. When writing an algorithm that modify the automaton, it is your responsibility to update the property bits as well. In this case it could be fixed by calling `aut.prop_universal(False); aut.prop_unambiguous(spot.trival_maybe()); ...` for each property, or by reseting all properties to `maybe` with `prop_reset()`:"
]
},
{
"cell_type": "code",
"execution_count": 11,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
""
],
"text/plain": [
""
]
},
"execution_count": 11,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"aut.prop_reset()\n",
"aut.show_storage(\"p\") # \"p\" displays only the properties"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Erasing edges\n",
"\n",
"Erasing a single edge, denoted by its edge index `i`, is not convenient because of the linked structure of the edges: the `next_succ` of the previous (but unknown given `i`) edge would have to be updated (or maybe the `succ` or `succ_tail` fields of `states` vector have to be updated).\n",
"\n",
"The `out_iteraser(s)` method provides a way to iterate over the outgoing edges of state `s` that allows erasing edges. The iteration does not follow the usual Python pattern because once you have looked at the current edge using `current()`, you have two choices: `advance()` to the next one, or `erase()` the current one (and advance to the next). Note that `it.current()` and `it.advance()` are written `*it` and `++it` in C++.\n",
"\n",
"The following example erases all the outgoing transitions of state `0` that belong to acceptance set `1`."
]
},
{
"cell_type": "code",
"execution_count": 12,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"pos=1, acc={1}, toerase=True\n",
"pos=2, acc={0,1}, toerase=True\n",
"pos=3, acc={0}, toerase=False\n"
]
}
],
"source": [
"it = aut.out_iteraser(0)\n",
"while it:\n",
" e = it.current()\n",
" toerase = e.acc.has(1)\n",
" print(\"pos={}, acc={}, toerase={}\".format(aut.edge_number(e), e.acc, toerase))\n",
" if toerase:\n",
" it.erase()\n",
" else:\n",
" it.advance()"
]
},
{
"cell_type": "code",
"execution_count": 13,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
""
],
"text/plain": [
""
]
},
"execution_count": 13,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"aut.show_storage(\"v\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Notice that the edges vector hasn't been resized, as doing so would renumber edges. Instead, erased edges have removed from the linked list of outgoing edges of `0`, and their `next_succ` field has been changed to point to themselves. \n",
"\n",
"You can test whether an edges has been erased with `is_dead_edge()`:"
]
},
{
"cell_type": "code",
"execution_count": 14,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"True"
]
},
"execution_count": 14,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"aut.is_dead_edge(2)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"However you usually do not have to care, because iterator methods will skip such dead edges. For instance:"
]
},
{
"cell_type": "code",
"execution_count": 15,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"edges[3].src=0, edges[3].dst=1\n",
"edges[4].src=1, edges[4].dst=0\n",
"edges[5].src=1, edges[5].dst=0\n",
"edges[6].src=1, edges[6].dst=1\n",
"edges[7].src=1, edges[7].dst=1\n",
"edges[8].src=2, edges[8].dst=0\n",
"edges[9].src=1, edges[9].dst=1\n"
]
}
],
"source": [
"for e in aut.edges(): # iterate over all non-erased edges\n",
" print(\"edges[{e}].src={src}, edges[{e}].dst={dst}\".format(e=aut.edge_number(e), src=e.src, dst=e.dst))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Similarly, `num_edges()` returns the count of non-erased edges."
]
},
{
"cell_type": "code",
"execution_count": 16,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"7"
]
},
"execution_count": 16,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"aut.num_edges()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Erased edges are actually removed by `merge_edges()`:"
]
},
{
"cell_type": "code",
"execution_count": 17,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
""
],
"text/plain": [
""
]
},
"execution_count": 17,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"aut.merge_edges()\n",
"aut.show_storage(\"v\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Another way to erase an edge, is to set its `cond` field to `bddfalse`. Strictly speaking, this does not really erase the edge, and it will still be iterated upon. However a subsequent call to `merge_edges()` will perform the removal of that edge."
]
},
{
"cell_type": "code",
"execution_count": 18,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
""
],
"text/plain": [
""
]
},
"execution_count": 18,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"aut.edge_storage(3).cond = buddy.bddfalse\n",
"aut.show_storage(\"v\")"
]
},
{
"cell_type": "code",
"execution_count": 19,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"edges[1].src=0, edges[1].dst=1\n",
"edges[2].src=1, edges[2].dst=0\n",
"edges[3].src=1, edges[3].dst=0\n",
"edges[4].src=1, edges[4].dst=1\n",
"edges[5].src=1, edges[5].dst=1\n",
"edges[6].src=2, edges[6].dst=0\n"
]
}
],
"source": [
"for e in aut.edges(): # iterate over all non-erased edges\n",
" print(\"edges[{e}].src={src}, edges[{e}].dst={dst}\".format(e=aut.edge_number(e), src=e.src, dst=e.dst))"
]
},
{
"cell_type": "code",
"execution_count": 20,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"False"
]
},
"execution_count": 20,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"aut.is_dead_edge(3)"
]
},
{
"cell_type": "code",
"execution_count": 21,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
""
],
"text/plain": [
""
]
},
"execution_count": 21,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"aut.merge_edges()\n",
"aut.show_storage(\"v\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Alternation\n",
"\n",
"The data structures seen so far only support a single destination per edge. Support for universal branching therefore calls for something new.\n",
"\n",
"Let's add some universal branching in our example automaton."
]
},
{
"cell_type": "code",
"execution_count": 22,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
"\n",
"\n",
"\n",
"\n",
"\n"
],
"text/plain": [
" *' at 0x7f616c1788a0> >"
]
},
"execution_count": 22,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"a = buddy.bdd_ithvar(aut.register_ap('a'))\n",
"s = aut.new_state()\n",
"aut.new_univ_edge(0, [0, s], a, [1])\n",
"aut.new_univ_edge(s, [0, 1], -a, [0])\n",
"aut"
]
},
{
"cell_type": "code",
"execution_count": 23,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
""
],
"text/plain": [
""
]
},
"execution_count": 23,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"aut.show_storage()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Here we have created two universal transitions: `0->[0,2]` and `2->[0,1]`. The destination groups `[0,2]` and `[0,1]` are stored in a integer vector called `dests`. Each group is encoded by its size immediately followed by the state numbers of the destinations. So group `[0,2]` get encoded as `2,0,2` at position `0` of `dests`, and group `[0,1]` is encoded as `2,0,1` at position `3`. Each group is denoted by the index of its size in the `dests` vector. When an edge targets a destination group, the complement of that destination index is written in the `dst` field of the `edges` entry, hence that `~0` and `~3` that appear here. Using a complement like this allows us to quickly detect universal edges by looking at the sign bit if their `dst` entry.\n",
"\n",
"To work on alternating automata, one can no longuer just blindingly use the `dst` field of outgoing iterations:"
]
},
{
"cell_type": "code",
"execution_count": 24,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"1\n",
"0\n",
"1\n",
"1\n",
"0\n",
"4294967295\n",
"4294967292\n"
]
}
],
"source": [
"for e in aut.edges():\n",
" print(e.dst)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Using such a large `e.dst` value as an index in `states` would likely crash the program. Instead we should iterate over all the successor of an edge using the `univ_dests()` method. Note that `univ_dests()` can be applied to regular edges as well."
]
},
{
"cell_type": "code",
"execution_count": 25,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"[1]\n",
"[0]\n",
"[1]\n",
"[1]\n",
"[0]\n",
"[0, 3]\n",
"[0, 1]\n"
]
}
],
"source": [
"for e in aut.edges():\n",
" print([d for d in aut.univ_dests(e.dst)])"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Note that `univ_dests()` can be applied to `e.dst` or `e`."
]
},
{
"cell_type": "code",
"execution_count": 26,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"[1]\n",
"[0]\n",
"[1]\n",
"[1]\n",
"[0]\n",
"[0, 3]\n",
"[0, 1]\n"
]
}
],
"source": [
"for e in aut.edges():\n",
" print([d for d in aut.univ_dests(e)])"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Note that the initial state get also use universal branching:"
]
},
{
"cell_type": "code",
"execution_count": 27,
"metadata": {},
"outputs": [],
"source": [
"aut.set_univ_init_state([0, 1, 2])"
]
},
{
"cell_type": "code",
"execution_count": 28,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
"\n",
"\n",
"\n",
"\n",
"\n"
],
"text/plain": [
" *' at 0x7f616c1788a0> >"
]
},
"execution_count": 28,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"aut"
]
},
{
"cell_type": "code",
"execution_count": 29,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"[0, 1, 2]\n"
]
}
],
"source": [
"print([d for d in aut.univ_dests(aut.get_init_state_number())])"
]
},
{
"cell_type": "code",
"execution_count": 30,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
""
],
"text/plain": [
""
]
},
"execution_count": 30,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"aut.show_storage()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Adding several transitions with the same destination groups may result in duplicates in the `dests` vector. Those groups get merged during `merge_edges()`."
]
},
{
"cell_type": "code",
"execution_count": 31,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
"\n",
"\n",
"\n",
"\n",
"\n"
],
"text/plain": [
" *' at 0x7f616c1788a0> >"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"image/svg+xml": [
""
],
"text/plain": [
""
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"image/svg+xml": [
"\n",
"\n",
"\n",
"\n",
"\n"
],
"text/plain": [
" *' at 0x7f616c1788a0> >"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"image/svg+xml": [
""
],
"text/plain": [
""
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"aut.new_univ_edge(3, [0,3], buddy.bddtrue, [0])\n",
"display(aut, aut.show_storage(\"vd\"))\n",
"aut.merge_edges()\n",
"display(aut, aut.show_storage(\"vd\"))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"Above group `~0` and `~10` have been merged."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Named properties\n",
"\n",
"Finally automata can also been attached [arbitrarily named properties](https://spot.lrde.epita.fr/concepts.html#named-properties). The `show_storage()` method will only display the name of these properties, not their contents. Properties like `automaton-name` are used to store a name for the automaton, `product-states` is filled by `product()` and holds a vector of pairs representing the source states in the product's operands, etc."
]
},
{
"cell_type": "code",
"execution_count": 32,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
"\n",
"\n",
"\n",
"\n",
"\n"
],
"text/plain": [
" *' at 0x7f616c0b3bd0> >"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"image/svg+xml": [
""
],
"text/plain": [
""
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"aub = spot.translate('a U b')\n",
"gfa = spot.translate('GFa')\n",
"prod = spot.product(aub, gfa)\n",
"prod.set_name(\"aub * gfa\")\n",
"display(prod, prod.show_storage())"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"These properties also need to be updated by algorithms. They can be reset with:"
]
},
{
"cell_type": "code",
"execution_count": 33,
"metadata": {},
"outputs": [
{
"data": {
"image/svg+xml": [
"\n",
"\n",
"\n",
"\n",
"\n"
],
"text/plain": [
" *' at 0x7f616c0b3bd0> >"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"image/svg+xml": [
""
],
"text/plain": [
""
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"prod.release_named_properties()\n",
"display(prod, prod.show_storage())"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.6.6"
}
},
"nbformat": 4,
"nbformat_minor": 2
}