-
Notifications
You must be signed in to change notification settings - Fork 134
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
Conversation
There was a problem hiding this 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
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); |
There was a problem hiding this comment.
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
// 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); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
// ensure we are using pure variables | ||
[x, y, z] = toVars([x, y, z]); | ||
let zero = toVar(0n); |
There was a problem hiding this comment.
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
// ensure we are using pure variables | ||
[xy, z] = toVars([xy, z]); |
There was a problem hiding this comment.
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
constraintSystem( | ||
'compact multi-range check', | ||
{ from: [Field, Field] }, | ||
Gadgets.compactMultiRangeCheck, | ||
ifNotAllConstant( | ||
contains(['RangeCheck0', 'RangeCheck0', 'RangeCheck1', 'Zero']) | ||
) | ||
); |
There was a problem hiding this comment.
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
rawMethods: Object.fromEntries( | ||
Object.entries(methods).map(([k, v]) => [k, v.method]) | ||
) as any, |
There was a problem hiding this comment.
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
src/lib/testing/constraint-system.ts
Outdated
There was a problem hiding this comment.
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
There was a problem hiding this 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! 🎉
There was a problem hiding this 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(); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)?
There was a problem hiding this comment.
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()
There was a problem hiding this 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 { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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)?
// 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); |
There was a problem hiding this comment.
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'); |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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>>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ConstrainConstantsHere
?
There was a problem hiding this 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!
// 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); |
There was a problem hiding this comment.
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))) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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']; |
There was a problem hiding this comment.
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.
testing/constraint-system.ts
xor
gateThis 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).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.