logo
Back to learn section

Brainfuck STARK Tutorial

Thorkil Værge, Ferdinand Sauer, and Alan Szepieniec

August 3, 2022

26 min read

Brainfuck STARK Tutorial
Brainfuck STARK Tutorial

In this tutorial we show how a simple, Turing complete programming language can be arithmetized such that a STARK prover and verifier can be built for it. With this construction, the correct execution of a program can be verified exponentially faster than it would take to re-run the program.

An exponential speedup of the verification of the correct evaluation of a program opens up a new era of computing, with applications in cloud computing, blockchain, security-critical systems, and much more.

The Turing complete programming language we have chosen for this tutorial is Brainfuck. It is a simple language, containing only eight instructions. They are used to manipulate values in an array, and to read to and write from this array. The language's simplicity makes it a prime candidate for arithmetization. It also means that programming in Brainfuck is not very ergonomic. That's part of the reason why we are currently designing and building our own Virtual Machine, TritonVM.

Motivation and Prerequisites

For this tutorial, a certain familiarity with polynomial arithmetic and finite field algebra is required to fully grasp the complex machinery of STARKs. In fact, it is very beneficial to already know what STARKs are and roughly how they work. If you need or want to refresh your memory on this subject, we recommend the excellent “Anatomy of a STARK.” Other good resources also focus on STARKs for more constraint calculations, for example on the Collatz Sequence or the hash function MIMC. That being said, we have tried to make this tutorial as intuitive as possible.

To that end, this tutorial purposefully ignores some details. For example, for fast calculations to be possible for both prover and verifier, the execution trace (and the associated tables) must be padded to have a length of 2k2^k. In this tutorial, we don't do that, sacrificing prover and verifier speed for conceptual ease. We also simplify the example calculations by presenting them as if they happen in the base field instead of the extension field. Using only the base field decreases security, but it is easier to grasp the involved concepts.

Outline of Arithmetization

Arithmetization of a virtual machine refers to expressing the state transition for the execution of each instruction as a polynomial. This implies that each new register value can be expressed in terms of previous register values using only addition, subtraction, multiplication, and division.

If we for example have a register that we call clk that counts the number of instructions that have been executed so far, then the value in this register can be expressed in terms of its value in the previous cycle as clk_n+1=clkn+1clk\_{n+1} = clk_n + 1. From this expression we can derive a constraint that must always hold if a list of instructions have been executed honestly:

clk_n+1clkn1=0clk\_{n+1} - clk_n - 1 = 0

The left-hand side of this expression can be thought of as a polynomial where the variables are rˉn\bar{r}_n and rˉ_n+1\bar{r}\_{n+1}, the register values in the current and the next step of the execution, respectively.

P(rˉn,rˉ_n+1)=clk_n+1clkn1P(\bar{r}_n, \bar{r}\_{n+1}) = clk\_{n+1} - clk_n - 1

In this expression it's implicitly understood that this polynomial must evaluate to zero in a correct execution.

P(rˉn,rˉ_n+1)=0P(\bar{r}_n, \bar{r}\_{n+1}) = 0

If we can identify all such constraint polynomials for this virtual machine, then we can use these polynomials to prove and verify the correct execution of a Brainfuck program.

One of the main benefits of this technique is that the verification runs faster than it takes to re-run the program. The correct execution of a program can be verified in O(log2(N))O(log^2(N)) time, where NN is the number of cycles that the program ran before termination. This verification time constitutes an asymptotic speedup from the regular way of verifying an execution, which is to re-run the program. Rerunning the program takes O(N)O(N) time.

The goal of this tutorial is to show how constraint polynomials can be architected such that they are compatible with a brainfuck virtual machine, not just a simple counter.

The Brainfuck Programming Language

Brainfuck is a small language with only eight instructions. Its central piece is an infinitely long array of memory cells, each holding some value. All cells are initialized to 0. A single memory pointer references one element in memory.

With this picture about Brainfuck's memory model in mind, it should be pretty straightforward to understand all instructions.

Instruction set

brainfuckC equivalentopcode (ascii)opcode aliasdescription
<i--;60a<a_{\lt}Decrease memory pointer
>i++;62a>a_{\gt}Increase memory pointer
+arr[i]++;43a+a_{+}Increase memory value
-arr[i]--;45aa_{-}Decrease memory value
[while(arr[i]) {91a[a_{\texttt{[}}If/while start
]}93a]a_{\texttt{]}}If/while end
,arr[i] = getc();44a,a_{,}Read from input
.putc(arr[i]);46a.a_{.}Write to output

We set IS={<,>,-,+,[,],,,.}\text{IS} = \{\texttt{<}, \texttt{>}, \texttt{-}, \texttt{+}, \texttt{[}, \texttt{]}, \texttt{,}, \texttt{.}\} to be the set of all instructions for more convenient notation below.

On Brainfuck Dialects

The STARK framework relies on polynomials that are defined over a finite field. This means that all calculations that you see here are modulus a specific prime number. For our implementation we use the prime 264232+12^{64} - 2^{32} + 1. So the dialect of Brainfuck in this tutorial uses cells – the elements of the memory array – that have values within the interval [0,p1][0, p - 1]. Some Brainfuck implementations use u8 cell values where the values are elements of the interval [0,255][0, 255]. If you have a program that relies on overflow/wrap around of these values, the dialect presented here will behave differently than expected.1

Compiling Brainfuck

In our implementation, the [ (respectively ]) instruction of a compiled program is directly followed with the location of the end (respectively beginning) of the loop. For example, +[>+<-]+ compiles to a+ a[ 9 a> a+ a< a a] 3 a+a_{+}\ a_{\texttt{[}}\ 9\ a_{\gt}\ a_{+}\ a_{\lt}\ a_{-}\ a_{\texttt{]}}\ 3\ a_{+}

012345678910
a+a_{+}a[a_{\texttt{[}}9a>a_{\gt}a+a_{+}a<a_{\lt}aa_{-}a]a_{\texttt{]}}3a+a_{+}

Expressed in integers, the fully compiled program is [43, 91, 9, 62, 43, 60, 45, 93, 3, 43].

Example Program

Throughout this tutorial, we will illustrate various constructions using the program ++>,<[>+.<-]. It reads a character from standard in (something that the user inputs with their keyboard) and prints the to following ASCII characters. So if a is input, the program will output bc.

The example program

++>,<[>+.<-]

compiles to

[a+ a+ a>a, a< a[ 14 a> a+ a. a< a a] 7][a_+\ a_+\ a_> a_,\ a_<\ a_\texttt{[}\ 14\ a_>\ a_+\ a_.\ a_<\ a_-\ a_\texttt{]}\ 7]

Where 14 and 7 represent jump targets for [, respectively ].

And it leaves the memory array as

[0, <user input> + 2, 0, 0, 0, ...]

Registers

The virtual machine that we have built for Brainfuck contains these registers:

idname
clkcycle
ipinstruction pointer
cicurrent instruction
ninext instruction
mpmemory pointer
mvmemory value
mvimemory value inverse

The value in the mvi register is 0 if and only if mv is 0. Otherwise, mvi has the inverse value of mv.

The registers contain the state before the instruction in ci has been executed.

If you've ever written a Brainfuck virtual machine you will probably think that this implementation has too many registers. Don't we just need an instruction pointer, a memory pointer, and the memory array? To execute the program, yes. But to create a proof of correct execution we need a few more registers that will be used to express the constraint polynomials. 2

Example Program

Running the example program ++>,<[>+.<-] until end yields the following register values for each cycle. In this example the user inputs a which has ASCII value 97. This is the value held in the mv register in cycle 44.

clkipcinimpmvmvi
0000a+a_{+}a+a_{+}000000
1111a+a_{+}a>a_{\gt}001111
2222a>a_{\gt}a,a_{,}0022212^{-1}
3333a,a_{,}a<a_{\lt}110000
4444a<a_{\lt}a[a_{\texttt{[}}11979797197^{-1}
5555a[a_{\texttt{[}}13130022212^{-1}
6677a>a_{\gt}a+a_{+}0022212^{-1}
7788a+a_{+}a.a_{.}11979797197^{-1}
8899a.a_{.}a<a_{\lt}11989898198^{-1}
991010a<a_{\lt}aa_{-}11989898198^{-1}
10101111aa_{-}a]a_{\texttt{]}}0022212^{-1}
11111212a]a_{\texttt{]}}77001111
121277a>a_{\gt}a+a_{+}001111
131388a+a_{+}a.a_{.}11989898198^{-1}
141499a.a_{.}a<a_{\lt}11999999199^{-1}
15151010a<a_{\lt}aa_{-}11999999199^{-1}
16161111aa_{-}a]a_{\texttt{]}}001111
17171212a]a_{\texttt{]}}77000000
181814140000000000

The above table which contains the register values for each clock cycle is commonly referred to as the execution trace.

The program terminates when the instruction pointer (ip) points beyond the length of the program.

Tables

The execution trace, presented for the example program above, plays a central role in the construction of the STARK proof.

In the previous tutorial, the honest calculation of a hash digest could be proved using only polynomial arithmetic on the execution trace, like the above table. In STARK engine for Brainfuck, we need more than that.

The execution trace defined above is the 1st^{\text{st}} of our so-called tables. In this context, the execution trace is called the Processor Table. It contains the values of all the registers and it is sorted by column “cycle.”

With this in mind, we can introduce a Memory Table which is similar to the processor table (execution trace) as it is the state of registers but sorted by the memory pointer. So the execution trace and the memory table have the same length, and the rows in each table is a permutation of the other table's rows.

The link between the program's execution and the actual program is the Instruction Table. It is constructed by concatenating the program, i.e., the list of instructions, with the execution trace. The resulting list is then sorted by instruction pointer first, and cycle second.

Finally, there are the Input Table and the Output Table. They are formed from the subset of rows in the execution trace which read from input or write to output, respectively. Concretely, for every executed ,, there is one row in the Input Table. Likewise, for every ., there is one row in the Output Table.

To sum up, we have five tables: processor table, instruction table, memory table, input table, and output table.

Each table has its own set of transition and boundary constraints that guarantees a specific property of the execution. These constraints are expressed in the form of constraint polynomials, similar to the ones introduced above.

