The 15-puzzle - cont.

Dictionaries/maps in Cairo

The next thing we should check is that the numbers of the second list (the values of the tiles being moved) are consistent with the first list. To do this, we need to store and update the location of each tile. This requires some kind of read-write memory, where we can track the location of each tile and update it when tiles are moved. But as Cairo’s memory is immutable, we can’t use it for that purpose.

Luckily, there is a library function called squash_dict which can assist us here. It allows to simulate the behavior of a read-write dict/map in Cairo.

The standard-library file dict_access.cairo defines the following struct:

struct DictAccess:
    member key : felt
    member prev_value : felt
    member new_value : felt
end

The function squash_dict(), which is defined in squash_dict.cairo, gets a list of DictAccess and treats them as “read-modify-write” instructions on a dict. It verifies that they make sense – if we change the value at key=3 to 10, the next entry which refers to key=3 must have prev_value=10. Finally, it returns the squashed dict – which is a list of DictAccess entries where (1) the squashed keys are sorted, (2) each key appears once and (3) the squashed prev_value refers to the prev_value in the first time the key appeared and similarly new_value refers to new_value in the last time it appeared.

Let’s take a look at an example. Suppose that we have the following list of DictAccess entries:

Key

Prev

New

7

3

2

5

4

4

7

2

10

0

2

3

7

10

0

0

3

4

0

4

5

Note that when 7 appears the second time (the third entry), the previous value 2 matches the new value in the first row, and the new value 10 matches the previous value in the next appearance of 7 in the fifth row. If this was not the case, squash_dict would fail.

The squashed dict in this case will be:

Key

Prev

New

0

2

5

5

4

4

7

3

0

You can see that it summarizes what happened to each key (e.g., key 7 started from 3, changed to 2, then to 10 and then to 0. Therefore in the squashed dict we see the values 3 and 0).

Preparing the list of DictAccess entries

Our keys will be the values on the tiles (the numbers between 1 and 15), and our values will be the locations. There is one problem: each location consists of two field elements and the each value in DictAccess is one field element. There are two possible solutions: (1) use a pointer to the location object, (2) convert the two coordinates to one number using the formula 4 * row + col. Here we chose to go with option (2).

from starkware.cairo.common.dict_access import DictAccess
from starkware.cairo.common.squash_dict import squash_dict

func build_dict(
        loc_list : Location*, tile_list : felt*, n_steps,
        dict : DictAccess*) -> (dict : DictAccess*):
    if n_steps == 0:
        # When there are no more steps, just return the dict
        # pointer.
        return (dict=dict)
    end

    # Set the key to the current tile being moved.
    assert dict.key = [tile_list]

    # Its previous location should be where the empty tile is
    # going to be.
    let next_loc : Location* = loc_list + Location.SIZE
    assert dict.prev_value = 4 * next_loc.row + next_loc.col

    # Its next location should be where the empty tile is
    # now.
    assert dict.new_value = 4 * loc_list.row + loc_list.col

    # Call build_dict recursively.
    return build_dict(
        loc_list=next_loc,
        tile_list=tile_list + 1,
        n_steps=n_steps - 1,
        dict=dict + DictAccess.SIZE)
end

The function gets a pointer to the list of locations, a pointer to the list of tiles (unlike the list of locations, this is a list of simple field elements, not structs), the number of steps in the solution and a pointer called dict. The function writes its new dict entries starting from dict, and returns the “updated” dict pointer – the pointer to the next address to write if you want to add more entries to the list. This way we can concatenate functions writing DictAccess lists. This pattern, of getting a pointer, reading/writing entries from that pointer and returning an updated pointer is very common in Cairo.

The line let next_loc : Location* = ... defines a reference – unlike tempvar/local, this does not allocate a memory cell. Instead, every time we refer to next_loc it will be replaced by loc_list + Location.SIZE. Thus, the scope of the reference is simply the scope of its expression.

The line return build_dict(...) is a tail recursion call: a recursion which ends with returning the values of the recursive call.

Exercise

How would you write the function without the return build_dict(...) syntax?

Note that you can use the Cairo tracer to debug your code if needed. In addition, you may refer to Debugging-related flags.

Final state

To make sure that the solution ends in the “solved” configuration, we will append 15 entries to the list of DictAccess entries created by build_dict(). The first entry will be (key=1, prev_value=0, new_value=0). We mentioned above that a DictAccess represents a read-modify-write operation. As new_value=prev_value, this entry is a simple read operation, used to guarantee that at the end of build_dict(), tile 1 is located at 0 (which is the top-left square). Similarly, we will add (key=2, prev_value=1, new_value=1), ..., (key=15, prev_value=14, new_value=14). It is slightly more efficient to write the loop backwards:

func finalize_state(dict : DictAccess*, idx) -> (
        dict : DictAccess*):
    if idx == 0:
        return (dict=dict)
    end

    assert dict.key = idx
    assert dict.prev_value = idx - 1
    assert dict.new_value = idx - 1

    # Call finalize_state recursively.
    return finalize_state(
        dict=dict + DictAccess.SIZE, idx=idx - 1)
