Persistent homology is built to read data, but the same machinery works on spaces you construct yourself, and that is where you can ask pure questions: are these two spaces the same? What distinguishes them? The first tool is Betti numbers, the counts of holes in each dimension. This tutorial starts with what they can do and then shows where they run out, and what to reach for next: cup products and Steenrod squares, which detect structure the counting cannot see.

There is no data in this tutorial. Every space is built in a few lines, as a simplicial set (a space glued together from simplices, with the freedom to glue them to themselves, which keeps the models tiny).

import scala.language.experimental.modularity
import org.appliedtopology.tda4j.TDAlab.F2.{*, given}  // a prebuilt lab: coefficients in Z/2

Betti numbers over two fields

space.bettiNumbers(p) returns the dimensions of the homology groups of space with coefficients in the field with p elements, Vector(b0, b1, b2, ...). We ask for two fields, p = 2 and p = 3, because comparing them exposes a subtle kind of hole that counting over one field can hide:

def betti[G](x: FiniteSimplicialSet[G]) = (x.bettiNumbers(2), x.bettiNumbers(3))

// A space can be described by a presentation: generators (loops), and relations (disks glued in along words in the loops).
// A letter is (generator number, +1 or -1). The torus is <a, b | a b a^-1 b^-1>: "going round a then b is the same as b then a".
val torus = SimplicialSet.presentationComplex(2, Seq(List((0, 1), (1, 1), (0, -1), (1, -1))))
val projectivePlane = SimplicialSet.presentationComplex(1, Seq(List((0, 1), (0, 1))))     // <a | a^2>
val kleinBottle = SimplicialSet.kleinBottle

betti(torus)             // (Vector(1, 2, 1), Vector(1, 2, 1))
betti(projectivePlane)   // (Vector(1, 1, 1), Vector(1, 0, 0))
betti(kleinBottle)       // (Vector(1, 2, 1), Vector(1, 1, 0))

The torus has one piece, two independent loops and one enclosed surface (1, 2, 1), whichever field you count in. The other two have a twist and show it: the projective plane has 1, 1, 1 over the field with 2 elements but only 1, 0, 0 over the field with 3, so its extra loop is a "double loop" (going round twice is trivial), which does not exist in odd characteristic. The Klein bottle is 1, 2, 1 over 2 and 1, 1, 0 over 3, so the torus and the Klein bottle cannot be told apart by counting holes over the field with 2 elements, but can by comparing the two fields.

Where counting runs out

Now the interesting case. Glue a sphere to two circles at a single point:

val circle = SimplicialSet.sphere(1)
val twoCircles = circle.wedge(circle.generators(0).head, circle, circle.generators(0).head)
val sphere = SimplicialSet.sphere(2)
val notATorus = twoCircles.wedge(twoCircles.generators(0).head, sphere, sphere.generators(0).head)

betti(notATorus)         // (Vector(1, 2, 1), Vector(1, 2, 1))

It has exactly the same Betti numbers as the torus over both fields: one piece, two loops, one enclosed surface. And yet it is a different space: on the torus the two loops and the surface are tied together (the surface is bounded by the loops going round each other), while on the wedge the surface floats free of the loops. Betti numbers cannot see that. Cup products can. A cup product multiplies two cohomology classes: two classes in degree 1 give a class in degree 2. On the torus the product of the two loop classes is the generator of the enclosed surface; on the wedge, every product of loop classes is zero.

def basis[G](x: FiniteSimplicialSet[G], degree: Int) = CupProduct.cohomologyBasis[G, CoefficientT](x, degree)
def cup[G](x: FiniteSimplicialSet[G], p: Int, q: Int, a: Map[G, CoefficientT], b: Map[G, CoefficientT]) = CupProduct.cup(x, p, q, a, b)
def isNonzero[G](x: FiniteSimplicialSet[G], degree: Int, cochain: Map[G, CoefficientT]) = !CupProduct.isCoboundary(x, degree, cochain)

def someProductOfOneClassesIsNonzero[G](x: FiniteSimplicialSet[G]): Boolean =
  val classes = basis(x, 1)
  classes.exists(a => classes.exists(b => isNonzero(x, 2, cup(x, 1, 1, a, b))))

someProductOfOneClassesIsNonzero(torus)       // true
someProductOfOneClassesIsNonzero(notATorus)   // false

basis finds cochains whose classes span the cohomology in a degree, cup multiplies cochains, and a product is nonzero as a class when it is not a coboundary (isCoboundary says whether it is the boundary-like kind that counts as zero). The torus has a nonzero product; the wedge does not. The two spaces have the same numbers of holes, but different ring structure on them.

Steenrod squares

Cup products are the first rung of a ladder of finer operations. Over the field with 2 elements, the Steenrod squares Sq^i send a class of degree n to one of degree n + i; the top one, Sq^n x, is the cup square x ∪ x, but the lower ones are genuinely new information. The next pair of spaces: the complex projective plane (the space of complex lines through the origin in complex 3-space, a four-dimensional manifold), and a sphere of dimension 2 glued to one of dimension 4.

val cp2 = SimplicialSet.complexProjectivePlaneKuhnel          // a 9-vertex triangulation of the complex projective plane
val sphere4 = SimplicialSet.sphere(4)
val s2s4 = sphere.wedge(sphere.generators(0).head, sphere4, sphere4.generators(0).head)

betti(cp2)               // (Vector(1, 0, 1, 0, 1), Vector(1, 0, 1, 0, 1))
betti(s2s4)              // the same

