rewalt

  1. (archaic) to overturn, throw down

  2. a library for rewriting, algebra, and topology, developed in Tallinn (aka Reval)

_images/readme_1.png

rewalt is a toolkit for higher-dimensional diagram rewriting, with applications in

  • higher and monoidal category theory,

  • homotopical algebra,

  • combinatorial topology,

and more. Thanks to its visualisation features, it can also be used as a structure-aware string diagram editor, supporting TikZ output so the string diagrams can be directly embedded in your LaTeX files.

_images/readme_2.png

It implements diagrammatic sets which, by the “higher-dimensional rewriting” paradigm, double as a model of

  • higher-dimensional rewrite systems, and of

  • directed cell complexes.

This model is “topologically sound”: a diagrammatic set built in rewalt presents a finite CW complex, and a diagram constructed in the diagrammatic set presents a valid homotopy in this CW complex.

A diagrammatic set can be seen as a generalisation of a simplicial set or of a cubical set with many more “cell shapes”. As a result, rewalt also contains a full implementation of finitely presented simplicial sets and cubical sets with connections.

Installation

rewalt is available for Python 3.7 and higher. You can install it with the command

pip install rewalt

If you want the bleeding edge, you can check out the GitHub repository.

Getting started

To get started, we recommend you check the Notebooks, which contain a number of worked examples from category theory, algebra, and homotopy theory.

Further reading

For a first introduction to the ideas of higher-dimensional rewriting, diagrammatic sets, and “topological soundness”, you may want to watch these presentations at the CIRM meeting on Higher Structures and at the GETCO 2022 conference.

A nice overview of the general landscape of higher-dimensional rewriting is Yves Guiraud’s mémoire d’habilitation.

So far there are two papers on the theory of diagrammatic sets: the first one containing the foundations, the second one containing some developments applied to categorical universal algebra.

A description and complexity analysis of some of the data structures and algorithms behind rewalt will be published in the proceedings of ACT 2022.

License

rewalt is distributed under the BSD 3-clause license.

Contributing

Currently, the only active developer of rewalt is Amar Hadzihasanovic.

Contributions are welcome. Please reach out either by sending me an email, or by opening an issue.

The theory of monoids

In this notebook, we will construct a presentation of the theory of monoids or associative algebras in rewalt. Depending on your favourite gadget, you may see this as the data presenting a monoidal category (PRO) or an operad.

Adding the sorts and operations

Let’s first import rewalt and create an empty diagrammatic set — an object of class DiagSet — that we will call Mon.

[1]:
import rewalt

Mon = rewalt.DiagSet()

You know how a monoidal category can be seen as a one-object bicategory (its delooping)? This is how we do it in rewalt too: the sorts of a monoidal theory are 1-cells going to and from a single 0-cell.

So first of all, we add a single 0-dimensional generator to our diagrammatic set.

[2]:
pt = Mon.add('pt')

This adds a 0-dimensional generator to Mon, assigns it the name 'pt' and returns the Diagram object that “picks” that generator only; we assign this diagram to the variable pt.

Next, we add a single 1-dimensional generator, corresponding to the single sort of our theory.

[3]:
a = Mon.add('a', pt, pt)

The two extra arguments that we gave to add specify the input, or source boundary of the new generator, and the output, or target boundary of the new generator, respectively. In this case they are both equal to the unique “point”.

By the way, if you fail to assign the output of add to a variable, you can always retrieve it later by giving the generator’s name to Mon’s indexer.

[4]:
assert a == Mon['a']

There is not much that we can do with 0-cells… but with 1-cells, we can create larger diagrams by pasting.

The paste method pastes together diagrams along the k-dimensional output boundary of one and the k-dimensional input boundary of the other, when these match each other.

For a 1-cell, the only non-trivial boundary is the 0-dimensional one; pasting along it corresponds to “concatenation of paths”. We can concatenate a to itself as many times as we want. Let’s also visualise the result as a “1-dimensional string diagram”.

[5]:
a.draw()
_images/notebooks_monoids_9_0.png
[6]:
a.paste(a).draw()
_images/notebooks_monoids_10_0.png
[7]:
a.paste(a).paste(a).draw()
_images/notebooks_monoids_11_0.png

And so on. Note that paste can also take an integer argument specifying the dimension of the boundary along which to paste; it defaults to the minimum of the two diagrams’ dimensions, minus 1. In this case the minimum of 1 and 1 is 1, which minus 1 equals 0, and that’s the boundary we want.

Now that we have the sorts, let’s add the operations. The monoid multiplication takes two inputs and returns one output. This corresponds to a 2-dimensional generator, whose input is a.paste(a), and output a.

[8]:
m = Mon.add('m', a.paste(a), a)

And let’s picture this as a string diagram.

[9]:
m.draw()
_images/notebooks_monoids_15_0.png

(As you can see, string diagrams by default go from bottom to top. If you prefer left-to-right, or top-to-bottom, or right-to-left orientation, you can pass it as an argument to draw; or to change the default setting, reassign rewalt.strdiags.DEFAULT['orientation'].)

[10]:
m.draw(orientation='lr')
_images/notebooks_monoids_17_0.png

Since we have a single sort, it is a little pointless to label the wires. Same for labelling the unique point. Let’s switch labels off for these generators.

[11]:
Mon.update('a', draw_label=False)
Mon.update('pt', draw_label=False)
m.draw()
_images/notebooks_monoids_19_0.png

Next, we want to add the monoid unit, which is a “nullary” operation. Here things get a little more subtle.

Cells in rewalt are not allowed to have “strictly lower-dimensional” inputs or outputs: if we try to add a 2-dimensional generator whose input is a 0-dimensional diagram, we will get an error.

[12]:
try:
    u = Mon.add('u', pt, a)
except ValueError:
    print('Nope')
Nope

Instead, we have to use “weak units”, in the form of degenerate diagrams. (This may seem like a hassle in dimension 2, where “everything can be strictified”, but pays off in higher dimensions.)

A simple constructor for degenerate diagrams is the unit method, which creates a “unit diagram”, one dimension higher.

[13]:
assert pt.dim == 0
assert not pt.isdegenerate

assert pt.unit().dim == 1
assert pt.unit().isdegenerate

So to add the monoid unit, we make pt.unit() its input.

In string diagrams, degenerate cells are represented as translucent wires (when wires), or as “node-less nodes” (when nodes).

[14]:
u = Mon.add('u', pt.unit(), a)
u.draw()
_images/notebooks_monoids_25_0.png

Adding “oriented equations”

Now we can compose diagrams with paste in two directions, along the 0-boundary (“horizontally”) or the 1-boundary (“vertically”)…

[15]:
u.paste(m, 0).draw()  # "horizontal" pasting
_images/notebooks_monoids_27_0.png
[16]:
u.paste(m, 0).paste(m).draw()  # ...and now "vertical" pasting
_images/notebooks_monoids_28_0.png

A useful alternative to paste (especially in an “operadic” setting) are the methods to_inputs and to_outputs, which allow us to paste a diagram only to some inputs and outputs of another diagram.

To use these in practice, one must know that every node and wire in a string diagram have a unique position. We can use the keyword arguments positions (both nodes and wires), nodepositions, and wirepositions to enable positions in string diagram output.

[17]:
m.draw(positions=True)
_images/notebooks_monoids_30_0.png

Now, we can paste another multiplication either to the input in position 0, or the input in position 1.

[18]:
m.to_inputs(0, m).draw()
_images/notebooks_monoids_32_0.png
[19]:
m.to_inputs(1, m).draw()
_images/notebooks_monoids_33_0.png

These two diagrams happen to be the two sides of the associativity equation, so let’s add this equation to our presentation!

Or rather, we add an oriented associativity equation, or associativity rewrite, or “associator”, as a 3-dimensional generator. All the cells in diagrammatic sets have a direction.

[20]:
assoc = Mon.add('assoc', m.to_inputs(0, m), m.to_inputs(1, m))
assoc.draw()
_images/notebooks_monoids_35_0.png

