Un istituto di credito pubblica il proprio modello di scoring. Il richiedente lo esegue sui propri dati e consegna all’istituto una ricevuta che dice “approvato” o “rifiutato”. L’istituto non vede mai i dati, e il richiedente non può modificare il modello né fornirgli dati inventati.
È il caso utile più piccolo di dimostrazione di un calcolo con un programma pubblico e parametri pubblici su input privati. Il modello è una rete neurale a due livelli in aritmetica intera. La stessa struttura si adatta a qualsiasi regola che un regolatore o una controparte voglia poter leggere: una formula di prezzo, un test di idoneità, un limite di rischio.
Programmi di esempio contiene la configurazione comune a tutte e quattro le pagine di esempio.
1. Che cosa dimostra
Tre parti:
- Il finanziatore distribuisce il programma con il modello come segmento pubblico. L’id del programma identifica quel modello. Chiunque può rileggere i pesi dalla chain.
- Un attestatore (un’agenzia di credito, una banca, un datore di lavoro) conosce i dati del richiedente. Consegna al richiedente i valori delle feature, un salt casuale di 16 byte e la propria firma sull’impegno (commitment)
SHA-256(salt ‖ features). - Il richiedente chiama il programma con il salt e le feature come input privato.
La chiamata dimostra che:
- ogni feature rientra nell’intervallo che il modello dichiara per essa;
- il punteggio del modello su quelle feature è pari o superiore alla soglia (stato
1) oppure inferiore (stato2); - l’impegno pubblicato è lo SHA-256 del salt e di quelle stesse feature.
Il finanziatore verifica l’id del programma della ricevuta, il suo stato e che l’impegno sia uno di quelli firmati dall’attestatore. Ricava un solo bit sul richiedente.
Gli input privati, da soli, non dimostrano nulla sul mondo. Senza il punto 3 un richiedente potrebbe inserire numeri qualsiasi. L’impegno è ciò che collega i numeri della prova a numeri garantiti da qualcuno. La firma stessa viene verificata off chain dal finanziatore: una verifica Dilithium o ECDSA non rientra nel budget di una chiamata, e non ha bisogno di stare dentro la prova, perché l’impegno è pubblico.
2. L’interfaccia
Ogni parola pubblica è un i32, memorizzato come u32 in complemento a due.
| parola pubblica | significato |
|---|---|
| 0 | il numero di feature n, da 1 a 16 |
| 1 | il numero di unità nascoste h, da 1 a 16 |
| 2 | la soglia |
| 3 | il numero di bit frazionari, lo shift s, da 0 a 31 |
poi 2n parole | min e max ammessi per ciascuna feature |
poi h × n parole | i pesi del primo livello, riga per riga |
poi h parole | i bias del primo livello |
poi h parole | i pesi del secondo livello |
| poi 1 parola | il bias del secondo livello |
| parola privata | significato |
|---|---|
| da 0 a 3 | il salt, 16 byte come quattro parole big-endian |
| dalla 4 in poi | le n feature, ciascuna un i32 |
| slot di output | significato |
|---|---|
| 0 | stato: 1 approvato, 2 rifiutato, 3 una feature è fuori intervallo, 4 modello non valido (una dimensione fuori dai limiti, oppure overflow aritmetico) |
| 1 | non usato |
| da 2 a 6 | i primi 20 byte dell’impegno, come cinque parole big-endian |
| 7 | non usato; un panic di Rust vi scrive 0xdeadbeef |
L’impegno viene pubblicato solo con stato 1 o 2. Un input fuori intervallo si interrompe prima che venga calcolato qualsiasi hash.
3. L’aritmetica
Il modello usa solo interi. La macchina non ha virgola mobile, e un verifier deve poter riprodurre il punteggio esattamente, cosa che la virgola mobile non garantisce da una macchina all’altra.
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
I pesi sono in virgola fissa con s bit frazionari: con s = 8, un peso di 256 vale 1,0. Le somme sono i64, e ogni moltiplicazione e addizione è controllata, così un modello con pesi abbastanza grandi da andare in overflow produce lo stato 4 invece di un punteggio errato. >> su un i64 negativo arrotonda verso meno infinito in Rust, e lo stesso fa >> in Python, ed è questo che permette a una funzione Python di dieci righe di fare da riferimento.
Il modello dimostrativo ha otto feature e quattro unità nascoste. I suoi pesi sono stati scritti a mano per questa pagina. Non è un modello addestrato e non dice nulla sul credito.
{
"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. Il guest
my-guests/score/src/main.rs, completo:
#![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();
}
Il modello è dato, non codice. I pesi vengono letti con read_public, quindi una sola immagine distribuita serve qualsiasi modello fino a 16 feature e 16 unità nascoste. Cambiare il modello significa un nuovo deploy dello stesso codice con un nuovo segmento pubblico, e un nuovo id di programma. Un finanziatore non può sostituire i pesi sotto gli occhi di un richiedente, e un richiedente non può scegliere un modello più indulgente: la ricevuta indica l’id del programma, e l’id vincola i pesi.
Gli intervalli fanno parte del modello. Senza di essi un richiedente non potrebbe mentire su un valore (lo impedisce l’impegno), ma un attestatore potrebbe firmare valori privi di senso, come un’età di −5, che un modello con un peso negativo premierebbe. Il controllo limita anche l’aritmetica.
Il salt svolge due compiti. Rende l’impegno non indovinabile: otto interi piccoli hanno poca entropia, e senza un salt chiunque potrebbe calcolare l’hash di richiedenti candidati e confrontarli. E fa passare 128 bit casuali attraverso il calcolo. Prima che il constraint set 7 oscurasse i totali del bus della prova, una chiamata su poche piccole parole private poteva essere forzata a partire dalla prova stessa, e le note del fullnode raccomandavano a tali programmi di leggere parole casuali. La chain 18 usa il constraint set 8, in cui il problema è risolto, ma un salt costa quattro parole di input e elimina la questione.
5. Il lato host
score.py usa solo la libreria standard di 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
Lo scorer di riferimento e l’impegno, per intero:
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
I numeri grandi sono i pesi negativi: 4294966784 è −512.
Il guest è stato verificato rispetto a questo riferimento su 300 richiedenti casuali, confrontando la decisione e tutte e cinque le parole dell’impegno, e su richiedenti i cui punteggi sono 599 e 600. Concordano in ogni caso.
6. Compilare ed eseguire
$ 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)
Un richiedente di 41 anni con un reddito di 85 mila, il 28% di rapporto debito/reddito, il 97% dei pagamenti puntuali, il 22% di utilizzo, 12 anni di storico, una richiesta di credito e nessun insoluto:
$ 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, i primi quattro byte dell’impegno. Il punteggio, 1075, non compare in nessuno degli output.
Un secondo richiedente (23, 31, 55, 82, 91, 2, 6, 2, con un altro salt) ottiene un punteggio di 417 e produce out[0] = 2 con il proprio impegno, negli stessi 1.810 cicli. Il primo richiedente con l’età cambiata in 17 produce out[0] = 3 e nessun impegno.
7. Il budget
| programma | 462 parole |
| segmento pubblico | 61 parole |
| parole private | 12 |
| cicli | 1.810 |
| compressioni SHA-256 | 1 (48 byte di messaggio rientrano in un blocco) |
| tier | 12 |
È il meno costoso dei quattro programmi. Il passaggio in avanti conta 36 moltiplicazioni. Un modello ai limiti del programma, 16 feature e 16 unità nascoste, richiede 272 moltiplicazioni e un segmento pubblico di 325 parole. Nell’emulatore misura 7.900 cicli, circa la metà del tier 14.
L’esecuzione non dipende dai dati: 200 richiedenti casuali, approvati e rifiutati senza distinzione, hanno impiegato esattamente 1.810 cicli ciascuno. Il gas limit dichiarato da una chiamata è pubblico ed è arrotondato dal gas dell’esecuzione, quindi un programma il cui numero di cicli seguisse i suoi input rivelerebbe un poco di essi. Questo non ha nulla da rivelare in tal modo.
Dalla chain 18 il wallet stampa il gas esatto dell’esecuzione e il limite che dichiara:
gas: 2011 (declaring 2048, tier 12)
1.948 righe a 1 gas ciascuna, più 63 per l’unica compressione.
8. Deploy
Le 61 parole del modello si pagano come le parole di codice: 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. Chiamata
$ 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`
Stato 1 e l’impegno 9430c381 8f5966f8 dd33f78e 05a7d079 355191e6 in esadecimale. h_pub è il digest delle 61 parole del modello. Le feature, il salt e il punteggio non sono nella transazione.
La chiamata ha richiesto poco più di tre minuti su un laptop Apple Silicon: 31 s per la prova della chiamata, 7 s per l’autorizzazione della spesa e 124 s per il bundle, con un picco di 6,2 GB. La prova è di 1.838.256 byte.
10. Verificalo tu stesso
Il lato del finanziatore, con nient’altro che la ricevuta e l’impegno firmato dall’attestatore:
- Il modello.
rand_getProgramPublicsull’id del programma restituisce le 61 parole. Confrontale con ilmodel.jsonpubblicato (score.py setuple ricostruisce). - Il codice. Ricompila
my-guests/scoreallo stesso commit dei circuits e confrontahccon ilcode_hashdel programma. - La ricevuta.
programè l’id del modello, l’output 0 è1, lo slot 7 non è3735928559. - L’impegno. Gli output da 2 a 6, stampati in esadecimale, sono i primi 20 byte dell’impegno firmato dall’attestatore:
$ python3 score.py commit 00112233445566778899aabbccddeeff 41 85 28 97 22 12 1 0
9430c3818f5966f8dd33f78e05a7d079355191e6
Anche un rifiuto è una ricevuta. La chiamata del secondo richiedente, 119e7834…1b3b sulla chain 18 (ripetuta sulla chain 20 come c8a60183…9f73, che rifiuta a sua volta), è stata dimostrata e registrata allo stesso modo:
$ 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
Stato 2, e 51636806 è 0x0313ea46. Il gas, il limite dichiarato e il tier sono gli stessi della chiamata approvata, quindi le due differiscono on-chain nella parola di stato e nell’impegno (commitment) e in nient’altro. Un richiedente non ha alcun motivo di pubblicare un rifiuto, ma il finanziatore può comunque chiedere la ricevuta, e un richiedente rifiutato non può produrne una di approvazione.
Che cosa la prova non garantisce
- Dati veritieri. La prova mostra che il modello è stato eseguito sui valori impegnati. Che i valori siano veri è parola dell’attestatore, e il finanziatore deve fidarsi della firma dell’attestatore per questo.
- Un richiedente non collegabile. Il commitment è un identificatore stabile: l’attestatore può riconoscerlo, e due ricevute con lo stesso commitment riguardano lo stesso richiedente e gli stessi dati. Un salt nuovo da parte dell’attestatore per ogni richiesta risolve il secondo problema, non il primo.
- Un modello segreto. I pesi sono pubblici. È proprio questo il punto per una regola che le persone devono poter leggere, e uno svantaggio per un finanziatore che considera il proprio modello un segreto industriale. Un richiedente può anche cercare gli input che supererebbero la valutazione, ma non può usarli senza una firma dell’attestatore su di essi.
- Protezione dal probing di un attestatore disonesto. Chiunque conosca le feature può eseguire da sé il modello pubblico. La privacy qui è quella del richiedente nei confronti del finanziatore e del pubblico, non della parte che ha fornito i dati.
- Un modello di grandi dimensioni. Sedici feature e sedici unità nascoste costituiscono una scorecard, non una rete profonda. Una chiamata è limitata al tier 14, circa sedicimila cicli.