def squareOfTheTwoClassIsSq2[G](x: FiniteSimplicialSet[G]): Boolean = isNonzero(x, 4, Steenrod.sq(x, 2, 2, basis(x, 2).head))
squareOfTheTwoClassIsSq2(cp2)    // true
squareOfTheTwoClassIsSq2(s2s4)   // false

Again, equal Betti numbers: one piece, one surface class in degree 2, one in degree 4. On the complex projective plane the generator of degree 2 squares to the generator of degree 4, Sq^2 of it is nonzero; on the sphere pair the two classes never meet and the square is zero. This is a classical fact, and here it is a one-line check.

On the real projective plane, Sq^1 of the degree-1 class is nonzero. (For a class of degree 1, Sq^1 is the cup square x ∪ x, so this says the generator of degree 1 squares to the generator of degree 2.)

val x = basis(projectivePlane, 1).head
isNonzero(projectivePlane, 2, Steenrod.sq(projectivePlane, 1, 1, x))    // true

Building spaces from other spaces

Constructions are what make this a calculator rather than a catalogue. The complex projective plane is not just something you can look up: it is what you get by gluing a four-dimensional disc onto a two-dimensional sphere along the Hopf map from the three-sphere, that is, the mapping cone of the Hopf map. SimplicialSet.hopfMap is a simplicial model of that map, and SSetMap.mappingCone does the gluing:

val hopfCone = SSetMap.mappingCone(SimplicialSet.hopfMap)
val hopfClass = basis(hopfCone, 2).head
isNonzero(hopfCone, 4, cup(hopfCone, 2, 2, hopfClass, hopfClass))    // true: it has the cohomology ring of the projective plane

So the construction really is the complex projective plane as far as cohomology is concerned: its degree-2 class squares to a nonzero class. A map with a constant image instead gives the sphere pair, with square zero. The Hopf map is exactly the map for which the square is nonzero.

The catalog SimplicialSet also has torus, realProjectiveSpace(n), horn, simplex and fromSimplicialComplex (for a space given as a list of top-dimensional simplices, such as a triangulated surface); every space has the methods cone, suspension, join, smash, wedge and product; and FundamentalGroup.presentation reads a presentation of the fundamental group back off a connected space: the inverse of presentationComplex. The user guide's Simplicial sets page tours all of it.

Limits

These are small, exact calculations on spaces with finitely many cells, over fields (the Betti numbers over p = 2 and p = 3 see torsion in the integer homology but do not compute it, and the Steenrod squares are implemented for p = 2 only). They have no command-line or MATLAB entry point, since a simplicial set needs its own input format. For the persistent version of the same idea, a filtration of such a space by subspaces, see persistent group cohomology.

The whole script

import scala.language.experimental.modularity
import org.appliedtopology.tda4j.TDAlab.F2.{*, given}  // a prebuilt lab: coefficients in Z/2

def betti[G](x: FiniteSimplicialSet[G]) = (x.bettiNumbers(2), x.bettiNumbers(3))
def basis[G](x: FiniteSimplicialSet[G], degree: Int) = CupProduct.cohomologyBasis[G, CoefficientT](x, degree)
def cup[G](x: FiniteSimplicialSet[G], p: Int, q: Int, a: Map[G, CoefficientT], b: Map[G, CoefficientT]) = CupProduct.cup(x, p, q, a, b)
def isNonzero[G](x: FiniteSimplicialSet[G], degree: Int, cochain: Map[G, CoefficientT]) = !CupProduct.isCoboundary(x, degree, cochain)

val torus = SimplicialSet.presentationComplex(2, Seq(List((0, 1), (1, 1), (0, -1), (1, -1))))
val projectivePlane = SimplicialSet.presentationComplex(1, Seq(List((0, 1), (0, 1))))
val kleinBottle = SimplicialSet.kleinBottle

val circle = SimplicialSet.sphere(1)
val twoCircles = circle.wedge(circle.generators(0).head, circle, circle.generators(0).head)
val sphere = SimplicialSet.sphere(2)
val notATorus = twoCircles.wedge(twoCircles.generators(0).head, sphere, sphere.generators(0).head)

def someProductOfOneClassesIsNonzero[G](x: FiniteSimplicialSet[G]): Boolean =
  val classes = basis(x, 1)
  classes.exists(a => classes.exists(b => isNonzero(x, 2, cup(x, 1, 1, a, b))))

val cp2 = SimplicialSet.complexProjectivePlaneKuhnel
val sphere4 = SimplicialSet.sphere(4)
val s2s4 = sphere.wedge(sphere.generators(0).head, sphere4, sphere4.generators(0).head)
def squareOfTheTwoClassIsSq2[G](x: FiniteSimplicialSet[G]): Boolean = isNonzero(x, 4, Steenrod.sq(x, 2, 2, basis(x, 2).head))

val x = basis(projectivePlane, 1).head
val sq1IsNonzero = isNonzero(projectivePlane, 2, Steenrod.sq(projectivePlane, 1, 1, x))

val hopfCone = SSetMap.mappingCone(SimplicialSet.hopfMap)
val hopfClass = basis(hopfCone, 2).head
val hopfSquareIsNonzero = isNonzero(hopfCone, 4, cup(hopfCone, 2, 2, hopfClass, hopfClass))

val results = (
  betti(torus), betti(notATorus), betti(projectivePlane), betti(kleinBottle), betti(cp2), betti(s2s4),
  someProductOfOneClassesIsNonzero(torus), someProductOfOneClassesIsNonzero(notATorus),
  squareOfTheTwoClassIsSq2(cp2), squareOfTheTwoClassIsSq2(s2s4), sq1IsNonzero, hopfSquareIsNonzero
)