Lecture 2-1: Paths

In Lecture 1-X, we saw that we could define types that represent equality in another inductive type, like Bool or . It would be tedious to have to define equality separately for every type (and worse, to check that every function preserves equality), and it would make proving general facts about equality difficult.

To resolve this issue, Cubical Type Theory takes a page from homotopy theory — the mathematical theory of continuous deformations of shapes. Classically, a homotopy is a continuous deformation of one object into another. In homotopy theory, we only care about the properties of objects which are unchanged by continuous deformation; so for the purposes of homotopy theory, to give a homotopy between objects is to say that they are the same, at least for all homotopy-theoretical purposes. In other words, to a homotopy theorist, a homotopy is a way to say two things are the same.

We will use this as inspiration for the concept of sameness that will apply to all types. Let’s see a bit more of the classical theory first, so we have something to ground our intuitions.

If are two continuous functions between spaces and (say, subsets of Euclidean space), then a homotopy between and is a function of two variables where and for all . The idea is that continuously transforms the function into the function as travels from to .

As we saw in Lecture 1-1, we can think of a function with two arguments as a function with one argument that gives back another function with one argument (via ×-curry and ×-uncurry). In this case, we can see a homotopy between and as a function into the space of continuous functions , so that and . In other words, a homotopy is a path in a function space, where by path we mean a continuous function out of the unit interval .

In general, if objects and are points that live in some space , then a homotopy between and is a path with and .

Aside: Before we begin: The following block lets us refer to some arbitrary types A, B, … and terms x : A, y : A, … without cluttering every definition with {A : Type} {B : Type}, and so on.

private
  variable
     ℓ' ℓ₂ ℓ₃ : Level
    A B C D : Type 
    x y : A

Paths

Let’s translate this into type theory. Agda provides a special “type” I to act as a version of the unit interval , equipped with two elements i0 : I and i1 : I, which act as the endpoints and . A path x ≡ y from x to y of type A will be a function h : I → A where h i0 is identical to x and h i1 is identical to y. Agda remembers the endpoints of a path, so that when we later evaluate the path at i0, we do get x exactly, and similarly for i1 and y.

Similarly to Level, the interval I is not actually a type, rather, we are just using it as a tool to describe our notion of sameness. For this reason, it and its endpoints get siloed in their own special universe.

_ : IUniv
_ = I

_ : I
_ = i0

_ : I
_ = i1

Aside: Above we make a “definition” with name _; this signals to Agda to check the type of what we provide, but then throw away the result. We will use this to demonstrate the type of some expression without having to invent a new name for it.

This prevents us from using all our usual type operations on I, which is good, since an element of I isn’t meant to be treated as a piece of data.

-- Uncomment these and try them out, if you want!
-- _ : Type
-- _ = I × I  -- error!

-- _ : Type
-- _ = Bool → I -- error!

However, since we want to discuss paths in any type, there is a special rule that for any actual type A : Type ℓ, functions I → A is also an actual type in Type ℓ.

_ : Type ℓ-zero
_ = I  Bool

Now we for paths with specified endpoints. For x and y, Agda provides a built-in type x ≡ y which is like a function I → A, but where the endpoints are known to be exactly x and y. That is, starting with p : x ≡ y, evaluating p i0 gives exactly x, and evaluating p i1 gives exactly y, regardless of what the definition of the path p actually is.

New paths x ≡ y in a type A are defined the same way that functions are: we assume we have an i : I, and then give an element of the type A. Agda will make sure that we indeed get x when i is i0 and y when i is i1.

The simplest path we can write down is the reflexivity path: for any element x, there is a constant path at x.

refl : {x : A}  x  x
refl {x = x} i = x

Interpreted as a statement about sameness, this means that x is the same as x — certainly a good start!

Even with only refl we are already able to prove some useful equalities. For example, The type Bool has the structure of a Boolean algebra, with respect to and and or. Here are some of the axioms:

or-idl : (x : Bool)  false or x  x
or-idl x = refl

or-idr : (x : Bool)  x or false  x
or-idr false = refl
or-idr true  = refl

or-comm : (x y : Bool)  x or y  y or x
or-comm false false = refl
or-comm false true  = refl
or-comm true  false = refl
or-comm true  true  = refl

or-LEM : (x : Bool)  x or (not x)  true
-- Exercise:
-- or-LEM x = {!!}

or-assoc : (x y z : Bool)  x or (y or z)  (x or y) or z
-- Exercise:
-- or-assoc x y z = {!!}

