Skip to content

Commit 41f7fa5

Browse files
jeswrclaude
andauthored
feat(proof-methods): Noir opening with field-constraint rotations and additions (#6815)
Adds circuits/noir_tuned: the variable-length BLAKE3 opening in Noir with each rotation and 32-bit addition written as a hinted split checked by one linear equation and explicit range checks, as blake3_leaf.py emits them. noir_var keeps the u32-operator version, so run.sh now compares four circuits: the baseline, both Noir versions and the hand-written ACIR. Claude-Session: https://claude.ai/code/session_015CB7XiW3NDsggNCuDcuJJo Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4eebabc commit 41f7fa5

7 files changed

Lines changed: 146 additions & 31 deletions

File tree

‎proof-methods/acir/README.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,12 @@ so the circuit reveals no digit count. The generator appends them to the
3030
compiled `circuits/spliced` program, which checks `h2(2, hs) == operand` and
3131
the comparison verdict, and it solves and checks its own witnesses.
3232

33-
`circuits/noir_var` writes the same variable-length algorithm in Noir, so the
34-
comparison separates the gain from the algorithm from the gain from writing
35-
ACIR by hand. `circuits/baseline` calls `full_u64_check`. Each circuit opens
33+
`circuits/noir_var` writes the same variable-length algorithm in Noir with
34+
u32 shifts, OR and `wrapping_add`. `circuits/noir_tuned` writes it in Noir
35+
with the rotations and additions as the field constraints the generator
36+
emits (a hinted split checked by one linear equation and range checks).
37+
Together they separate the gain from the algorithm, from how the arithmetic
38+
is written, and from writing ACIR by hand. `circuits/baseline` calls `full_u64_check`. Each circuit opens
3639
`leaf::N` values.
3740

3841
```sh
@@ -44,7 +47,7 @@ BB=bb python3 proof-methods/acir/tamper.py 30
4447
python3 -m unittest proof-methods/acir/test_blake3_leaf.py
4548
```
4649

47-
`run.sh` proves and verifies all three circuits for the given values, with
50+
`run.sh` proves and verifies all four circuits for the given values, with
4851
bounds that do not reveal them. It does not pin tool versions or thread
4952
counts; compare timings only from one machine and one toolchain.
5053
`tamper.py` changes one witness of the hand-written circuit at a time and
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
[workspace]
2-
members = ["leaf", "baseline", "spliced", "noir_var", "encode"]
2+
members = ["leaf", "baseline", "spliced", "noir_var", "noir_tuned", "encode"]

‎proof-methods/acir/circuits/leaf/src/lib.nr‎

Lines changed: 104 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -31,22 +31,73 @@ pub fn verdict(op: u32, bound: u64, value: u64) -> bool {
3131
}
3232
}
3333