Each table serves the purpose of proving a specific quality of the execution:

  1. The processor table proves that each instruction transforms the state as defined in the VM.
  2. The memory table proves that memory values are consistent, for example that all memory values are initialized to zero and that when a memory value is accessed again, it has not changed since the last cycle in which this memory value was accessed.
  3. The instruction table guarantees that the expected program is read into the instruction registers
  4. The input table proves that the correct values are read into memory.
  5. The output table proves that the program writes the correct values to output.

From Tables via Polynomials to Codewords

We briefly recall how in a STARK, an execution trace is turned into a codeword representing a low-degree polynomial. The same technique is used for all of the five tables defined above, not just the Processor Table, which is the execution trace in our STARK engine.

First, the values in the columns of the tables are interpolated, resulting in a bunch of polynomials. For interpolation, we use the so-called ο\omicron-domain: οi_i=0N1\\{ \omicron^i \\}\_{i=0}^{N-1}. The value of NN is the length of the longest table – the Instruction Table. The value of οFp\omicron \in \mathbb{F}_p depends on NN; namely, ο\omicron must generate a subgroup of Fp\mathbb{F}_p^\star with cardinality at least NN. Each interpolation polynomial tic(x)ti_c(x) represents one column cc.

Second, the interpolation polynomials tic(x)ti_c(x) are evaluated on a domain that's larger than οi_i=0N1\\{\omicron^i\\}\_{i=0}^{N-1}. In our case, we use the so-called Ω\Omega-domain: Ωi_i=04N1\\{\Omega^i\\}\_{i=0}^{4N-1}. As before, Ω\Omega depends on NN, and must generate a subgroup of appropriate cardinality.

The two-step of interpolation over the ο\omicron-domain and evaluation on the Ω\Omega-domain is called low degree extension. Its result is one codeword per column from the table we started with.

Third, the AIR polynomials are evaluated on the low-degree-extended codewords. This gives rise to one transition codeword per AIR polynomial.

Fourth, each transition codeword is divided by the zerofier codeword, resulting in a set of quotient codewords.

Only if the prover is honest does each quotient codeword correspond to a polynomial of low degree. The low-degreeness of the quotient codewords is then proved using the FRI protocol.

The Constraints

Now that we have defined Brainfuck, execution traces, tables, and polynomial arithmetic, we are ready to look at which constraints that apply for this virtual machine and its tables. These constraints allow us to calculate both the transition polynomials and transition quotients defined above. If all transition quotients have a sufficiently low degree, then we have proven that all constraints are satisfied and thus that the computation is integral.

The constraints that apply for a STARK engine can be categorized into boundary constraints, consistency constraints, transition constraints, and terminal constraints. These will be covered and defined below.

Boundary Constraints

A part of the conditions that must be proven in the STARK proof is that the values of the registers are valid at the beginning of program execution. Boundary constraints ensure that the registers are initialized correctly.

iddescriptionpolynomialnote
B0B_0cycle is initially 0clkclk
B1B_1instruction pointer is initially 0ipip
B2B_2current instruction is a valid instruction_opIS(cia_op)\prod\_{op \in IS}(ci - a\_{op})*
B3B_3memory pointer is initially 0mpmp
B4B_4memory value is initially 0mvmv
B5B_5memory value inverse is initially 0mvimvi*

The constraints marked with an asterisk (*) are unnecessary since they are also ensured by the consistency constraints.

There is no initial constraint on register “next instruction.” This is due to how looping works: if the very first instruction is [, then “next instruction” is the location of the corresponding ], which is at an arbitrary location (albeit fixed, given a concrete program).

Consistency Constraints

Consistency constraints are constraint polynomials that must apply to all rows in the tables and that involve only one row. In other words, it binds values of one register to values in other registers in each cycle. Formally, a consistency constraint is P(ti(x))P(ti(x)), where ti(x)ti(x) is a trace interpolant, the interpolation of all values in a column.

iddescriptionpolynomialnote
C0C_0memory_value is 0 or memory_value_inverse is the inverse of memory_valuemv(mvmvi1)mv\cdot(mv \cdot mvi - 1)
C1C_1memory_value_inverse is 0 or memory_value_inverse is the inverse of memory_valuemvi(mvmvi1)mvi\cdot(mv \cdot mvi - 1)
C2C_2current instruction is a valid instruction_opIS(cia_op)\prod\_{op \in IS}(ci - a\_{op})*

The last consistency constraint, C2C_2 is handled by the transition constrainst T1T3T1-T3, so we don't need a separate constraint for this.

Using the fact that 0 has no multiplicative inverse, C0C_0 and C1C_1 allow the expression mvmvi1mv\cdot mvi - 1 to be interpreted as is_zero. Concretely, if both C0C_0 and C1C_1 hold, we have