OK, that’s enough of that — it’s straightforward to keep going.

Aside: You can find all the laws for a Boolean algebra listed on Wikipedia, or you can peek ahead to Lecture 2-X and take all the laws for a De Morgan algebra (but where ∧ = and and ∨ = or and ~ = not) together with the “Law of Excluded Middle”: b or (not b).

Aside: The constructor has low precedence, so x or y ≡ y or x means (x or y) ≡ (y or x), and not something weird like x or (y ≡ y) or x.

A type of paths is a type like any other, so we can define functions that accept paths as arguments and produce paths as results. It is easy to show that any function sends equal inputs to equal outputs.

apⁿ : (f : A  B)
   x  y
   f x  f y
apⁿ f p i = f (p i)

Here, we are composing the function f with the “function” x ≡ y. When we plug in i = i0, we get that the left endpoint is f (p i0), which is f x, and when we plug in i = i1, we similarly get the right endpoint as f (p i1), i.e. f y. So indeed, this defines a path f x ≡ f y.

apⁿ-bin : (f : A  B  C) {a a' : A} {b b' : B}
   a  a'
   b  b'
   f a b  f a' b'
-- Exercise:
-- apⁿ-bin f p q = {!!}

apⁿ-∘ : (f : A  B) (g : B  C)
   (p : x  y)
   apⁿ (g  f) p  apⁿ g (apⁿ f p)
-- Exercise:
-- apⁿ-∘ f g p = {!!}

apⁿ is simple but useful. For example, we can re-prove some of the properties of addition (+ℕ-≡ℕ-idl, +ℕ-≡ℕ-idr, +ℕ-≡ℕ-assoc) using our new notion of equality. In each recursive step, you will have to use apⁿ to convert a path n ≡ m to a path suc n ≡ suc m. (We didn’t have to do that previously, because suc n ≡ℕ suc m was defined to be n ≡ℕ m. Win some lose some!)

+ℕ-idl : (n : )  (zero +ℕ n)  n
-- Exercise:
-- +ℕ-idl n = {!!}

+ℕ-idr : (n : )  (n +ℕ zero)  n
-- Exercise:
-- +ℕ-idr n = {!!}

+ℕ-assoc : (n m k : )  (n +ℕ (m +ℕ k))  ((n +ℕ m) +ℕ k)
-- Exercise:
-- +ℕ-assoc n m k = {!!}

Another use is to show that the constructors for inductive types are injective. If the same constructor is used on both endpoints of a path, we can peel that constructor off.

suc-inj : {x y : }  suc x  suc y  x  y
-- Exercise: (Hint: use predℕ!)
-- suc-inj p = {!!}

inl-inj : {x y : A}  Path (A  B) (inl x) (inl y)  x  y
inl-inj {A = A} {x = x} p = apⁿ uninl p
  where
    uninl : A  B  A
    uninl (inl a) = a
    uninl (inr _) = x

inr-inj : {x y : B}  Path (A  B) (inr x) (inr y)  x  y
-- Exercise:
-- inr-inj {B = B} {x = x} p = {!!}

Here we use the alternative name Path to construct the type of paths rather than so that we can specify the type that the path exists in. If we just write inl x ≡ inl y then Agda can see that the left side of has to be A because x : A and y : A, but it has no way of knowing that the right side should be B!

Inductive Types with Path Constructors

How can we get our hands on some more interesting paths? One way is to define inductive types that have path constructors. (These are often called “Higher Inductive Types” or HITs.)

Our first use of a path constructor is a more symmetrical version of the integers. Remember that the definition of we gave back in Lecture 1-X is a little janky — we have to treat the negative integers and the positive integers asymmetrically, assigning zero to the pos side and shifting the negsuc side down by one. Now that we have paths, we can define a symmetric version of the integers as two copies of the the natural numbers, one for the positive integers and one for the negative — as long as we add in a path between “positive 0” and “negative 0” to make them the same!

data ℤˢ : Type where
  posˢ :   ℤˢ
  negˢ :   ℤˢ
  zeroˢ≡ : posˢ zero  negˢ zero

Arithmetic using these integers is easier to reason about than the version involving negsuc. First, here’s the successor function, which you should compare to sucℤ.

sucℤˢ : ℤˢ  ℤˢ
sucℤˢ (posˢ x) = posˢ (suc x)
sucℤˢ (negˢ zero) = posˢ (suc zero)
sucℤˢ (negˢ (suc x)) = negˢ x
sucℤˢ (zeroˢ≡ i) = posˢ (suc zero)

