Joseph Junior Mensah
Puzzle15
← BACK TO PROJECTS
RustFormal MethodsDigital Logic2024

◧Puzzle15

The 4×4 sliding-tile puzzle written twice in Rust — once as a state machine you can play and search with BFS, and once as a bit-vector transition system whose next-state logic is a mux tree a solver can reason about.

Timeline

November 2024

Team

Solo build

Role

Rust & circuit modelling

Skills

Rust, Formal Methods, Digital Logic

Built with

Rust·patronus·baa·easy-smt·Bit-vector logic·BFS·cargo test

LONG  STORY  SHORT

I built the same sliding puzzle twice — once as Rust you can play, once as a circuit — and the second one taught me what a rule actually costs in hardware.

The 15-puzzle is fifteen numbered tiles in a 4×4 frame with one square left empty. You slide a neighbouring tile into the gap, over and over, until the board is ordered. It is the kind of problem that looks like a toy right up until you try to write down its rules precisely.

This repo makes you write them down twice. The first half, lib.rs, is a plain Rust model: a board, four moves, a parser, and a breadth-first search that finds the shortest sequence between any two positions. The second half, circuits.rs, throws that away and asks for the same game as a transition system — bit-vector registers and next-state functions, built with patronus, the kind of representation a model checker consumes.

It came to me as an exercise scaffold: a fork, a file of failing tests, and an instruction to work through them in order because each one builds on the last. What makes it worth writing up is not the puzzle. It is that the two halves solve an identical problem and disagree completely about what a rule is.

The default board — fifteen tiles and the hole at (3, 3), which is where every legal move begins.
The default board — fifteen tiles and the hole at (3, 3), which is where every legal move begins.

The board, and where its numbers come from

The state is one fixed-size array: [[Option<u8>; 4]; 4]. No heap, cheap to copy — which matters later, because the search clones boards by the thousand. The empty square is not a magic 0; it is None, so the type system knows an empty square is not a tile you can read.

The layout is worth a moment. Rather than typing out sixteen assignments, I read three known cells off the finished grid, wrote them as three simultaneous equations, and solved for the coefficients — the derivation is still sitting in the source as a comment. What comes back is one expression that fills the whole board.

x0x1x2x3y01234y15678y29101112y3131415none

The closed form

tile(x, y) = 4y + x + 1

Read off three known cells, solve the simultaneous equations, and the whole board falls out of one expression — the highlighted cell is 4(1) + 2 + 1 = 7.

The type

[[Option<u8>; 4]; 4]

Fixed size, no heap, cheap to copy — which matters when the search clones thousands of boards. The hole is not a sentinel number; it is None, so the type system knows an empty square is not a tile.

x is the column and y is the row, so the array is indexed tile_array[x][y] — and Display transposes it back to print row by row.

The one thing to keep straight is that x is the column and y is the row, so the array is indexed tile_array[x][y] — and Display transposes it back on the way out, printing row by row so the string matches how a human reads a grid.

Four moves, named for the tile

The Move enum is where I got caught first, and where reading the tests carefully paid for itself. A variant is named for what the tile does, not what the hole does. LeftToRight does not move the hole to the right. It takes the tile sitting to the left of the hole and slides it rightward into the gap — so the hole ends up one column further left.

Once that clicks, perform_move is short: find the None, check the bound in the direction the hole is about to travel, swap. If the bound fails, the move returns false and the board is untouched.

Move::LeftToRight

7·
→
·7

The tile to the left of the hole slides right; the hole moves to x − 1.

returns false when x == 0

Move::RightToLeft

·8
→
8·

The tile to the right of the hole slides left; the hole moves to x + 1.

returns false when x == 3

Move::TopToBottom

3·
→
·3

The tile above the hole drops down; the hole moves to y − 1.

returns false when y == 0

Move::BottomToTop

·11
→
11·

The tile below the hole rises up; the hole moves to y + 1.

returns false when y == 3

Every move is a bounds check and a swap against whichever square holds None.

perform_moves then plays a whole slice and returns how many moves succeeded, not whether all of them did. That is a deliberately forgiving contract — an illegal move in the middle of a sequence is skipped, not fatal — and the tests lean on it: a five-move sequence that returns 3 is the assertion, not a failure.

Parsing a board without trusting it

from_str reads the same text Display writes, which sounds trivial and isn't. A row splits on | into six fields, not four: the delimiters at each end produce empty strings that have to be stepped over. Four spaces mean the hole. Anything else has to parse as a number and land in range.

The real work is the last gate. After sixteen cells are read, the whole board goes into a HashSet and the count has to be exactly 16 — which rejects a duplicated tile and a second hole with the same check, because two Nones collapse into one entry just like two 7s do.

1exactly 4 lines26 fields per split on |3blank, or a number ≤ 154all 16 entries unique
15 rows of tilesFive lines — the grid is not 4 × 4.
2| 1 | 2 | 3 |Three columns splits into 5 fields, not 6.
2| 1 | 2 , 3 | |A comma is not a separator, so the row is short.
3| 22 |Parses fine as a u8 and is still not a tile.
4two blank cellsTwo holes collapse to one entry in the set.
4| 2 | … | 2 |A duplicate tile — 15 unique entries, not 16.
The uniqueness gate does double duty — it catches a repeated tile and a second hole with the same check.

Every one of those rejects is a fixture in the test file. Writing the failing cases turned out to be more instructive than writing the happy path: it forced the question of what "a valid board" actually means, rather than what the parser happened to accept.

Finding the shortest path, not just a path

find_shortest_path walks from one board to another. The interesting constraint is in the doc comment: it might run forever if no path exists, so the guarantee has to come from the traversal itself rather than from a bound on the work.

It is breadth-first, and the reason it returns a minimal path is entirely structural. The frontier is strictly first-in-first-out, so states are reached in nondecreasing order of depth, and the first time you touch a state is also the cheapest way to have touched it. Marking each state as seen at enqueue time rather than at visit time keeps duplicates out of the queue instead of merely ignoring them later.

depth 0depth 1depth 2depth 3T→BL→R⋮⋮⋮⋮⋮GameState::default()target — 3 moves

Frontier

FIFO

insert(0) in, pop() out

Seen

HashSet

marked on enqueue, not on visit

Branching

2 – 4

corner, edge, interior

Reachable

16! / 2

10,461,394,944 boards

Because the frontier is strictly first-in-first-out, the first time a state is reached is also the cheapest — which is what makes the returned path minimal rather than merely valid.

Two things I would change if this were more than an exercise. The frontier is a Vec used as a deque — pop() from the back, insert(0, …) at the front — and that front insert shifts the whole buffer, so it is O(n) per push where a VecDeque would be O(1). And with 16!/2 ≈ 10.5 billion reachable boards, plain BFS is fine for the shallow cases the tests ask for and hopeless for a scrambled board; that is where an A* with a Manhattan-distance heuristic would belong.

What the search does give you for free is GameState's Hash and Eq implementations doing real work. The board being a small, ordinary, comparable value is what makes the visited set possible at all.

The turn: the same game, as a circuit

Here the exercise changes shape. circuits.rs asks for the puzzle again — but as a transition system, the representation hardware verification tools actually work on. You declare symbols, give each one an initial value and a next-state expression, and you have described a circuit rather than a procedure. patronus can then serialise it, simulate it, or hand it to an SMT solver.

The gap between the two halves is the whole point, and it is not about syntax:

A transition system: some state, the value it starts at, and a combinational function that computes what it becomes on the next clock edge.
A transition system: some state, the value it starts at, and a combinational function that computes what it becomes on the next clock edge.

Software model

lib.rs

A board you mutate

State
One [[Option<u8>; 4]; 4] owned by a GameState.
A move
Scan for None, bounds-check, swap two cells.
Illegal
perform_move returns false and nothing changes.
Time
Sequential — one swap happens, then the next.
same four moves⇄

Circuit model

circuits.rs

A board that recomputes itself

State
Sixteen bv<4> registers, 0 standing in for the hole.
A move
A 2-bit input; all 16 registers evaluate at once.
Illegal
Every mux selects 'unchanged' — nothing to reject.
Time
Parallel — one clock edge advances the whole board.
Software asks where is the hole? and acts once. Hardware has no such luxury: every square must decide, simultaneously, what it becomes next.

In Rust, a move is one swap, and I find the hole by scanning for it. A circuit cannot scan. There is no loop and no "then"; there is only what every register evaluates to on the same clock edge. So the question stops being "which two squares do I swap?" and becomes "for each of the sixteen squares independently, what does it become?" — and the answer for most of them, most of the time, is the same thing it already was.

None of that machinery is mine. The exercise hands you three crates and expects the modelling to happen in them:

patronus

The transition-system IR: an expression context, states with init/next functions, inputs, a serialiser, and the interpreter the tests step through.

0.22 · Context · TransitionSystem · Interpreter

baa

Bit-vector arithmetic. Every constant on the board is a BitVecValue, so widths are checked rather than assumed.

0.14 · BitVecValue · WidthInt

easy-smt

The SMT bridge patronus hands a system to when a property has to be proven rather than simulated.

0.2 · the road past simulation

std only, otherwise

The game model leans on arrays, Option, and HashSet — the search needs Hash + Eq on GameState and nothing more.

Rust 2021 · no unsafe

Three crates from the patronus ecosystem, and nothing else — no game engine, no solver bindings of my own.

Learning the primitives on three counters

The file walks you in through counters, and the progression is well chosen. The first is given: a register that adds one to itself forever. The second has to stop at a configurable maximum. The third gains an en input that decides whether it ticks at all.

The hint that unlocks it is that ctx.bv_ite — pick a or b based on a condition — is a multiplexer. Which means the second counter is not "an if-statement in a circuit"; the comparator and the mux are the circuit, and the if was only ever a way of talking about them.

GivenFree-running

[next] add(count, 2'b01)

The reference design: one adder, wraps at 2ʷ. Nothing decides anything.

Task 1Saturating

[next] ite(ugte(count, 32'x00000007), 32'x00000007, add(count, 32'x00000001))

A comparator feeds a mux. This is the moment an if-statement becomes a piece of hardware.

Task 2Enabled

[next] ite(en, ite(ugte(count, 32'x0000007b), 32'x0000007b, add(count, 32'x00000001)), count)

An input gates the whole thing. Nesting the muxes is how control flow is written in a circuit.

Given, then Task 1, then Task 2 — each nested ite is one more multiplexer standing where a branch used to be.

Those expressions are the real output of the test run — patronus serialises each system, so you can read back the thing you just described and check it says what you meant. Watching a nested ite appear where I had written nested conditionals is the moment the mental model flipped for me: control flow in hardware is structure, not sequence. The enabled counter passes 20,000 simulated cycles against a reference count, toggling the enable every other one.

Sixty-four bits, sixty-four muxes

The board becomes sixteen bv<4> registers named pos_x_y, with 0 standing in for the hole — four bits is exactly enough for 0–15, so the encoding is tight rather than convenient. The move is a 2-bit input decoded into four one-hot conditions, and is_empty for each square is just a comparison against zero.

Then the next-state function. For one square, it is a chain of four muxes — one per direction — each defaulting to "hold" and only overriding when its direction is selected, the square is in bounds, and the neighbour it would trade with is the hole:

pos_x_y(hold)pos(x−1, y)iteL→Rx > 0 ∧ hole leftpos(x+1, y)iteR→Lx < 3 ∧ hole rightpos(x, y−1)iteT→By > 0 ∧ hole abovepos(x, y+1)iteB→Ty < 3 ∧ hole belownextpos_x_y

Repeated for all sixteen squares: 64 bits of state, 64 muxes, and one 2-bit move input decoded into four one-hot conditions.

The default is the leftmost wire — hold. A square only changes when the selected direction is in bounds and the neighbour it would trade with is the hole.

That structure is repeated sixteen times, and the shape of the result is the lesson. In software, an illegal move is something you detect and reject. Here there is nothing to reject: an illegal move is simply a set of select conditions that all evaluate false, and every register quietly holds its value. The bounds check isn't a guard clause — it's a wire that never asserts.

Where it stands

The software half is done and green. The circuit half is where the work stopped, and I would rather show that accurately than tidy it up after the fact.

oktests::my_tests

Board, display, parsing, moves, equality, and shortest path — the whole software model.

okcircuits::tests::test_counter_0

The reference free-running counter, checked against its serialised form.

okcircuits::tests::test_counter_1

Saturating at 7 over 16 simulated cycles.

okcircuits::tests::test_counter_2

20,000 cycles with the enable toggling, against a reference count.

failedcircuits::tests::test_puzzle15

Panics building the L→R mux for x = 0 — the arms are constructed eagerly, so pos_to_index(x − 1, y) underflows before the guard can matter.

test result: FAILED. 4 passed; 1 failed; 0 ignored

Run today, on the commit as it stands — the software half is finished and the circuit half is where the work stopped.

The failure is not mysterious, and it is a genuinely Rust-flavoured mistake. Each mux arm is built inside a closure that patronus evaluates eagerly — it is constructing an expression graph, not deferring a branch. So even though the guard correctly refuses to read a neighbour when x == 0, the other arm still calls pos_to_index(x - 1, y) on an unsigned x, and 0 - 1 panics before the guard is ever consulted. That is precisely the "overflow error" the last commit is named after.

There is a second problem queued up behind it: where the guard falls back it yields c.zero(4), a four-bit zero, and that gets and-ed with a one-bit move condition. Mismatched widths — the same class of bug, caught by the type system of the bit-vector library rather than by Rust's.

The fix is to hoist the neighbour lookup out of the closure so the index is only computed when it exists, and to make the fallback a one-bit false. What I would build on top, given the time, is the thing the easy-smt dependency is sitting there for: stop simulating single moves and start proving properties — that tile values are a permutation at every step, that exactly one square is ever empty, that the circuit and the Rust model agree for every move from every reachable board.

Why I built it this way

  • Read the tests before writing the code. The move names, the six-field row format, the "count the successes" contract — none of that is stated anywhere else. The test file was the specification.
  • Let the type system carry the hole. Option<u8> instead of a sentinel means an empty square can never be mistaken for a tile, and it makes the uniqueness check catch two holes for free.
  • Get the guarantee from the structure. BFS returns a minimal path because the frontier is FIFO, not because anything measures or compares lengths afterwards.
  • Build the second model to find out what the first assumed. Writing the puzzle as a circuit exposed every place the Rust version quietly relied on doing one thing at a time.
  • Report the state honestly. Four tests pass, one fails, and I can name the line and the fix. An exercise you can diagnose is worth more than one you can only claim.

NEXT  UP  …