Basics of wiring diagrams

Using Catlab, you can create, manipulate, serialize, and visualize wiring diagrams, also known as string diagrams. The flexible data structure for wiring diagrams allows arbitrary data to be attached to boxes, ports, and wires, and supports recursively nested diagrams.

You can interact with wiring diagrams using two different progamming interfaces:

  1. Categorical: A high-level, functional interface expressed in terms of categorical concepts, such as composition (compose), monoidal products (otimes), duplication (mcopy), and deletion (delete).

  2. Imperative: A lower-level, mutating interface to directly manipulate boxes, ports, and wires, via operations like adding boxes (add_box) and wires (add_wire).

In this notebook, we introduce both interfaces. We do not explicitly cover the visualization API, although for illustrative purposes we will draw wiring diagrams using Graphviz. Thus, you should install Graphviz if you wish to run this notebook.

In [1]:
using Catlab.WiringDiagrams

using Catlab.Graphics
import Catlab.Graphics: Graphviz

show_diagram(d::WiringDiagram) = to_graphviz(d,
  orientation=LeftToRight,
  labels=true, label_attr=:xlabel,
  node_attrs=Graphviz.Attributes(
    :fontname => "Courier",
  ),
  edge_attrs=Graphviz.Attributes(
    :fontname => "Courier",
  )
)
Out[1]:
show_diagram (generic function with 1 method)

Data structures

The basic building blocks of a wiring diagram are boxes, ports, and wires. The top-level data structure is WiringDiagram, defined in the module Catlab.WiringDiagrams. A wiring diagram consists of boxes (usually of type Box) connected by wires (of type Wire). Each box has a sequence of input ports and a sequence of output ports, as does the wiring diagram itself. The wires have sources and targets, both of which consist of a box and a port on that box.

The boxes in a wiring diagram are indexed by integer IDs. Boxes can be retrieved by ID, and wires refer to boxes using their IDs. Two special IDs, obtained by input_id and output_id methods, refer to the inputs and outputs of the diagram itself. In this way, wires can connect the (inner) boxes of a diagram to the diagram's "outer box".

The WiringDiagram data structure is an elaborate wrapper around a directed graph from LightGraphs.jl. The underlying DiGraph object can be accessed using the graph method. The vertices of this graph are exactly the box IDs. The graph should never be mutated directly, on pain of creating inconsistent state, but it does allow convenient access to the large array of graph algorithms supported by LightGraphs.

All this is somewhat abstract but should become clearer as we see concrete examples.

Categorical interface

In this example, the wiring diagrams will carry symbolic expressions (of type Catlab.ObExpr and Catlab.HomExpr).

In [2]:
using Catlab.Theories

A, B, C, D = Ob(FreeBiproductCategory, :A, :B, :C, :D)
f = Hom(:f, A, B)
g = Hom(:g, B, C)
h = Hom(:h, C, D)

f
Out[2]:
$f : A \to B$

Generators

Convert each of the morphism generators into a diagram with a single box.

In [3]:
f, g, h = to_wiring_diagram(f), to_wiring_diagram(g), to_wiring_diagram(h)
f
Out[3]:
WiringDiagram{BiproductCategory}([:A], [:B], 
[ -2 => {inputs},
  -1 => {outputs},
  1 => Box(:f, [:A], [:B]) ],
[ Wire((-2,1) => (1,1)),
  Wire((1,1) => (-1,1)) ])
In [4]:
show_diagram(f)
Out[4]:
G n1 f n0in1:e->n1:w A n1:e->n0out1:w B

Composition

In [5]:
compose(f,g)
Out[5]:
WiringDiagram{BiproductCategory}([:A], [:C], 
[ -2 => {inputs},
  -1 => {outputs},
  1 => Box(:f, [:A], [:B]),
  2 => Box(:g, [:B], [:C]) ],
[ Wire((-2,1) => (1,1)),
  Wire((1,1) => (2,1)),
  Wire((2,1) => (-1,1)) ])
In [6]:
show_diagram(compose(f,g))
Out[6]:
G n1 f n0in1:e->n1:w A n2 g n1:e->n2:w B n2:e->n0out1:w C

Monoidal products

In [7]:
otimes(f,h)
Out[7]:
WiringDiagram{BiproductCategory}([:A,:C], [:B,:D], 
[ -2 => {inputs},
  -1 => {outputs},
  1 => Box(:f, [:A], [:B]),
  2 => Box(:h, [:C], [:D]) ],
[ Wire((-2,1) => (1,1)),
  Wire((-2,2) => (2,1)),
  Wire((1,1) => (-1,1)),
  Wire((2,1) => (-1,2)) ])
In [8]:
show_diagram(otimes(f,h))
Out[8]:
G n1 f n0in1:e->n1:w A n2 h n0in2:e->n2:w C n1:e->n0out1:w B n2:e->n0out2:w D

Copy and merge, delete and create

In [9]:
mcopy(codom(f),2)
Out[9]:
WiringDiagram{BiproductCategory}([:B], [:B,:B], 
[ -2 => {inputs},
  -1 => {outputs},
   ],
[ Wire((-2,1) => (-1,1)),
  Wire((-2,1) => (-1,2)) ])
In [10]:
show_diagram(mcopy(codom(f),2))
Out[10]:
G n0in1:e->n0out1:w B n0in1:e->n0out2:w B
In [11]:
show_diagram(compose(f, mcopy(codom(f),2)))
Out[11]:
G n1 f n0in1:e->n1:w A n1:e->n0out1:w B n1:e->n0out2:w B
In [12]:
show_diagram(compose(mcopy(dom(f),2), otimes(f,f)))
Out[12]:
G n1 f n0in1:e->n1:w A n2 f n0in1:e->n2:w A n1:e->n0out1:w B n2:e->n0out2:w B

Imperative interface

We now show how to manipulate wiring diagrams using the low-level, imperative interface. The diagrams will carry Julia symbols.

In [13]:
f = Box(:f, [:A], [:B])
g = Box(:g, [:B], [:C])
h = Box(:h, [:C], [:D])

f
Out[13]:
Box(:f, [:A], [:B])

Composition

For example, here is how to manually construct a composition of two boxes.

The add_box! method adds a box to a wiring diagrams and returns the ID assigned to the box. How the boxes are indexed is an implementation detail that you should not rely on; use the IDs that the system gives you.

In [14]:
d = WiringDiagram([:A], [:C])

fv = add_box!(d, f)
gv = add_box!(d, g)

add_wires!(d, [
  (input_id(d),1) => (fv,1),
  (fv,1) => (gv,1),
  (gv,1) => (output_id(d),1),
])

nboxes(d)
Out[14]:
2
In [15]:
nwires(d)
Out[15]:
3
In [16]:
d
Out[16]:
WiringDiagram([:A], [:C], 
[ -2 => {inputs},
  -1 => {outputs},
  1 => Box(:f, [:A], [:B]),
  2 => Box(:g, [:B], [:C]) ],
[ Wire((-2,1) => (1,1)),
  Wire((1,1) => (2,1)),
  Wire((2,1) => (-1,1)) ])
In [17]:
show_diagram(d)
Out[17]:
G n1 f n0in1:e->n1:w A n2 g n1:e->n2:w B n2:e->n0out1:w C

Products

Here is how to manually construct a product of two boxes.

In [18]:
d = WiringDiagram([:A,:C], [:B,:D])

fv = add_box!(d, f)
hv = add_box!(d, h)

add_wires!(d, [
  (input_id(d),1) => (fv,1),
  (input_id(d),2) => (hv,1),
  (fv,1) => (output_id(d),1),
  (hv,1) => (output_id(d),2),
])

d
Out[18]:
WiringDiagram([:A,:C], [:B,:D], 
[ -2 => {inputs},
  -1 => {outputs},
  1 => Box(:f, [:A], [:B]),
  2 => Box(:h, [:C], [:D]) ],
[ Wire((-2,1) => (1,1)),
  Wire((-2,2) => (2,1)),
  Wire((1,1) => (-1,1)),
  Wire((2,1) => (-1,2)) ])
In [19]:
show_diagram(d)
Out[19]:
G n1 f n0in1:e->n1:w A n2 h n0in2:e->n2:w C n1:e->n0out1:w B n2:e->n0out2:w D