1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
mod client;
mod evaluator;
mod server;
pub use client::{ClientContext, EncryptedInput, EncryptedScore, SCALE};
pub use evaluator::FheEvaluator;
pub use server::ServerContext;
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use tfhe::prelude::*;
// -----------------------------------------------------------------------
// Key management model (for readers of these tests)
//
// PRIVATE — ClientContext.client_key
// Used to encrypt inputs and decrypt outputs.
// Never leaves the client.
//
// PUBLIC — ServerContext (obtained via ClientContext::server_context())
// Used by the inference server to perform homomorphic operations.
// Safe to share; reveals nothing about plaintexts or the client key.
//
// FHE key generation is intentionally expensive (~1-3 s per call).
// -----------------------------------------------------------------------
/// Maximum rounding error introduced by the fixed-point encoding.
/// Any `f32` is stored as `round(v * SCALE) / SCALE`, so the error
/// is at most half a unit of least precision: `0.5 / SCALE`.
const FIXED_POINT_EPSILON: f32 = 0.5 / SCALE;
/// Encrypt a single feature with the **private** key, then decrypt it
/// with the same private key, and assert the value is recovered within
/// fixed-point rounding error.
fn assert_round_trip(client: &ClientContext, original: f32) {
// Encryption uses the private ClientKey.
let ciphertext: EncryptedInput = client.encrypt(&[original]);
// Decryption also uses the private ClientKey.
// The server never sees this step.
let raw: i32 = ciphertext[0].decrypt(&client.client_key);
let recovered: f32 = raw as f32 / SCALE;
// The expected decoded value is the rounded fixed-point representation,
// not necessarily `original` exactly.
let expected: f32 = (original * SCALE)
.round()
.clamp(i32::MIN as f32, i32::MAX as f32) as i32 as f32
/ SCALE;
approx::assert_abs_diff_eq!(recovered, expected, epsilon = 1e-6);
// Also sanity-check against the original within the rounding bound.
approx::assert_abs_diff_eq!(recovered, original, epsilon = FIXED_POINT_EPSILON + 1e-6);
}
// -----------------------------------------------------------------------
// Round-trip: encrypt with private key, decrypt with private key
// -----------------------------------------------------------------------
#[test]
fn round_trip_typical_inference_vector() {
// Represents a realistic feature vector passed to an XGBoost model
// (e.g. age=35, income=0.72 normalised, debt_ratio=-0.15, score=320.0).
let client: ClientContext = ClientContext::generate().unwrap();
let features: &[f32] = &[35.0, 0.72, -0.15, 3.20];
let ciphertext: EncryptedInput = client.encrypt(features);
for (i, &original) in features.iter().enumerate() {
let raw: i32 = ciphertext[i].decrypt(&client.client_key);
let recovered = raw as f32 / SCALE;
approx::assert_abs_diff_eq!(recovered, original, epsilon = FIXED_POINT_EPSILON + 1e-6);
}
}
#[test]
fn round_trip_negative_features() {
let client: ClientContext = ClientContext::generate().unwrap();
for &v in &[-1.0_f32, -0.5, -100.0, -327.0] {
assert_round_trip(&client, v);
}
}
#[test]
fn round_trip_zero() {
let client: ClientContext = ClientContext::generate().unwrap();
assert_round_trip(&client, 0.0);
}
#[test]
fn round_trip_positive_features() {
let client: ClientContext = ClientContext::generate().unwrap();
for &v in &[0.01_f32, 1.0, 50.5, 327.0] {
assert_round_trip(&client, v);
}
}
// -----------------------------------------------------------------------
// Fixed-point precision
// -----------------------------------------------------------------------
#[test]
fn fixed_point_rounding_within_one_ulp() {
// A value with sub-milli precision (1.2347) should decode as 1.235,
// not 1.2347 — the fourth decimal is lost in encoding.
let client: ClientContext = ClientContext::generate().unwrap();
let ciphertext: EncryptedInput = client.encrypt(&[1.2347]);
let raw: i32 = ciphertext[0].decrypt(&client.client_key);
let recovered: f32 = raw as f32 / SCALE;
// 1.2347 * 1000 = 1234.7 → rounds to 1235 → 1.235
approx::assert_abs_diff_eq!(recovered, 1.235, epsilon = 1e-6);
}
// -----------------------------------------------------------------------
// FHE circuit evaluation: FheEvaluator::predict matches PlaintextEvaluator
// -----------------------------------------------------------------------
/// Maximum rounding error for a single accumulated term (leaf or base_score).
/// Each f32 is quantised to `round(v * SCALE) / SCALE`, so the error per
/// term is at most `0.5 / SCALE`. Two terms (leaf + base_score) gives
/// the bound used below.
const CIRCUIT_EPSILON: f32 = 2.0 * (0.5 / SCALE);
/// Verify that `FheEvaluator::predict` agrees with `PlaintextEvaluator`
/// on the stump regression fixture (threshold 1.5, leaves ±0.5, base 1.0).
///
/// This test performs real TFHE bootstrapping and takes ~5–30 s depending
/// on the machine. It is the integration proof that the FHE circuit
/// correctly implements the decision-tree evaluation.
#[test]
fn fhe_stump_matches_plaintext() {
use crate::eval::{Evaluator as _, PlaintextEvaluator};
use crate::model::WeirwoodTree;
// --- Client ---
let client: ClientContext = ClientContext::generate().unwrap();
let server_ctx: ServerContext = client.server_context();
// Install the server key on the calling thread; FheEvaluator::new installs
// it on worker threads via start_handler.
server_ctx.set_active();
// --- Server setup ---
let evaluator: FheEvaluator = FheEvaluator::new(server_ctx);
let model: WeirwoodTree =
WeirwoodTree::from_json_file("tests/fixtures/stump_regression.json").unwrap();
// Probe both branches and the exact boundary.
// stump: feature[0] <= 1.5 → left (leaf = -0.5), else right (leaf = 0.5)
// base_score = 1.0 → expected scores: 0.5 (left) and 1.5 (right)
let test_cases: &[f32] = &[
0.0, // clearly left → plaintext 0.5
1.5, // at threshold → plaintext 0.5 (≤ goes left)
2.0, // clearly right → plaintext 1.5
];
for &feature_val in test_cases {
let features: Vec<f32> = vec![feature_val];
// --- Client: plaintext reference and encryption ---
let plaintext_score: f32 = PlaintextEvaluator.predict(&model, &features);
let encrypted_input: EncryptedInput = client.encrypt(&features);
// --- Server: FHE evaluation ---
let encrypted_score: EncryptedScore = evaluator.predict(&model, &encrypted_input);
// --- Client: decryption and comparison ---
let fhe_score: f32 = client.decrypt_score(&encrypted_score);
approx::assert_abs_diff_eq!(fhe_score, plaintext_score, epsilon = CIRCUIT_EPSILON);
}
}
// -----------------------------------------------------------------------
// FHE circuit evaluation: multi-tree ensemble
// -----------------------------------------------------------------------
/// Verify that `FheEvaluator::predict` correctly accumulates scores across
/// multiple trees on the `two_trees_binary.json` fixture.
///
/// Model layout:
/// Tree 1: feature[0] <= 1.5 → left(-0.3), right(+0.3)
/// Tree 2: feature[1] <= 2.0 → left(-0.2), right(+0.2)
/// base_score = 0.0
///
/// Expected raw scores:
/// [0.0, 0.0] → -0.3 + -0.2 = -0.5
/// [2.0, 3.0] → 0.3 + 0.2 = 0.5
/// [0.0, 3.0] → -0.3 + 0.2 = -0.1
/// [2.0, 0.0] → 0.3 + -0.2 = 0.1
///
/// Rounding error per tree is at most 0.5/SCALE; with 2 trees and no
/// base_score the bound is 2 * 0.5/SCALE = 0.01.
#[test]
fn fhe_two_trees_matches_plaintext() {
use crate::eval::{Evaluator as _, PlaintextEvaluator};
use crate::model::WeirwoodTree;
// epsilon: 2 leaf quantizations, no base_score contribution
const EPSILON: f32 = 2.0 * (0.5 / SCALE);
// --- Client ---
let client: ClientContext = ClientContext::generate().unwrap();
let server_ctx: ServerContext = client.server_context();
// Install the server key on the calling thread; FheEvaluator::new installs
// it on worker threads via start_handler.
server_ctx.set_active();
// --- Server setup ---
let evaluator: FheEvaluator = FheEvaluator::new(server_ctx);
let model: WeirwoodTree =
WeirwoodTree::from_json_file("tests/fixtures/two_trees_binary.json").unwrap();
let test_cases: &[[f32; 2]] = &[
[0.0, 0.0], // both left → -0.5
[2.0, 3.0], // both right → +0.5
[0.0, 3.0], // left, right → -0.1
[2.0, 0.0], // right, left → +0.1
];
for features in test_cases {
let features_vec: Vec<f32> = features.to_vec();
let plaintext_score: f32 = PlaintextEvaluator.predict(&model, &features_vec);
let encrypted_input: EncryptedInput = client.encrypt(&features_vec);
let encrypted_score: EncryptedScore = evaluator.predict(&model, &encrypted_input);
let fhe_score: f32 = client.decrypt_score(&encrypted_score);
approx::assert_abs_diff_eq!(fhe_score, plaintext_score, epsilon = EPSILON);
}
}
// -----------------------------------------------------------------------
// Key separation: a different private key cannot decrypt the ciphertext
// -----------------------------------------------------------------------
#[test]
fn wrong_private_key_gives_garbage() {
// Simulate two independent users. client_alice holds the private key
// used to encrypt; client_bob is a completely unrelated key pair.
// Decrypting alice's ciphertext with bob's private key must not
// recover the original value.
let client_alice: ClientContext = ClientContext::generate().unwrap();
let client_bob: ClientContext = ClientContext::generate().unwrap();
let original: f32 = 0.42; // a feature value
let ciphertext: EncryptedInput = client_alice.encrypt(&[original]);
let decrypted_by_alice: i32 = ciphertext[0].decrypt(&client_alice.client_key);
let decrypted_by_bob: i32 = ciphertext[0].decrypt(&client_bob.client_key);
let expected: i32 = (original * SCALE).round() as i32;
assert_eq!(
decrypted_by_alice, expected,
"alice should recover her own ciphertext"
);
assert_ne!(
decrypted_by_bob, expected,
"bob's key should not decrypt alice's ciphertext \
(collision negligible with 128-bit security)"
);
}
}