On positive integers, we use the ordinary successor of the enclosed natural number. On negative integers, we check if the natural number is zero, and if so, give back positive one, and use the enclosed predecessor otherwise.

Notice that we have defined what the function does on zero twice! Once as posˢ zero, and again as negˢ zero. The final case for the path constructor zeroˢ≡ forces us to demonstrate that we give the same answer both times. And indeed we do, so that final case can be defined by the constant path at posˢ (suc zero).

It is easy to convert between these integers and the original ones.

ℤˢ→ℤ : ℤˢ  
-- Exercise:
-- ℤˢ→ℤ z = {!!}

ℤ→ℤˢ :   ℤˢ
-- Exercise:
-- ℤ→ℤˢ z = {!!}

Complete the definition of addition.

predℤˢ : ℤˢ  ℤˢ
-- Exercise:
-- predℤˢ z = {!!}

_+ℤˢ_ : ℤˢ  ℤˢ  ℤˢ
-- Exercise:
-- m +ℤˢ n = {!!}

The simplest non-trivial type involving a path constructor is the circle , so named because it contains a point base and a path loop which goes from base to base, forming a circle.

data  : Type where
  base : 
  loop : base  base

There’s not a huge amount we can do with without technology from later lectures, but we can at least describe its recursion principle. The recursion principle for the circle states that to produce a function S¹ → A for any type A, we need to specify a point a : A, and a loop l : a ≡ a starting and ending at that point. In other words, to produce a function S¹ → A, we just need to draw a circle in the type A.

S¹-rec : {A : Type }
   (a : A)
   a  a
     A
S¹-rec a l base = a
S¹-rec a l (loop i) = l i

You may be able to guess that this forms part of a universal mapping property. Not only can we build a function S¹ → A out of a point and a loop in A, but we can go back.

S¹-ump-fro : {A : Type }
   (  A)
   Σ[ a  A ] a  a
-- Exercise:
-- S¹-ump-fro f = {!!}

Paths in Pair and Function Types

Now we can ask what paths look like in various types. Inductive data types (like Bool) will be covered in detail in Lecture 2-X. Let’s begin with something easier: what is a path in a pair type? It’s a pair of paths.

To prove these, remember that any path in A is secretly a function I → A so, ignoring the endpoints, the first exercise is asking for a function (I → A) × (I → B) → (I → A × B). It should be easy to come up with such a function, and it turns out that this obvious definition has the correct endpoints.

×≡→≡× : {x y : A × B}  (x .fst  y .fst) × (x .snd  y .snd)  x  y
-- Exercise:
-- ×≡→≡× p = {!!}

≡×→×≡ : {x y : A × B}  x  y  (x .fst  y .fst) × (x .snd  y .snd)
-- Exercise:
-- ≡×→×≡ p = {!!}

Similarly, what is a path in a function type? It is a function landing in paths! This is the principle of “function extensionality”: to say that f is the same as g means that, for all x, f x is the same as g x.

mvrnote: relate back to homotopy discussion

funextˢ : {f g : A  B}
   ((x : A)  f x  g x)
   f  g
-- Exercise:
-- funextˢ f = {!!}

funextˢ⁻ : {f g : A  B}
   f  g
   ((x : A)  f x  g x)
-- Exercise:
-- funextˢ⁻ p = {!!}

This works for functions with any number of arguments:

funext2 : {f g : A  B  C}
       (p : (x : A) (y : B)  f x y  g x y)
        f  g
-- Exercise:
-- funext2 p i x y = {!!}

Try using funext to prove some unsurprising facts about function composition:

∘-assoc : (h : C  D)
         (g : B  C)
         (f : A  B)
         (h  g)  f  h  (g  f)
-- Exercise:
-- ∘-assoc h g f = {!!}

∘-idl : (f : A  B)  idfun  f  f
-- Exercise:
-- ∘-idl f = {!!}

∘-idr : (f : A  B)  f  idfun  f
-- Exercise:
-- ∘-idr f i x = {!!}

Paths over Paths

A path in a type A is a function p : I → A with fixed endpoints x : A and y : A. But what if A is itself a path of types A : I → Type? Then we consider dependent functions p : (i : I) → A i with fixed endpoints x : A i0 and y : A i; these are called “paths over the path A”, or sometimes simply “path-overs”. The name for this in Agda is PathP, for “Path (over) P(ath)”. This is another built-in notion, like .

