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

Move Field to JS #902

Merged
merged 55 commits into from
May 31, 2023
Merged

Move Field to JS #902

merged 55 commits into from
May 31, 2023

Conversation

mitschabaude
Copy link
Collaborator

@mitschabaude mitschabaude commented May 8, 2023

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 types Bool, Group and Scalar, and a major cleanup of the API surface between OCaml and TypeScript.

Changes:

  • use new lower level Snarky.field module exposed from OCaml, see snarky.d.ts
  • reimplements the entire Field class, see the new field.ts. strategy:
    • constant (out of snark) operations are implemented in TS on top of the TS field arithmetic (bindings/crypto). this is an important preparation step for being able to load the jsoo bundle async after page load
    • some simple provable methods, like mul() or equals(), are reimplemented on top of low level snarky building blocks: constraint-adding functions and exists. The goal of this is to give more visibility into the workings of snarkyjs for the community.
    • some more complicated provable methods, like toBits() and greaterThan(), are kept in OCaml to limit scope
    • the internal composition of a Field (as { 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.
  • all previously deprecated methods on Field are removed. It just felt like a waste of time reimplementing all of them
  • the isZero() method is deprecated because it is equivalent to equals(0)
  • a new efficient assertNotEquals() method is added
  • comprehensive tests are added in field.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 and Scalar, and returned by functions such as Poseidon.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 as Uint8array, and not bigints) 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)

Base automatically changed from refactor/js-circuit to main May 10, 2023 20:08
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 for reviewers!

Comment on lines 470 to 472
feePayer,
'assert_equal'
'Member already exists!'
);
Copy link
Collaborator Author

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);
Copy link
Collaborator Author

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';
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 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(), () => {
Copy link
Collaborator Author

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;
Copy link
Collaborator Author

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

Comment on lines +173 to +180
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);
}
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 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 the bigint -> 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 or field Cvar.t)
  • we wrap the resulting variables back into a Field

Copy link
Member

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 ^^

Copy link
Collaborator Author

@mitschabaude mitschabaude May 31, 2023

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 {
Copy link
Collaborator Author

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() {
Copy link
Collaborator Author

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;
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 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);
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 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

Copy link
Member

@Trivo25 Trivo25 left a 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!

src/lib/field.ts Show resolved Hide resolved
@@ -43,11 +45,19 @@ declare interface ProvablePure<T> extends Provable<T> {
check: (x: T) => void;
}

// ocaml types
type MlTuple<X, Y> = [0, X, Y];
Copy link
Member

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?

Copy link
Collaborator Author

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

Copy link
Member

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.

src/lib/field.test.ts Outdated Show resolved Hide resolved
Copy link
Member

@Trivo25 Trivo25 left a comment

Choose a reason for hiding this comment

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

awesome! lgtm

src/lib/field.ts Show resolved Hide resolved
src/lib/field.ts Show resolved Hide resolved
src/lib/field.ts Outdated Show resolved Hide resolved
src/lib/field.ts Show resolved Hide resolved
Copy link
Member

@bkase bkase left a 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!

src/lib/field.ts Outdated Show resolved Hide resolved
src/lib/field.ts Outdated Show resolved Hide resolved
*
* @return A bigint equivalent to the bigint representation of the Field.
*/
toBigInt() {
Copy link
Member

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.

Copy link
Collaborator Author

@mitschabaude mitschabaude May 31, 2023

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

Copy link
Member

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)

src/lib/field.ts Show resolved Hide resolved
Comment on lines +173 to +180
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);
}
Copy link
Member

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 ^^

src/lib/field.ts Show resolved Hide resolved
src/lib/field.ts Show resolved Hide resolved
src/lib/field.ts Show resolved Hide resolved
src/lib/field.ts Show resolved Hide resolved
src/lib/field.ts Show resolved Hide resolved
@mitschabaude mitschabaude mentioned this pull request May 31, 2023
@mitschabaude mitschabaude merged commit c331808 into main May 31, 2023
@mitschabaude mitschabaude deleted the refactor/field branch May 31, 2023 14:14
@mitschabaude mitschabaude mentioned this pull request Jun 1, 2023
@Trivo25 Trivo25 mentioned this pull request Jun 5, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
4 participants