mvmvi1={1if memory_value=00else.mv \cdot mvi - 1 = \begin{cases} 1 & \text{if }\texttt{memory\_value}=0 \\ 0 & \text{else.} \end{cases}

In Brainfuck we need a way of checking if mv=0mv = 0 because this is how we determine if a jump should be taken or not. The expression (mvmvi1)(mv\cdot mvi - 1) allows for exactly this since this combination is non-zero if and only if mv=0mv = 0.

Transition Constraints

A transition constraint is a constraint polynomial that involves two consecutive rows (like two consecutive states of a virtual machine), such that the next state of the virtual machine can be linked to the current state of the virtual machine. Formally a transition constraint has the form P(ti(x)),ti(οx))P(ti(x)), ti(\omicron\cdot x)) where οx\omicron \cdot x is the xx value for the next row in the table; ο\omicron (omicron) is the value with which xx must be multiplied to get the next row in a table.

Processor Table

The processor table ensures the consistency for the part of the execution that relates to the registers of the virtual machine. The processor table records all the register values for each cycle that the program ran.

iddescriptionpolynomial
P0P_0cycle increases by one per stepclk_n+1clkn1clk\_{n+1} - clk_n - 1
P1P_1instruction mutates state correctly (part 1)_opIS(instr(1)_op(rn,r_n+1)_opop(cia_op))\sum\_{op\in IS}\left( instr^{(1)}\_{op}(\vec{r}_n, \vec{r}\_{n+1}) \cdot \prod\_{op' \neq op}(ci - a\_{op'}) \right)
P2P_2instruction mutates state correctly (part 2)_opIS(instr(2)_op(rn,r_n+1)_opop(cia_op))\sum\_{op\in IS}\left( instr^{(2)}\_{op}(\vec{r}_n, \vec{r}\_{n+1}) \cdot \prod\_{op' \neq op}(ci - a\_{op'}) \right)
P3P_3instruction mutates state correctly (part 3)_opIS(instr(3)_op(rn,r_n+1)_opop(cia_op))\sum\_{op\in IS}\left( instr^{(3)}\_{op}(\vec{r}_n, \vec{r}\_{n+1}) \cdot \prod\_{op' \neq op}(ci - a\_{op'}) \right)

There are a few things to unpack for P1P_1 through P3P_3. First up: rn\vec{r}_n is the vector of all registers in cycle nn, i.e., rn=(clkn,ipn,cin,nin,mpn,mvn,mvin)\vec{r}_n=(clk_n, ip_n, ci_n, ni_n, mp_n, mv_n, mvi_n).

Next, the components of P1P_1 through P3P_3 are as follows.

  1. _opop(cia_op)\prod\_{op' \neq op}(ci - a\_{op'}) This polynomial, called the deselector for operation opop, evaluates to zero at any opcode that is not opop.

  2. instr(i)_op(rn,r_n+1)instr^{(i)}\_{op}(\vec{r}_n, \vec{r}\_{n+1}) This polynomial is a part of a constraint modeling the transition for only a single instruction, namely opop. Brainfuck has 8 instructions, and each instruction has its own instr(i)_opinstr^{(i)}\_{op} for 1i31 \leq i \leq 3. Any partial instruction polynomials will evaluate to 0 if evaluated on (rn,r_n+1)(\vec{r}_n, \vec{r}\_{n+1}) such that rn\vec{r}_n corresponds to instruction opop. Only when seen together do the instr(i)_opinstr^{(i)}\_{op} describe the “full” instruction polynomial.

In the following, all polynomials instr(i)_opinstr^{(i)}\_{op} are listed.

Notice that the description of the condition under which the polynomial evaluates to zero is the same as the definition of the associated Brainfuck instruction.

opopiidescriptioninstr(i)_op(rn,r_n+1)instr^{(i)}\_{op}(\vec{r}_n, \vec{r}\_{n+1})
+1instruction pointer increases by 1ip_n+1ipn1ip\_{n+1} - ip_n - 1
 2memory pointer stays the samemp_n+1mpnmp\_{n+1} - mp_n
 3memory value increases by 1mv_n+1mvn1mv\_{n+1} - mv_n - 1
-1instruction pointer increases by 1ip_n+1ipn1ip\_{n+1} - ip_n - 1
 2memory pointer stays the samemp_n+1mpnmp\_{n+1} - mp_n
 3memory value decreases by 1mv_n+1mvn+1mv\_{n+1} - mv_n + 1
>1instruction pointer increases by 1ip_n+1ipn1ip\_{n+1} - ip_n - 1
 2memory pointer increases by 1mp_n+1mpn1mp\_{n+1} - mp_n - 1
 300
<1instruction pointer increases by 1ip_n+1ipn1ip\_{n+1} - ip_n - 1
 2memory pointer decreases by 1mp_n+1mpn+1mp\_{n+1} - mp_n + 1
 300
[1mv != 0 ⇒ ip increases by 2
mv == 0 ⇒ ip is set to ni
mvn(ipn+1ipn2)+(mvnmvin1)(ipn+1nin)mv_n \cdot (ip_{n+1} - ip_n - 2) + (mv_n \cdot mvi_n - 1) \cdot (ip_{n+1} - ni_n)
 2memory pointer stays the samemp_n+1mpnmp\_{n+1} - mp_n
 3memory value stays the samemv_n+1mvnmv\_{n+1} - mv_n
]1mv == 0 ⇒ ip increases by 2
mv != 0 ⇒ ip is set to ni
(mvnmvin1)(ipn+1ipn2)+mvn(ipn+1ni)(mv_n \cdot mvi_n - 1) \cdot (ip_{n+1} - ip_n - 2) + mv_n \cdot (ip_{n+1} - ni)
 2memory pointer stays the samemp_n+1mpnmp\_{n+1} - mp_n
 3memory value stays the samemv_n+1mvnmv\_{n+1} - mv_n
.1instruction pointer increases by 1ip_n+1ipn1ip\_{n+1} - ip_n - 1
 2memory pointer stays the samemp_n+1mpnmp\_{n+1} - mp_n
 3memory value stays the samemv_n+1mvnmv\_{n+1} - mv_n
,1instruction pointer increasesip_n+1ipn1ip\_{n+1} - ip_n - 1
 2memory pointer stays the samemp_n+1mpnmp\_{n+1} - mp_n
 300
Example Program

The processor table for the example program ++>,<[>+.<-] was already derived when we derived the execution trace, as they are the same. The values in the processor table can be verified to match all above constraints.

clkipcinimpmvmvi
0000a+a_{+}a+a_{+}000000
1111a+a_{+}a>a_{\gt}001111
2222a>a_{\gt}a,a_{,}0022212^{-1}
3333a,a_{,}a<a_{\lt}110000
4444a<a_{\lt}a[a_{\texttt{[}}11979797197^{-1}
5555a[a_{\texttt{[}}13130022212^{-1}
6677a>a_{\gt}a+a_{+}0022212^{-1}
7788a+a_{+}a.a_{.}11979797197^{-1}
8899a.a_{.}a<a_{\lt}11989898198^{-1}
991010a<a_{\lt}aa_{-}11989898198^{-1}
10101111aa_{-}a]a_{\texttt{]}}0022212^{-1}
11111212a]a_{\texttt{]}}77001111
121277a>a_{\gt}a+a_{+}001111
131388a+a_{+}a.a_{.}11989898198^{-1}
141499a.a_{.}a<a_{\lt}11999999199^{-1}
15151010a<a_{\lt}aa_{-}11999999199^{-1}
16161111aa_{-}a]a_{\texttt{]}}001111
17171212a]a_{\texttt{]}}77000000
181814140000000000

Let nn be the row number which for this table is always equal to the clkclk column value. The you can verify that for all rows, all constraint polynomials hold. For example

Exercise: Verify that the above four constraints apply for the row pairs (1,2), (10,11), and (15,16).

The deselector expression does a lot of the heavy lifting here. It allows us to make conditional arithmetic constraints such as saying "if instruction is a>a_{\gt} then ...". This is achieved by choosing the deselector expression such that it evaluates to zero for all but one instruction.

For the a+a_{+} instruction we have the deselector expression _opa+(cia_op)=(cia)(cia>)(cia<)(cia[)(cia])(cia.)(cia,)\prod\_{op' \neq a_{+}}(ci - a\_{op'}) \\ = (ci - a_{-})\cdot(ci - a_{\gt})\cdot(ci - a_{\lt})\cdot(ci - a_{\texttt{[}})\cdot(ci - a_{\texttt{]}})\cdot(ci - a_{.})\cdot(ci - a_{,})

This expression evaluates to 00 if the instruction is anything else than a+a_{+} because one of the factors will be zero. And since the the transition polynomials must all evaluate to zero when the table values are plugged in, we can ignore all the constraints except the one whose deselector is not zero for this instruction. This is what allows us to describe the arithmetization in a conditional way such that we can say "if ci=a+ci = a_{+} then ...".

The deselector is also what makes the C2C_2 consistency constraint unnecessary. The C2C_2 would ensure that the instruction in the current instruction (ci) register is a valid instruction. If the ci register does not hold a valid instruction, all the deselector expressions evaluate to a non-zero value and all transition constraints are active. Since the transition constraints for different instructions are mutually exclusive, this ensures that no invalid instruction will be accepted in ci.

Memory Table

The memory table is formed from three register values sorted first by memory pointer, then by cycle. Memory table consists of three rows: clk, mp, and mv. That is: cycle count, memory pointer, and memory value.

Note that the index nn in the following equations refers to row number in the instruction table and not to cycle count. Only in the processor table do the index and the cycle count correspond, because only the processor table is sorted by the cycle count. In this section, nn is the row number after sorting the instruction table.

Only the processor table has consistency and boundary constraints. So we only consider transition constraints on the memory table.

iddescriptionpolynomial
M0M_0Memory pointer increases by one or by zero. Brainfuck only allows the memory pointer to change by one in each cycle.(mp_n+1mpn1)(mp_n+1mpn)(mp\_{n+1}-mp_n-1)\cdot(mp\_{n+1}-mp_n)
M1M_1If memory pointer does not increase and the memory value does change, then the cycle count must only increase by one.(mp_n+1mpn1)(mv_n+1mvn)(clk_n+1clkn1)(mp\_{n+1} - mp_n - 1)\cdot(mv\_{n+1}-mv_n)\cdot(clk\_{n+1}-clk_n-1)
M2M_2If memory pointer increases by 1, then memory value must be set to zero. This is because Brainfuck memory is initialized to zero and an increase in the mpmp value represents the 1st cycle that this memory location is accessed.(mp_n+1mpn)mv_n+1(mp\_{n+1} - mp_n)\cdot mv\_{n+1}

The transition constraint M1M_1 ensures that memory access is consitent: When a value is re-visited it cannot have changed in the interim. M2M_2 deals with the correct initialization of memory.

Example Program

The processor table for the example program ++>,<[>+.<-] was presented above. The rows in the memory table are a permutation of rows of the processor table, but the transition constraints on the memory table only deal with three registers, clk, mp, and mv, so the memory table only needs three columns.

clkmpmv
000000
110011
220022
550022
660022
10100022
11110011
12120011
16160011
17170000
18180000
331100
44119797
77119797
88119898
99119898
1313119898
1414119999
1515119999

The transition constraints for the memory table are

Exercise: Verify that the above three constraints apply for the row pairs with clock cycle (0, 1) (18,3) and (9, 13).

Instruction Table

The instruction table has three columns: The instruction pointer ip, the current instruction ci, and the next instruction ni. The rows are formed by first concatenating the entire program with the execution trace and then sorting the resulting rows by instruction pointer. So the instruction table is always longer than the processor table by the size of the program.

iddescriptionpolynomial
I0I_0Instruction pointer increases by 0 or 1.(ip_n+1ipn1)(ip_n+1ipn)(ip\_{n+1} - ip_n - 1)\cdot(ip\_{n+1} - ip_n)
I1I_1If instruction pointer is unchanged, then
the current instruction is also unchanged.
(ip_n+1ipn1)(ci_n+1cin)(ip\_{n+1} - ip_n - 1)\cdot(ci\_{n+1}-ci_n)
I2I_2If instruction pointer is unchanged, then
the next instruction is also unchanged.
(ip_n+1ipn1)(ni_n+1nin)(ip\_{n+1}-ip_n-1)\cdot (ni\_{n+1} - ni_n)

Since the entire program is included and the program occupies a contiguous part of the instruction memory, starting from 0, the instruction pointer can only increase by 0 or 1 for each row. Given this requirement is met, we can use (ip_n+1ipn1)(ip\_{n+1} - ip_n - 1) to check if the instruction pointer has changed in the next row. If the instruction pointer has not changed, then both the current instruction and next instruction must be unchanged in the next row.

The intuitive interpretation of these constraints is that a program cannot change during its execution. When the program returns to an instruction pointer inside a for-loop, the value of the instruction cannot have changed. That also applies to the "next instruction" value, as this holds any jump destination.

Example Program

The instruction table for the example program ++>,<[>+.<-] is formed by concatenating the program with the processor table and sorting for instruction pointer and then clock cycle. Each instruction in the program will be repeated m+1m+1 times in this table where mm is the number of times the instruction is executed.

ipcini
00a+a_{+}a+a_{+}
00a+a_{+}a+a_{+}
11a+a_{+}a>a_{\gt}
11a+a_{+}a>a_{\gt}
22a>a_{\gt}a,a_{,}
22a>a_{\gt}a,a_{,}
33a,a_{,}a<a_{\lt}
33a,a_{,}a<a_{\lt}
44a<a_{\lt}a[a_{\texttt{[}}
44a<a_{\lt}a[a_{\texttt{[}}
55a[a_{\texttt{[}}1313
55a[a_{\texttt{[}}1313
77a>a_{\gt}a+a_{+}
77a>a_{\gt}a+a_{+}
77a>a_{\gt}a+a_{+}
88a+a_{+}a.a_{.}
88a+a_{+}a.a_{.}
88a+a_{+}a.a_{.}
99a.a_{.}a<a_{\lt}
99a.a_{.}a<a_{\lt}
99a.a_{.}a<a_{\lt}
1010a<a_{\lt}aa_{-}
1010a<a_{\lt}aa_{-}
1010a<a_{\lt}aa_{-}
1111aa_{-}a]a_{\texttt{]}}
1111aa_{-}a]a_{\texttt{]}}
1111aa_{-}a]a_{\texttt{]}}
1212a]a_{\texttt{]}}77
1212a]a_{\texttt{]}}77
1212a]a_{\texttt{]}}77

The transition constraints for the memory table were

Exercise: Verify that these three transition constraints hold for the row pairs where the ipip values are (1, 1); (5,5); and (10, 11).

Input and Output Table

The input table consists of all the values that are printed to standard out. The , instruction in Brainfuck reads a character from input and stores this in the memory cell that the memory pointer points to. The . instruction prints the value from the indicated memory cell to output.

The length of the input table is the number of reads encountered in the execution of the program. The length of the output table is the number of writes in the execution of the program.

There are no constraint polynomials defined for the base columns for these tables.

Example Program

The input table for the example program ++>,<[>+.<-] consists of one row and one column whose value is the value entered by the user when the program was run. In the above examples we set this input value to a = 97.

input value
9797

The output table for the example program consists of the values that were printed to standard out. These values are the two ASCII codepoints after what the user entered.

output value
9898
9999

Extension Columns

The correctness of each table is proved with the constraints defined above. But there is something missing: The constraints above ensure that each table is internally consistent, but we also need to prove that the tables all refer to the same program execution.