34-
fn rotr(x: u32, r: u32) -> u32 {
35-
(x >> r) | (x << (32 - r))
34+
unconstrained fn split(x: u32, r: u32) -> (Field, Field) {
35+
((x & ((1 << r) - 1)) as Field, ((x >> r) | (x << (32 - r))) as Field)
3636
}
3737

38-
fn g(s: &mut [u32; 16], a: u32, b: u32, c: u32, d: u32, x: u32, y: u32) {
38+
/// x >>> R as the hand-written ACIR does it: x = rot * 2^R - lo * (2^32 - 1)
39+
/// with lo < 2^R and rot < 2^32. Both bounds are explicit; the compiler drops
40+
/// the 32-bit check where an XOR on the result already implies it.
41+
fn rotr<let R: u32>(x: u32) -> u32 {
42+
// Safety: lo and rot are constrained below.
43+
let (lo, rot) = unsafe { split(x, R) };
44+
lo.assert_max_bit_size::<R>();
45+
rot.assert_max_bit_size::<32>();
46+
let p: Field = (1 << R) as Field;
47+
assert_eq(x as Field, rot * p - lo * 4294967295);
48+
rot as u32
49+
}
50+
51+
unconstrained fn carry_split(s: Field) -> (Field, Field) {
52+
let v = s as u64;
53+
((v & 0xFFFFFFFF) as Field, (v >> 32) as Field)
54+
}
55+
56+
/// (sum of 32-bit terms) mod 2^32 with an explicit carry of at most 2 bits.
57+
fn add(s: Field) -> u32 {
58+
// Safety: out and the carry are range-checked below.
59+
let (out, carry) = unsafe { carry_split(s) };
60+
out.assert_max_bit_size::<32>();
61+
carry.assert_max_bit_size::<2>();
62+
assert_eq(s, out + carry * 4294967296);
63+
out as u32
64+
}
65+
66+
/// The obvious Noir: u32 shifts, OR and wrapping_add.
67+
fn g_plain(s: &mut [u32; 16], a: u32, b: u32, c: u32, d: u32, x: u32, y: u32) {
3968
s[a] = s[a].wrapping_add(s[b]).wrapping_add(x);
40-
s[d] = rotr(s[d] ^ s[a], 16);
69+
s[d] = rotr_plain(s[d] ^ s[a], 16);
4170
s[c] = s[c].wrapping_add(s[d]);
42-
s[b] = rotr(s[b] ^ s[c], 12);
71+
s[b] = rotr_plain(s[b] ^ s[c], 12);
4372
s[a] = s[a].wrapping_add(s[b]).wrapping_add(y);
44-
s[d] = rotr(s[d] ^ s[a], 8);
73+
s[d] = rotr_plain(s[d] ^ s[a], 8);
4574
s[c] = s[c].wrapping_add(s[d]);
46-
s[b] = rotr(s[b] ^ s[c], 7);
75+
s[b] = rotr_plain(s[b] ^ s[c], 7);
76+
}
77+
78+
fn rotr_plain(x: u32, r: u32) -> u32 {
79+
(x >> r) | (x << (32 - r))
4780
}
4881

49-
fn compress(cv: [u32; 8], block: [u32; 16], block_len: u32, flags: u32) -> [u32; 8] {
82+
/// The same G with the rotations and additions written as field
83+
/// constraints, the way blake3_leaf.py emits them.
84+
fn g(s: &mut [u32; 16], a: u32, b: u32, c: u32, d: u32, x: u32, y: u32) {
85+
s[a] = add(s[a] as Field + s[b] as Field + x as Field);
86+
s[d] = rotr::<16>(s[d] ^ s[a]);
87+
s[c] = add(s[c] as Field + s[d] as Field);
88+
s[b] = rotr::<12>(s[b] ^ s[c]);
89+
s[a] = add(s[a] as Field + s[b] as Field + y as Field);
90+
s[d] = rotr::<8>(s[d] ^ s[a]);
91+
s[c] = add(s[c] as Field + s[d] as Field);
92+
s[b] = rotr::<7>(s[b] ^ s[c]);
93+
}
94+
95+
fn compress<let TUNED: u32>(
96+
cv: [u32; 8],
97+
block: [u32; 16],
98+
block_len: u32,
99+
flags: u32,
100+
) -> [u32; 8] {
50101
let mut s: [u32; 16] = [0; 16];
51102
for i in 0..8 {
52103
s[i] = cv[i];
@@ -58,14 +109,46 @@ fn compress(cv: [u32; 8], block: [u32; 16], block_len: u32, flags: u32) -> [u32;
58109
s[15] = flags;
59110
let mut m = block;
60111
for _ in 0..7 {
61-
g(&mut s, 0, 4, 8, 12, m[0], m[1]);
62-
g(&mut s, 1, 5, 9, 13, m[2], m[3]);
63-
g(&mut s, 2, 6, 10, 14, m[4], m[5]);
64-
g(&mut s, 3, 7, 11, 15, m[6], m[7]);
65-
g(&mut s, 0, 5, 10, 15, m[8], m[9]);
66-
g(&mut s, 1, 6, 11, 12, m[10], m[11]);
67-
g(&mut s, 2, 7, 8, 13, m[12], m[13]);
68-
g(&mut s, 3, 4, 9, 14, m[14], m[15]);
112+
if TUNED == 1 {
113+
g(&mut s, 0, 4, 8, 12, m[0], m[1]);
114+
} else {
115+
g_plain(&mut s, 0, 4, 8, 12, m[0], m[1]);
116+
}
117+
if TUNED == 1 {
118+
g(&mut s, 1, 5, 9, 13, m[2], m[3]);
119+
} else {
120+
g_plain(&mut s, 1, 5, 9, 13, m[2], m[3]);
121+
}
122+
if TUNED == 1 {
123+
g(&mut s, 2, 6, 10, 14, m[4], m[5]);
124+
} else {
125+
g_plain(&mut s, 2, 6, 10, 14, m[4], m[5]);
126+
}
127+
if TUNED == 1 {
128+
g(&mut s, 3, 7, 11, 15, m[6], m[7]);
129+
} else {
130+
g_plain(&mut s, 3, 7, 11, 15, m[6], m[7]);
131+
}
132+
if TUNED == 1 {
133+
g(&mut s, 0, 5, 10, 15, m[8], m[9]);
134+
} else {
135+
g_plain(&mut s, 0, 5, 10, 15, m[8], m[9]);
136+
}
137+
if TUNED == 1 {
138+
g(&mut s, 1, 6, 11, 12, m[10], m[11]);
139+
} else {
140+
g_plain(&mut s, 1, 6, 11, 12, m[10], m[11]);
141+
}
142+
if TUNED == 1 {
143+
g(&mut s, 2, 7, 8, 13, m[12], m[13]);
144+
} else {
145+
g_plain(&mut s, 2, 7, 8, 13, m[12], m[13]);
146+
}
147+
if TUNED == 1 {
148+
g(&mut s, 3, 4, 9, 14, m[14], m[15]);
149+
} else {
150+
g_plain(&mut s, 3, 4, 9, 14, m[14], m[15]);
151+
}
69152
let mut next = m;
70153
for i in 0..16 {
71154
next[i] = m[PERMUTATION[i]];
@@ -90,8 +173,9 @@ unconstrained fn digit_count(value: u64) -> u32 {
90173
}
91174

92175
/// big-endian(blake3(token)[1..32]) for the canonical xsd:integer token of
93-
/// value, with one variable-length hash over two compressions.
94-
pub fn literal_hash(value: u64) -> Field {
176+
/// value, with one variable-length hash over two compressions. TUNED = 1
177+
/// writes the rotations and additions as field constraints; 0 uses u32 ops.
178+
pub fn literal_hash<let TUNED: u32>(value: u64) -> Field {
95179
let mut digits: [u8; 20] = [0; 20];
96180
let mut remaining = value;
97181
for i in 0..20 {
@@ -148,8 +232,8 @@ pub fn literal_hash(value: u64) -> Field {
148232
let two = n > 18;
149233
let len = n + 46;
150234
let (len1, flags1) = if two { (64, 1) } else { (len, 11) };
151-
let out1 = compress(IV, first, len1, flags1);
152-
let out2 = compress(out1, second, if two { len - 64 } else { 0 }, 10);
235+
let out1 = compress::<TUNED>(IV, first, len1, flags1);
236+
let out2 = compress::<TUNED>(out1, second, if two { len - 64 } else { 0 }, 10);
153237
let digest = if two { out2 } else { out1 };
154238
let mut hs: Field = 0;
155239
for i in 0..8 {
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[package]
2+
name = "noir_tuned"
3+
type = "bin"
4+
authors = ["sparq"]
5+
6+
[dependencies]
7+
sparq_zk_compose_core = { path = "../../../../zk/compose/compose_core" }
8+
leaf = { path = "../leaf" }
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// The same variable-length BLAKE3 opening as blake3_leaf.py, written in Noir
2+
// with the rotations and additions as field constraints.
3+
use sparq_zk_compose_core::hashes::{h2, TYPE_CODE_LITERAL};
4+
5+
fn main(
6+
operands: pub [Field; leaf::N],
7+
ops: pub [u32; leaf::N],
8+
bounds: pub [u64; leaf::N],
9+
values: [u64; leaf::N],
10+
) {
11+
for i in 0..leaf::N {
12+
let hs = leaf::literal_hash::<1>(values[i]);
13+
assert_eq(h2(TYPE_CODE_LITERAL, hs), operands[i], "operand encoding mismatch");
14+
assert(leaf::verdict(ops[i], bounds[i], values[i]), "filter verdict mismatch");
15+
}
16+
}

‎proof-methods/acir/circuits/noir_var/src/main.nr‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ fn main(
88
values: [u64; leaf::N],
99
) {
1010
for i in 0..leaf::N {
11-
let hs = leaf::literal_hash(values[i]);
11+
let hs = leaf::literal_hash::<0>(values[i]);
1212
assert_eq(h2(TYPE_CODE_LITERAL, hs), operands[i], "operand encoding mismatch");
1313
assert(leaf::verdict(ops[i], bounds[i], values[i]), "filter verdict mismatch");
1414
}

‎proof-methods/acir/run.sh‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
#!/usr/bin/env bash
2-
# Builds the three openings of N private xsd:integer literals and proves and
2+
# Builds the four openings of N private xsd:integer literals and proves and
33
# verifies each with UltraHonk:
44
# baseline - full_u64_check: 20 fixed-length BLAKE3 black boxes per value
5-
# noir_var - one variable-length BLAKE3 per value, written in Noir
5+
# noir_var - one variable-length BLAKE3 per value, written in Noir with u32
6+
# shifts, OR and wrapping_add
7+
# noir_tuned - the same in Noir, with rotations and additions written as
8+
# field constraints as the hand-written ACIR does
69
# hand - the same variable-length BLAKE3 as hand-written ACIR,
710
# appended to the compiled `spliced` program
811
# Each value is compared with value <= 2^64 - 1 (op 1), which every u64
@@ -29,17 +32,18 @@ bounds = [$(join "${bounds[@]}")]
2932
values = [$(join "${values[@]}")]"
3033
printf '%s\n' "$common" > baseline/Prover.toml
3134
printf '%s\n' "$common" > noir_var/Prover.toml
35+
printf '%s\n' "$common" > noir_tuned/Prover.toml
3236
printf '%s\nhs = [%s]\n' "$common" "$(join "${hs[@]}")" > spliced/Prover.toml
33-
for p in baseline noir_var spliced; do
37+
for p in baseline noir_var noir_tuned spliced; do
3438
"$NARGO" execute --package "$p" >/dev/null
3539
done
3640
python3 ../blake3_leaf.py splice target/spliced.json target/spliced.gz target/hand.json target/hand.gz >&2
3741
# bb has no UltraHonk witness check, so prove and verify.
38-
for p in baseline noir_var hand; do
42+
for p in baseline noir_var noir_tuned hand; do
3943
out=target/proof-$p
4044
rm -rf "$out" && mkdir -p "$out"
4145
"$BB" prove -s ultra_honk -b "target/$p.json" -w "target/$p.gz" -o "$out" --write_vk >"$out/log" 2>&1 \
4246
&& "$BB" verify -s ultra_honk -p "$out/proof" -k "$out/vk" -i "$out/public_inputs" >>"$out/log" 2>&1 \
4347
|| { echo "$p does not prove and verify (see $out/log)" >&2; exit 1; }
4448
done
45-
echo "values $*: all three prove and verify" >&2
49+
echo "values $*: all four prove and verify" >&2

0 commit comments

Comments
 (0)