Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Foreign fields 2: Test DSL to detect broken gate chains #1241

Merged
merged 22 commits into from
Nov 16, 2023

Conversation

mitschabaude
Copy link
Collaborator

@mitschabaude mitschabaude commented Nov 13, 2023

  • Adds a new DSL for testing constraint system layouts -- see testing/constraint-system.ts
  • Uncovers and fixes broken gate chains in multi-range check and rotation/shift gadgets
  • Remove unnecessary avoidance of constant input to xor gate

This is how a test in the DSL looks like. It asserts properties about the constraint system generated by Gadgets.multiRangeCheck(). Namely, it checks that the CS contains the sequence 'RangeCheck0', 'RangeCheck0', 'RangeCheck1', 'Zero' (except if all inputs are constant; in this case, it asserts that the CS is empty).

constraintSystem(
  'multi-range check',
  { from: [Field, Field, Field] },
  (x, y, z) => Gadgets.multiRangeCheck([x, y, z]),
  ifNotAllConstant(
    contains(['RangeCheck0', 'RangeCheck0', 'RangeCheck1', 'Zero'])
  )
);

Below is test output for the original, broken version of multi-range check. To help with debugging these tests, we try to pretty-print the CS in a way that is compact but not leaves out important details.

In this example, the CS contains a generic gate (at index 2) that breaks the expected chain of multi-range check gates, which makes it invalid. This is exactly the kind of bug that the DSL is designed to catch.

image

@mitschabaude mitschabaude changed the title Test DSL to detect broken gate chains Add test DSL to detect broken gate chains Nov 13, 2023
@mitschabaude mitschabaude marked this pull request as ready for review November 13, 2023 16:28
@mitschabaude mitschabaude requested a review from a team as a code owner November 13, 2023 16:28
Copy link
Collaborator Author

@mitschabaude mitschabaude left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some comments to help with reviewing

Comment on lines -40 to +41
let allOnesF = new Field(2n ** BigInt(length) - 1n);

let allOnes = Provable.witness(Field, () => {
return allOnesF;
});

allOnesF.assertEquals(allOnes);
let allOnes = new Field(2n ** BigInt(length) - 1n);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

defensive logic like this is no longer necessary - we can easily check that the resulting gate layout is correct

Comment on lines +250 to +252
// flush zero var to prevent broken gate chain (zero is used in rangeCheck64)
// TODO this is an abstraction leak, but not clear to me how to improve
toVar(0n);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the kind of thing that is impossible to catch without tests

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In OCaml we had a PR where whenever you add a gate it adds a "shadow row" that specifies what the next row should be with Some or None (for anything), per cell in the next row.

I guess this PR is about something similar.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also had some tests which added an even/odd number of generic operations before calling the actual gadget so that in case the gadget was creating some "orphan" generic which was coincidentally correctly pushed to the end of the chain, we could catch it and fix the gadget accordingly. Do you think this could make sense in your tests?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@querolita I think my tests already exhibit that case, because I am creating random linear combinations of variables as inputs, something like x + 20y + 3z + 17, and this will generate an even or odd number of generic gates when passed to a gate, depending on the length of the linear combination. Since we test many random inputs, I'm pretty sure all cases are covered

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In OCaml we had a PR where whenever you add a gate it adds a "shadow row" that specifies what the next row should be with Some or None (for anything), per cell in the next row.

That sounds interesting! Do you know where that PR is?

Comment on lines +67 to +69
// ensure we are using pure variables
[x, y, z] = toVars([x, y, z]);
let zero = toVar(0n);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is what fixed the multi-range check gadget

Comment on lines +94 to +95
// ensure we are using pure variables
[xy, z] = toVars([xy, z]);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix for the compact range check gadget

Comment on lines +52 to +59
constraintSystem(
'compact multi-range check',
{ from: [Field, Field] },
Gadgets.compactMultiRangeCheck,
ifNotAllConstant(
contains(['RangeCheck0', 'RangeCheck0', 'RangeCheck1', 'Zero'])
)
);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removing ad-hoc tests which didn't catch the bugs in favor of comprehensive tests using the DSL

Comment on lines +437 to +439
rawMethods: Object.fromEntries(
Object.entries(methods).map(([k, v]) => [k, v.method])
) as any,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can now access the original zkprogram methods (not wrapped to return proofs) on program.rawMethods. this was asked for by community members as well and it helped me reuse the methods for tests in this PR

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the new code! I think it's pretty well-documented with comments

@mitschabaude mitschabaude changed the title Add test DSL to detect broken gate chains Foreign fields 2: Test DSL to detect broken gate chains Nov 13, 2023
Copy link
Contributor

@MartinMinkov MartinMinkov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fantastic! Bonus points for your DSL design, seems straightforward to extend it! But you added a pretty extensive suite for our Gadgets already! 🎉