You can see that, when we draw a 3-dimensional diagram, we obtain a “2-dimensional slice” string diagram, where nodes correspond to 3-cells and wires to 2-cells. (In general, for an n-dimensional diagram, nodes are n-dimensional cells and wires are (n-1)-dimensional cells).

Here, assoc is a 3-dimensional cell that has two m 2-cells in its input, and two m 2-cells in its output.

To see the two “sides” of the rewrite, we can either use the draw_boundaries method, or first call input/output and only then draw.

[21]:
assoc.draw_boundaries()
_images/notebooks_monoids_37_0.png
_images/notebooks_monoids_37_1.png

Next, let’s add left unitality and right unitality equations/rewrites. The left-hand side of the left unitality equation is this.

[22]:
m.to_inputs(0, u).draw()
_images/notebooks_monoids_39_0.png

This diagram is supposed to be equal to “the identity operation” on our sort (which would be the unit on a)… but not quite, because it contains a weak unit in the input; instead we want to equate to another degenerate cell called the left unitor on a. We build it like this.

[23]:
a.lunitor('-').draw()
_images/notebooks_monoids_41_0.png

The argument '-' specifies that the unit should appear in the input, and not the output.

Now we can add the “left unitality” generator.

[24]:
lunit = Mon.add('lunit', m.to_inputs(0, u), a.lunitor('-'))
lunit.draw()
_images/notebooks_monoids_43_0.png

We proceed similarly for the “right unitality” generator.

[25]:
runit = Mon.add('runit', m.to_inputs(1, u), a.runitor('-'))
runit.draw()
runit.draw_boundaries()
_images/notebooks_monoids_45_0.png
_images/notebooks_monoids_45_1.png
_images/notebooks_monoids_45_2.png

Making the equations go both ways

That’s it, we now have a presentation of the theory of monoids!

Except our “equations” are really directed rewrites. What if we want to use them in both directions? Luckily, we have methods for “weakly inverting” a generator. Let’s try it on assoc.

[26]:
Mon.invert('assoc')
[26]:
(<rewalt.diagrams.Diagram at 0x7f72f7faf100>,
 <rewalt.diagrams.Diagram at 0x7f72f7faeb60>,
 <rewalt.diagrams.Diagram at 0x7f72f843f250>)

This returned 3 diagrams, which corresponds to the fact that 3 new generators were added. Let’s see what happened. We can see a list of the generators, ordered by dimension, with the DiagSet method by_dim.

[27]:
Mon.by_dim
[27]:
{0: {'pt'},
 1: {'a'},
 2: {'m', 'u'},
 3: {'assoc', 'assoc⁻¹', 'lunit', 'runit'},
 4: {'inv(assoc, assoc⁻¹)', 'inv(assoc⁻¹, assoc)'}}

So, first of all, there’s a new 3-dimensional generator, assoc⁻¹.

[28]:
Mon['assoc⁻¹'].draw()
Mon['assoc⁻¹'].draw_boundaries()
_images/notebooks_monoids_51_0.png
_images/notebooks_monoids_51_1.png
_images/notebooks_monoids_51_2.png

This is the “weak inverse” of assoc: a generator with the same boundaries as assoc, but going in the reverse direction. If a generator has a weak inverse, we can get it with the inverse attribute.

[29]:
assert assoc.inverse == Mon['assoc⁻¹']

Then, we have two new 4-dimensional generators, inv(assoc, assoc⁻¹) and inv(assoc⁻¹, assoc).

[30]:
Mon['inv(assoc, assoc⁻¹)'].draw()
Mon['inv(assoc, assoc⁻¹)'].draw_boundaries()
_images/notebooks_monoids_55_0.png
_images/notebooks_monoids_55_1.png
_images/notebooks_monoids_55_2.png

This generator “exhibits” the fact that assoc⁻¹ is a right inverse (right in diagrammatic order; left in composition order) for assoc: it goes from the pasting of assoc and assoc⁻¹, to a weak unit on the input of assoc.

We call this a right invertor for assoc, and can get it with the rinvertor attribute.

Similarly, inv(assoc, assoc⁻¹) exhibits the fact that assoc⁻¹ is a left inverse for assoc. We call this a left invertor for assoc, and can retrieve it with the linvertor attribute.

Note that the left invertor for assoc is the right invertor for assoc⁻¹, and vice versa!

[31]:
assert assoc.rinvertor == Mon['inv(assoc, assoc⁻¹)']
assert assoc.linvertor == assoc.inverse.rinvertor

In the theory of diagrammatic sets, these two “witnesses” should, themselves, be weakly invertible cells; since this would require an infinite number of generators, we leave it to the user to invert them when/if needed.

[32]:
Mon['inv(assoc⁻¹, assoc)'].draw()
Mon['inv(assoc⁻¹, assoc)'].draw_boundaries()
_images/notebooks_monoids_59_0.png
_images/notebooks_monoids_59_1.png
_images/notebooks_monoids_59_2.png

Computing with diagrammatic rewrites

Let’s start using our presentation to make some diagrammatic computations. First, we create a 2-dimensional diagram.

[33]:
start = m.to_inputs(0, m).to_inputs(0, m)
start.draw(nodepositions=True)
_images/notebooks_monoids_61_0.png

In traditional algebraic notation, this would correspond to the term \(m(m(m(x, y), z), w)\).

We see that we can apply an associativity rewrite/equation in two places, corresponding to the nodes in positions (0, 1) and to the nodes in positions (1, 2).

We can “apply rewrites” with the rewrite method. The result of rewrite is not going to be the “rewritten” 2-dimensional diagram. Instead, it will be a 3-dimensional diagram whose input is the original diagram, and output is the rewritten diagram: an “embodiment” of the rewrite operation.

(The rewrite method is, in fact, a special instance of to_outputs; once you understand the principles of higher-dimensional rewriting, you should be able to see why).

[34]:
rew1 = start.rewrite([0,1], assoc)
rew1.draw()
rew1.output.draw(nodepositions=True)
_images/notebooks_monoids_63_0.png
_images/notebooks_monoids_63_1.png

In the rewritten diagram, we can only apply assoc to the nodes (0, 2).

[35]:
rew2 = rew1.output.rewrite([0, 2], assoc)
rew2.output.draw(nodepositions=True)
_images/notebooks_monoids_65_0.png

Now, we can apply assoc to the nodes (1, 2).

[36]:
rew3 = rew2.output.rewrite([1, 2], assoc)
rew3.output.draw()
_images/notebooks_monoids_67_0.png

We cannot apply assoc anywhere else. (Of course we could start applying assoc⁻¹).

Let’s put together our sequence of rewrites.

[37]:
seq1 = rewalt.Diagram.with_layers(rew1, rew2, rew3)
seq1.draw()
_images/notebooks_monoids_69_0.png

(We could have equally defined seq1 as rew1.paste(rew2).paste(rew3)).

We can use the method rewrite_steps to get all our rewrite steps… and we can even produce a little gif animation with all the steps. (We’ll make it loop backwards as well so it doesn’t end too soon.)

[38]:
rewalt.strdiags.to_gif(*seq1.rewrite_steps, loop=True, path='monoids_1.gif')

AssocSequence1

Let’s go back to the start and pick a different rewrite, the one on nodes (1, 2).

[39]:
rew4 = start.rewrite([1, 2], assoc)
rew4.output.draw(nodepositions=True)
_images/notebooks_monoids_74_0.png
[40]:
rew5 = rew4.output.rewrite([0, 2], assoc)
rew5.output.draw()
_images/notebooks_monoids_75_0.png
[41]:
seq2 = rew4.paste(rew5)
seq2.draw()
rewalt.strdiags.to_gif(*seq2.rewrite_steps, loop=True, path='monoids_2.gif')
_images/notebooks_monoids_76_0.png

AssocSequence2

