# Solving Advent of Code 2020 day 17 by not solving it

... in which we take a look at the day 17 problem from Advent of Code 2020, Conway Cubes. We will end up solving it eventually, but in a very roundabout way; you will see why shortly.

Along the way, we will:

• test and refactor,
• avoid hard problems, and
• write some idiomatic Python, from scratch.

There are both shorter and faster ways of solving this, but we'll take the scenic route, in a way that helps you translate those skills to your regular, non-puzzle coding.

You can read this without having solved the problem, but to get the most from it, I recommend you do it on your own first, and then follow along as we do it again here.

Contents

## The problem #

The problem might look familiar (if you didn't read it yet, go do that first):

• we have a grid of cells that can be either active or inactive
• cells change state at each step in time depending on their neighbors

These things are called cellular automata, the most famous of which is probably Conway's Game of Life. Our problem seems to have the same rules as Life:

• any active cell with 2 or 3 active neighbors remains active
• any inactive cell with 3 active neighbors becomes active
• all other cells become/remain inactive

... except the grid is 3-dimensional.

In part two of the problem, the grid is 4-dimensional.

You might notice that this is relatively hard to visualize: the example output has 5 layers after 3 cycles in 3 dimensions, and 25 layers after only 2 cycles in 4 dimensions! Understanding what's happening in 4D seems difficult even assuming the number of planes remains 25 for future iterations.1

The normal flow of solving the problem would be something like this:

• implement the 3D version; then,
• after seeing the second part, either
• do 4D in a similar fashion, or
• generalize 3D to an arbitrary number of dimensions.

We'll do the opposite: implement 2D, which is trivial to check by hand, and once we have some tests, generalize that.

## Parsing the input #

First, let's parse the input.

We'll transform the input string into a list of lists of integers, where 0 and 1 represent inactive and active cubes, respectively. For the test input, it should look like this:

``````[[0, 1, 0], [0, 0, 1], [1, 1, 1]]
``````

Using numbers separates concerns, but also allows for nicer code: we can write `if active` instead of `if active == '#'`, and `sum(cubes)` instead of `sum(active == '#' for active in cubes)`.

 ``````1 2 3 4 5 6 7 8`````` ``````CHARACTERS = '.#' CHR_TO_NUM = {c: i for i, c in enumerate(CHARACTERS)} def parse_input(input): return [ [CHR_TO_NUM[c] for c in line] for line in input.splitlines() ] ``````

Later on, we'll need to turn that back into a string; let's take care of it now:

 ``````10 11 12 13 14 15 16`````` ``````NUM_TO_CHR = dict(enumerate(CHARACTERS)) def format_2d(plane): return ''.join( ''.join(NUM_TO_CHR[n] for n in line) + '\n' for line in plane ) ``````

We're not bothering to minimize the output like the example does (hide empty rows/columns), since we won't be looking at more than one layer per cycle anyway.

Having both functions, we can write a basic round-trip test:

 ``````18 19 20 21 22 23 24`````` ``````TEST_INPUT = """\ .#. ..# ### """ assert format_2d(parse_input(TEST_INPUT)) == TEST_INPUT ``````

## The world #

There's more than one way to represent the world, but most straightforward should be the same we did the input, as a nested list of integers.

The grid is supposed to be infinite; there are no infinite lists in Python, so we have to cheat: we'll make a big enough world, and check if we've reached the edges.

For now, 8 cubes ought to be enough for anyone:

 ``````27 28 29`````` ``````x_size = y_size = 8 world = [ * x_size for _ in range(y_size)] ``````

`[ * x_size] * y_size` won't work, because the outer list will contain the (same) inner list y_size times:

``````>>> l = [ * 3] * 3
>>> l
[[0, 0, 0], [0, 0, 0], [0, 0, 0]]
>>> l = 1
>>> l
[[1, 0, 0], [1, 0, 0], [1, 0, 0]]
``````

We'll place the input in the middle, to give it room to grow in all directions:

 ``````32 33 34 35 36 37 38 39 40`````` ``````input = parse_input(TEST_INPUT) x_offset = (x_size - len(input)) // 2 y_offset = (y_size - len(input)) // 2 for y, line in enumerate(input): for x, active in enumerate(line): world[y + y_offset][x + x_offset] = active print(format_2d(world)) ``````

At this point, the script should output this:

``````........
........
...#....
....#...
..###...
........
........
........
``````

## Simulation #

Let's simulate one cycle.

There are many optimizations to this, but again, we'll do the straightforward thing: go through each cell, count its neighbors, and change its state according to the rules.

The cube state changes simultaneously for all cubes, so we cannot update the world in-place: given two neighboring cubes updated in order, the first cube's old state will be gone when we get to the second one. To avoid this, we will use two worlds, always reading from the old world and changing the new one.

We'll put the logic in a function that takes the two worlds as arguments:

 ``27`` ``````def simulate(old, new): ``````

Going through each cell:

 ``````28 29`````` `````` for y, line in enumerate(old): for x, active in enumerate(line): ``````

The cell's neighbors are all the cells in a 3x3 square centered on it, except the cell itself:

 ``````31 32 33 34 35 36 37 38 39`````` `````` active_neighbors = 0 for i in -1, 0, 1: for j in -1, 0, 1: if i == j == 0: continue nx = x + i ny = y + j ``````

`old[ny][nx]` should now get us the neighbor's state; however, there are a couple of (literal) edge cases:

• The current cell has one of the coordinates 0 (is on the top or left edge). Due to how list indexing works, the neighbors to the top/left (coordinate -1) will be cells from the opposite edge of the world. We don't want this: the world is supposed to be infinite, not wrap around.
• The current cell has one of the coordinates `size - 1` (is on the bottom or right edge). We'll get an IndexError when trying to get the bottom/right neighbor.

In both cases, we want to know when the current cube is active, since it's possible one of its out of bounds neighbors should become active:

 ``````41 42 43 44 45 46 47 48 49 50 51`````` `````` if nx < 0 or ny < 0: if active: raise RuntimeError(f"active on edge: {(x, y)}") continue try: active_neighbors += old[ny][nx] except IndexError: if active: raise RuntimeError(f"active on edge: {(x, y)}") continue ``````

For now, the exceptions will remain unhandled, and we'll make the world bigger by hand when required. We could do something else, like catch the exception, scale the world by some constant factor, and continue simulating.

Having that, we set the cell's new state according to the rules:

 ``````53 54 55 56 57 58`````` `````` if active: new_active = int(2 <= active_neighbors <= 3) else: new_active = int(active_neighbors == 3) new[y][x] = new_active ``````

Let's call that 6 times. Instead instead of creating a new world on each iteration, we can reuse the old one from the previous iteration, since we'd be throwing it away anyway.

 ``````77 78 79 80 81 82 83 84`````` ``````old = world new = [ * x_size for _ in range(y_size)] for cycle in range(6): simulate(old, new) new, old = old, new print(f"after cycle #{cycle + 1}") print(format_2d(old)) ``````
Running the script should output this:
``````........
........
...#....
....#...
..###...
........
........
........

after cycle #1
........
........
........
..#.#...
...##...
...#....
........
........

after cycle #2
........
........
........
....#...
..#.#...
...##...
........
........

after cycle #3
........
........
........
...#....
....##..
...##...
........
........

after cycle #4
........
........
........
....#...
.....#..
...###..
........
........

after cycle #5
........
........
........
........
...#.#..
....##..
....#...
........

after cycle #6
........
........
........
........
.....#..
...#.#..
....##..
........
``````

A quick visual inspection confirms our simulation is correct.

You may notice that after 4 cycles, we have the initial pattern, but moved one cell to the bottom and right; this pattern is called a glider.

To wrap up, we'll calculate the puzzle answer (the number of cubes left in the active state after the sixth cycle), and add a test to make sure we won't break anything later on:

 `````` 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102`````` ``````world = old active_cubes = sum(active for line in world for active in line) print("the result is", active_cubes) assert active_cubes == 5 assert world == parse_input("""\ ........ ........ ........ ........ .....#.. ...#.#.. ....##.. ........ """) ``````

The script up to this point.

## Cleaning up #

Now that our 2D implementation is correct, we can make it more general.

We won't do that straight away, though; to make it possible to call in multiple ways and generalize incrementally, we'll split the script into functions.

Throughout these changes, our test suite should keep passing.

While working on the script, I used entr to re-run it automatically on every save:

``````echo conway_cubes.py | entr -rc python3 conway_cubes.py
``````

Your editor may have have a "save and run" function; if it doesn't, entr is a nifty editor-independent way of achieving the same thing.

First, let's extract the neighbor-counting logic:

 ``````27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50`````` ``````def get_active_neighbors(world, active, x, y): active_neighbors = 0 for i in -1, 0, 1: for j in -1, 0, 1: if i == j == 0: continue nx = x + i ny = y + j if nx < 0 or ny < 0: if active: raise RuntimeError(f"active on edge: {(x, y)}") continue try: active_neighbors += world[ny][nx] except IndexError: if active: raise RuntimeError(f"active on edge: {(x, y)}") continue return active_neighbors ``````
 ``````53 54 55 56 57`````` ``````def simulate(old, new): for y, line in enumerate(old): for x, active in enumerate(line): active_neighbors = get_active_neighbors(old, active, x, y) # ... ``````

Then, the construction of worlds:

 ``````66 67`````` ``````def make_world(x, y): return [ * x for _ in range(y)] ``````
 ``72`` ``````world = make_world(x_size, y_size) ``````
 ``87`` ``````new = make_world(x_size, y_size) ``````

Then, the input copying:

 ``````70 71 72 73 74 75`````` ``````def copy_centered_2d(src, dst): y_offset = (len(dst) - len(src)) // 2 x_offset = (len(dst) - len(src)) // 2 for y, line in enumerate(src): for x, point in enumerate(line): dst[y + y_offset][x + x_offset] = point ``````
 ``84`` ``````copy_centered_2d(input, world) ``````

Finally, let's wrap the whole solution in a function.

Again, to separate logic from presentation, we'll split this into a generator that yields new states, forever; it yields the initial state first for convenience:

 ``````78 79 80 81 82 83 84 85 86 87 88`````` ``````def simulate_forever(size, input): old = make_world(size, size) new = make_world(size, size) copy_centered_2d(input, old) yield old while True: simulate(old, new) old, new = new, old yield old ``````

... and a function that drives it for 6 cycles, printing the resulting worlds.

Since we want to use the function for more dimensions, but don't want to implement formatting for anything except 2D, we'll make printing optional through the magic of dependency injection.

Also, to keep the tests working, we'll return the final state and the number of active cubes.

 `````` 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109`````` ``````def run(size, input, format_world=None): worlds = simulate_forever(size, input) for cycle, world in zip(range(6 + 1), worlds): print(f"after cycle #{cycle}: ", end='') if format_world: print() print(format_world(world)) else: print('...') active_cubes = sum(active for line in world for active in line) print("the result is", active_cubes) print() return world, active_cubes world, active_cubes = run(8, parse_input(TEST_INPUT), format_world=format_2d) ``````

The script up to this point.

## Going multidimensional #

OK, we're ready now: let's follow `simulate_forever` and make it work with N dimensions.

First, we'll fix `make_world`.

Instead of one size argument per dimension, it will take a single argument, a tuple of sizes. Using `size` or `sizes` as the name might be confusing (since they are different kinds of sizes); how about we call it `shape`?

To handle the arbitrary number of dimensions, `make_world` will be recursive:

 ``````66 67 68 69 70`````` ``````def make_world(shape): head, *rest = shape if not rest: return  * head return [make_world(rest) for _ in range(head)] ``````

We now need to bubble this change up to `run`. For convenience, it will keep its `size`, but get a new `dimensions` argument, so we can have an n-dimensional "square" world.

 ``````81 82 83 84`````` ``````def simulate_forever(shape, input): old = make_world(shape) new = make_world(shape) # ... ``````
 ``````94 95 96`````` ``````def run(size, dimensions, input, format_world=None): worlds = simulate_forever((size,) * dimensions, input) # ... ``````
 ``112`` ``````world, active_cubes = run(8, 2, parse_input(TEST_INPUT), format_world=format_2d) ``````

Next, input copying. For 2D, the destination was the world itself; for higher dimensions, the destination is the (2D) plane in the middle of each higher dimension:

 ``````81 82 83 84 85 86 87 88 89 90`````` ``````def simulate_forever(shape, input): old = make_world(shape) new = make_world(shape) copy_dst = old for n in shape[:-2]: copy_dst = copy_dst[(n - 1) // 2] copy_centered_2d(input, copy_dst) # ... ``````

Continuing with `simulate`. We need a generic version of the nested loop that goes over all the cells. We'll write a version of enumerate that works with nested lists, and instead of i, value pairs, yields (..., k, j, i), value pairs:

 ``````53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68`````` ``````def ndenumerate(world, dimensions=None): if dimensions is None: dimensions = 0 temp = world while isinstance(temp, list): dimensions += 1 temp = temp if dimensions == 1: for i, value in enumerate(world): yield (i,), value return for i, part in enumerate(world): for t, value in ndenumerate(part, dimensions - 1): yield (i,) + t, value ``````

You may notice we stole the function name from numpy. In the first draft of this article it was called `coord_enumerate` and it generated ..., k, j, i, value tuples, but I changed it to make things easier for people already familiar with numpy.

Unlike with numpy arrays, we have to infer the number of dimensions, since our world doesn't have an explicit shape.

Also unlike the numpy version, ours only works with lists; to make it work with other sequences, we could use something like:

``````isinstance(temp, collections.abc.Sequence)
``````

We'll leave `get_active_neighbors` last, and finish `simulate` first:

 ``````71 72 73 74 75 76 77 78 79 80 81 82 83`````` ``````def simulate(old, new): for coords, active in ndenumerate(old): active_neighbors = get_active_neighbors(old, active, *reversed(coords)) if active: new_active = int(2 <= active_neighbors <= 3) else: new_active = int(active_neighbors == 3) target = new for coord in coords[:-1]: target = target[coord] target[coords[-1]] = new_active ``````

`run` also goes through all the cubes to count the active ones, so we'll change it to use `ndenumerate` as well:

 ``128`` `````` active_cubes = sum(active for _, active in ndenumerate(world)) ``````

Finally, let's do `get_active_neighbors`.

First, we need to flatten the loop that generates neighbor coordinates.

We could use recursion, like we did with `make_world` and `ndenumerate`, but the standard library already has a tool for this: itertools.product generates the cartesian product of some iterables; even better, it can generate the product of an iterable with itself.

 ``````1 2 3 4`````` ``````from itertools import product # ... ``````
 ``````30 31 32`````` ``````def make_directions(dimensions): ts = product((-1, 0, 1), repeat=dimensions) return [t for t in ts if t != (0,) * dimensions] ``````

Even if itertools.product did not have a `repeat` argument, we could still emulate it with argument unpacking:

``````product(* [(-1, 0, 1)] * dimensions)
``````

After using `make_directions`, and making the neighbor counting parts generic as well, `get_active_neighbors` becomes:

 ``````35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60`````` ``````def get_active_neighbors(world, active, coords): active_neighbors = 0 for offsets in make_directions(len(coords)): neighbor_coords = [ coord + offset for coord, offset in zip(coords, offsets) ] if any(coord < 0 for coord in neighbor_coords): if active: raise RuntimeError(f"active on edge: {coords}") continue try: neighbor = world for coord in neighbor_coords: neighbor = neighbor[coord] except IndexError: if active: raise RuntimeError(f"active on edge: {coords}") continue active_neighbors += neighbor return active_neighbors ``````

The last remaining thing is to fix its call site in `simulate`:

 ``83`` `````` active_neighbors = get_active_neighbors(old, active, coords) ``````

We're done!

## 3D #

After 6 cycles with the test input, there should be 112 active cubes:

 ``````159 160`````` ``````_, active_cubes = run(8, 3, parse_input(TEST_INPUT)) assert active_cubes == 112 ``````

It turns out 8 cubes is not enough:

``````after cycle #0: ...
after cycle #1: ...
after cycle #2: ...
after cycle #3: ...
Traceback (most recent call last):
File "conway_cubes.py", line 161, in <module>
_, active_cubes = run(8, 3, parse_input(TEST_INPUT))
File "conway_cubes.py", line 131, in run
for cycle, world in zip(range(6 + 1), worlds):
File "conway_cubes.py", line 123, in simulate_forever
simulate(old, new)
File "conway_cubes.py", line 84, in simulate
active_neighbors = get_active_neighbors(old, active, coords)
File "conway_cubes.py", line 47, in get_active_neighbors
raise RuntimeError(f"active on edge: {coords}")
RuntimeError: active on edge: (2, 3, 0)
``````

Good thing we're checking for that. Bumping the world size to 16 fixes it:

``````after cycle #0: ...
after cycle #1: ...
after cycle #2: ...
after cycle #3: ...
after cycle #4: ...
after cycle #5: ...
after cycle #6: ...
the result is 112
``````

Time to plug the puzzle input; I assume they are different for everyone, so feel free to use yours instead.

At first I got another `active on edge` error; since running with the test input already takes about a second, I increased the world size by smaller increments instead of doubling it; 20 is enough for my input:

 ``````162 163 164 165 166 167 168 169 170 171 172 173`````` ``````INPUT = """\ .##...#. .#.###.. ..##.#.# ##...#.# #..#...# #..###.. .##.#### ..#####. """ _, active_cubes = run(20, 3, parse_input(INPUT)) ``````

`the result is 386`, which unlocks the second part of the problem.

## 4D #

Moving on to even higher dimensions:

The 4D cube count for the test input should be 848:

 ``````175 176 177 178`````` ``````_, active_cubes = run(16, 4, parse_input(TEST_INPUT)) assert active_cubes == 848 _, active_cubes = run(20, 4, parse_input(INPUT)) ``````

... and after 10 seconds the first cycle hasn't ended yet.

Let's add some timings in `run` before we let this run any further.

 ``2`` ``````import time ``````
 ``````128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150`````` ``````def run(size, dimensions, input, format_world=None): worlds = simulate_forever((size,) * dimensions, input) total_time = 0 start = time.perf_counter() for cycle, world in zip(range(6 + 1), worlds): end = time.perf_counter() print(f"after cycle #{cycle} ({end - start :.2f}s): ", end='') if format_world: print() print(format_world(world)) else: print('...') total_time += end - start start = time.perf_counter() active_cubes = sum(active for _, active in ndenumerate(world)) print(f"the result is {active_cubes} ({total_time:.2f}s)") print() return world, active_cubes ``````

We use time.perf_counter because it is the "clock with the highest available resolution to measure a short duration"; since the timescales we're dealing with don't probably need it, we could have used time.monotonic (on Unix, they're actually the same).

In general, you should avoid time.time for measuring durations, because it can skip backward or forward (e.g. daylight saving time), or move slower or faster than real time (e.g. leap smearing).

After about 1 minute, I get the (correct) result for the test input.

After 2 more minutes, `the result is 2276`; this is also correct.

The script up to this point.

## Conclusions #

What have we learned?

Sometimes, it's easier to start with a simpler problem, and iterate towards the real one: we did most of the coding for 2D, which was easier to understand and debug; once we got that general enough, 3D and 4D just worked.

Tests make refactoring quicker. Even for "exploratory" coding like this, when you don't know exactly what the final code will look like, or even what you're trying to achieve, some parts "settle" earlier than others; once that happens, a simple high-level test allows you to focus on the next thing without having to constantly check that stuff that was working is still working.

The 4D simulation is really slow ... exponentially slow. We'll see at least one way of improving that in a future post.

## Bonus: CLI and tests #

When run, the script goes through all the tests and then prints the results for the two parts of the problem (3 and 4 dimensions).

Let's make it a bit easier to run a single simulation with a specific world size, dimension, and number of cycles; this will be useful when profiling.

First, we'll move the formatting test (line 22) and the other tests to a new file, `test_conway_cubes.py`, and into test functions; we leave `TEST_INPUT` in the module since we'll use it from the CLI as well.

 `````` 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29`````` ``````from textwrap import dedent from conway_cubes import TEST_INPUT, format_2d, parse_input, run def test_parse_roundtrip(): assert format_2d(parse_input(TEST_INPUT)) == TEST_INPUT def test_2(): world, active_cubes = run(8, 2, parse_input(TEST_INPUT), format_world=format_2d) assert active_cubes == 5 assert world == parse_input(dedent("""\ ........ ........ ........ ........ .....#.. ...#.#.. ....##.. ........ """)) def test_3(): _, active_cubes = run(16, 3, parse_input(TEST_INPUT)) assert active_cubes == 112 def test_4(): _, active_cubes = run(16, 4, parse_input(TEST_INPUT)) assert active_cubes == 848 ``````

We want to be able to run fewer cycles, so we add a new argument to `run`:

 ``120`` ``````def run(size, dimensions, input, format_world=None, cycles=6): ``````
 ``125`` `````` for cycle, world in zip(range(cycles + 1), worlds): ``````

And then remove the rest of the top level code in favor of a rudimentary CLI:

 ``````145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175`````` ``````TEST_INPUT = """\ .#. ..# ### """ INPUT = """\ .##...#. .#.###.. ..##.#.# ##...#.# #..#...# #..###.. .##.#### ..#####. """ def main(args): input = {'test': TEST_INPUT, 'real': INPUT}[args] size, dimensions, cycles = map(int, args[1:4]) run( size, dimensions, parse_input(input), format_world=format_2d if dimensions == 2 else None, cycles=cycles, ) if __name__ == '__main__': import sys main(sys.argv[1:]) ``````

We now can run the tests using pytest. The 4D one takes a while; we use `-k` to skip it:

``````\$ pytest test_conway_cubes.py -q -k 'not 4'
...                                                                      [100%]
3 passed, 1 deselected in 1.14s
``````

The script is invoked like this:

``````\$ python3 conway_cubes.py real 20 4 6
after cycle #0 (0.01s): ...
after cycle #1 (24.15s): ...
after cycle #2 (23.45s): ...
after cycle #3 (23.79s): ...
after cycle #4 (24.31s): ...
after cycle #5 (24.15s): ...
after cycle #6 (24.07s): ...
the result is 2276 (143.94s)
``````

## Bonus: more tests #

Our tests cover the most common cases, but there's something we neglected entirely: edge cases.

At the moment, that's not necessarily an issue: the code dealing with it is straightforward, and the puzzle itself is testing it for us – if we go over the edge and the script doesn't fail, we won't get the right result.

However, the code may become less straightforward after optimizing it, and we don't want to rely on the website for validation every time we make a change.

Let's fix that.

The first test checks we get errors when there are active cells on the edges:

 ``3`` ``````import pytest ``````
 ``````34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61`````` ``````@pytest.mark.parametrize('input', [ """\ #.. ... ... """, """\ .#. ... ... """, """\ ... ..# ... """, """\ ... ... .#. """, ] ) @pytest.mark.parametrize('dimensions', [2, 3, 4]) def test_edge_errors(input, dimensions): with pytest.raises(RuntimeError): run(3, dimensions, parse_input(dedent(input)), cycles=1) ``````

Here, the parametrize decorator defines 4 different `input` and 3 different `dimensions` arguments, so that the `test_edge_errors` function will run using them in turn, in all possible combinations.

The second test checks that the new world can have active cells on the edges, as long as the old one didn't:

 `````` 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103`````` ``````@pytest.mark.parametrize('input, expected_output', [ ( """\ ..... .#... .#... .#... ..... """, """\ ..... ..... ###.. ..... ..... """ ), ( """\ ..... ..... ..... .###. ..... """, """\ ..... ..... ..#.. ..#.. ..#.. """ ), ] ) def test_edge_ok(input, expected_output): world, _ = run(5, 2, parse_input(dedent(input)), cycles=1) assert format_2d(world) == dedent(expected_output) ``````

Because of parametrization, we get 14 actual tests: 12 for `test_edge_errors`, and 2 for `test_edge_ok`:

``````\$ pytest test_conway_cubes.py -q -k edge
..............                                                           [100%]
14 passed, 4 deselected in 0.06s
``````
1. If you think that's a very conservative assumption, trust your instincts. [return]

This is part of a series: