一名放貸機構公開其評分模型。申請人用自己的數據運行該模型,再向放貸機構提交一份寫着「已批核」或「已拒絕」的回執。放貸機構看不到數據,申請人也無法更改模型,或向模型輸入虛構的數據。
這是在公開程序與公開參數、私有輸入之下證明一項計算的最小實用個案。模型是以整數運算實現的兩層神經網絡。同一形態適用於監管機構或交易對手希望能夠查閱的任何規則:定價公式、資格測試、風險限額。
Example programs 載有四個示例頁面共用的設置。
1. 證明甚麼
涉及三方:
- 放貸機構部署程序,並以模型作為其公開段。程序 id 即指明該模型。任何人都可以從鏈上讀回權重。
- 認證方(信貸資料機構、銀行、僱主)掌握申請人的數據。它向申請人提供特徵值、一個隨機的 16 字節鹽值,以及它對承諾值
SHA-256(salt ‖ features)的簽名。 - 申請人以鹽值和特徵值作為私有輸入來調用程序。
該次調用證明:
- 每項特徵都在模型為其聲明的範圍之內;
- 模型對這些特徵計算出的分數達到或高於門檻(狀態
1),或低於門檻(狀態2); - 所公布的承諾值是該鹽值與這些相同特徵的 SHA-256。
放貸機構會檢查回執的程序 id、其狀態,以及該承諾值是否為認證方所簽署。它從中只得知關於申請人的一個比特。
**私有輸入本身並不能證明任何關於現實世界的事。**沒有第 3 點,申請人可以隨意填入任何數字。承諾值正是把證明中的數字與有人擔保的數字連繫起來的環節。簽名本身由放貸機構在鏈下驗證:Dilithium 或 ECDSA 的驗證放不進一次調用的預算,而且由於承諾值是公開的,簽名也無須放進證明之內。
2. 接口
每個公開字都是 i32,以其二補數 u32 存儲。
| 公開字 | 含義 |
|---|---|
| 0 | 特徵數目 n,1 至 16 |
| 1 | 隱藏單元數目 h,1 至 16 |
| 2 | 門檻 |
| 3 | 小數位數,即移位量 s,0 至 31 |
之後 2n 個字 | 每項特徵允許的 min 與 max |
之後 h × n 個字 | 第一層的權重,逐行排列 |
之後 h 個字 | 第一層的偏置 |
之後 h 個字 | 第二層的權重 |
| 之後 1 個字 | 第二層的偏置 |
| 私有字 | 含義 |
|---|---|
| 0 至 3 | 鹽值,16 字節,以四個大端序的字表示 |
| 4 起 | n 項特徵,每項為一個 i32 |
| 輸出槽 | 含義 |
|---|---|
| 0 | 狀態:1 已批核,2 已拒絕,3 有特徵超出範圍,4 模型有誤(某個大小越界,或運算溢出) |
| 1 | 未使用 |
| 2 至 6 | 承諾值的前 20 字節,以五個大端序的字表示 |
| 7 | 未使用;Rust 的 panic 會在此寫入 0xdeadbeef |
承諾值只在狀態為 1 或 2 時公布。輸入超出範圍時,程序會在進行任何哈希之前停止。
3. 運算
模型只用整數。該機器沒有浮點運算,而驗證者必須能夠精確重現分數,浮點運算無法保證在不同機器之間做到這一點。
hidden[j] = max( (b1[j] + Σ w1[j][i] · x[i]) >> s , 0 ) for each hidden unit j
score = (b2 + Σ w2[j] · hidden[j]) >> s
approved = score >= threshold
權重為帶 s 個小數位的定點數:s = 8 時,權重 256 即代表 1.0。求和使用 i64,每一次乘法和加法都經過檢查,因此權重大到足以溢出的模型會得出狀態 4,而不是錯誤的分數。對負數 i64 作 >>,Rust 向負無窮取整,Python 的 >> 亦然,所以一個十行的 Python 函數即可作為參考實現。
示範模型有八項特徵和四個隱藏單元。其權重是為本頁以手工寫成的。它不是訓練出來的模型,也不能說明任何關於信貸的事。
{
"shift": 8,
"threshold": 600,
"features": [
{"name": "age_years", "min": 18, "max": 120},
{"name": "income_thousands", "min": 0, "max": 100000},
{"name": "debt_to_income_pct", "min": 0, "max": 1000},
{"name": "on_time_payments_pct", "min": 0, "max": 100},
{"name": "utilisation_pct", "min": 0, "max": 100},
{"name": "history_years", "min": 0, "max": 100},
{"name": "inquiries_12m", "min": 0, "max": 100},
{"name": "delinquencies", "min": 0, "max": 100}
],
"w1": [
[ 64, 96, -512, 1536, -640, 1024, -2048, -8192],
[ 0, 256, -1024, 0, 0, 0, 0, 0],
[ 0, 0, 0, 2560, -1280, 0, -4096, -16384],
[ 128, 0, 0, 0, 0, 2048, 0, 0]
],
"b1": [25600, 12800, -128000, 0],
"w2": [160, 96, 224, 128],
"b2": 76800
}
4. Guest 程序
my-guests/score/src/main.rs,完整內容:
#![no_std]
#![no_main]
use guest_sdk::{halt, read_input, read_public, sha256_compress, write_output};
const MAX_FEATURES: usize = 16;
const MAX_HIDDEN: usize = 16;
const APPROVED: u32 = 1;
const DECLINED: u32 = 2;
const OUT_OF_RANGE: u32 = 3;
const BAD_MODEL: u32 = 4;
/// SHA-256's initial state (FIPS 180-4 §5.3.3).
const IV: [u32; 8] = [
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
];
/// SHA-256 of `words`, each word taken as four big-endian bytes. The message is whole words,
/// so the padding is whole words too: `0x80000000`, zeros, then the bit length in the last
/// word of a block (the length's high word is always zero here).
fn sha256_words(words: &[u32]) -> [u32; 8] {
let mut buf = [0u32; 24];
buf[16..].copy_from_slice(&IV);
let mut fill = 0;
for &w in words {
buf[fill] = w;
fill += 1;
if fill == 16 {
sha256_compress(&mut buf);
fill = 0;
}
}
buf[fill] = 0x8000_0000;
fill += 1;
if fill > 14 {
buf[fill..16].fill(0);
sha256_compress(&mut buf);
fill = 0;
}
buf[fill..15].fill(0);
buf[15] = 32 * words.len() as u32;
sha256_compress(&mut buf);
let mut out = [0u32; 8];
out.copy_from_slice(&buf[16..]);
out
}
/// `weight · x`, added to `acc`; `None` when it does not fit an i64.
fn mul_add(acc: i64, weight: u32, x: i64) -> Option<i64> {
acc.checked_add((weight as i32 as i64).checked_mul(x)?)
}
/// Runs the model in the public segment over the private features.
fn score(features: &[i32], hidden: usize, shift: u32) -> Option<i64> {
let n = features.len();
// The public segment after the header and the feature ranges:
// hidden × n first-layer weights, hidden biases, hidden second-layer weights, one bias.
let w1 = 4 + 2 * n;
let b1 = w1 + hidden * n;
let w2 = b1 + hidden;
let b2 = w2 + hidden;
let mut out = read_public(b2 as u32) as i32 as i64;
for j in 0..hidden {
let mut acc = read_public((b1 + j) as u32) as i32 as i64;
for (i, &x) in features.iter().enumerate() {
acc = mul_add(acc, read_public((w1 + j * n + i) as u32), x as i64)?;
}
// Fixed point: drop the fractional bits, then ReLU.
let h = (acc >> shift).max(0);
out = mul_add(out, read_public((w2 + j) as u32), h)?;
}
Some(out >> shift)
}
/// Public segment, fixed at deploy (every value an i32):
/// [features n, hidden units, threshold, fractional bits,
/// n × (min, max), hidden × n weights, hidden biases, hidden weights, bias]
/// Private input: [salt (4 words), n features].
/// Outputs: [status, 0, commitment (first 5 words), 0], where
/// commitment = SHA-256(salt ‖ features), every word big-endian.
#[no_mangle]
pub extern "C" fn main() -> ! {
let n = read_public(0) as usize;
let hidden = read_public(1) as usize;
let threshold = read_public(2) as i32 as i64;
let shift = read_public(3);
if n == 0 || n > MAX_FEATURES || hidden == 0 || hidden > MAX_HIDDEN || shift > 31 {
write_output(0, BAD_MODEL);
halt();
}
// The committed message: the salt, then the features, in input order.
let mut message = [0u32; 4 + MAX_FEATURES];
for (i, w) in message.iter_mut().enumerate().take(4 + n) {
*w = read_input(i as u32);
}
let mut features = [0i32; MAX_FEATURES];
let mut in_range = true;
for i in 0..n {
let x = message[4 + i] as i32;
let min = read_public(4 + 2 * i as u32) as i32;
let max = read_public(5 + 2 * i as u32) as i32;
in_range &= min <= x && x <= max;
features[i] = x;
}
if !in_range {
write_output(0, OUT_OF_RANGE);
halt();
}
let status = match score(&features[..n], hidden, shift) {
Some(s) if s >= threshold => APPROVED,
Some(_) => DECLINED,
None => BAD_MODEL,
};
let commitment = sha256_words(&message[..4 + n]);
write_output(0, status);
for k in 0..5 {
write_output(2 + k as u32, commitment[k]);
}
halt();
}
**模型是數據,不是代碼。**權重以 read_public 讀取,因此同一個已部署的映像可以服務任何最多 16 項特徵、16 個隱藏單元的模型。更換模型即是以新的公開段重新部署同一份代碼,並得到新的程序 id。放貸機構無法在申請人背後替換權重,申請人也無法挑選較寬鬆的模型:回執指明程序 id,而該 id 綁定了權重。
**範圍是模型的一部分。**沒有範圍,申請人固然無法謊報數值(承諾值可防止這一點),但認證方卻可能簽署無意義的數值,例如 −5 歲的年齡,而帶負權重的模型會因此給予獎勵。這項檢查同時也限定了運算的邊界。
**鹽值有兩個作用。**其一,它令承諾值無法被猜出:八個小整數的熵很低,沒有鹽值的話,任何人都可以對候選申請人逐一哈希再作比對。其二,它把 128 個隨機比特帶進計算之中。在約束集 7 對證明的總線總和加入遮蔽之前,對少量小型私有字進行的調用可以直接從證明本身被暴力破解,fullnode 的備註因此要求此類程序讀取隨機字。Chain 18 運行的是約束集 8,該問題已經修復,但鹽值只需四個輸入字,就能使這個疑問不復存在。
5. 主機端
score.py 只使用 Python 標準庫:
score.py setup <model.json> writes public.txt
score.py witness <model.json> <salt: 32 hex> <x0> <x1> … prints the private words;
stderr: the score, the decision, the commitment
score.py commit <salt: 32 hex> <x0> <x1> … prints the commitment alone
參考評分器與承諾值,完整內容:
def score(m, x):
"""The guest's arithmetic exactly: integers, a floor shift after each layer, ReLU between."""
out = m["b2"]
for row, b, w2 in zip(m["w1"], m["b1"], m["w2"]):
h = max((b + sum(w * xi for w, xi in zip(row, x))) >> m["shift"], 0)
out += w2 * h
return out >> m["shift"]
def commitment(salt: bytes, x):
return hashlib.sha256(salt + b"".join(struct.pack(">i", xi) for xi in x)).digest()
$ cd my-guests/score
$ python3 score.py setup model.json
wrote public.txt (61 words)
$ cat public.txt
8 4 600 8 18 120 0 100000 0 1000 0 100 0 100 0 100 0 100 0 100 64 96 4294966784 1536 4294966656 1024 4294965248 4294959104 0 256 4294966272 0 0 0 0 0 0 0 0 2560 4294966016 0 4294963200 4294950912 128 0 0 0 0 2048 0 0 25600 12800 4294839296 0 160 96 224 128 76800
較大的數字是負權重:4294966784 即 −512。
Guest 程序已對照此參考實現,以 300 名隨機申請人進行測試,比較其決定及全部五個承諾值字,亦測試了分數為 599 和 600 的申請人。每一例均相符。
6. 構建與運行
$ rand-guest build my-guests/score --max-words 65535
462 words against a cap of 65535 (fits); 0 ecall(s) with a non-static a7
OK
wrote my-guests/score/image.bin and its .sha256 (462 words, hc c63d7b15a165a11d007ce48bc37563b71f77fcc4991ffe0182c8cf4c77b6e7b5, program id ce8d234faf737c24bdd2826933ac843973c140c53e76fbb2cccaef3073da9818)
一名申請人,41 歲,收入 8.5 萬,負債收入比 28%,97% 的還款準時,信貸使用率 22%,信貸記錄 12 年,一次查詢,無拖欠記錄:
$ python3 score.py witness model.json 00112233445566778899aabbccddeeff 41 85 28 97 22 12 1 0 > witness.txt
score 1075 approved
commitment 9430c3818f5966f8dd33f78e05a7d079355191e6
$ cat witness.txt
1122867 1146447479 2291772091 3437096703 41 85 28 97 22 12 1 0
$ rand-guest run image.bin --public $(cat public.txt) --input $(cat witness.txt) --tier 14
out[0] = 1
out[1] = 0
out[2] = 2486223745
out[3] = 2405000952
out[4] = 3711170446
out[5] = 94883961
out[6] = 894538214
out[7] = 0
cycles 1810
tier 12
tier 14: fits (cycles 1946 of 16383, Poseidon2 permutations 136 of 2048)
2486223745 即 0x9430c381,為承諾值的前四個字節。分數 1075 沒有出現在任何輸出之中。
第二名申請人(23、31、55、82、91、2、6、2,使用另一個鹽值)得分 417,得出 out[0] = 2 及其自己的承諾值,同樣是 1,810 個周期。把第一名申請人的年齡改為 17,則得出 out[0] = 3,且沒有承諾值。
7. 預算
| 程序 | 462 個字 |
| 公開段 | 61 個字 |
| 私有字 | 12 |
| 周期 | 1,810 |
| SHA-256 壓縮 | 1(48 字節的消息可放入一個區塊) |
| 層級 | 12 |
這是四個程序之中成本最低的一個。前向傳遞為 36 次乘法。處於程序上限的模型,即 16 項特徵和 16 個隱藏單元,是 272 次乘法和 325 個字的公開段。它在模擬器中量得 7,900 個周期,約為層級 14 的一半。
運行與數據無關:200 名隨機申請人,不論獲批核或被拒絕,每次都恰好用了 1,810 個周期。一次調用所聲明的 gas 上限是公開的,並由該次運行的 gas 取整得出,因此周期數隨輸入而變的程序會洩露少量輸入信息。本程序沒有任何信息可以此方式洩露。
自 chain 18 起,錢包會列印該次運行的確切 gas 及其所聲明的上限:
gas: 2011 (declaring 2048, tier 12)
1,948 行,每行 1 gas,另加一次壓縮的 63。
8. 部署
模型的 61 個字與代碼字同樣收費:0.001 + 0.0001 × (462 + 61) = 0.0533 RAND。
$ rand fee deploy 462 --public-words 61
0.0533 RAND
$ rand program deploy image.bin --public public.txt
program id: 7bbec14289f4446231b3a9a5ac18cb0613a373ca8a14fe4f046f3fde7ae6c73c (462 words, hc 157b3dc61da165a18be47c00b76375c3c4fc771f01fe1f994ccfc882b5e7b677, public input 61 words, digest a08a7c691c45babeb39e953930d4d7174662b7b8adcc2d0f4e6c193d362d6668)
proving the spend authorisation on this machine (tier 10; seconds)…
authorisation proved in 7.4s: tier 10, 1364940 bytes
proving the bundle (tier 14; about a minute and a half on a laptop)…
proved in 123.5s: tier 14, 1497319 bytes
submitted deploy d47a9323ea53bfafc3faa36d004416d374bf3c2a48971d87e346be426b0caf14
0 RAND out, 59.7229 RAND change, fee 0.0533 RAND, anchored at height 98062, proved in 123.5s (bundle) + 7.4s (auth)
9. 調用
$ rand call 7bbec14289f4446231b3a9a5ac18cb0613a373ca8a14fe4f046f3fde7ae6c73c \
$(sed 's/[0-9][0-9]*/--input &/g' witness.txt)
proving the call locally (12 inputs stay private, over the program's 61-word public input)…
gas: 2011 (declaring 2048, tier 12)
fee 0.005673037 RAND (incl. two price steps of headroom)
proved in 30.6s: tier 12, 1838256 bytes, outputs [1, 0, 2486223745, 2405000952, 3711170446, 94883961, 894538214, 0]
proving the spend authorisation on this machine (tier 10; seconds)…
authorisation proved in 6.8s: tier 10, 1369611 bytes
proving the bundle (tier 14; about a minute and a half on a laptop)…
proved in 124.3s: tier 14, 1499112 bytes
submitted call 454c1b9eb1e159e682d4ff26b71f77d8a950b813533d91a89293dd432fdcf6f7
0 RAND out, 59.696058627 RAND change, fee 0.005673037 RAND, anchored at height 98832, proved in 124.3s (bundle) + 6.8s (auth)
{
"h_in": "ef80c744a8cdea62b56e334baaf7372e4a2e6c8282d37aa8ae96b4f039ec1a22",
"h_pub": "a08a7c691c45babeb39e953930d4d7174662b7b8adcc2d0f4e6c193d362d6668",
"height": 98993,
"index": 0,
"outputs": [
1,
0,
2486223745,
2405000952,
3711170446,
94883961,
894538214,
0
],
"program": "7bbec14289f4446231b3a9a5ac18cb0613a373ca8a14fe4f046f3fde7ae6c73c",
"tier": 12,
"tx": "454c1b9eb1e159e682d4ff26b71f77d8a950b813533d91a89293dd432fdcf6f7"
}
input transcript published; open it with `rand open-call 454c1b9eb1e159e682d4ff26b71f77d8a950b813533d91a89293dd432fdcf6f7`
狀態為 1,承諾值為十六進制的 9430c381 8f5966f8 dd33f78e 05a7d079 355191e6。h_pub 是該模型 61 個字的摘要。特徵、鹽值和分數都不在交易之中。
該次調用在 Apple Silicon 筆記本電腦上耗時略多於三分鐘:調用證明 31 秒,支出授權 7 秒,捆綁包 124 秒,峰值為 6.2 GB。證明大小為 1,838,256 字節。
10. 自行核實
放貸機構一方,手上只有回執和認證方簽署的承諾值:
- **模型。**對程序 id 調用
rand_getProgramPublic,會返回那 61 個字。把它們與已公布的model.json比較(score.py setup可重建它們)。 - **代碼。**在相同的 circuits commit 重新構建
my-guests/score,並將hc與程序的code_hash比較。 - 回執。
program是模型的 id,輸出 0 為1,槽 7 不是3735928559。 - **承諾值。**輸出 2 至 6 以十六進制列印,即是認證方所簽署承諾值的前 20 字節:
$ python3 score.py commit 00112233445566778899aabbccddeeff 41 85 28 97 22 12 1 0
9430c3818f5966f8dd33f78e05a7d079355191e6
拒絕同樣是一份回執。第二位申請人的調用 119e7834…1b3b 在 18 號鏈上,並在 20 號鏈上重複執行(見 c8a60183…9f73,結果同樣是拒絕),證明和提交的方式與前者相同:
$ python3 score.py witness model.json 0f1e2d3c4b5a69788796a5b4c3d2e1f0 23 31 55 82 91 2 6 2 > witness.txt
score 417 declined
commitment 0313ea462178574c49a10fe7ea1d84207652e1a3
$ rand call 7bbec14289f4446231b3a9a5ac18cb0613a373ca8a14fe4f046f3fde7ae6c73c \
$(sed 's/[0-9][0-9]*/--input &/g' witness.txt)
…
gas: 2011 (declaring 2048, tier 12)
proved in 26.1s: tier 12, 1836624 bytes, outputs [2, 0, 51636806, 561534796, 1235292135, 3927802912, 1985143203, 0]
…
submitted call 119e7834033edd19bba8e10c1a764e8bdbc0b1c8415a42f041faa392c6411b3b
狀態為 2,而 51636806 即 0x0313ea46。燃料、聲明的上限和級別都與獲批調用相同,因此兩者在鏈上只有狀態字和承諾值不同,其他方面並無差別。申請人沒有理由公開一次拒絕,但貸款方無論如何都可以要求出示回執,而被拒絕的申請人無法拿出一份獲批的回執。
證明不能給你的
- 真實的數據。 證明只顯示模型在已承諾的數值上運行過。這些數值是否真實,取決於認證方的說法,貸款方必須就此信任認證方的簽名。
- 無法關聯的申請人。 承諾值是一個固定的標識符:認證方可以識別它,而且兩份承諾值相同的回執屬於同一位申請人和同一組數據。認證方為每次申請提供新的鹽值,可以解決後者,但解決不了前者。
- 保密的模型。 權重是公開的。對於一條人人都必須能夠閱讀的規則,這正是要點所在;但對於把模型視為商業秘密的貸款方,這是一個缺點。申請人也可以搜尋能通過審核的輸入,但沒有認證方對這些輸入的簽名,就無法使用它們。
- 防止不誠實的認證方窺探。 任何知道特徵的人都可以自行運行這個公開模型。這裏的私隱是申請人相對於貸款方和公眾而言的,而不是相對於提供數據的一方。
- 大型模型。 十六個特徵加十六個隱藏單元只是一張評分卡,而不是深度網絡。每次調用上限為第 14 級,約一萬六千個週期。