You can see that seq1 and seq2 are two different sequences of rewrites with the same starting and ending point.

If you are familiar with the characterisation of monoidal categories as pseudomonoids in the monoidal 2-category of categories with cartesian product, you may recognise the two sides of Mac Lane’s pentagon equation!

Indeed, we can add a 4-dimensional generator between the two, embodying Mac Lane’s pentagon.

[42]:
pentagon = Mon.add('pentagon', seq1, seq2)
pentagon.draw()
pentagon.draw_boundaries()
_images/notebooks_monoids_79_0.png
_images/notebooks_monoids_79_1.png
_images/notebooks_monoids_79_2.png

We could go on and add generators corresponding to Mac Lane’s triangle… but this was supposed to be about the theory of monoids, not of lax or pseudomonoids, so let’s stop here instead.

Generating string diagrams

For any higher-dimensional diagram that we can create in rewalt, we can output a string diagram representation both as an image (with the Matplotlib backend), or as TikZ code that we can include in our LaTeX files.

Thus, one of the intended applications of rewalt is also to be a structure-aware, type-aware string diagram generator: we can build our string diagrams the way we build the morphisms/homotopies/operations/rewrites that they represent, and let rewalt do the typesetting for us.

In this notebook, we will work out one example, and explore the customisation options that we have.

Note that the placement and general style of nodes and wires is not currently customisable (except for the choice of orientation). However, rewalt is open source software and everyone is welcome to modify the algorithm to suit their aesthetic preferences.

A presentation of adjunctions

As an example, we will construct a presentation of the “theory of adjunctions”, or “walking adjunction”, whose models in a bicategory are adjunctions internal to that bicategory. (This has “dualities in monoidal categories” as a special case.) The triangle/zigzag/snake equations of adjunctions are some of the most well-known and recognisable in string diagrams.

The theory of adjunctions has two 0-cells and two 1-cells between them, going in opposite directions.

[1]:
import rewalt

Adj = rewalt.DiagSet()
x = Adj.add('x')
y = Adj.add('y')
l = Adj.add('l', x, y)
r = Adj.add('r', y, x)

Then, we need to add two 2-cells, the unit and counit of the adjunction.

[2]:
eta = Adj.add('η', x.unit(), l.paste(r))  # unit
eps = Adj.add('ε', r.paste(l), y.unit())  # counit

This is how rewalt draws the unit and counit by default.

[3]:
eta.draw()
eps.draw()
_images/notebooks_stringdiagrams_6_0.png
_images/notebooks_stringdiagrams_6_1.png

We can use the picture as a visual aid to see how to paste the unit and counit together to get the left-hand side of the triangle equations. For example, if we add an l to the right of eta

[4]:
eta.paste(l).draw(wirepositions=True)
_images/notebooks_stringdiagrams_8_0.png

… we can plug an eps to the wires in positions (3, 1).

[5]:
lhs1 = eta.paste(l).to_outputs([3, 1], eps)
lhs1.draw()
_images/notebooks_stringdiagrams_10_0.png

This needs to be equated to “the identity on l”, except we have weak units on x in the input and on y in the output.

We can in fact obtain the degenerate 2-cell with the right type as one of the cubical degeneracies on l.

[6]:
rhs1 = l.cube_degeneracy(1)
rhs1.draw()
_images/notebooks_stringdiagrams_12_0.png

We can now add our first “oriented equation”.

[7]:
eq1 = Adj.add('eq1', lhs1, rhs1)
eq1.draw()
_images/notebooks_stringdiagrams_14_0.png

For the second one, we can proceed symmetrically. We add an r to the left of eta

[8]:
r.paste(eta).draw(wirepositions=True)
_images/notebooks_stringdiagrams_16_0.png

… and we plug an eps to the wires in positions (0, 2) to get the left-hand side of the second equation.

[9]:
lhs2 = r.paste(eta).to_outputs([0, 2], eps)
lhs2.draw()
_images/notebooks_stringdiagrams_18_0.png

To get the right-hand-side, we use a different cubical degeneracy on r.

[10]:
rhs2 = r.cube_degeneracy(0)
rhs2.draw()
_images/notebooks_stringdiagrams_20_0.png

And finally, we add the second triangle equation.

[11]:
eq2 = Adj.add('eq2', lhs2, rhs2)
eq2.draw()
_images/notebooks_stringdiagrams_22_0.png

That’s it, we have a presentation. (We could also invert eq1 and eq2 but that’s besides the point of this exercise).

Customising string diagrams

Let’s return to the first triangle equation. The default string diagram representation of its left-hand side is this.

[12]:
eq1.input.draw()
_images/notebooks_stringdiagrams_25_0.png

Let’s make it a bit nicer.

First of all, it is quite common to draw units and counits as “bent wires” (aka “cups and caps”), without a node, so that the triangle equations look like topological trivialities.

We can do this by disabling node drawing for these generators of Adj.

[13]:
Adj.update('ε', draw_node=False)
Adj.update('η', draw_node=False)
eq1.input.draw()
_images/notebooks_stringdiagrams_27_0.png

Then, since we have only two 1-cells, why not also colour-code them?

[14]:
Adj.update('l', color='blue')
Adj.update('r', color='magenta')
eq1.input.draw()
_images/notebooks_stringdiagrams_29_0.png

When we are working in rewalt, it is good to see the weak units, because we need to take them into account to know that everything typechecks.

However, we may want to “hide them away” if, for example, our diagrams are to be interpreted in a strict 2-category. We can do this by changing the alpha factor for degenerate wires to 0.

[15]:
eq1.input.draw(degenalpha=0)
_images/notebooks_stringdiagrams_31_0.png

Note that this still shows the weak unit labels, which is actually helpful in this setting because it reminds us of the type of l and r. If we wanted to get rid of them, we could deactivate labels for these generators.

[16]:
Adj.update('x', draw_label=False)
Adj.update('y', draw_label=False)
eq1.input.draw(degenalpha=0)
_images/notebooks_stringdiagrams_33_0.png
[17]:
Adj.update('x', draw_label=True)
Adj.update('y', draw_label=True)

There are different factions on what the “correct” orientation of string diagrams is. In rewalt, the default is bottom-to-top, but it can be changed.

[18]:
eq1.input.draw(degenalpha=0, orientation='lr')
eq1.input.draw(degenalpha=0, orientation='rl')
eq1.input.draw(degenalpha=0, orientation='tb')
_images/notebooks_stringdiagrams_36_0.png
_images/notebooks_stringdiagrams_36_1.png
_images/notebooks_stringdiagrams_36_2.png

We can change the default settings by reassigning the values of rewalt.strdiags.DEFAULT. Let’s say we want all our string diagrams to be top-to-bottom with no degenerate wires.

[19]:
rewalt.strdiags.DEFAULT['orientation'] = 'tb'
rewalt.strdiags.DEFAULT['degenalpha'] = 0

Now, how about a dark theme?

[20]:
Adj.update('l', color='cyan')
eq1.input.draw(bgcolor='0.2', fgcolor='white')
_images/notebooks_stringdiagrams_40_0.png

Let’s see what the sides of our two triangle equations look like now.

[21]:
eq1.draw_boundaries(bgcolor='0.2', fgcolor='white')
eq2.draw_boundaries(bgcolor='0.2', fgcolor='white')
_images/notebooks_stringdiagrams_42_0.png
_images/notebooks_stringdiagrams_42_1.png
_images/notebooks_stringdiagrams_42_2.png
_images/notebooks_stringdiagrams_42_3.png

If we are happy with the look, we can output TikZ code. Note that both labels and colour settings are passed to the TikZ output as they are, so we should change the background colour setting to something that LaTeX can recognise.

TikZ output uses coordinates in \([0, 1] \times [0, 1]\). Since this is quite small, the output is scaled 3x by default; this can be changed with the scale, xscale, and yscale keyword arguments.

Also, by default, all wires are drawn with a contour, which is useful in higher dimensions when wires can overlap. Since we are in 2d and this doesn’t happen, we can avoid drawing contours by setting the depth keyword argument to False.

