Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Supporting new assigned types

In order to use user defined types as inputs or outputs in a harness it is necessary to implement traits that make the type compatible with the extraction system.

Let’s do a small refresher on why this is necessary. A harness can be defined as follows.

#[entry("control-flow/select/native/native")]
#[harness]
pub fn select_native(
    chip: &NativeChip<F>,
    layouter: &mut impl Layouter<F>,
    (cond, a, b): (AssignedBit<F>, AssignedNative<F>, AssignedNative<F>),
) -> Result<AssignedNative<F>, Error> {
    chip.select(layouter, &cond, &a, &b)
}

In this example the input has type (AssignedBit<F>, AssignedNative<F>, AssignedNative<F>) and the output has type AssignedNative<F>. When lowering to Picus we cannot use these types directly because PCL does not support user defined types and everything is a field element. The extractor works around this issue by creating two auxiliary tables (one for inputs and another for outputs) that represent the inputs and outputs of the circuit as individual cells. In the example above we need 3 cells in the input auxiliary table and 1 cells in the output auxiliary table. This is because AssignedNative<F> is an alias over Halo2’s AssignedCell<F,F>, which takes up 1 cell, and AssignedBit<F> wraps an AssignedNative<F>.

As an example, we are going to add support to the following type as we explain the concepts.

struct AssignedFoo<F> {
    foo: [AssignedNative<F>; 10],
    bar: AssignedBit<F>
}

The extractor knows how many cells a type occupies using the CellReprSize trait from the mdnt-support crate. This trait has a constant usize that declares the amount of cells required. Types such as AssignedCell<F,F> or [T;N] already implement this trait, so we can leverage that for implementing the trait for AssignedFoo.

// Showcase of the implementation for AssignedCell.
impl<V, F> CellReprSize for AssignedCell<V, F> {
    const SIZE: usize = 1;
}

// For our type we can leverage the existing implementations.
impl<F> CellReprSize for AssignedFoo<F> {
    const SIZE: usize = <[AssignedNative<F>; 10]>::SIZE + <AssignedBit<F>>::SIZE; // Total 11 cells.
}

Now that we have the trait that declares the size of the type we can add support for reading, writing, or both. For using the type as an input in a harness we need to implement LoadFromCells. And for using the type as an output we need to implement StoreIntoCells. Both traits have 4 type parameters: F, C, E, and L. F is the type of the field and needs to implement the ff::PrimeField trait. C is the chip we are using for the harness. In the example at the beginning of this chapter it would be NativeChip. E is the extraction adaptor, which is a type that has a set of associated types representing different Halo2 concepts. E must implement the Halo2Types trait in mdnt-support. Last, L does not necessarily need to implement any trait in order to satisfy either LoadFromCells or StoreIntoCells. However, some implementations may require an instance of a Layouter and L can be constrained to that trait (L: Layouter<F>).

impl<F, L, C, E> LoadFromCells<F, C, E, L> for AssignedFoo<F>
where 
    F: PrimeField,
    E: Halo2Types<F>
{
    fn load(
        ctx: &mut ICtx<F, E>,
        chip: &C,
        layouter: &mut impl LayoutAdaptor<F, E, Adaptee = L>,
        injected_ir: &mut InjectedIR<E::RegionIndex, E::Expression>,
    ) -> Result<Self, E::Error> {
        // Inside this method we need to construct an instance of the type.
        Ok(Self {
            // We can leverage the existing implementations of the trait.
            foo: ctx.load(chip, layouter, injected_ir)?,
            bar: ctx.load(chip, layouter, injected_ir)?,
        })
    }
}

impl<F, L, C, E> StoreIntoCells<F, C, E, L> for AssignedFoo<F>
where 
    F: PrimeField,
    E: Halo2Types<F>
{
    fn store(
        self,
        ctx: &mut OCtx<F, E>,
        chip: &C,
        layouter: &mut impl LayoutAdaptor<F, E, Adaptee = L>,
        injected_ir: &mut InjectedIR<E::RegionIndex, E::Expression>,
    ) -> Result<(), E::Error> {
        // Inside this method we need to convert the type into a set of cells.
        // Again, we can leverage existing implementations.
        self.foo.store(ctx, chip, layouter, injected_ir)?;
        self.bar.store(ctx, chip, layouter, injected_ir)
    }
}

Inside those traits the programmer has the freedom to perform the conversion in different ways. The ctx gives access to the raw cells. The chip and layouter allow performing the conversion via some method the chip implements. For example, because the target chip implements a trait that already has the desired logic (adding C: ThatTrait to the where bounds). injected_ir allows writing Haloumi IR encoding semantic properties of the type. For example, the implementations for AssignedBit and AssignedByte in midnight-circuits emit IR that constraints the value to be within the range of the types; 0-1 and 0-255 respetively.

These traits require that the type has a known size and won’t work well with types that have dynamically sized types such as Vec. To work around this limitation we can use the following pattern.

/// Dynamically sized version of AssignedFoo
struct AssignedFooDyn<F> {
    foo: Vec<AssignedNative<F>>,
    bar: AssignedBit<F>
}

/// We create a new type that has a known length.
/// We will implement the traits on this type and use it as input and/or output.
/// Inside the harness we then convert between this type and [`AssignedFooDyn`].
struct LoadedFooDyn<F, const N: usize> {
    foo: AssignedFooDyn<F>,
    _marker: PhantomData<[(); N]>
}

impl<F, const N: usize> From<LoadedFooDyn<F, N>> for AssignedFooDyn<F> {
    fn from(value: LoadedFooDyn<F, N>) -> Self {
        value.foo
    }
}

// And implement the traits on this new type wrapper. 

impl<F, const N: usize> CellReprSize for LoadedFooDyn<F, N> {
    const SIZE: usize = <AssignedNative<F>>::SIZE * N + <AssignedBit<F>>::SIZE; // Total N+1 cells.
}

impl<F, L, C, E, const N: usize> LoadFromCells<F, C, E, L> for LoadedFooDyn<F, N>
where 
    F: PrimeField,
    E: Halo2Types<F>
{
    fn load(
        ctx: &mut ICtx<F, E>,
        chip: &C,
        layouter: &mut impl LayoutAdaptor<F, E, Adaptee = L>,
        injected_ir: &mut InjectedIR<E::RegionIndex, E::Expression>,
    ) -> Result<Self, E::Error> {
        let foo_arr: [AssignedNative<F>; N] = ctx.load(chip, layouter, injected_ir)?;
        Ok(Self {
            foo: AssignedFooDyn {
                foo: foo_arr.to_vec(),
                bar: ctx.load(chip, layouter, injected_ir)?,
            },
            _marker: PhantomData
        })
    }
}

impl<F, L, C, E, const N: usize> StoreIntoCells<F, C, E, L> for LoadedFooDyn<F, N>
where 
    F: PrimeField,
    E: Halo2Types<F>
{
    fn store(
        self,
        ctx: &mut OCtx<F, E>,
        chip: &C,
        layouter: &mut impl LayoutAdaptor<F, E, Adaptee = L>,
        injected_ir: &mut InjectedIR<E::RegionIndex, E::Expression>,
    ) -> Result<(), E::Error> {
        let foo_arr: [AssignedNative<F>; N] = self.foo.foo.try_into().map_err(|_| {
            // Handle error...
        })?;
        foo_arr.store(ctx, chip, layouter, injected_ir)?;
        self.foo.bar.store(ctx, chip, layouter, injected_ir)
    }
}