_ : (A : I  Type) (x : A i0) (y : A i1)  Type
_ = PathP

Similarly to paths, if we have p : PathP A x y, then p i0 is x and p i1 is y always, regardless of the actual definition of p.

In fact, the type x ≡ y is defined in terms of PathP, where the path of types happens to the constant path at the type A. This is just like non-dependent functions A → B are exactly dependent functions (x : A) → B, where B happens to be a constant type and not depend on x.

≡-again : (A : Type) (x : A) (y : A)  Type
-- Exercise: (easy)
-- ≡-again A x y = {!!}

We can upgrade apⁿ to apply to dependent functions:

-- Exercise:
-- ap : {B : A → Type ℓ₂} 
--   → {x y : A}
--   → (f : (a : A) → B a)
--   → (p : x ≡ y)
--   → PathP {!!} {!!} {!!}

ap f p i = f (p i)

-- Exercise:
-- ap-bin :
--   {B : A → Type ℓ₂}
--   {C : (x : A) → B x → Type ℓ₃}
--   (f : (x : A) → (y : B x) → C x y)
--   {a a' : A} {b : B a} {b' : B a'}
--   → (p : a ≡ a')
--   → (q : PathP {!!} {!!} {!!})
--   → PathP {!!} {!!} {!!}

ap-bin f p q i = f (p i) (q i)

Let’s return to paths in pair types, but look at dependent pairs. There are actually two places dependency could show up here. The first is the obvious one, when B depends on A. The definitions are the same as in the non-dependent case, so try to fill in the parameters to the PathP type.

Aside: Here we are going to use an “anonymous module”, to collect the parameters A, B, x and y that are identical between the two exercises ΣPathP→PathPΣ’ and PathPΣ→ΣPathP’.

module _ {A : Type } {B : A  Type ℓ₂}
  {x y : Σ[ a  A ] B a}
  where

  -- Exercise:
  -- ΣPathP→PathPΣ' : Σ[ p ∈ (fst x ≡ fst y) ] PathP {!!} {!!} {!!}
  --        → x ≡ y

  ΣPathP→PathPΣ' eq i .fst = eq .fst i
  ΣPathP→PathPΣ' eq i .snd = eq .snd i

  -- Exercise:
  -- PathPΣ→ΣPathP' : x ≡ y
  --        → Σ[ p ∈ (fst x ≡ fst y) ] PathP {!!} {!!} {!!}

  PathPΣ→ΣPathP' eq .fst i = eq i .fst
  PathPΣ→ΣPathP' eq .snd i = eq i .snd

There is a second possible notion of dependency: it could be that the types A and B themselves are paths of types, that is, they depend on an element of I. Again, the actual definitions are identical; but their types become more powerful.

module _ {A : I  Type } {B : (i : I)  A i  Type ℓ₂}
  {x : Σ[ a  A i0 ] B i0 a} {y : Σ[ a  A i1 ] B i1 a}
  where

  -- Exercise:
  -- ΣPathP→PathPΣ : Σ[ p ∈ PathP {!!} {!!} {!!} ] PathP {!!} {!!} {!!}
  --        → PathP (λ i → Σ[ a ∈ A i ] B i a) x y

  ΣPathP→PathPΣ p i .fst = p .fst i
  ΣPathP→PathPΣ p i .snd = p .snd i

  -- Exercise:
  -- PathPΣ→ΣPathP : PathP (λ i → Σ[ a ∈ A i ] B i a) x y
  --        → Σ[ p ∈ PathP {!!} {!!} {!!} ] PathP {!!} {!!} {!!}

  PathPΣ→ΣPathP p .fst i = p i .fst
  PathPΣ→ΣPathP p .snd i = p i .snd

And now dependent functions. Similarly to what we have just seen for Σ-types, there are lots of ways to add dependency to the arguments of function extensionality. The most obvious is to allow B to depend on A, not involving the cubical interval:

module _ {A : Type } {B : A  Type ℓ₂}
  {f g : (a : A)  B a}
  where
  funext : ((x : A)  (f x  g x))  f  g
  funext h i x = h x i
  
  funext⁻ : f  g  ((x : A)  (f x  g x))
  funext⁻ p x i = p i x

mvrnote:

module _ {A : Type } {B : I  Type ℓ'}
  {f : A  B i0} {g : A  B i1}
  where
  funextP : 
      ((x : A)  PathP B (f x) (g x))
     PathP  i  A  B i) f g
  funextP h i x = h x i
  
  funextP⁻ : 
      PathP  i  A  B i) f g
     ((x : A)  PathP B (f x) (g x))
  funextP⁻ p x i = p i x

Path-overs are also what is required to describe the induction principle of the circle; the upgraded version of S¹-rec for dependent functions. If we have a type family A : S¹ → Type over the circle rather than just a single type, the provided point must be an element of the type family at base, and the loop is a path from that point to itself, lying over the path of types A ∘ loop.

S¹-ind-≡ : {A :   Type }
        (a : A base)
        PathP  i  A (loop i)) a a
        (s : )  A s