[22]:
eq1.input.draw(
    bgcolor='darkgray', fgcolor='white', depth=False,
    tikz=True, xscale=8, yscale=6, path='stringdiagrams_1.tex')

Here’s the generated TikZ code and the output PDF compiled with pdflatex.

Fun with higher-dimensional shapes

We can have string diagram representations not only of “diagrams in a DiagSet”, but also of shapes and maps of shapes of diagrams.

For example, this is the shape of the diagram we have been using as example.

[23]:
eq1.input.shape.draw()
_images/notebooks_stringdiagrams_47_0.png

Every wire and node corresponds to a unique face of the diagram shape, specified by its dimension (2 for nodes, 1 for wires) and position. We can match them to elements of the oriented face poset of the diagram shape.

[24]:
eq1.input.shape.hasse(labels=False)
_images/notebooks_stringdiagrams_49_0.png

A quick way to get some interesting higher-dimensional diagrams, and see some of the things that happen with string diagram representations in higher dimensions, is to use some of the constructors for special higher-dimensional shapes, such as simplices and cubes.

For example, these are the string diagrams for the 3-dimensional boundaries of the oriented 4-cube.

[25]:
tesseract = rewalt.Shape.cube(4)
tesseract.draw_boundaries(labels=False)
_images/notebooks_stringdiagrams_51_0.png
_images/notebooks_stringdiagrams_51_1.png

You can see that wires can cross each other in 3-dimensional diagrams.

For something even more complicated, let’s look at a cubical connection map on the 4-cube, which is a surjective map from the 5-cube.

(Since this will contain many degenerate cells, we will reinstate weak units in string diagrams.)

[26]:
connection = tesseract.cube_connection(1, '-')
connection.draw_boundaries(labels=False, degenalpha=0.1)
_images/notebooks_stringdiagrams_53_0.png
_images/notebooks_stringdiagrams_53_1.png

And let’s play a little bit with colours.

[27]:
connection.draw_boundaries(
    labels=False, bgcolor='0.2', fgcolor='0.9', degenalpha=0.4,
    nodecolor='gold', nodestroke='white')
_images/notebooks_stringdiagrams_55_0.png
_images/notebooks_stringdiagrams_55_1.png

Looks nice, no?

Exploring simplices and cubes

Diagrammatic sets — the structure implemented by rewalt’s DiagSet class — support a wide variety of “shapes of diagrams”, while remaining “topologically sound”. This makes them a convenient tool for diagrammatic reasoning in higher category, higher algebra, and homotopy theory.

Among these shapes are some subclasses that are widely used on their own: in particular, the simplices and the cubes. Indeed, both simplicial sets and cubical sets with connections are special instances of diagrammatic sets (their categories are full subcategories of the category of diagrammatic sets).

Reflecting this, rewalt contains a full implementation of (finitely presented) simplicial sets and of (finitely presented) cubical sets with connections. These are nothing more than diagrammatic sets whose generators all have simplicial and cubical shapes! The Diagram objects that have simplicial or cubical shapes come with special methods for constructing simplicial and cubical faces, degeneracies, and connections.

Since all our shapes have a “globular” orientation (half a boundary is “input”, half a boundary is “output”), our simplices are in fact Street’s oriented simplices. Similarly our cubes are “oriented” as in cubical ω-categories.

Understanding higher-dimensional oriented simplices and cubes can be difficult. In this notebook, we will try to use rewalt and its visualisation methods to get a grip on some low-but-not-too-low-dimensional examples.

Oriented simplices

Oriented simplices of any dimension are built with the Shapes.simplex constructor. Let’s start with the lowest possible dimension: -1.

[1]:
import rewalt

empty = rewalt.Shape.simplex(-1)

This is just the empty diagram shape.

[2]:
len(empty)
[2]:
0

The 0-dimensional simplex is a point.

[3]:
point = rewalt.Shape.simplex(0)
point.draw()
_images/notebooks_simplicescubes_6_0.png

The 1-dimensional oriented simplex is an arrow.

[4]:
arrow = rewalt.Shape.simplex(1)
arrow.draw()
_images/notebooks_simplicescubes_8_0.png

Things get a little more interesting in dimension 2. The oriented 2-simplex is a triangle with two output sides and one input side. In string diagrams, it is, for example, the shape of a comonoid comultiplication.

[5]:
triangle = rewalt.Shape.simplex(2)
triangle.draw()
_images/notebooks_simplicescubes_10_0.png

Let’s go one dimension higher. The oriented 3-simplex is a tetrahedron with two output faces and two input faces, each of them shaped as an oriented 2-simplex.

Let’s draw both its top-dimensional “slice” string diagram, and its input and output boundaries.

[6]:
tetrahedron = rewalt.Shape.simplex(3)
tetrahedron.draw()
_images/notebooks_simplicescubes_12_0.png
[7]:
tetrahedron.draw_boundaries()
_images/notebooks_simplicescubes_13_0.png
_images/notebooks_simplicescubes_13_1.png

If we stick to the interpretation of the oriented 2-simplex as “the shape of a comultiplication”, then the oriented 3-simplex is “the shape of a (co)associativity equation”, or “the shape of a coassociator”!

What happens if we go to dimension 4?

[8]:
pentachoron = rewalt.Shape.simplex(4)
pentachoron.draw()
_images/notebooks_simplicescubes_15_0.png

This is a pentachoron, also known as the 5-cell, with three output tetrahedral faces and two input tetrahedral faces.

Let’s see what its boundaries look like, starting from the input.

[9]:
penta_input = pentachoron.input
penta_input.draw()
_images/notebooks_simplicescubes_17_0.png

This is a slice of a 3-dimensional diagram with two 3-dimensional cells.

This is still hard to visualise directly in three dimensions; instead, we are going to try to visualise it as a sequence of rewrites on 2-dimensional diagrams.

For that purpose, we use the generate_layering method, which creates a “layering” of a diagram into a sequence of rewrites, one for each one of its top-dimensional cells. Then, we can

  • get a list of the layers with the layers attribute, or

  • get a list of the corresponding “rewrite steps” with the rewrite_steps attribute.

[10]:
penta_input.generate_layering()
rewalt.strdiags.draw(*penta_input.layers)
_images/notebooks_simplicescubes_19_0.png
_images/notebooks_simplicescubes_19_1.png
[11]:
rewalt.strdiags.draw(*penta_input.rewrite_steps)
_images/notebooks_simplicescubes_20_0.png
_images/notebooks_simplicescubes_20_1.png
_images/notebooks_simplicescubes_20_2.png

So, we can see that

  • first the 3-dimensional face El(3, 0) “rewrites” the triangles El(2, 0) and El(2, 1) into the triangles El(2, 3) and El(2, 4),

  • then the 3-dimensional face El(3, 1) “rewrites” the triangles El(2, 2) and El(2, 3) into the triangles El(2, 5) and El(2, 6).

We can also create a gif “movie” of the rewrite steps (and make it loop backwards so it doesn’t stop too soon).

[12]:
rewalt.strdiags.to_gif(
    *penta_input.rewrite_steps,
    loop=True, path='simplicescubes_1.gif')

PentaInput

Now, let’s look at the output boundary of the oriented 4-simplex.

[13]:
penta_output = pentachoron.output
penta_output.draw()
_images/notebooks_simplicescubes_25_0.png

This is the slice of a 3-dimensional diagram with three 3-dimensional cells. Let’s proceed as with the input.

[14]:
penta_output.generate_layering()
rewalt.strdiags.draw(*penta_output.layers)
_images/notebooks_simplicescubes_27_0.png
_images/notebooks_simplicescubes_27_1.png
_images/notebooks_simplicescubes_27_2.png
[15]:
rewalt.strdiags.draw(*penta_output.rewrite_steps)
_images/notebooks_simplicescubes_28_0.png
_images/notebooks_simplicescubes_28_1.png
_images/notebooks_simplicescubes_28_2.png
_images/notebooks_simplicescubes_28_3.png

Let’s also make a movie of these.

[16]:
rewalt.strdiags.to_gif(
    *penta_output.rewrite_steps, loop=True,
    path='simplicescubes_2.gif')

PentaOutput

The two sides of the oriented 4-simplex are, in fact, the two sides of an equation dual to Mac Lane’s pentagon. This was featured at the end of this other notebook.

Maps of simplices

So far we have only looked at the oriented simplices “in isolation”. Let’s see how we can use rewalt to understand their face and degeneracy maps.

Faces are quite simple; let’s look at the example of the 2-simplex. This has 3 faces.

[17]:
triangle.draw()
for n in range(3):
    triangle.simplex_face(n).draw()
_images/notebooks_simplicescubes_34_0.png
_images/notebooks_simplicescubes_34_1.png
_images/notebooks_simplicescubes_34_2.png
_images/notebooks_simplicescubes_34_3.png

By comparing labels, we can see that

  • the 0th face of the 2-simplex is the rightmost output,

  • the 1st face of the 2-simplex is the only input, and

  • the 2nd face of the 2-simplex is the leftmost output.

In general, the faces of an oriented simplex alternate between inputs and outputs, always starting with an output at index 0.

Let’s look at degeneracies; these are somewhat more interesting. There are two degeneracies on the 1-simplex.

[18]:
arrow.draw()
for n in range(2):
    arrow.simplex_degeneracy(n).draw()
_images/notebooks_simplicescubes_36_0.png
_images/notebooks_simplicescubes_36_1.png
_images/notebooks_simplicescubes_36_2.png

The two diagrams represent two surjective (“collapsing”) maps from the 2-simplex to the 1-simplex. The string diagrams tell us that

  • the 0th degeneracy sends the 2-cell, its input, and the rightmost output of the 2-simplex onto the 1-cell of the 1-simplex, and collapses the leftmost output onto its input 0-cell;

  • the 1st degeneracy sends the 2-cell, its input, and the leftmost output of the 2-simplex onto the 1-cell of the 1-simplex, and collapses the rightmost output onto its output 0-cell.

Now, let’s take a look at one degeneracy of the 2-simplex.

[19]:
triangle.simplex_degeneracy(0).draw()
_images/notebooks_simplicescubes_38_0.png

This represents a collapsing map from the 3-simplex onto the 2-simplex; the string diagram tells us which input and which output of the 3-simplex are collapsed, and which are sent to the 2-cell of the 2-simplex.

Let’s obtain some more information by looking at the boundaries.

[20]:
triangle.simplex_degeneracy(0).draw_boundaries()
_images/notebooks_simplicescubes_40_0.png
_images/notebooks_simplicescubes_40_1.png

This tells us exactly how the two collapsed 2-dimensional faces of the 3-simplex are collapsed: we can tell that, in both cases, it is the leftmost output that is collapsed, hence the 0-th degeneracy of the 1-simplex is used.

By the way, if we want a precise (but not very intuitive) description of a map, we can use the Hasse diagram visualisation:

[21]:
triangle.simplex_degeneracy(0).hasse()
_images/notebooks_simplicescubes_42_0.png

This shows us the “oriented face poset” of the source of the map — here, the 3-simplex — with each element labelled with its image through the map. For example, the third element of the third row from the bottom is labelled with El(1, 0); this means that the map sends El(2, 2) to El(1, 0) (we are counting from 0).

Constructing a simplicial set

Let’s briefly look at how we can use rewalt to construct a simplicial set. As a simple example, we will construct the 3-dimensional real projective space \(\mathbb{R}P^3\), with its cell structure made up of a single cell in each dimension.

The first step is to create an empty diagrammatic set.

[22]:
RP3 = rewalt.DiagSet()

To ensure that this is really a simplicial set, we only add generators with the add_simplex method, taking, as arguments, the simplicial faces of the new generator in the same order as given by simplex_face.

(In dimension 0 and 1, there’s no substantial difference between add and add_simplex).

[23]:
c0 = RP3.add_simplex('c0')
c1 = RP3.add_simplex('c1', c0, c0)

We construct degenerate simplices over the generators with the simplex_degeneracy method.

[24]:
c2 = RP3.add_simplex('c2', c1, c0.simplex_degeneracy(0), c1)
c2.draw()
_images/notebooks_simplicescubes_49_0.png
[25]:
c3 = RP3.add_simplex(
    'c3',
    c2, c1.simplex_degeneracy(0), c1.simplex_degeneracy(1), c2)
c3.draw()
_images/notebooks_simplicescubes_50_0.png
[26]:
c3.draw_boundaries()
_images/notebooks_simplicescubes_51_0.png
_images/notebooks_simplicescubes_51_1.png

There we go; RP3 is now a simplicial model of the 3-dimensional real projective space. We can check that this is “really” a simplicial set:

[27]:
RP3.issimplicial
[27]:
True

In future releases, we plan to add features that will allow us to automatically compute some topological invariants of cell complexes constructed as DiagSet objects.

Oriented cubes

Let’s move on from simplices to cubes; these can be obtained with the Shape.cube constructor. Unlike in simplices, there is no (-1)-cube. The 0-cube and the 1-cube are, in fact, the same as the 0-simplex and the 1-simplex.

[28]:
assert point == rewalt.Shape.cube(0)
assert arrow == rewalt.Shape.cube(1)

So the first interesting case is the oriented 2-cube: this is a square with two output faces and two input faces.

[29]:
square = rewalt.Shape.cube(2)
square.draw()
_images/notebooks_simplicescubes_58_0.png

Next, the oriented 3-cube has three output faces and three input faces. (In fact, the oriented n-cube always has n inputs and n outputs.)

[30]:
cube = rewalt.Shape.cube(3)
cube.draw()
_images/notebooks_simplicescubes_60_0.png
[31]:
cube.draw_boundaries()
_images/notebooks_simplicescubes_61_0.png
_images/notebooks_simplicescubes_61_1.png

You may see the 2-dimensional boundaries of the oriented 3-cube, in string diagrams, as the shapes of the two sides of the Yang-Baxter equation, or the two sides of the third Reidemeister move.

Let’s move on to the 4-dimensional cube.

[32]:
tesseract = rewalt.Shape.cube(4)
tesseract.draw()
_images/notebooks_simplicescubes_63_0.png

As expected, it has four input faces and four output faces. Let’s proceed as we did with the 4-simplex to understand what is happening.

[33]:
tess_input = tesseract.input
tess_input.draw(wirelabels=False)
_images/notebooks_simplicescubes_65_0.png

(We have deactivated wire labels to make the image less crowded.)

[34]:
tess_input.generate_layering()
rewalt.strdiags.draw(*tess_input.layers)
_images/notebooks_simplicescubes_67_0.png
_images/notebooks_simplicescubes_67_1.png
_images/notebooks_simplicescubes_67_2.png
_images/notebooks_simplicescubes_67_3.png
[35]:
rewalt.strdiags.draw(*tess_input.rewrite_steps, wirelabels=False)
_images/notebooks_simplicescubes_68_0.png
_images/notebooks_simplicescubes_68_1.png
_images/notebooks_simplicescubes_68_2.png
_images/notebooks_simplicescubes_68_3.png
_images/notebooks_simplicescubes_68_4.png

Now we turn the sequence of rewrite steps into a gif.

[36]:
rewalt.strdiags.to_gif(
    *tess_input.rewrite_steps, loop=True,
    wirelabels=False,
    path='simplicescubes_3.gif')

TessInput

Next we focus on the output of the 4-cube.