This is done by proving that each table is either a permutation or a subset of another table.

To prove that a specific table is a permutation or a subset, we extend the tables with more columns than the ones which correspond to register values. We call these new columns extension columns.

To link a table to another, both of the linked tables need one extension column. The value in this extension column is either a running product (for permutations) or running sum (for sublists). The terminal value of these extension column will agree for an honest STARK proof when defined as follows:

Permuation Running Product (prpprp) prp_n+1=init_i=0n+1(βdclkiempifmvi)prp\_{n+1} = init\cdot \prod\_{i=0}^{n+1}\left( \beta - d\cdot clk_i - e\cdot mp_i - f \cdot mv_i \right) The β\beta, dd, ee, and ff values are chosen by the Fiat-Shamir public oracle. This running product is calculated in two tables that are permuations of each other.

This product is independent of the order in which the register values appear. The above expression calculates the values in the extension columns linking the memory table and the processor table. The initinit value is an initialization value that is chosen by the prover and must be equal for both linked tables.

Example Program

Let's add the permutation running product (prpprp) that links the memory table and the processor table to the processor table. For this, we set β=3,d=e=f=1\beta = 3, d = e = f = 1 to make the calculations simpler. We also assume that the user enters an a as the input. In a real proof, these values would be chosen by the Fiat-Shamir public oracle, and the calculations would take place in larger domain than the B field. We set init=7init = 7.

clkipcinimpmvmviprp
0000a+a_{+}a+a_{+}0000007
1111a+a_{+}a>a_{\gt}0011117
2222a>a_{\gt}a,a_{,}0022212^{-1}1844674406941458431418446744069414584314
3333a,a_{,}a<a_{\lt}1100001844674406941458434218446744069414584342
4444a<a_{\lt}a[a_{\texttt{[}}11979797197^{-1}1844674406941458425818446744069414584258
5555a[a_{\texttt{[}}13130022212^{-1}1844674406941459068418446744069414590684
6677a>a_{\gt}a+a_{+}0022212^{-1}1844674406941454614318446744069414546143
7788a+a_{+}a.a_{.}11979797197^{-1}1844674406941485156718446744069414851567
8899a.a_{.}a<a_{\lt}11989898198^{-1}1844674406941485156718446744069414851567
991010a<a_{\lt}aa_{-}11989898198^{-1}1844674407236070422518446744072360704225
10101111aa_{-}a]a_{\texttt{]}}0022212^{-1}1844674375417975459318446743754179754593
11111212a]a_{\texttt{]}}7700111134675831270083467583127008
121277a>a_{\gt}a+a_{+}0011111844670592600018723318446705926000187233
131388a+a_{+}a.a_{.}11989898198^{-1}457720972765056457720972765056
141499a.a_{.}a<a_{\lt}11999999199^{-1}1839593704143766310518395937041437663105
15151010a<a_{\lt}aa_{-}11999999199^{-1}57411941613920974085741194161392097408
16161111aa_{-}a]a_{\texttt{]}}00111195866521002259310449586652100225931044
17171212a]a_{\texttt{]}}770000001263426302111636218512634263021116362185
181814140000000000765976425698632571765976425698632571
54251448325378306145425144832537830614

Let's also add the matching extension column to the memory table. The initinit value and the β,d,e,f\beta, d, e, f values must be the same as those used in the processor table.

clkmpmvprp
00000077
11001177
2200221844674406941458431418446744069414584314
5500221844674406941458434218446744069414584342
6600221844674406941458419518446744069414584195
101000221844674406941458520318446744069414585203
111100111844674406941457461918446744069414574619
121200111844674406941469104318446744069414691043
161600111844674406941330365718446744069413303657
171700001844674406943507494518446744069435074945
181800001844674406908673433718446744069086734337
33110055734497285573449728
441197971844674405269423513718446744052694235137
7711979716887552675841688755267584
881198981844656843886675558518446568438866755585
991198981861683806984601618616838069846016
13131198981645474239594106060916454742395941060609
14141199991819800099200070450118198000992000704501
151511999996612236783538353399661223678353835339
54251448325378306145425144832537830614

Both tables have a terminal value for the prpprp column of 54251448325378306145425144832537830614.

As required by the protocol and the laws of mathematics, both tables have the same terminal value exactly because the rows in the memory table is a permutation of those in the processor table. Had the rows in the processor table not matched those in the memory table, the running product in the two tables would have been different with a probability of 112641 - \frac{1}{2^{64}}. Stated differently: It would have been possible for the prover to cheat through brute force, by making on average 2632^{63} proofs. Since we are targeting a higher security than this, all extension columns are actually calculated in an extension of the prime field. Elements in this extension field can be uniquely represented by a tuple of three base field elements, so the security for this check becomes considerably higher than 21282^{128}.

Running Evaluation (rere) For tables that are not permutations of each other but rather one being a sublist of the other, we cannot use the permutation running product as defined above.

Instead we use the running sum.

ren=_i=0nδnimvire_n = \sum\_{i=0}^n\delta^{n-i} mv_i This sum depends on the order in which the mvimv_i values appear, so it can be used to verify that one table contains rows that are an (order-preserving) sublist of another table.

