Types

The powdr-pil language has the following types:

  • bool
  • int (integer)
  • fe (field element)
  • string
  • tuple
  • array
  • function type
  • expr (expression)
  • ! ("bottom" or "unreachable" type)
  • enum types

In addition, there are the col and inter types, but they are special in that they are only used for declaring columns, but cannot appear as the type of an expression. See Declaring and Referencing Columns for details.

Powdr-pil performs Hindley-Milner type inference. This means that, similar to Rust, the type of a symbol does not always have to be specified. The compiler will try to find a type for every symbol depending both on the value assigned to the symbol and on the context the symbol is used in. It is an error if the type is not uniquely determined.

Symbols can have a generic type, but in those cases, you have to explicitly specify the generic type. Such declarations can require type variables to satisfy certain trait bounds. Currently, only built-in traits are supported (see the next section).

Literal numbers do not have a specific type, they can be either int, fe or expr (the types that implement the FromLiteral trait), and their type can also stay generic until evaluation.

Example

The following snippet defines a function that takes a value of a generic type and returns the value incremented by one. The type bounds on the generic type are FromLiteral and Add. The type checker will complain if we do not specify the type bounds. The bound Add is required because we use the + operator in the function and FromLiteral is needed because we use the literal 1 as a value of that type.

let<T: FromLiteral + Add> add_one: T -> T = |i| i + 1;

Declaring and Referencing Columns

A symbol declared to have type col or inter (or col[k] / inter[k]) is a bit special:

These symbols represent columns in the arithmetization and the types of values that can be assigned to such symbols and the references to the symbols are different from their declared type.

If you assign a value to a col symbol, that value is expected to have type int -> fe or int -> int (or an array thereof). This allows the simple declaration of a fixed column let byte: col = |i| i & 0xff; without complicated conversions. The integer value is converted to a field element during evaluation, but it has to be non-negative and less than the field modulus.

Symbols of declared type col are fixed (those with value) or witness columns (those without value).

A symbol of declared type inter is an intermediate column. You can assign it a value of type expr. The idea of an intermediate column is that it is an algebraic expression of other columns that you do not want to compute multiple times.

Note that if you use let x: expr = a * b;, the symbol x is just a name in the PIL environment, this will not create an intermediate column. The difference between inter and expr in this case is that if you use let x: inter = ..., the expression might not be inlined into constraints (depending on the backend), while if you use let x: expr = ..., it will always be inlined.

If you reference a symbol of declared type inter or col, the type of the reference is expr (or expr[]). A byte constraint is as easy as [ X ] in [ byte ], since the expected types in plookup columns is expr. The downside is that you cannot evaluate columns as functions. If you want to do that, you either have to assign a copy to an int -> int symbol: let byte_f: int -> int = |i| i & 0xff; let byte: col = byte_f;. Or you can use the built-in function std::prover::eval if you want to do that inside a prover query or hint.

All other symbols use their declared type both for their value and for references to these symbols.

Built-in Traits

FromLiteral: Implemented by int, fe, expr. The type of a number literal needs to implement FromLiteral.

Add: Implemented by int, fe, expr, T[], string. Used by <T: Add> +: T, T -> T (binary plus).

Sub: Implemented by int, fe, expr. Used by <T: Sub> -: T, T -> T (binary minus).

Neg: Implemented by int, fe, expr. Used by <T: Neg> -: T -> T (unary minus).

Mul: Implemented by int, fe, expr. Used by <T: Mul> *: T, T -> T (binary multiplication).

Pow: Implemented by int, fe, expr, Used by <T: Pow> **: T, int -> T (exponentiation).

Ord: Implemented by int. Used by <T: Ord> op: T, T, -> bool for op being one of <, >, <=, >=.

Eq: Implemented by int, fe, expr. Used by <T: Eq> op: T, T -> bool for op being one of ==, !=.

List of Types

Bool

Type name: bool

Booleans are the results of comparisons. They allow the following operators:

  • &&: logical conjunction
  • ||: logical disjunction
  • !: logical negation

Short-circuiting is not performed when evaluating boolean operators. This means that (1 == 1) || std::check::panic("reason") will cause a panic abort.

Integer

Type name: int

Integers in powdr-pil have unlimited size. Array index requires an integer and row indices (for example the input to a fixed column defined through a function) are also integers.

Integer implements FromLiteral, which means that literal numbers can be used in contexts where int is expected.

Integers allow the following operators, whose result is always an integer:

  • +: addition
  • -: subtraction (also unary negation)
  • *: multiplication
  • /: integer division rounding towards zero, division by zero results in a runtime error
  • **: exponentiation, the exponent needs to be non-negative and fit 32 bits, otherwise a runtime error is triggered
  • %: remainder after division, (for signed arguments, p % q == sgn(p) * abs(p) % abs(q)), remainder by zero results in a runtime error
  • &: bit-wise conjunction
  • |: bit-wise disjunction
  • ^: bit-wise exclusive or
  • <<: bit-wise shift left, the shift amount needs to be non-negative and fit 32 bits, otherwise a runtime error is triggered
  • >>: bit-wise shift right, the shift amount needs to be non-negative and fit 32 bits, otherwise a runtime error is triggered

The exponentiation operator on field elements requires a non-negative integer as exponent. It has the signature **: fe, int -> fe.

In addition, the following comparison operators are allowed, the result is a boolean:

  • <: less than
  • <=: less or equal
  • ==: equal
  • !=: not equal
  • >=: greater or equal
  • >: greater than

Field Element

Type name: fe

Field elements are elements of a particular but unspecified prime field. The exact field is chosen when powdr is run. The modulus of that field can be accessed via std::field::modulus().

Field elements are the values stored in (fixed and witness) columns. Arithmetic inside constraints (algebraic expressions) is also always finite field arithmetic.

The type fe implements FromLiteral, which means that literal numbers can be used in contexts where fe is expected. If the literal number is not less than the field modulus, a runtime error is caused.

Field elements allow the following operators, where the result is always a field element:

  • +: finite field addition
  • -: finite field subtraction (also unary negation)
  • *: finite field multiplication

There is also an exponentiation operator ** on field elements. It requires the exponent to be a non-negative integer and thus has the signature **: fe, int -> fe. If the exponent is negative, a runtime error is triggered. 0**0 is defined as 1.

The following comparison operators exist for field elements, whose result is a boolean:

  • ==: equality comparison
  • !=: inequality comparison

Since finite fields do not have an inherent order as integers do, if you want to compare them using <, you have to first convert them to integers.

String

Type name: string

String literals are written as "string content". They are mainly used for debugging or documentation purposes, since they cannot occur in constraints.

They allow the following operators:

  • +: string concatenation

Tuple

Type name: (..., ..., ...)

Tuples are complex types that are composed from other types, either zero or two or more. There is no tuple type with a single element ((int) is the same as int). The empty tuple type is written as ().

Examples include (int, int) (a pair of integers) and ((fe[], int), ()) (a tuple consisting of a tuple that contains an array of field elements and an integer and an empty tuple).

Tuples values are constructed using parentheses: (1, 2) constructs the tuple that consists of a one and a two.

Tuples do not allow any operators.

Array

Type name: _[]

Arrays are statically or dynamically-sized collections of elements each of the same type denoted for example as int[] (a dynamically-sized array of integers) or int[2] (an array of integers with static size two). Array values can be constructed inline using [1, 2] (the array containing the two elements one and two).

The built-in function std::array::len can be used to retrieve the length of an array (statically or dynamically sized) and the elements of an array a can be accessed using a[0], a[1], etc.

The type checker currently only knows dynamically-sized arrays, which means that it does not compare the sizes of statically-sized array types.

Arrays allow the following operators:

  • +: array concatenation
  • _[]: array index access, the index needs to be a non-negative integer that is less than the length of the array, otherwise a runtime error is triggered

Function

Type name: T1, T2, ..., Tn -> T0

Function type names are for example denoted as int, fe -> int or -> int. Note that (int, fe) -> int is a function that takes a single tuple as parameter while int, fe -> int takes two parameters of type integer and field element.

Functions can be constructed using the lambda expression notation. For example |x, y| x + y returns a function that performs addition. The lambda expression || 7 is a function that returns a constant (has no parameters). Lambda functions can capture anything in their environment and thus form closures.

Functions allow the following operators:

  • _(...): function evaluation

Powdr-pil is usually side-effect free, but there are some built-in functions that have side-effects: These are std::debug::print and std::check::panic and all functions that call them. Expressions are eagerly evaluated from left to right.

Expression

Type name: expr

Expressions are the elements of the algebraic expressions used in constraints.

References to columns have type expr and expr also implements FromLiteral, which means that literal numbers can be used in contexts where expr is expected.

Example:

let x: col;
let y: col;
let f: -> expr = || x + y;
let g = || 7;
f() = g();

The first two lines define the witness columns x and y. The next two lines define the utility functions f and g. The function f adds the two columns x and y symbolically - it essentially returns the expression x + y. The last line is at statement level and it is expected that it evaluates to a constraint, in this case, a polynomial identity. Because of that, g is inferred to have type -> expr, which is compatible with the literal 7.

Since expressions are built from abstract column references, applying operators does not perform any operations but instead constructs an abstract expression structure / syntax tree.

Expressions allow the following operators, which always construct new expressions:

  • +: additive combination of expressions
  • -: subtractive combination of expressions (also unary negation)
  • *: multiplicative combination of expressions
  • **: exponential combination of an expression with an integer constant
  • ': reference to the next row of a column, can only be applied directly to columns and only once

The operator = on expressions constructs a constraint (see [../builtins#Constr]).

Bottom Type

Type name: !

The bottom type essentially is the return type of a function that never returns, which currently only happens if you call the panic function. The bottom type is compatible with any other type, which means that you can call the panic function in any context.

Enum Types

Enums are user-defined types that can hold different named alternatives plus data. An enum type has a (namespaced) name that uniquely identifies it and is also used to reference the type.

Enums are declared in the following way:

enum EnumName {
    Variant1,
    Variant2(),
    Variant3(int),
    Variant4(int, int[], EnumName),
}

The variants must have unique names inside the enum and they can optionally take additional data. Each variant declares a type constructor function that can be used to create a value of the enum:

let a = EnumName::Variant1;
let b = EnumName::Variant2();
let c = EnumName::Variant3(3);
let d = EnumName::Variant4(1, [2, 3], EnumName::Variant1);

Recursive enums are allowed.

Enums do not allow any operators.