[37]:
tess_output = tesseract.output
tess_output.draw(wirelabels=False)
_images/notebooks_simplicescubes_73_0.png
[38]:
tess_output.generate_layering()
rewalt.strdiags.draw(*tess_output.layers)
_images/notebooks_simplicescubes_74_0.png
_images/notebooks_simplicescubes_74_1.png
_images/notebooks_simplicescubes_74_2.png
_images/notebooks_simplicescubes_74_3.png
[39]:
rewalt.strdiags.to_gif(
    *tess_output.rewrite_steps, loop=True,
    wirelabels=False,
    path='simplicescubes_4.gif')

TessOutput

In the two rewrite sequences corresponding to the input and output boundary of the 4-cube, you may recognise the shapes of the two sides of the Zamolodchikov tetrahedron equation.

(Why “tetrahedron equation” if its shape is a 4-cube? Not sure!)

Maps of cubes

In contrast to simplices, faces of cubes are specified by two arguments: thinking of the n-cube as \([0, 1]^n\), one argument is an integer ranging from 0 to (n-1), specifying which coordinate to fix, and the other is a bit (for us, a sign: '-' or '+') specifying whether to set the coordinate to 0 or to 1.

[40]:
for n in range(2):
    for sign in ('-', '+'):
        square.cube_face(n, sign).draw()
_images/notebooks_simplicescubes_79_0.png
_images/notebooks_simplicescubes_79_1.png
_images/notebooks_simplicescubes_79_2.png
_images/notebooks_simplicescubes_79_3.png

Cubes also have two different kinds of “collapse” maps:

  • degeneracies, which collapse the cube along a single coordinate (specified by an integer argument), and

  • connections, which “fold” the cube along a pair of consecutive coordinates (specified by an integer argument), in two different ways (specified by a “sign” argument).

In rewalt, we can get a string-diagrammatic picture of these collapse maps.

[41]:
for n in range(2):
    arrow.cube_degeneracy(n).draw()
_images/notebooks_simplicescubes_81_0.png
_images/notebooks_simplicescubes_81_1.png
[42]:
for sign in ('-', '+'):
    arrow.cube_connection(0, sign).draw()
_images/notebooks_simplicescubes_82_0.png
_images/notebooks_simplicescubes_82_1.png

As we saw in another notebook, being familiar with these degeneracies, which are neither “units” or “unitors”, can be handy when constructing presentations of monoidal or higher algebraic theories.

Constructing a cubical set

Constructing a cubical set with connections is just like constructing a simplicial set, except we use the add_cube method instead of the add_simplex method when adding generators.

Let’s construct a simple cubical model of the torus, with one 0-cell, two 1-cells, and one 2-cell.

[43]:
T = rewalt.DiagSet()
pt = T.add_cube('pt')
a = T.add_cube('a', pt, pt)
b = T.add_cube('b', pt, pt)
s = T.add_cube('s', a, a, b, b)
s.draw()
_images/notebooks_simplicescubes_85_0.png

That’s all! T is a torus.

We can check that the diagrammatic set we constructed is, indeed, a cubical set:

[44]:
T.iscubical
[44]:
True

Notice that if we look at this diagrammatic set as string rewrite system instead, it is a presentation of the free commutative monoid on the 2 generators a and b. Of course, the free abelian group on two generators is the first homology group of the torus.

Mixing them together

One of the reasons why simplices and cubes are “nice” families of shapes is that both are generated by the iteration of a binary operation, which defines a monoidal structure on their respective shape categories:

  • simplices are iterated joins of points;

  • cubes are iterated products of intervals.

In fact, both joins and products have “oriented” counterparts, and all shapes of rewalt are closed under both of these operations:

  • the join of shapes, accessed either with the join method, or with the shift operators >> and <<, and

  • the Gray product of shapes, accessed either with the gray method, or with the multiplication operator *.

Indeed, this is how rewalt constructs oriented simplices and oriented cubes.

[45]:
assert arrow == point >> point
assert triangle == arrow >> point
assert square == arrow * arrow
assert cube == arrow * square

Joins are useful, for instance, for constructing cones, while products are useful for constructing cylinders. So the first operation is natural in a simplicial context, but not in a cubical context; while the second operation is natural in a cubical context but not in a simplicial context.

One nice thing about diagrammatic sets is that we do not need to choose! We can build a cylinder on a simplex…

[46]:
cylinder = arrow * triangle
cylinder.draw()
_images/notebooks_simplicescubes_92_0.png
[47]:
cylinder.draw_boundaries()
_images/notebooks_simplicescubes_93_0.png
_images/notebooks_simplicescubes_93_1.png

… and we can build a cone on a cube.

[48]:
cone = square >> point
cone.draw()
_images/notebooks_simplicescubes_95_0.png
[49]:
cone.draw_boundaries()
_images/notebooks_simplicescubes_96_0.png
_images/notebooks_simplicescubes_96_1.png

The Eckmann–Hilton argument

A nice theoretical feature of rewalt is “topological soundness”: a diagrammatic set can be geometrically realised as a CW complex with one cell for each of its generators, and every diagram that we construct in the diagrammatic set corresponds to a valid homotopy in its realisation.

One of the first non-trivial homotopies that one encounters in algebraic topology are the “braiding” homotopies between two 2-cells, exhibiting the fact that \(\pi_2\) of a space is always an abelian group. The construction of these homotopies is known as Eckmann–Hilton argument, and is also the basis of the identification of braided monoidal categories with “doubly degenerate” tricategories.

In this notebook, we will implement the Eckmann–Hilton argument in rewalt, by constructing both homotopies in a diagrammatic set with a single 0-dimensional generator and two 2-dimensional generators. Thanks to topological soundness, you can also see this as a formal proof of the usual homotopical Eckmann–Hilton.

First of all, let’s create a diagrammatic set, and add all the generators. We will colour-code the two 2-cells, one in blue and one in magenta.

[1]:
import rewalt

EH = rewalt.DiagSet()
pt = EH.add('pt', draw_label=False)
a = EH.add('a', pt.unit(), pt.unit(), color='blue')
b = EH.add('b', pt.unit(), pt.unit(), color='magenta')

First braiding

The “braiding homotopies” will be made of degenerate cells, starting from the pasting “b after a”, and ending in the pasting “a after b”.

Our construction of these homotopies will be, essentially, an implementation of the “train tracks” proof by André Joyal and Joachim Kock. Let’s start from the beginning.

[2]:
start = a.paste(b)
start.draw(nodepositions=True)
_images/notebooks_eckmannhilton_3_0.png

Let’s introduce some weak units between a and b; one would be sufficient, but we’ll do two for reasons of symmetry.

[3]:
rew1 = start.rewrite(0, a.runitor('+'))
rew1.output.draw(nodepositions=True)
_images/notebooks_eckmannhilton_5_0.png
[4]:
rew2 = rew1.output.rewrite(2, b.lunitor('+'))
rew2.output.draw(nodepositions=True)
_images/notebooks_eckmannhilton_6_0.png

Now, we want to “split” the units in positions (1, 2) into two “train tracks”. This can be done with a “fully degenerate” cell over pt, of the appropriate shape:

[5]:
globe = rewalt.Shape.globe(2)
triangle = rewalt.Shape.simplex(2)

track_split_shape = globe.paste(globe).atom(triangle.paste(triangle.dual()))
track_split_shape.draw_boundaries()
_images/notebooks_eckmannhilton_8_0.png
_images/notebooks_eckmannhilton_8_1.png

You can see that track_split_shape is a 3-dimensional shape with input and output of the shape we desire, going from “single track” (pasting of two 2-globes) to “double track” (pasting of a 2-simplex with its dual).

To get a “fully degenerate” cell over pt of shape track_split_shape, we use the degeneracy method.

[6]:
track_split = pt.degeneracy(track_split_shape)
track_split.draw_boundaries()
_images/notebooks_eckmannhilton_10_0.png
_images/notebooks_eckmannhilton_10_1.png
[7]:
rew3 = rew2.output.rewrite([1, 2], track_split)
rew3.output.draw(nodepositions=True)
_images/notebooks_eckmannhilton_11_0.png

Now, our goal is to “move a to the right track, and move b to the left track”. This can be done with appropriate degenerate cells over a and b.

These degenerate cells are neither units or unitors. However, just like units and unitors, they can be obtained from pullbacks of a and b over particular collapse maps from a “partially collapsed cylinder” on their shape, as provided by the inflate method of the Shape class.

(I do not expect that this is particulary intuitive; you should try fiddling with inflate to get an idea of the collapses you can get.)

This, for example, is the map we can use to move a from the bottom to the right track.

[8]:
switch_br_map = globe.inflate(globe.all().boundary('+', 0))
switch_br_map.draw_boundaries()
_images/notebooks_eckmannhilton_13_0.png
_images/notebooks_eckmannhilton_13_1.png

Every other “switch” map we will get as a dual of this one. For example, the “top-to-left” that we need for b is the dual in dimensions 1 and 2 (“horizontal and vertical flip”).

[9]:
switch_tl_map = switch_br_map.dual(1, 2)
switch_tl_map.draw_boundaries()
_images/notebooks_eckmannhilton_15_0.png
_images/notebooks_eckmannhilton_15_1.png
[10]:
a_switch_br = a.pullback(switch_br_map)

rew4 = rew3.output.rewrite([0, 1], a_switch_br)
rew4.output.draw(nodepositions=True)
_images/notebooks_eckmannhilton_16_0.png
[11]:
b_switch_tl = b.pullback(switch_tl_map)

rew5 = rew4.output.rewrite([1, 3], b_switch_tl)
rew5.output.draw(nodepositions=True)
_images/notebooks_eckmannhilton_17_0.png

Now we will move a to the top, then b to the bottom. For that, we use pullbacks along other duals of our original “switch” map.

[12]:
switch_rt_map = switch_br_map.dual(2, 3)
a_switch_rt = a.pullback(switch_rt_map)

rew6 = rew5.output.rewrite([2, 3], a_switch_rt)
rew6.output.draw(nodepositions=True)
_images/notebooks_eckmannhilton_19_0.png
[13]:
switch_lb_map = switch_br_map.dual(1, 3)
b_switch_lb = b.pullback(switch_lb_map)

rew7 = rew6.output.rewrite([0, 1], b_switch_lb)
rew7.output.draw(nodepositions=True)
_images/notebooks_eckmannhilton_20_0.png

The relative positions of a and b have been exchanged! Now we only need to get rid of the “train tracks” and other units between them.

We used degenerate cells to introduce them, and degenerate cells are always “weakly invertible”, so we can just use their “weak inverses”, obtained with the inverse method.

[14]:
rew8 = rew7.output.rewrite([1, 2], track_split.inverse)
rew8.output.draw(nodepositions=True)
_images/notebooks_eckmannhilton_22_0.png
[15]:
rew9 = rew8.output.rewrite([0, 1], b.runitor('-'))
rew9.output.draw(nodepositions=True)
_images/notebooks_eckmannhilton_23_0.png
[16]:
rew10 = rew9.output.rewrite([1, 2], a.lunitor('-'))
rew10.output.draw(nodepositions=True)
_images/notebooks_eckmannhilton_24_0.png

We are done! Let’s put all our rewrites together, and see what our proof looks like as a slice of a 3-dimensional diagram.

[17]:
eh1 = rewalt.Diagram.with_layers(
    rew1, rew2, rew3, rew4, rew5, rew6, rew7, rew8, rew9, rew10)
eh1.draw()
_images/notebooks_eckmannhilton_26_0.png

See? It’s a braiding where the b strand is passing over the a strand.

We can also assemble all our rewrites into a gif animation. We will also make it loop backwards.

[18]:
rewalt.strdiags.to_gif(
    *eh1.rewrite_steps, degenalpha=0.2,
    loop=True, path='eckmannhilton_1.gif')

EckmannHilton1

Second braiding

In our proof, we made the choice of moving a onto the right track, and b onto the left track; but we might as well have made a different choice. This would have led to a non-equivalent homotopy, the dual braiding.

Let’s go back to the step where we had the choice, and make a different one. This corresponds to “horizontally flipping” all the maps we used the first time.

[19]:
rew3.output.draw(nodepositions=True)
_images/notebooks_eckmannhilton_31_0.png
[20]:
switch_bl_map = switch_br_map.dual(1)
a_switch_bl = a.pullback(switch_bl_map)

rew4d = rew3.output.rewrite([0, 1], a_switch_bl)
rew4d.output.draw(nodepositions=True)
_images/notebooks_eckmannhilton_32_0.png
[21]:
switch_tr_map = switch_tl_map.dual(1)
b_switch_tr = b.pullback(switch_tr_map)

rew5d = rew4d.output.rewrite([2, 3], b_switch_tr)
rew5d.output.draw(nodepositions=True)
_images/notebooks_eckmannhilton_33_0.png
[22]:
switch_lt_map = switch_rt_map.dual(1)
a_switch_lt = a.pullback(switch_lt_map)

rew6d = rew5d.output.rewrite([1, 3], a_switch_lt)
rew6d.output.draw(nodepositions=True)
_images/notebooks_eckmannhilton_34_0.png
[23]:
switch_rb_map = switch_lb_map.dual(1)
b_switch_rb = b.pullback(switch_rb_map)

rew7d = rew6d.output.rewrite([0, 2], b_switch_rb)
rew7d.output.draw(nodepositions=True)
_images/notebooks_eckmannhilton_35_0.png

That’s it; the last few steps are the same as the first time. Let’s put the whole sequence together.

[24]:
eh2 = rewalt.Diagram.with_layers(
    rew1, rew2, rew3, rew4d, rew5d, rew6d, rew7d, rew8, rew9, rew10)
eh2.draw()
_images/notebooks_eckmannhilton_37_0.png

See? Now it is the blue (a) strand that crosses over the magenta (b) strand.

And let’s make another animation.

[25]:
rewalt.strdiags.to_gif(
    *eh2.rewrite_steps, degenalpha=0.2,
    loop=True, path='eckmannhilton_2.gif')

EckmannHilton2

The diagrams eh1 and eh2 have the same input and output; they could, in principle, be the input and output of another cell.

By topological soundness, however, we know that there isn’t a diagram between eh1 and eh2: the geometric realisation of EH is a bouquet of two 2-spheres, and in this space there isn’t a homotopy between the two “braidings”.

You are welcome to add one by hand, if you really want.

[26]:
symmetriser = EH.add('symmetriser', eh1, eh2)
symmetriser.draw()
_images/notebooks_eckmannhilton_42_0.png

Presenting a category

The “higher-dimensional rewrite systems” that we construct in rewalt are interpretable in higher-dimensional categories, but they are, in general, different from higher-dimensional categories, in that they have no notion of composition of diagrams; that is, there’s no way, in general, to “turn a diagram with many n-cells into a single n-cell”.

Nevertheless, rewalt contains an implementation of a model of higher categories, in the form of diagrammatic sets with weak composites. This allows us to “declare” a cell to be the composite of a diagram; the composition is exhibited by a higher-dimensional compositor cell.

In this notebook, we will use the dedicated methods to construct a presentation of a simple finite category, consisting of a commuting square of four morphisms.

Adding all objects and morphisms

We start by creating an empty DiagSet, and adding all the objects and morphisms of our category. We have four objects (0-generators).

[1]:
import rewalt

C = rewalt.DiagSet()

x0 = C.add('x0')
x1 = C.add('x1')
x2 = C.add('x2')
x3 = C.add('x3')

Then we add the four morphisms (1-generators) that form the boundary of our commuting square.

[2]:
f0 = C.add('f0', x0, x1)
f1 = C.add('f1', x1, x3)
g0 = C.add('g0', x0, x2)
g1 = C.add('g1', x2, x3)

Now we have two parallel diagrams of two 1-cells: f0.paste(f1) and g0.paste(g1). We add the “diagonal” morphism that will be the composite of both diagrams.

[3]:
h = C.add('h', x0, x3)

That’s it; now we move on to the compositors.

Adding compositors

We declare a generator to be the “weak composite” of a diagram with the make_composite method. This will add a “compositor” 2-cell, and return it as a Diagram object.

[4]:
c_f = C.make_composite('h', f0.paste(f1))
c_f.draw()
_images/notebooks_presentcategory_9_0.png
[5]:
c_g = C.make_composite('h', g0.paste(g1))
c_g.draw()
_images/notebooks_presentcategory_10_0.png

We can check that a diagram has a composite with the hascomposite attribute; if a diagram has a composite, we can retrieve it with the composite attribute.

[6]:
f0.paste(f1).hascomposite
[6]:
True
[7]:
f0.paste(f1).composite == h
[7]:
True

A compositor allows us to rewrite a diagram into a cell. Now, according to the theory, to exhibit a genuine weak composite, the compositor would need to be weakly invertible.

As we saw in another notebook, since weak invertibility requires an infinite “tower” of cells, the approach of rewalt is to “invert only when needed”. That also applies to compositors, which are created in “one direction only”, and must be explicitly inverted if needed.

(Another reason to not invert by default is that one may want to use DiagSet objects to implement different kinds of higher structures, such as representable multicategories or “lax” versions thereof, where it is important that compositors only go “one way”.)

[8]:
c_f_inv, c_f_rinvertor, c_f_linvertor = C.invert(c_f)
c_f_inv.draw()
_images/notebooks_presentcategory_15_0.png

Now that we have an inverse compositor, we can “rewrite” g0.paste(g1) into f0.paste(f1) via their shared composite.

[9]:
g_to_f = c_g.paste(c_f_inv)
g_to_f.draw()
_images/notebooks_presentcategory_17_0.png

To go the other way around, we need to invert the compositor for g0.paste(g1).

[10]:
c_g_inv, c_g_rinvertor, c_g_linvertor = C.invert(c_g)

f_to_g = c_f.paste(c_g_inv)
f_to_g.draw()
_images/notebooks_presentcategory_19_0.png

This pair of diagrams “embodies” the commuting square with sides f0, f1, g0, g1.

We can use the “invertors” to show that the two diagrams are each other’s weak inverse.

[11]:
f_to_g.paste(g_to_f).draw(nodepositions=True)
_images/notebooks_presentcategory_21_0.png
[12]:
rew1 = f_to_g.paste(g_to_f).rewrite([1, 2], c_g_linvertor)
rew1.output.draw(nodepositions=True)
_images/notebooks_presentcategory_22_0.png
[13]:
rew2 = rew1.output.rewrite([0, 1], c_f.runitor('-'))
rew2.output.draw(nodepositions=True)
_images/notebooks_presentcategory_23_0.png
[14]:
rew3 = rew2.output.rewrite([0, 1], c_f_rinvertor)
rew3.output.draw()
_images/notebooks_presentcategory_24_0.png

Composites involving units

Now in C all 1-dimensional diagrams have composites, so we can see C as a category.

Except, in fact, not all 1-dimensional diagrams have composites that C knows of!

[15]:
x0.unit().paste(f0).hascomposite
[15]:
False

Nevertheless, we can certainly turn this diagram into a single cell, using the left unitor for f0.

[16]:
f0.lunitor('-').draw()
_images/notebooks_presentcategory_28_0.png

This is even already “weakly invertible”, as all degenerate cells are.

[17]:
f0.lunitor('-').inverse.draw()
_images/notebooks_presentcategory_30_0.png

So why does rewalt not consider unitors to be compositors?

There is a good reason: rewalt does not make a distinction between presentations of categories, bicategories, or n-categories for any other n. And there are certainly non-strict bicategories in which the composite of a 1-cell with a unit is not equal to the 1-cell.

So if we want C to know that f0 is, indeed, the composite of x0.unit() and f0, we need to make it explicit.

[18]:
c_x0_f0 = C.make_composite('f0', x0.unit().paste(f0))

This will add a compositor which is not the same as the left unitor on f0.

(The reason you cannot declare an existing degenerate cell to be a compositor is that rewalt wants compositors to be generators, so it can remember which compositors a DiagSet contains just by their list of names).

So if we want to “equate” the compositor to the unitor, we have to do it “weakly”, by adding a 3-cell between them.

[19]:
comp_to_lu = C.add('comp_to_lu', c_x0_f0, f0.lunitor('-'))
comp_to_lu.draw()
_images/notebooks_presentcategory_34_0.png

diagrams

Implements diagrammatic sets and diagrams.

rewalt.diagrams.DiagSet()

Class for diagrammatic sets, a model of higher-dimensional rewrite systems and/or directed cell complexes.

rewalt.diagrams.Diagram(ambient)

Class for diagrams, that is, mappings from a shape to an "ambient" diagrammatic set.

rewalt.diagrams.SimplexDiagram(ambient)

Subclass of Diagram for diagrams whose shape is an oriented simplex.

rewalt.diagrams.CubeDiagram(ambient)

Subclass of Diagram for diagrams whose shape is an oriented cube.

rewalt.diagrams.PointDiagram(ambient)

Subclass of Diagram for diagrams whose shape is a point.

shapes

Implements shapes of cells and diagrams.

rewalt.shapes.Shape()

Inductive subclass of ogposets.OgPoset for shapes of cells and diagrams.

rewalt.shapes.ShapeMap(ogmap, **params)

An overlay of ogposets.OgMap for total maps between Shape objects.

rewalt.shapes.Simplex()

Subclass of Shape for oriented simplices.

rewalt.shapes.Cube()

Subclass of Shape for oriented cubes.

ogposets

Implements oriented graded posets, their elements, subsets, and maps.

rewalt.ogposets.OgPoset(face_data, ...)

Class for oriented graded posets, that is, finite graded posets with an orientation, defined as a {'-', '+'}-labelling of the edges of their Hasse diagram.

rewalt.ogposets.OgMap(source, target[, mapping])

Class for (partial) maps of oriented graded posets, compatible with boundaries.

rewalt.ogposets.El(dim, pos)

Class for elements of an oriented graded poset.

rewalt.ogposets.GrSet(*elements)

Class for sets of elements of an oriented graded poset, graded by their dimension.

rewalt.ogposets.GrSubset(support, ambient, ...)

Class for graded subsets, that is, pairs of a GrSet and an "ambient" OgPoset, where the first is seen as a subset of the second.

rewalt.ogposets.Closed(support, ambient, ...)

Subclass of GrSubset for (downwards) closed subsets.

rewalt.ogposets.OgMapPair(fst, snd)

Class for pairs of maps of oriented graded posets.

strdiags

Implements string diagram visualisations.

rewalt.strdiags.StrDiag(diagram)

Class for string diagram visualisations of diagrams and shapes.

rewalt.strdiags.draw(*diagrams, **params)

Given any number of diagrams, generates their string diagrams and draws them.

rewalt.strdiags.draw_boundaries(diagram[, dim])

Given a diagram, generates the string diagram of its input and output boundaries of a given dimension, and draws them.

rewalt.strdiags.to_gif(diagram, *diagrams, ...)

Given a non-zero number of diagrams, generates their string diagrams and outputs a GIF animation of the sequence of their visualisations.

hasse

Implements oriented Hasse diagram visualisation.

rewalt.hasse.Hasse(ogp)

Class for "oriented Hasse diagrams" of oriented graded posets.

rewalt.hasse.draw(*ogps, **params)

Given any number of oriented graded posets, or maps, or diagrams, generates their Hasse diagrams and draws them.

drawing

Drawing backends.

rewalt.drawing.DrawBackend(**params)

Abstract drawing backend for placing nodes, wires, arrows, and labels on a canvas.

rewalt.drawing.MatBackend(**params)

Drawing backend outputting Matplotlib figures.

rewalt.drawing.TikZBackend(**params)

Drawing backend outputting TikZ code that can be embedded in a LaTeX document.

Indices and tables