-
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
Move Field to JS #902
Move Field to JS #902
Conversation
59e9894
to
91a4cfe
Compare
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 for reviewers!
feePayer, | ||
'assert_equal' | ||
'Member already exists!' | ||
); |
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 changes to errors required us to update some of the tests
/** | ||
* An element of a finite field. | ||
*/ | ||
const Field = toFunctionConstructor(InternalField); |
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.
Here is where Field
is turned from a class into a "class that can also be used as a function", to support Field(x)
@@ -0,0 +1,110 @@ | |||
import { Context } from './global-context.js'; |
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 file collects the lower level global state / circuit runners that were previously in provable.ts
and proof_system.ts
. This is done so that core modules like Field
can use this without creating a circular dependency with the higher-level stuff in provable.ts
and proof_system.ts
@@ -194,12 +198,10 @@ function witness<T, S extends FlexibleProvable<T> = FlexibleProvable<T>>( | |||
|
|||
let id = snarkContext.enter({ ...ctx, inWitnessBlock: true }); | |||
try { | |||
fields = Snarky.exists(type.sizeInFields(), () => { | |||
let [, ...fieldVars] = Snarky.exists(type.sizeInFields(), () => { |
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.
exists
operates no longer on OCaml field classes (which we try to remove wherever we can) but directly on Field.t
and Field.Constant.t
, the OCaml field types
@@ -170,22 +265,22 @@ declare class Field { | |||
* let sum = a.add(5) | |||
* ``` | |||
*/ | |||
add(y: Field | number | string | boolean): Field; | |||
add(y: Field | number | string | bigint): Field; |
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 nobody ever used a boolean
to specify a field element, anywhere. I removed this feature and made sure you can use bigint
everywhere instead
add(y: Field | bigint | number | string): Field { | ||
if (this.isConstant() && isConstant(y)) { | ||
return new Field(Fp.add(this.toBigInt(), toFp(y))); | ||
} | ||
// return new AST node Add(x, y) | ||
let z = Snarky.field.add(this.value, Field.#toVar(y)); | ||
return new Field(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.
this method shows the typical patterns that we use everywhere:
- the input is tested for constants (without yet converting
y
to a constant field element, because thebigint
->Uint8array
conversion is fairly expensive) - if everything is constant, we use
Fp
which is the TS finite field implementation - if not, we use lower level Snarky functions that operate on
FieldVar
(in OCaml:Field.t
orfield Cvar.t
) - we wrap the resulting variables back into a
Field
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.
Since this is a pattern we use everywhere, can/should we abstract it away?
function binop<T>(x: Field, y: Fieldlike, tsImpl: (Field, Fieldlike) => T, snarkyImpl: (Field, Fieldlike) => T): T {
if (x.isConstant() && isConstant(y)) {
return tsImpl(x.toBigInt(), toFp(y));
}
return snarkyImpl(x.value, Field.#toVar(y));
}
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.
We could do that, but I don't feel it would make the code better. IMO, patterns are often better than abstractions, as in this case.
Abstractions use specialized concepts (binop(x, y, tsImpl, snarkyImpl)
) that have to be learned, while patterns use plain language features like if conditions that everyone already knows
Abstractions get leaky, or are only partially applicable. Patterns are flexible to adapt to any use case.
Unrelated reason why I'm not in favor is that I try to avoid all wrapper functions, because they add two lines to our stack traces and our stack traces are too long:
...
at binop (snarkyjs/src/lib/field.ts:192:11)
at <anonymous> (snarkyjs/src/lib/field.ts:210:18)
...
/** | ||
* Multiplies this {@link Field} element with another coercible to a field. | ||
*/ | ||
mul(y: Field | bigint | number | string): Field { |
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 thought it was quite instructive how mul()
works, and how complicated it actually is!
/** | ||
* @deprecated use `x.equals(0)` which is equivalent | ||
*/ | ||
isZero() { |
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.
isZero()
is the base method for implementing equals()
. Still, I think there should be just one way to do things, so I deprecated isZero()
with the plan to make it a private helper method in the future.
Btw, I think how this is implemented is VERY instructive and interesting
}); | ||
|
||
function isField(x: unknown): x is Field { | ||
return x instanceof Field || (x as any) instanceof SnarkyFieldConstructor; |
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 some places we still have to deal with the possibility of field classes created in OCaml
); | ||
|
||
// arithmetic, both in- and outside provable code | ||
equivalent2((x, y) => x.add(y), Fp.add); |
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 idea of these tests is that you have two implementations - the TS finite field Fp
, and Field
- and you test that they are equivalent, both in- and outside provable code. "Equivalent" also means that one throws an error if and only if the other throws an error
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.
first pass looks great!
@@ -43,11 +45,19 @@ declare interface ProvablePure<T> extends Provable<T> { | |||
check: (x: T) => void; | |||
} | |||
|
|||
// ocaml types | |||
type MlTuple<X, Y> = [0, X, 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.
is there a reason why OCaml arrays always start with a leading 0?
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.
Here's the source of truth: https://github.com/ocsigen/js_of_ocaml#data-representation
But I don't know the reason for that leading 0. Maybe @dannywillems knows :D
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 would guess it is to mimic the memory layout of the native compiler. The native Caml runtime will represent tuples, records and arrays with block with a tag 0
, documentation here.
Tuples, records, and arrays are all represented identically at runtime as a block with tag 0. Tuples and records have constant sizes determined at compile time, whereas arrays can be of variable length. While arrays are restricted to containing a single type of element in the OCaml type system, this is not required by the memory representation.
And in the JS runtime, it seems it is mimic (tuple, records and arrays are identical, i.e. a JS array with the first element set to 0
.
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.
awesome! lgtm
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.
Amazing! Excited for snarkyjs/light
!
* | ||
* @return A bigint equivalent to the bigint representation of the Field. | ||
*/ | ||
toBigInt() { |
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.
can we change this debugToBigInt()
or toBigIntDebug()
something
like that to make it clear in the name that it's only for debugging?
We've seen so many people make this mistake, it's probably worth it.
I imagine tactically, you'd need to deprecate these since they're in-use already and we can remove them fully later.
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 I'm going to punt on these suggestions, to discuss more as we have a bit of design space here.
What about the following ideas:
On variables, toBigInt()
uses As_prover.read_var
which is only available in an asProver
block. This leads to the infamous and confusing error Can't evaluate prover code outside an as_prover block
=> why don't we just pass in a read_var
/ toConstant()
function to asProver
and witness
blocks?
Provable.asProver((toConstant) => {
console.log(toConstant(myValue).toString())
}
This would be the only supported way to ever call As_prover.read_var
, and it would always be valid.
However, on constant field elements, the toBigInt()
/ toString()
functions are still very useful, and not only for debugging, but mainly for IO. That's why I'm also not convinced of the name change. However, with the change above, we would never even try read_var
inside toBigInt()
and would fail with a simpler error message:
Field.toBigInt(): This method can't be called on variables. <some more info about variables vs constants>
This would get ideal once we start having a separate type for constants. Then, toBigInt()
would only be present on the constant type.
Another idea that I've been kicking around is that we shouldn't emphasize using constant Field elements so much. Instead, everything that's not a variable should just be a plain JS type, like bigint or number, and it should be possible to use that in place of constant Field elements everywhere
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.
Instead, everything that's not a variable should just be a plain JS type, like bigint or number, and it should be possible to use that in place of constant Field elements everywhere
I like this ^^, especially because you can monkey-patch things in JavaScript (though of course we should be careful)
add(y: Field | bigint | number | string): Field { | ||
if (this.isConstant() && isConstant(y)) { | ||
return new Field(Fp.add(this.toBigInt(), toFp(y))); | ||
} | ||
// return new AST node Add(x, y) | ||
let z = Snarky.field.add(this.value, Field.#toVar(y)); | ||
return new Field(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.
Since this is a pattern we use everywhere, can/should we abstract it away?
function binop<T>(x: Field, y: Fieldlike, tsImpl: (Field, Fieldlike) => T, snarkyImpl: (Field, Fieldlike) => T): T {
if (x.isConstant() && isConstant(y)) {
return tsImpl(x.toBigInt(), toFp(y));
}
return snarkyImpl(x.value, Field.#toVar(y));
}
Something like that ^^
closes #869
bindings: o1-labs/o1js-bindings#12
The main goal of this PR is to improve the maintainability of snarkyjs' core, replacing the OCaml implementation of the Field class we had before with a new TS class
Field
. This will be followed up by similar refactors to other core typesBool
,Group
andScalar
, and a major cleanup of the API surface between OCaml and TypeScript.Changes:
Snarky.field
module exposed from OCaml, seesnarky.d.ts
Field
class, see the newfield.ts
. strategy:mul()
orequals()
, are reimplemented on top of low level snarky building blocks: constraint-adding functions andexists
. The goal of this is to give more visibility into the workings of snarkyjs for the community.toBits()
andgreaterThan()
, are kept in OCaml to limit scope{ value: Cvar(Constant | Var(int) | ... ) }
) is explicitly encoded as part of the public type. Again, this is to improve visibility into what's going on.Field
are removed. It just felt like a waste of time reimplementing all of themisZero()
method is deprecated because it is equivalent toequals(0)
assertNotEquals()
method is addedfield.unit-test.ts
An important property of this PR is that it does not change any of the example verification keys in
vk_regression.json
. In all reimplementations, we aimed for exact equivalence of constraints generated. That way, we can be sure that we didn't introduce wrong constraints accidentally. (I found optimizations to a few of the provable methods that would change constraints, but those are delayed for possible future PRs that are much smaller in scope.)In some sense, the PR leaves snarkyjs in an in-between stage: The old OCaml field class is still used under the hood by
Bool
,Group
andScalar
, and returned by functions such asPoseidon.hash
. So, we have to make the new class fully compatible with the old class. We might revisit some design choices (such as storing out-of-circuit Fields asUint8array
, and notbigints
) when this restriction is lifted later.As for user-facing changes, we arguably improve the error messages in
Field
methods on constants, and also fix quite a few bugs as a side effect of reimplementing stuff:closes #858
closes #469
closes #432
closes #743
closes #865
closes #723 (I think it's fair to say that this makes assertion errors clearer, compared to the weird hex spelling in the Ocaml error)