end

Note that we keep using the pattern where the dict argument refers to the place the function should start writing (it’ll be the end of build_dict()), and the function returns the pointer to the end of the new written entries.

Initial state

We will handle the initial state differently – we will simply go over the squashed dict (applied on the results of both build_dict() and finalize_state()) and “print” to the program output the initial state. This way, the verifier of the proof will know the initial configuration which we solved.

from starkware.cairo.common.serialize import serialize_word

func output_initial_values{output_ptr : felt*}(
        squashed_dict : DictAccess*, n):
    if n == 0:
        return ()
    end

    serialize_word(squashed_dict.prev_value)

    # Call output_initial_values recursively.
    return output_initial_values(
        squashed_dict=squashed_dict + DictAccess.SIZE, n=n - 1)
end

Note that we need the implicit argument output_ptr in order to call serialize_word().

Putting it all together

from starkware.cairo.common.alloc import alloc

func check_solution{output_ptr : felt*, range_check_ptr}(
        loc_list : Location*, tile_list : felt*, n_steps):
    alloc_locals

    # Start by verifying that loc_list is valid.
    verify_location_list(loc_list=loc_list, n_steps=n_steps)

    # Allocate memory for the dict and the squashed dict.
    let (local dict_start : DictAccess*) = alloc()
    let (local squashed_dict : DictAccess*) = alloc()

    let (dict_end) = build_dict(
        loc_list=loc_list,
        tile_list=tile_list,
        n_steps=n_steps,
        dict=dict_start)

    let (dict_end) = finalize_state(dict=dict_end, idx=15)

    let (squashed_dict_end : DictAccess*) = squash_dict(
        dict_accesses=dict_start,
        dict_accesses_end=dict_end,
        squashed_dict=squashed_dict)

    # Store range_check_ptr in a local variable to make it
    # accessible after the call to output_initial_values().
    local range_check_ptr = range_check_ptr

    # Verify that the squashed dict has exactly 15 entries.
    # This will guarantee that all the values in the tile list
    # are in the range 1-15.
    assert squashed_dict_end - squashed_dict = 15 *
        DictAccess.SIZE

    output_initial_values(squashed_dict=squashed_dict, n=15)

    # Output the initial location of the empty tile.
    serialize_word(4 * loc_list.row + loc_list.col)

    # Output the number of steps.
    serialize_word(n_steps)

    return ()
end

We have mentioned before that in order to perform comparison between two values you need to use a Cairo builtin named “range-check”. We haven’t used it ourself, but the function squash_dict() requires it. Due to the way builtins are implemented in the Cairo machine, functions that need to use a builtin (and all the functions calling them) require that a pointer to the builtin will be passed as an argument, and that the updated pointer will be returned (the same way we treat the dict pointers). This happens automatically when we add the implicit argument range_check_ptr. Thus, check_solution() gets an implicit argument called range_check_ptr and the Cairo compiler passes it to squash_dict(). squash_dict() returns an updated pointer and check_solution() returns the same value to its caller. You can learn more about the range-check builtins and on builtins in general here.

This is a good time to mention the reference rebinding mechanism. Up to the call to squash_dict(), range_check_ptr referred to the argument of the function. Since squash_dict() has an implicit argument named range_check_ptr, this function call rebinds the definition of the term range_check_ptr to the returned value from squash_dict. This allows chaining calls to functions without giving new names to the variables (in fact, we have also used reference rebinding for dict_end). You should note that while it looks like a variable name range_check_ptr is changing its value, this is not the case – Cairo is immutable. Rather than the value changing, the meaning of range_check_ptr throughout the function changes. You can learn more about reference rebinding here.

Now, let’s focus on the line local range_check_ptr = range_check_ptr. Try to compile without it (you can use the main() below), and see what happens. Due to the call to output_initial_values, the reference range_check_ptr which we got from squash_dict is revoked (see Revoked references). The solution is to store this value in a local variable (local variables are not revoked).

Let’s modify our previous dummy main to see the results of what we did so far (note that you’ll need to put the %builtin directive at the top of the file, and use --layout=small to cairo-run due to the usage of builtins):

%builtins output range_check

from starkware.cairo.common.registers import get_fp_and_pc

func main{output_ptr : felt*, range_check_ptr}():
    alloc_locals

    local loc0 : Location
    assert loc0.row = 0
    assert loc0.col = 2
    local loc1 : Location
    assert loc1.row = 1
    assert loc1.col = 2
    local loc2 : Location
    assert loc2.row = 1
    assert loc2.col = 3
    local loc3 : Location
    assert loc3.row = 2
    assert loc3.col = 3
    local loc4 : Location
    assert loc4.row = 3
    assert loc4.col = 3

    local tile0 = 3
    local tile1 = 7
    local tile2 = 8
    local tile3 = 12

    # Get the value of the frame pointer register (fp) so that
    # we can use the address of loc0.
    let (__fp__, _) = get_fp_and_pc()
    check_solution(loc_list=&loc0, tile_list=&tile0, n_steps=4)
    return ()
end