Example Program

Let's expand the output table with its running evaluation extension column. The δ\delta (delta) in the equation above is one of the prover's challenges, and is picked by the Fiat-Shamir random public oracle. To make the calculations simple here, we set it to 2 but in a real program it is a random extension field element which can be expressed as FB3\mathbb{F_B}^3 where FB\mathbb{F_B} is the (base) prime field defined by the prime 264232+12^{64} - 2^{32} + 1.

output valuere
98989898
9999982+99=29598 \cdot 2 + 99 = 295

Had the prover cheated with the order and switched the rows around, they would have calculated this instead

output valuere
99999999
9898992+98=29699 \cdot 2 + 98 = 296

This shows that the running evaluation guarantees to preserve the order with a security that is the size of the field in which the challenge is picked and in which the extension column values are calculated.

But how does this work in the processor table? How are the extension column values that link the processor table to the output table calculated?

The ithi^{th} row of the linked column in the processor table is calculated as

evaln={evaln1,if cina,γevaln1+mvn,if cin=a,eval_n = \begin{cases} eval_{n-1}, & \text{if } ci_n \ne a_{,} \\\\ \gamma\cdot eval_{n-1} + mv_n, & \text{if } ci_n = a_{,} \\\\ \end{cases}

where a,a_{,} is the , (read) instruction and mv_n+1mv\_{n+1} is the (n+1)th^{th} memory value, i.e. the value that was read in the execution of this , instruction.

This piecewise function can be arithmetized in a similar way that the instructions are arithmetized: with a deselector polynomial that only activates a transition requirement if the current instruction is a , (read) instruction: _opop(cia_op)\prod\_{op' \neq op}(ci - a\_{op'}).

Transition Quotients

Extension columns, like base columns, also have transition constraints. These transition quotients for extension columns do no more than validate the integral calculation of the running product or sum. They are formed on the basis of transition constraints that link two adjacent rows of the table where the extension columns have been added.

Terminal Quotients

The terminal values of the extension columns are public. The prover includes the terminal values in the proof stream and the verifier checks that a low-degree quotient can be formed from both linked extension columns by dividing out the zerofier of the terminal, which is a linear equation with root in the last element in the group generated by the ο\omicron generator, that is: the generator that forms the cyclical subgroup over which the tables are interpolated. If the terminal values do not match, at least one of these divisions will not result in a low-degree polynomial as the division would be P(x)xr\frac{P(x)}{x-r} where P(x)P(x) does not have a root in x=rx = r. Only if P(x)P(x) has a root in x=rx = r does P(x)xr\frac{P(x)}{x-r} result in a low-degree polynomial. The FRI protocol ensures that the submitted codeword is of low degree.

Boundary Quotients

A cell value permiperm_i of the extension column of a permutated table is calculated recursively as perm_n+1=permnαiciregiperm\_{n+1} = perm_n\cdot \alpha\cdot\sum_i c_i\cdot reg_i where the regireg_i values are cell values of the other rows, and the cic_i values are challenges chosen by the verifier through Fiat-Shamir. And where perm0=initperm_0 = init where initinit is a value chosed by the prover which must be randomly sampled from a uniform distribution to achieve zero-knowledge. The initinit values of the permutated columns must be the same for both colums, otherwise an adversary could pick a desired terminal value, get the challenges and calculate the column values from the desired terminal value and find a derived initial value. To thwart this attack, we calculate f(x)g(x)x1\frac{f(x) - g(x)}{x - 1} where f(x),g(x)f(x), g(x) represent the permutation-column values. The division by zero only results in a low-degree polynomial if f(x),g(x)f(x), g(x) share the initial value in their first row, which is represented by the value of the polynomials in ο0=1\omicron^0=1 (omicron to the power of zero). ο\omicron (omicron) is the generator of the cyclic subgroup over which f(x),g(x)f(x), g(x) were interpolated.

Marrying Processor Table and Instruction Table

The height of the instruction table is that of the processor table plus the length of the program (the number of instructions). We need to prove that a subset of the rows in the instruction table are a permutation of the processor table, and that a subset of the rows in the instruction table are the program. And we need to prove that these sets are disjoint and that their union constitute all rows in the instruction table. To see exactly how this is done, you should have a look at Alan Szepieniec's tutorial that does a better job describing permutation and evaluation arguments.

Further Reading

As mentioned above, Alan Szepieniec's tutorial does a better job at describing how tables are linked to each other. If you have an easier time reading code than equations, you can have a look at either the Python implemenation or the Rust implementation the latter being a part of the twenty-first cryptography library that we have published.

If you're thinking that Brainfuck is a useless programming language and can't see the point of any of this, you can have a look at our more serious attempt at designing a STARK VM: Triton VM.


Footnotes

  1. According to the Wikipedia article on Brainfuck, the type used for the cell values is not well-defined, so different dialects of Brainfuck use different types for this. Therefore, our choice of values in the interval [0,264232+1)[0, 2^{64} - 2^{32} + 1) is just another dialect.

  2. It's of course possible though that we could have done a better job. Maybe we could do with fewer registers if we had come up with different constraint polynomials.