S¹-ind-≡ a l base = a
S¹-ind-≡ a l (loop i) = l i

The input l : PathP (λ i → A (loop i)) a a involves a path λ i → A (loop i) from A base to itself, that is, a path between two types. Right now we have no way of producing interesting paths between types, but univalence will come to the rescue in Lecture 2-X.

Squares

A path in a type of paths is a function with shape a : I → (I → A). This is equivalent (again via something like ×-curry) to a function I × I → A, and we can therefore think of paths-between-paths as functions of two interval variables i and j. Though we can’t use the elements of I as data and so don’t let ourselves actually form the type I × I, we can nevertheless think of a function of two interval variables corresponding to a square.

         a-1
   a01 — — — > a11
    ^           ^             ^
a0- |           | a1-       j |
    |           |             ∙ — >
   a00 — — — > a10              i
         a-0

We will see a square like this as a path from the left side to the right side, that is, as a path between the paths a0- and a1-. However, these don’t have the same type; observing the endpoints, we see that a0- : a00 ≡ a01 and a1- : a10 ≡ a11. Using the other two paths a-0 and a-1, we may construct a path of types that continuously transforms from the type of a0- to the type of a1-, as we sweep from left to right.

Square-sweep : {A : Type } {a00 a01 a10 a11 : A}
   (a-0 : a00  a10) (a-1 : a01  a11)
   (I  Type )
Square-sweep a-0 a-1 i = a-0 i  a-1 i

Plugging in the endpoints of I, we indeed see that

  • (Square-sweep a-0 a-1 i0) = (a00 ≡ a01) and
  • (Square-sweep a-0 a-1 i1) = (a10 ≡ a11)

by definition.

We want to say that the square is somehow an element of this continuously varying path type. With PathP, we can do exactly this, and define the type of squares as paths over the continuously varying path Square-sweep:

Square : {A : Type } {a00 a01 a10 a11 : A}
   (a0- : a00  a01)
   (a1- : a10  a11)
   (a-0 : a00  a10)
   (a-1 : a01  a11)
   Type 
Square a0- a1- a-0 a-1 = PathP (Square-sweep a-0 a-1) a0- a1-

Here’s the picture again, for you to inspect:

         a-1
   a01 — — — > a11
    ^           ^             ^
a0- |           | a1-       j |
    |           |             ∙ — >
   a00 — — — > a10              i
         a-0

mvrnote: Square p q refl refl is the same as p ≡ q

Elements of the Square A type are squares exist in a constant type A. But just as we can upgrade Path to PathP where the type A can vary over the path, we can upgrade Square to SquareP where the type can vary over the square.

We will fill in the definition, but try filling in the types of the sides of the square.

-- Exercise:
-- SquareP :
--   (A : I → I → Type ℓ)
--   {a₀₀ : A i0 i0} {a₀₁ : A i0 i1} {a₁₀ : A i1 i0} {a₁₁ : A i1 i1}
--   (a₀- : {!!})
--   (a₁- : {!!})
--   (a-₀ : {!!})
--   (a-₁ : {!!})
--   → Type ℓ

SquareP A a₀- a₁- a-₀ a-₁
  = PathP  i  PathP  j  A i j) (a-₀ i) (a-₁ i))
          a₀-
          a₁-

For some practice thinking with squares, write a version of ap that applies to squares.

ap-Square : (f : A  B)
   {a₀₀ a₀₁ a₁₀ a₁₁ : A}
   {a₀- : Path A a₀₀ a₀₁}
   {a₁- : Path A a₁₀ a₁₁}
   {a-₀ : Path A a₀₀ a₁₀}
   {a-₁ : Path A a₀₁ a₁₁}
   Square a₀- a₁- a-₀ a-₁
   Square (ap f a₀-) (ap f a₁-) (ap f a-₀) (ap f a-₁)
-- Exercise:
-- ap-Square f s = {!!}

Next, write down the function that flips a square along the diagonal:

         a-1                           a1-
   a01 — — — > a11               a10 — — — > a11
    ^           ^                 ^           ^
a0- |           | a1-   ~~>   a-0 |           | a-1
    |           |                 |           |
   a00 — — — > a10               a00 — — — > a01
         a-0                           a0-
flipSquare : {a₀₀ a₀₁ a₁₀ a₁₁ : A }
   {a₀- : Path A a₀₀ a₀₁}
   {a₁- : Path A a₁₀ a₁₁}
   {a-₀ : Path A a₀₀ a₁₀}
   {a-₁ : Path A a₀₁ a₁₁}
   Square a₀- a₁- a-₀ a-₁
   Square a-₀ a-₁ a₀- a₁-
-- Exercise:
-- flipSquare s = {!!}

Once you’ve figured this out, try to define a similar function flipSquareP, where the type now varies over the square. Here, the trick is not so much the definition itself — it will be the same as flipSquare — but rather the type.

flipSquareP : 
  (A : I  I  Type )
  {a₀₀ : A i0 i0} {a₀₁ : A i0 i1} {a₁₀ : A i1 i0} {a₁₁ : A i1 i1}
  {a₀- : PathP  j  A i0 j) a₀₀ a₀₁}
  {a₁- : PathP  j  A i1 j) a₁₀ a₁₁}
  {a-₀ : PathP  i  A i i0) a₀₀ a₁₀}
  {a-₁ : PathP  i  A i i1) a₀₁ a₁₁}
  -- Exercise:
  -- → SquareP {!!} a₀- a₁- a-₀ a-₁
  -- → SquareP {!!} a-₀ a-₁ a₀- a₁-

flipSquareP A s = λ i j  s j i

Any homotopy between functions is automatically “natural”, in the following sense. Say we have a homotopy between f and g, that is, a function H : (x : A) → (f x ≡ g x). If we have a path p : x ≡ y in A, then there are two ways we could get from f x to g y: the two ways of going around the following square:

            H y
      f y — — — > g y
       ^           ^             ^
ap f p |           | ap g p    j |
       |           |             ∙ — >
      f x — — — > g x              i
            H x

mvrnote: illustration

It is not hard to produce a square that shows these are in fact equal, as paths in B:

homotopy-natural : {f g : A  B}
   (H : (x : A)  (f x  g x))
   {x y : A}
   (p : x  y)
   Square (ap f p) (ap g p) (H x) (H y)
-- Exercise:
-- homotopy-natural H p k i = {!!}

mvnrote: delete this? And this even works in higher dimensions. We could define a “Cube” type to use here, but as a one-off just writing the PathP manually will do.

homotopy-natural-cube : {f g : A  B}
   (H : (x : A)  (f x  g x))
   {a b c d : A}
   {r : a  c} {s : b  d}
   {t : a  b} {u : c  d}
   (sq : Square t u r s)
   PathP  k  Square (homotopy-natural H t k) 
                        (homotopy-natural H u k) 
                        (homotopy-natural H r k) 
                        (homotopy-natural H s k))
    (ap-Square f sq) 
    (ap-Square g sq)
homotopy-natural-cube H sq k i j = H (sq i j) k

Higher Path Constructors

Inductive types can also contain path-of-path constructors. Here’s a nice example: the torus, which consists a basepoint, two circles connected to that basepoint, and a square region with sides as follows:

         line1
      pt — — — > pt
      ^           ^
      |           |                 ^
line2 |           | line2         j |
      |           |                 ∙ — >
      pt — — — > pt                   i
         line1

mvrnote: this will really need some pictures

data Torus : Type where
  torus-base  : Torus
  torus-loop1  : torus-base  torus-base
  torus-loop2  : torus-base  torus-base
  torus-square : Square torus-loop2 torus-loop2 torus-loop1 torus-loop1

Topologically, the torus is equal to the cartesian product of two circles. We can prove this directly! mvrnote: refer to the picture

Torus→S¹×S¹ : Torus   × 
-- Exercise:
-- Torus→S¹×S¹ t = {!!}

S¹×S¹→Torus :  ×   Torus
-- Exercise:
-- S¹×S¹→Torus c = {!!}

References and Further Reading