Copy link
Contributor

@ymekuria ymekuria left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great @mitschabaude! I just had a question.


if (checked) {
return xor(a, allOnes, length);
} else {
return allOnes.sub(a);
return allOnes.sub(a).seal();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does .seal() do here?

Copy link
Collaborator Author

@mitschabaude mitschabaude Nov 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes sure that the constraint created by the subtraction is added at this point. In my tests I found that the constraint system created by unchecked not() is empty (I would have expected a generic gate, for the subtraction).

It was empty because additions and subtractions are treated as a form of lazy constraint / AST, which are only turned into an actual constraint once the result is included in some multiplication or assertion or custom gate.

seal() triggers that concretizing of the AST, by creating a new variable that is asserted equal with the old variable, so with seal() this gadget immediately leads to a generic gate in the CS. I thought this was the better behavior for a gadget, as it's normalized and not dependent on the remaining circuit where this subtraction will end up in the CS.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So seal() is for making sure snarky doesn't add generic gates where they are not desired (e.g. in the middle of a chained gate)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The snarky version of seal() could be used for that! The o1js version not because it doesn't "seal" constants. The o1js version was intended more to prevent unnecessary large numbers of constraints when the same long AST is flushed to generic gates again and again. The function added in this PR that does what you descirbe @jspada is called toVar()

Copy link

@jspada jspada left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this code is great. Approved with some minor suggestions and reminders.

// TODO: handle (special) constants
scale(c: FieldConst, x: FieldVar): FieldVar {
return [FieldType.Scale, c, x];
scale(c: bigint | FieldConst, x: FieldVar): FieldVar {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a note from our discussion of this code.

Remember to do the "collapsing" of scale that the OCaml code does, i.e. Scale a (Scale b x) becomes Scale a*b x

add(x: FieldVar, y: FieldVar): FieldVar {
if (FieldVar.isConstant(x) && x[1][1] === 0n) return y;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you do these types of checks often, perhaps you want to create a FieldVar.isZeroConst(x) helper for brevity.


if (checked) {
return xor(a, allOnes, length);
} else {
return allOnes.sub(a);
return allOnes.sub(a).seal();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So seal() is for making sure snarky doesn't add generic gates where they are not desired (e.g. in the middle of a chained gate)?

Comment on lines +250 to +252
// flush zero var to prevent broken gate chain (zero is used in rangeCheck64)
// TODO this is an abstraction leak, but not clear to me how to improve
toVar(0n);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In OCaml we had a PR where whenever you add a gate it adds a "shadow row" that specifies what the next row should be with Some or None (for anything), per cell in the next row.

I guess this PR is about something similar.

// check that gate chains stay intact

function xorChain(bits: number) {
return repeat(Math.ceil(bits / 16), 'Xor16').concat('Zero');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice

* Given a Field, collapse its AST to a pure Var. See {@link FieldVar}.
*
* This is useful to prevent rogue Generic gates added in the middle of gate chains,
* which are caused by snarky auto-resolving constants, adds and scales to vars.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really awesome

*
* Same as `Field.seal()` with the difference that `seal()` leaves constants as is.
*/
function toVar(x: Field | bigint) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this is better called ConstrainConstantHere or something like that?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not just for constants though!

/**
* Apply {@link toVar} to each element of a tuple.
*/
function toVars<T extends Tuple<Field | bigint>>(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ConstrainConstantsHere?

Copy link
Member

@querolita querolita left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very useful for debugging, congrats!

Comment on lines +250 to +252
// flush zero var to prevent broken gate chain (zero is used in rangeCheck64)
// TODO this is an abstraction leak, but not clear to me how to improve
toVar(0n);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also had some tests which added an even/odd number of generic operations before calling the actual gadget so that in case the gadget was creating some "orphan" generic which was coincidentally correctly pushed to the end of the chain, we could catch it and fix the gadget accordingly. Do you think this could make sense in your tests?

'and',
{ from: [Field, Field] },
Bitwise.rawMethods.and,
ifNotAllConstant(contains(xorChain(64)))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you only supporting And64 then?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, this tests a specific example circuit -- the and() method on the Bitwise zkprogram, which happens to use 64-bit and. but we support all bit lengths

ifNotAllConstant(contains(xorChain(64)))
);

let rotChain: GateType[] = ['Rot64', 'RangeCheck0', 'RangeCheck0'];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, but remember to have copy constraints to the RangeCheck0's to constant zero for the two most significant sublimbs to make it 64-bit instead of 88-bit.

@mitschabaude mitschabaude merged commit aec1e89 into main Nov 16, 2023
13 checks passed
@mitschabaude mitschabaude deleted the feature/cs-test branch November 16, 2023 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants