Skip to main content

optirs_core/privacy/
secure_aggregation.rs

1// Bonawitz-style Pairwise-Mask Secure Aggregation Protocol
2//
3// This module implements the practical secure aggregation protocol introduced
4// by Bonawitz et al. (2017) for federated learning. The protocol is the
5// foundational scheme behind production federated systems (e.g. Google
6// Gboard, Apple's on-device learning) and provides cryptographic
7// confidentiality of individual client gradients while still allowing the
8// server to compute their exact sum.
9//
10// Reference
11// ---------
12//   * Bonawitz, K., Ivanov, V., Kreuter, B., Marcedone, A., McMahan, H. B.,
13//     Patel, S., Ramage, D., Segal, A., and Seth, K. "Practical Secure
14//     Aggregation for Privacy-Preserving Machine Learning." CCS 2017.
15//   * Bonawitz, K., Eichner, H., Grieskamp, W., et al. "Towards Federated
16//     Learning at Scale: System Design." SysML 2019. (Production
17//     considerations and dropout reconstruction details.)
18//
19// Protocol overview
20// -----------------
21// Each ordered pair of clients (i, j) with i != j agrees on a shared
22// pseudo-random vector m_{ij} ∈ Z_p^d (where p is a large prime-ish modulus
23// and d is the gradient dimension). In a real deployment the seed for this
24// PRNG would be derived from a Diffie-Hellman key agreement performed in an
25// earlier protocol round; for the demo we derive the seed deterministically
26// from the (sorted) client pair and the per-round seed published by the
27// server, so the same mask is reproducible on both sides.
28//
29// Each client i then constructs a self-mask
30//
31//     mask_i = Σ_{j ∈ S : i < j} m_{ij} − Σ_{j ∈ S : j < i} m_{ji}
32//
33// where the sign of each pairwise mask is determined purely by the lexical
34// order of the two client identifiers. The client uploads the quantised
35// masked gradient y_i = (Q(g_i) + mask_i) mod p to the server. When the
36// server sums all received submissions modulo p, every pairwise term
37// m_{ij} appears exactly twice -- once with the (+) sign from client i and
38// once with the (-) sign from client j -- so the masks telescope to zero
39// and the server recovers Σ Q(g_i) mod p. Dequantisation then yields the
40// true gradient sum up to quantisation error.
41//
42// Dropout robustness
43// ------------------
44// If client d drops out before submitting, every other online client still
45// included m_{*d} in their personal mask, so the server's running sum
46// retains a non-zero residue. In a production deployment, the online
47// participants would reveal to the server the pairwise masks they hold with
48// the dropped client (or a t-out-of-n Shamir share of the seed) -- since the
49// dropped client never uploaded anything, revealing this information leaks
50// nothing about its gradient. In this demo implementation the server is
51// able to reconstruct the dropped client's pairwise masks directly because
52// every mask is deterministically derived from `round_seed`. This is a
53// deliberate simplification that captures the *aggregation arithmetic* of
54// the protocol while keeping the public surface small enough to test
55// exhaustively.
56//
57// Relation to existing OptiRS modules
58// -----------------------------------
59// OptiRS already ships two adjacent privacy primitives that should not be
60// confused with this one:
61//
62//   * `optirs_core::privacy::secure_multiparty` -- a Shamir-secret-sharing
63//     based BGW / GMW / SPDZ MPC stack (different cryptographic family,
64//     general-purpose arithmetic circuits).
65//   * `optirs_core::privacy::byzantine_tolerance` -- robust aggregation
66//     (median, trimmed mean, Krum) designed to *exclude* malicious
67//     gradients rather than mask honest ones.
68//
69// Both are cited here as related prior art. This module is intentionally
70// independent of them and provides the specific Bonawitz pairwise-additive
71// masking primitive that is missing from the existing stack.
72
73use crate::error::{OptimError, Result};
74use scirs2_core::ndarray::Array1;
75use scirs2_core::random::Random;
76use serde::{Deserialize, Serialize};
77use std::collections::{HashMap, HashSet};
78
79/// Unique identifier for a participant in a single aggregation round.
80pub type ClientId = u64;
81
82/// Configuration for the Bonawitz-style secure aggregation protocol.
83///
84/// All clients in a round must agree on the same configuration so that the
85/// derived pairwise masks line up. The `round_seed` field acts as a public
86/// salt that lets the same fleet of clients run independent aggregation
87/// rounds (different `round_seed`s produce independent pairwise masks).
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct SecureAggregationConfig {
90    /// Total number of clients expected to participate in the round.
91    pub num_clients: usize,
92
93    /// Dimensionality of the gradient vectors being aggregated.
94    pub gradient_dim: usize,
95
96    /// Public, per-round salt mixed into every pairwise seed derivation.
97    pub round_seed: u64,
98
99    /// Quantisation scale. Gradients are quantised by `round(g * scale)`
100    /// before being lifted into the modular group; a larger scale gives
101    /// finer fixed-point precision at the cost of needing a larger modulus
102    /// to avoid wraparound.
103    pub quantization_scale: f64,
104
105    /// Modulus `p` of the additive group `Z_p` used for masking and
106    /// aggregation. Must be substantially larger than
107    /// `quantization_scale * num_clients * max_gradient_magnitude` to
108    /// avoid information-destroying wraparound.
109    pub modulus: i64,
110
111    /// Whether to support dropout reconstruction at the server.
112    pub support_dropouts: bool,
113}
114
115impl Default for SecureAggregationConfig {
116    fn default() -> Self {
117        Self {
118            num_clients: 0,
119            gradient_dim: 0,
120            round_seed: 42,
121            quantization_scale: 1.0e6,
122            // The Mersenne prime 2^31 − 1. Wide enough to keep ~24-bit
123            // gradients safe through a modest cohort while still fitting
124            // in a signed 64-bit integer for safe modular arithmetic.
125            modulus: 2_147_483_647,
126            support_dropouts: true,
127        }
128    }
129}
130
131/// A single client's masked gradient submission.
132///
133/// Conceptually `values[k] = (Q(gradient[k]) + mask_i[k]) mod modulus`. The
134/// values are always non-negative and strictly less than `modulus` so the
135/// representation is canonical and serde-friendly.
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
137pub struct MaskedGradient {
138    /// Identifier of the submitting client.
139    pub client_id: ClientId,
140
141    /// Per-coordinate masked + quantised values, each in `[0, modulus)`.
142    pub values: Vec<i64>,
143}
144
145/// Server-side aggregator state for a single round.
146///
147/// The aggregator owns no cryptographic key material -- every pairwise mask
148/// can be recomputed on demand from `round_seed`. It keeps track of which
149/// clients have successfully submitted, which clients are known to have
150/// dropped, and cached pairwise masks used during dropout reconstruction.
151#[derive(Debug, Clone)]
152pub struct SecureAggregator {
153    config: SecureAggregationConfig,
154    received: HashMap<ClientId, MaskedGradient>,
155    dropped: HashSet<ClientId>,
156    pairwise_mask_cache: HashMap<(ClientId, ClientId), Vec<i64>>,
157}
158
159// ---------------------------------------------------------------------------
160// Pure client-side helper functions
161// ---------------------------------------------------------------------------
162
163/// Derive the seed for the pairwise PRNG shared by `client_a` and
164/// `client_b`.
165///
166/// The result is symmetric in its two `ClientId` arguments: the *unordered*
167/// pair `{client_a, client_b}` determines a single seed. This lets each
168/// client independently compute the same pairwise mask without exchanging
169/// any further state.
170///
171/// The combiner uses a Wang-style multiplicative hash on the rotated client
172/// identifiers, XOR-mixed with `round_seed`. It is *not* a cryptographic
173/// hash -- production deployments would substitute SHA-256 (or HKDF over a
174/// Diffie-Hellman secret) here.
175pub fn derive_pairwise_seed(client_a: ClientId, client_b: ClientId, round_seed: u64) -> u64 {
176    let (lo, hi) = if client_a <= client_b {
177        (client_a, client_b)
178    } else {
179        (client_b, client_a)
180    };
181
182    // Mix: rotate the ids by relatively prime amounts so the high bits of
183    // small ids end up in different lanes, then xor with the round salt and
184    // run through a Wang-style 64-bit avalanche so neighbouring inputs
185    // produce well-separated seeds.
186    let mut h = round_seed;
187    h ^= lo.rotate_left(13);
188    h ^= hi.rotate_left(29);
189    h = h.wrapping_mul(0x9E37_79B9_7F4A_7C15_u64);
190    h ^= h >> 30;
191    h = h.wrapping_mul(0xBF58_476D_1CE4_E5B9_u64);
192    h ^= h >> 27;
193    h = h.wrapping_mul(0x94D0_49BB_1331_11EB_u64);
194    h ^= h >> 31;
195    h
196}
197
198/// Generate a deterministic pairwise mask of the requested dimension.
199///
200/// Each coordinate is drawn uniformly from `[0, modulus)` using the seeded
201/// PRNG. The function is pure: identical `(seed, dim, modulus)` triples
202/// always produce identical output, which is exactly the property that
203/// makes the masks cancel on the server.
204pub fn generate_pairwise_mask(seed: u64, dim: usize, modulus: i64) -> Vec<i64> {
205    if dim == 0 || modulus <= 0 {
206        return Vec::new();
207    }
208
209    let mut rng = Random::seed(seed);
210    let mut mask = Vec::with_capacity(dim);
211    for _ in 0..dim {
212        let v: i64 = rng.gen_range(0..modulus);
213        mask.push(v);
214    }
215    mask
216}
217
218/// Quantise a real-valued gradient into the additive modular group `Z_p`.
219///
220/// The mapping is `q_k = round(g_k * scale) mod modulus`. Negative
221/// post-rounding values are folded into `[0, modulus)` via `rem_euclid`,
222/// which guarantees the canonical non-negative representative used
223/// everywhere in the protocol.
224pub fn quantize_gradient(gradient: &Array1<f64>, scale: f64, modulus: i64) -> Result<Vec<i64>> {
225    if !scale.is_finite() || scale <= 0.0 {
226        return Err(OptimError::InvalidParameter(format!(
227            "quantization scale must be positive and finite, got {scale}"
228        )));
229    }
230    if modulus <= 1 {
231        return Err(OptimError::InvalidParameter(format!(
232            "modulus must be > 1, got {modulus}"
233        )));
234    }
235
236    let mut out = Vec::with_capacity(gradient.len());
237    for &g in gradient.iter() {
238        if !g.is_finite() {
239            return Err(OptimError::InvalidParameter(format!(
240                "gradient entries must be finite, got {g}"
241            )));
242        }
243        let scaled = (g * scale).round();
244        // The intermediate must fit in i64; clamp at i64::MAX/MIN to avoid
245        // panics on pathological inputs before taking the modulus.
246        let clipped = scaled.max(i64::MIN as f64).min(i64::MAX as f64) as i64;
247        out.push(clipped.rem_euclid(modulus));
248    }
249    Ok(out)
250}
251
252/// Inverse of [`quantize_gradient`] applied to a vector of canonical
253/// `[0, modulus)` representatives.
254///
255/// Values strictly greater than `modulus / 2` are interpreted as negative
256/// fixed-point numbers (the standard two's-complement style centring around
257/// zero), then scaled back to `f64`.
258pub fn dequantize_gradient(quantized: &[i64], scale: f64, modulus: i64) -> Vec<f64> {
259    if scale <= 0.0 || modulus <= 1 {
260        return Vec::new();
261    }
262    let half = modulus / 2;
263    quantized
264        .iter()
265        .map(|&q| {
266            let centred = if q > half { q - modulus } else { q };
267            (centred as f64) / scale
268        })
269        .collect()
270}
271
272/// Compute the additive mask vector `mask_i` that client `client_id`
273/// adds to its quantised gradient before upload.
274///
275/// For every peer `other_id` in `all_client_ids`:
276///   * if `client_id < other_id` the pairwise mask is *added*;
277///   * if `client_id > other_id` the pairwise mask is *subtracted*.
278///
279/// Identical peers (`other_id == client_id`) are skipped, and duplicates
280/// are de-duplicated before the loop runs.
281///
282/// The total mask vector contains values in `[0, modulus)`. All arithmetic
283/// is performed modulo `modulus`, so the result is directly compatible with
284/// the masked-submission protocol.
285pub fn compute_client_mask(
286    client_id: ClientId,
287    all_client_ids: &[ClientId],
288    round_seed: u64,
289    dim: usize,
290    modulus: i64,
291) -> Result<Vec<i64>> {
292    if modulus <= 1 {
293        return Err(OptimError::InvalidParameter(format!(
294            "modulus must be > 1, got {modulus}"
295        )));
296    }
297    if !all_client_ids.contains(&client_id) {
298        return Err(OptimError::InvalidParameter(format!(
299            "client {client_id} not found in participating client set"
300        )));
301    }
302
303    // De-duplicate peers while preserving iteration determinism.
304    let mut unique: Vec<ClientId> = all_client_ids.to_vec();
305    unique.sort_unstable();
306    unique.dedup();
307
308    let mut total = vec![0_i64; dim];
309    for &other_id in unique.iter() {
310        if other_id == client_id {
311            continue;
312        }
313        let seed = derive_pairwise_seed(client_id, other_id, round_seed);
314        let pairwise = generate_pairwise_mask(seed, dim, modulus);
315        if client_id < other_id {
316            for (acc, m) in total.iter_mut().zip(pairwise.iter()) {
317                *acc = (*acc + *m).rem_euclid(modulus);
318            }
319        } else {
320            for (acc, m) in total.iter_mut().zip(pairwise.iter()) {
321                *acc = (*acc - *m).rem_euclid(modulus);
322            }
323        }
324    }
325
326    Ok(total)
327}
328
329/// End-to-end client helper: quantise `gradient`, add the pairwise mask,
330/// and package the result as a [`MaskedGradient`] ready for upload.
331pub fn submit_gradient(
332    client_id: ClientId,
333    gradient: &Array1<f64>,
334    all_client_ids: &[ClientId],
335    config: &SecureAggregationConfig,
336) -> Result<MaskedGradient> {
337    if gradient.len() != config.gradient_dim {
338        return Err(OptimError::DimensionMismatch(format!(
339            "gradient length {} does not match configured gradient_dim {}",
340            gradient.len(),
341            config.gradient_dim
342        )));
343    }
344
345    let quantised = quantize_gradient(gradient, config.quantization_scale, config.modulus)?;
346    let mask = compute_client_mask(
347        client_id,
348        all_client_ids,
349        config.round_seed,
350        config.gradient_dim,
351        config.modulus,
352    )?;
353
354    let mut values = Vec::with_capacity(quantised.len());
355    for (q, m) in quantised.iter().zip(mask.iter()) {
356        values.push((*q + *m).rem_euclid(config.modulus));
357    }
358
359    Ok(MaskedGradient { client_id, values })
360}
361
362// ---------------------------------------------------------------------------
363// Server-side aggregator
364// ---------------------------------------------------------------------------
365
366impl SecureAggregator {
367    /// Build a new aggregator for one round.
368    ///
369    /// Validates the configuration up front so that downstream operations
370    /// can assume well-formed values. The dropout safety check requires
371    /// that the modulus is at least one order of magnitude larger than the
372    /// nominal quantised value range, which guarantees that the partial
373    /// masked sum cannot silently wrap.
374    pub fn new(config: SecureAggregationConfig) -> Result<Self> {
375        if config.num_clients == 0 {
376            return Err(OptimError::InvalidConfig(
377                "num_clients must be greater than zero".to_string(),
378            ));
379        }
380        if config.gradient_dim == 0 {
381            return Err(OptimError::InvalidConfig(
382                "gradient_dim must be greater than zero".to_string(),
383            ));
384        }
385        if config.modulus <= 1 {
386            return Err(OptimError::InvalidConfig(format!(
387                "modulus must be > 1, got {}",
388                config.modulus
389            )));
390        }
391        if !config.quantization_scale.is_finite() || config.quantization_scale <= 0.0 {
392            return Err(OptimError::InvalidConfig(format!(
393                "quantization_scale must be positive and finite, got {}",
394                config.quantization_scale
395            )));
396        }
397        // Guard against trivially-small moduli that cannot meaningfully
398        // hold even a single quantised value with headroom for masking.
399        let min_modulus = (config.quantization_scale * 10.0).ceil() as i128;
400        if (config.modulus as i128) < min_modulus {
401            return Err(OptimError::InvalidConfig(format!(
402                "modulus {} is too small for quantization_scale {}; need at least {}",
403                config.modulus, config.quantization_scale, min_modulus
404            )));
405        }
406
407        Ok(Self {
408            config,
409            received: HashMap::new(),
410            dropped: HashSet::new(),
411            pairwise_mask_cache: HashMap::new(),
412        })
413    }
414
415    /// Accept a client submission.
416    ///
417    /// Re-submissions from the same `client_id` overwrite the previous
418    /// value (later submissions are considered more authoritative). A
419    /// submission whose vector length does not match the configured
420    /// dimension is rejected with [`OptimError::DimensionMismatch`].
421    pub fn receive(&mut self, submission: MaskedGradient) -> Result<()> {
422        if submission.values.len() != self.config.gradient_dim {
423            return Err(OptimError::DimensionMismatch(format!(
424                "submission from client {} has {} values, expected {}",
425                submission.client_id,
426                submission.values.len(),
427                self.config.gradient_dim
428            )));
429        }
430        for &v in submission.values.iter() {
431            if v < 0 || v >= self.config.modulus {
432                return Err(OptimError::InvalidParameter(format!(
433                    "submission value {v} from client {} is outside [0, {})",
434                    submission.client_id, self.config.modulus
435                )));
436            }
437        }
438
439        let client_id = submission.client_id;
440        // A client cannot simultaneously be marked dropped *and* submit.
441        self.dropped.remove(&client_id);
442        self.received.insert(client_id, submission);
443        Ok(())
444    }
445
446    /// Mark a client as having dropped out of this round.
447    ///
448    /// Dropped clients are excluded from the aggregated sum but the masks
449    /// they would have used are still cancelled by the
450    /// dropout-reconstruction phase of [`Self::aggregate`].
451    pub fn mark_dropped(&mut self, client_id: ClientId) -> Result<()> {
452        if self.received.contains_key(&client_id) {
453            return Err(OptimError::InvalidParameter(format!(
454                "client {client_id} has already submitted; cannot mark as dropped"
455            )));
456        }
457        self.dropped.insert(client_id);
458        Ok(())
459    }
460
461    /// Aggregate the received submissions, cancelling any masks belonging
462    /// to dropped clients.
463    ///
464    /// Returns the dequantised real-valued sum of the *received* clients'
465    /// gradients. Dropped clients contribute nothing to the sum (their
466    /// gradient is, by definition, never uploaded) but their pairwise
467    /// masks are still removed so the remaining clients' uploads
468    /// telescope correctly.
469    pub fn aggregate(&self) -> Result<Array1<f64>> {
470        let total_known = self.received.len() + self.dropped.len();
471        if total_known < self.config.num_clients {
472            return Err(OptimError::InvalidConfig(format!(
473                "incomplete round: {} received + {} dropped < num_clients = {}",
474                self.received.len(),
475                self.dropped.len(),
476                self.config.num_clients
477            )));
478        }
479        if self.received.is_empty() {
480            return Err(OptimError::InvalidConfig(
481                "cannot aggregate: no client submissions received".to_string(),
482            ));
483        }
484
485        let modulus = self.config.modulus;
486        let dim = self.config.gradient_dim;
487
488        // Step 1: sum all received masked submissions modulo p. The masks
489        // among the received clients automatically telescope to zero.
490        let mut acc = vec![0_i64; dim];
491        for submission in self.received.values() {
492            for (a, v) in acc.iter_mut().zip(submission.values.iter()) {
493                *a = (*a + *v).rem_euclid(modulus);
494            }
495        }
496
497        // Step 2: dropout reconstruction. For every dropped client d, the
498        // received clients still hold a copy of m_{i,d} or -m_{d,i} that
499        // never got cancelled. We subtract the *net* residual contributed
500        // by d -- that is, the sum over online peers of the signed
501        // pairwise mask the dropped client *would have applied*.
502        if self.config.support_dropouts && !self.dropped.is_empty() {
503            let online_ids: Vec<ClientId> = self.received.keys().copied().collect();
504            for &dropped_id in self.dropped.iter() {
505                // From the dropped client's perspective, its own mask was
506                // mask_d = Σ_{j>d, j online} m_{d,j} − Σ_{j<d, j online} m_{j,d}.
507                // The online peers contributed the *negation* of these terms
508                // to the running sum (because the signs flip from the peer's
509                // perspective), so the residual still in `acc` equals
510                //   residual = − mask_d   (mod p)
511                // and we therefore add mask_d to cancel it.
512                let dropped_mask = compute_client_mask(
513                    dropped_id,
514                    &Self::peer_universe(&online_ids, dropped_id),
515                    self.config.round_seed,
516                    dim,
517                    modulus,
518                )?;
519                for (a, m) in acc.iter_mut().zip(dropped_mask.iter()) {
520                    *a = (*a + *m).rem_euclid(modulus);
521                }
522            }
523        }
524
525        Ok(Array1::from(dequantize_gradient(
526            &acc,
527            self.config.quantization_scale,
528            modulus,
529        )))
530    }
531
532    /// Reset the per-round state so the same aggregator can be reused.
533    pub fn reset(&mut self) {
534        self.received.clear();
535        self.dropped.clear();
536        self.pairwise_mask_cache.clear();
537    }
538
539    /// Number of client submissions currently held.
540    pub fn received_count(&self) -> usize {
541        self.received.len()
542    }
543
544    /// Number of clients currently marked as dropped.
545    pub fn dropped_count(&self) -> usize {
546        self.dropped.len()
547    }
548
549    /// Read-only access to the aggregator's configuration.
550    pub fn config(&self) -> &SecureAggregationConfig {
551        &self.config
552    }
553
554    /// Construct the universe `{dropped_id} ∪ online_ids` used to
555    /// reproduce the dropped client's local mask. The dropped id is
556    /// included so that `compute_client_mask` accepts the call; the
557    /// online_ids drive the actual signed mask accumulation.
558    fn peer_universe(online_ids: &[ClientId], dropped_id: ClientId) -> Vec<ClientId> {
559        let mut universe = Vec::with_capacity(online_ids.len() + 1);
560        universe.push(dropped_id);
561        universe.extend_from_slice(online_ids);
562        universe
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    /// Small absolute tolerance for quantisation round-trip equality.
571    const QUANT_TOL: f64 = 1.0e-4;
572
573    fn make_config(num_clients: usize, dim: usize) -> SecureAggregationConfig {
574        SecureAggregationConfig {
575            num_clients,
576            gradient_dim: dim,
577            round_seed: 17,
578            quantization_scale: 1.0e6,
579            modulus: 2_147_483_647,
580            support_dropouts: true,
581        }
582    }
583
584    fn vec_close(a: &Array1<f64>, b: &Array1<f64>, tol: f64) -> bool {
585        if a.len() != b.len() {
586            return false;
587        }
588        a.iter().zip(b.iter()).all(|(x, y)| (x - y).abs() <= tol)
589    }
590
591    // ----- Pairwise seed derivation --------------------------------------
592
593    #[test]
594    fn test_pairwise_seed_symmetric() {
595        assert_eq!(
596            derive_pairwise_seed(1, 2, 42),
597            derive_pairwise_seed(2, 1, 42)
598        );
599        assert_eq!(
600            derive_pairwise_seed(7, 41, 100),
601            derive_pairwise_seed(41, 7, 100)
602        );
603        assert_eq!(
604            derive_pairwise_seed(0, u64::MAX, 0),
605            derive_pairwise_seed(u64::MAX, 0, 0)
606        );
607    }
608
609    #[test]
610    fn test_pairwise_seed_changes_with_round_seed() {
611        let s1 = derive_pairwise_seed(3, 5, 1);
612        let s2 = derive_pairwise_seed(3, 5, 2);
613        let s3 = derive_pairwise_seed(3, 5, 12345);
614        assert_ne!(
615            s1, s2,
616            "different round seeds must produce different mask seeds"
617        );
618        assert_ne!(s1, s3);
619        assert_ne!(s2, s3);
620    }
621
622    #[test]
623    fn test_pairwise_seed_differs_across_pairs() {
624        // Distinct pairs with the same round seed should hash to distinct
625        // values with overwhelming probability. The avalanche keeps these
626        // separated even for tiny ids.
627        let mut seen = HashSet::new();
628        let round = 999;
629        for a in 0_u64..20 {
630            for b in (a + 1)..20 {
631                let s = derive_pairwise_seed(a, b, round);
632                assert!(seen.insert(s), "duplicate seed for pair ({a}, {b}): {s}");
633            }
634        }
635    }
636
637    // ----- Pairwise mask generation --------------------------------------
638
639    #[test]
640    fn test_generate_pairwise_mask_deterministic_with_seed() {
641        let m1 = generate_pairwise_mask(123_456, 32, 2_147_483_647);
642        let m2 = generate_pairwise_mask(123_456, 32, 2_147_483_647);
643        assert_eq!(m1, m2);
644    }
645
646    #[test]
647    fn test_generate_pairwise_mask_correct_length() {
648        let m = generate_pairwise_mask(7, 100, 2_147_483_647);
649        assert_eq!(m.len(), 100);
650        let m_empty = generate_pairwise_mask(7, 0, 2_147_483_647);
651        assert_eq!(m_empty.len(), 0);
652    }
653
654    #[test]
655    fn test_generate_pairwise_mask_values_in_modulus_range() {
656        let modulus = 2_147_483_647_i64;
657        let m = generate_pairwise_mask(0xDEAD_BEEF, 512, modulus);
658        for v in m {
659            assert!(v >= 0, "mask values must be non-negative, got {v}");
660            assert!(v < modulus, "mask values must be < modulus, got {v}");
661        }
662    }
663
664    #[test]
665    fn test_generate_pairwise_mask_changes_with_seed() {
666        let m1 = generate_pairwise_mask(1, 32, 2_147_483_647);
667        let m2 = generate_pairwise_mask(2, 32, 2_147_483_647);
668        assert_ne!(m1, m2);
669    }
670
671    // ----- Quantisation round-trip ---------------------------------------
672
673    #[test]
674    fn test_quantize_dequantize_roundtrip() {
675        let scale = 1.0e6;
676        let modulus = 2_147_483_647_i64;
677        let input = Array1::from(vec![1.5, -2.3, 0.0, 4.7]);
678        let q = quantize_gradient(&input, scale, modulus).expect("quantise must succeed");
679        let back = dequantize_gradient(&q, scale, modulus);
680        let back_arr = Array1::from(back);
681        assert!(
682            vec_close(&input, &back_arr, 1.0e-5),
683            "round-trip failed: {input:?} -> {q:?} -> {back_arr:?}"
684        );
685    }
686
687    #[test]
688    fn test_round_trip_quantize_handles_negatives() {
689        let scale = 1.0e6;
690        let modulus = 2_147_483_647_i64;
691        let input = Array1::from(vec![-1.5, -3.5, -0.000_5]);
692        let q = quantize_gradient(&input, scale, modulus).expect("quantise");
693        // All canonical representatives are non-negative.
694        for v in q.iter() {
695            assert!(*v >= 0 && *v < modulus, "value out of canonical range: {v}");
696        }
697        let back = Array1::from(dequantize_gradient(&q, scale, modulus));
698        assert!(
699            vec_close(&input, &back, 1.0e-5),
700            "negative round-trip failed: {input:?} -> {back:?}"
701        );
702    }
703
704    #[test]
705    fn test_quantize_rejects_non_finite_gradient() {
706        let input = Array1::from(vec![1.0, f64::NAN]);
707        let r = quantize_gradient(&input, 1.0e6, 2_147_483_647);
708        match r {
709            Err(OptimError::InvalidParameter(_)) => {}
710            other => panic!("expected InvalidParameter for NaN gradient, got {other:?}"),
711        }
712        let input = Array1::from(vec![f64::INFINITY]);
713        match quantize_gradient(&input, 1.0e6, 2_147_483_647) {
714            Err(OptimError::InvalidParameter(_)) => {}
715            other => panic!("expected InvalidParameter for inf gradient, got {other:?}"),
716        }
717    }
718
719    #[test]
720    fn test_quantize_rejects_non_positive_scale() {
721        let input = Array1::from(vec![1.0_f64]);
722        match quantize_gradient(&input, 0.0, 2_147_483_647) {
723            Err(OptimError::InvalidParameter(_)) => {}
724            other => panic!("expected InvalidParameter for scale=0, got {other:?}"),
725        }
726        match quantize_gradient(&input, -1.0, 2_147_483_647) {
727            Err(OptimError::InvalidParameter(_)) => {}
728            other => panic!("expected InvalidParameter for negative scale, got {other:?}"),
729        }
730    }
731
732    // ----- Client mask construction --------------------------------------
733
734    #[test]
735    fn test_compute_client_mask_two_clients_opposite_signs() {
736        let modulus = 2_147_483_647_i64;
737        let dim = 16;
738        let round = 7;
739        let clients: Vec<ClientId> = vec![10, 20];
740
741        let mask_a = compute_client_mask(10, &clients, round, dim, modulus).expect("client a mask");
742        let mask_b = compute_client_mask(20, &clients, round, dim, modulus).expect("client b mask");
743
744        // mask_a + mask_b should be ≡ 0 (mod modulus) because the two
745        // pairwise contributions have opposite signs.
746        for (a, b) in mask_a.iter().zip(mask_b.iter()) {
747            let sum = (a + b).rem_euclid(modulus);
748            assert_eq!(sum, 0, "pair masks must cancel: a={a}, b={b}, sum={sum}");
749        }
750    }
751
752    #[test]
753    fn test_compute_client_mask_unknown_client_errors() {
754        let r = compute_client_mask(99, &[1, 2, 3], 0, 4, 2_147_483_647);
755        match r {
756            Err(OptimError::InvalidParameter(_)) => {}
757            other => panic!("expected InvalidParameter for unknown client, got {other:?}"),
758        }
759    }
760
761    #[test]
762    fn test_compute_client_mask_n_clients_sum_to_zero() {
763        // Across all clients, every pairwise mask is added once and
764        // subtracted once, so the *total* mask sum must be exactly zero.
765        let modulus = 2_147_483_647_i64;
766        let dim = 8;
767        let round = 555;
768        let clients: Vec<ClientId> = vec![1, 7, 13, 42, 100];
769
770        let mut total = vec![0_i64; dim];
771        for &cid in clients.iter() {
772            let m = compute_client_mask(cid, &clients, round, dim, modulus).expect("mask");
773            for (t, x) in total.iter_mut().zip(m.iter()) {
774                *t = (*t + *x).rem_euclid(modulus);
775            }
776        }
777        assert_eq!(total, vec![0_i64; dim]);
778    }
779
780    // ----- End-to-end aggregation ----------------------------------------
781
782    #[test]
783    fn test_aggregate_two_clients_recovers_sum() {
784        let dim = 5;
785        let config = make_config(2, dim);
786        let clients: Vec<ClientId> = vec![1, 2];
787
788        let g1 = Array1::from(vec![0.5, -1.25, 3.0, 0.0, 7.7]);
789        let g2 = Array1::from(vec![-0.5, 2.5, -1.0, 4.4, -2.2]);
790        let expected: Array1<f64> = &g1 + &g2;
791
792        let sub1 = submit_gradient(1, &g1, &clients, &config).expect("submit 1");
793        let sub2 = submit_gradient(2, &g2, &clients, &config).expect("submit 2");
794
795        let mut agg = SecureAggregator::new(config).expect("aggregator");
796        agg.receive(sub1).expect("recv 1");
797        agg.receive(sub2).expect("recv 2");
798        let out = agg.aggregate().expect("aggregate");
799
800        assert!(
801            vec_close(&out, &expected, QUANT_TOL),
802            "expected {expected:?}, got {out:?}"
803        );
804    }
805
806    #[test]
807    fn test_aggregate_five_clients_recovers_sum() {
808        let dim = 7;
809        let config = make_config(5, dim);
810        let clients: Vec<ClientId> = vec![3, 11, 19, 47, 101];
811
812        let gradients: Vec<Array1<f64>> = vec![
813            Array1::from(vec![1.0, 2.0, -3.0, 4.5, -5.5, 0.1, 0.01]),
814            Array1::from(vec![-1.0, 1.0, 3.0, -2.0, 0.0, -0.1, 0.99]),
815            Array1::from(vec![0.5, -0.5, 0.25, 0.0, 1.0, 2.5, -2.5]),
816            Array1::from(vec![10.0, -10.0, 5.0, -5.0, 2.5, -2.5, 0.0]),
817            Array1::from(vec![0.001, -0.001, 0.002, -0.002, 100.0, -100.0, 0.0]),
818        ];
819
820        let expected = gradients.iter().fold(Array1::zeros(dim), |acc, g| &acc + g);
821
822        let mut agg = SecureAggregator::new(config.clone()).expect("aggregator");
823        for (cid, g) in clients.iter().zip(gradients.iter()) {
824            let sub = submit_gradient(*cid, g, &clients, &config).expect("submit");
825            agg.receive(sub).expect("recv");
826        }
827        let out = agg.aggregate().expect("aggregate");
828
829        assert!(
830            vec_close(&out, &expected, QUANT_TOL),
831            "expected {expected:?}, got {out:?}"
832        );
833    }
834
835    #[test]
836    fn test_aggregate_with_dropout_recovers_sum() {
837        // Five-client cohort. Client 19 drops out. The server must still
838        // recover the sum of the other four gradients exactly.
839        let dim = 4;
840        let config = make_config(5, dim);
841        let clients: Vec<ClientId> = vec![3, 11, 19, 47, 101];
842        let dropped_id: ClientId = 19;
843
844        let gradients: HashMap<ClientId, Array1<f64>> = [
845            (3, Array1::from(vec![1.0, 2.0, -3.0, 4.0])),
846            (11, Array1::from(vec![-1.5, 0.5, 2.5, -2.0])),
847            (19, Array1::from(vec![100.0, 100.0, 100.0, 100.0])), // dropped, unused
848            (47, Array1::from(vec![0.25, 0.5, 0.75, 1.0])),
849            (101, Array1::from(vec![-0.1, -0.2, -0.3, -0.4])),
850        ]
851        .into_iter()
852        .collect();
853
854        let mut expected: Array1<f64> = Array1::zeros(dim);
855        for (cid, g) in gradients.iter() {
856            if *cid != dropped_id {
857                expected = &expected + g;
858            }
859        }
860
861        let mut agg = SecureAggregator::new(config.clone()).expect("aggregator");
862        for &cid in clients.iter() {
863            if cid == dropped_id {
864                agg.mark_dropped(cid).expect("mark dropped");
865            } else {
866                let sub =
867                    submit_gradient(cid, &gradients[&cid], &clients, &config).expect("submit");
868                agg.receive(sub).expect("recv");
869            }
870        }
871
872        assert_eq!(agg.received_count(), 4);
873        assert_eq!(agg.dropped_count(), 1);
874
875        let out = agg.aggregate().expect("aggregate with dropout");
876        assert!(
877            vec_close(&out, &expected, QUANT_TOL),
878            "dropout recovery failed: expected {expected:?}, got {out:?}"
879        );
880    }
881
882    #[test]
883    fn test_aggregate_with_multiple_dropouts_recovers_sum() {
884        // Stronger version of the dropout test: two clients drop out of a
885        // six-client cohort. The aggregation arithmetic should still
886        // produce the exact sum of the remaining four gradients.
887        let dim = 3;
888        let mut config = make_config(6, dim);
889        config.round_seed = 4242;
890        let clients: Vec<ClientId> = vec![1, 2, 3, 4, 5, 6];
891        let dropped: Vec<ClientId> = vec![2, 5];
892
893        let gradients: HashMap<ClientId, Array1<f64>> = [
894            (1, Array1::from(vec![1.0, 0.0, 0.0])),
895            (2, Array1::from(vec![0.0, 1.0, 0.0])),
896            (3, Array1::from(vec![0.0, 0.0, 1.0])),
897            (4, Array1::from(vec![1.0, 1.0, 1.0])),
898            (5, Array1::from(vec![-1.0, -1.0, -1.0])),
899            (6, Array1::from(vec![0.5, 0.5, 0.5])),
900        ]
901        .into_iter()
902        .collect();
903
904        let mut expected: Array1<f64> = Array1::zeros(dim);
905        for (cid, g) in gradients.iter() {
906            if !dropped.contains(cid) {
907                expected = &expected + g;
908            }
909        }
910
911        let mut agg = SecureAggregator::new(config.clone()).expect("aggregator");
912        for &cid in clients.iter() {
913            if dropped.contains(&cid) {
914                agg.mark_dropped(cid).expect("mark dropped");
915            } else {
916                let sub =
917                    submit_gradient(cid, &gradients[&cid], &clients, &config).expect("submit");
918                agg.receive(sub).expect("recv");
919            }
920        }
921
922        let out = agg.aggregate().expect("aggregate");
923        assert!(
924            vec_close(&out, &expected, QUANT_TOL),
925            "multi-dropout recovery failed: expected {expected:?}, got {out:?}"
926        );
927    }
928
929    #[test]
930    fn test_aggregate_missing_clients_errors() {
931        // Three-client cohort, only two reported (one received, one
932        // dropped). aggregate() must refuse to produce a result.
933        let dim = 4;
934        let config = make_config(3, dim);
935        let clients: Vec<ClientId> = vec![1, 2, 3];
936        let g1 = Array1::from(vec![1.0, 2.0, 3.0, 4.0]);
937        let sub1 = submit_gradient(1, &g1, &clients, &config).expect("submit 1");
938
939        let mut agg = SecureAggregator::new(config).expect("aggregator");
940        agg.receive(sub1).expect("recv 1");
941        agg.mark_dropped(2).expect("mark 2");
942
943        match agg.aggregate() {
944            Err(OptimError::InvalidConfig(_)) => {}
945            other => panic!("expected InvalidConfig for incomplete round, got {other:?}"),
946        }
947    }
948
949    #[test]
950    fn test_receive_validates_dim() {
951        let config = make_config(2, 8);
952        let mut agg = SecureAggregator::new(config).expect("aggregator");
953        let bad = MaskedGradient {
954            client_id: 1,
955            values: vec![0_i64; 7], // wrong length
956        };
957        match agg.receive(bad) {
958            Err(OptimError::DimensionMismatch(_)) => {}
959            other => panic!("expected DimensionMismatch, got {other:?}"),
960        }
961    }
962
963    #[test]
964    fn test_receive_validates_value_range() {
965        let config = make_config(2, 3);
966        let modulus = config.modulus;
967        let mut agg = SecureAggregator::new(config).expect("aggregator");
968
969        let bad_high = MaskedGradient {
970            client_id: 1,
971            values: vec![0, modulus, 0],
972        };
973        match agg.receive(bad_high) {
974            Err(OptimError::InvalidParameter(_)) => {}
975            other => panic!("expected InvalidParameter for value=modulus, got {other:?}"),
976        }
977
978        let bad_neg = MaskedGradient {
979            client_id: 1,
980            values: vec![0, -1, 0],
981        };
982        match agg.receive(bad_neg) {
983            Err(OptimError::InvalidParameter(_)) => {}
984            other => panic!("expected InvalidParameter for negative value, got {other:?}"),
985        }
986    }
987
988    // ----- Config validation ---------------------------------------------
989
990    #[test]
991    fn test_invalid_config_zero_clients_errors() {
992        let config = SecureAggregationConfig {
993            num_clients: 0,
994            gradient_dim: 4,
995            ..SecureAggregationConfig::default()
996        };
997        match SecureAggregator::new(config) {
998            Err(OptimError::InvalidConfig(_)) => {}
999            other => panic!("expected InvalidConfig for num_clients=0, got {other:?}"),
1000        }
1001    }
1002
1003    #[test]
1004    fn test_invalid_config_zero_dim_errors() {
1005        let config = SecureAggregationConfig {
1006            num_clients: 3,
1007            gradient_dim: 0,
1008            ..SecureAggregationConfig::default()
1009        };
1010        match SecureAggregator::new(config) {
1011            Err(OptimError::InvalidConfig(_)) => {}
1012            other => panic!("expected InvalidConfig for gradient_dim=0, got {other:?}"),
1013        }
1014    }
1015
1016    #[test]
1017    fn test_modulus_too_small_errors() {
1018        let config = SecureAggregationConfig {
1019            num_clients: 3,
1020            gradient_dim: 4,
1021            round_seed: 0,
1022            quantization_scale: 1.0e6,
1023            modulus: 100, // far smaller than 10 * scale
1024            support_dropouts: true,
1025        };
1026        match SecureAggregator::new(config) {
1027            Err(OptimError::InvalidConfig(_)) => {}
1028            other => panic!("expected InvalidConfig for tiny modulus, got {other:?}"),
1029        }
1030    }
1031
1032    #[test]
1033    fn test_invalid_config_non_positive_scale_errors() {
1034        let config = SecureAggregationConfig {
1035            num_clients: 2,
1036            gradient_dim: 4,
1037            round_seed: 0,
1038            quantization_scale: 0.0,
1039            modulus: 2_147_483_647,
1040            support_dropouts: false,
1041        };
1042        match SecureAggregator::new(config) {
1043            Err(OptimError::InvalidConfig(_)) => {}
1044            other => panic!("expected InvalidConfig for scale=0, got {other:?}"),
1045        }
1046    }
1047
1048    #[test]
1049    fn test_invalid_config_modulus_one_errors() {
1050        let config = SecureAggregationConfig {
1051            num_clients: 2,
1052            gradient_dim: 4,
1053            round_seed: 0,
1054            quantization_scale: 1.0e6,
1055            modulus: 1,
1056            support_dropouts: false,
1057        };
1058        match SecureAggregator::new(config) {
1059            Err(OptimError::InvalidConfig(_)) => {}
1060            other => panic!("expected InvalidConfig for modulus=1, got {other:?}"),
1061        }
1062    }
1063
1064    // ----- Mark dropped validation ---------------------------------------
1065
1066    #[test]
1067    fn test_mark_dropped_after_submit_errors() {
1068        let config = make_config(2, 4);
1069        let clients = vec![1_u64, 2];
1070        let g = Array1::from(vec![1.0, 2.0, 3.0, 4.0]);
1071        let sub = submit_gradient(1, &g, &clients, &config).expect("submit");
1072        let mut agg = SecureAggregator::new(config).expect("aggregator");
1073        agg.receive(sub).expect("recv");
1074        match agg.mark_dropped(1) {
1075            Err(OptimError::InvalidParameter(_)) => {}
1076            other => panic!("expected InvalidParameter, got {other:?}"),
1077        }
1078    }
1079
1080    #[test]
1081    fn test_reset_clears_state() {
1082        let config = make_config(2, 3);
1083        let clients = vec![1_u64, 2];
1084        let g = Array1::from(vec![1.0, 2.0, 3.0]);
1085        let sub = submit_gradient(1, &g, &clients, &config).expect("submit");
1086        let mut agg = SecureAggregator::new(config).expect("aggregator");
1087        agg.receive(sub).expect("recv");
1088        agg.mark_dropped(2).expect("mark");
1089        assert_eq!(agg.received_count(), 1);
1090        assert_eq!(agg.dropped_count(), 1);
1091        agg.reset();
1092        assert_eq!(agg.received_count(), 0);
1093        assert_eq!(agg.dropped_count(), 0);
1094    }
1095
1096    // ----- Serde round-trips ---------------------------------------------
1097
1098    #[test]
1099    fn test_masked_gradient_serde_roundtrip() {
1100        let mg = MaskedGradient {
1101            client_id: 42,
1102            values: vec![0_i64, 1, 1_000_000, 2_147_483_646],
1103        };
1104        let json = serde_json::to_string(&mg).expect("serialise");
1105        let back: MaskedGradient = serde_json::from_str(&json).expect("deserialise");
1106        assert_eq!(mg, back);
1107    }
1108
1109    #[test]
1110    fn test_secure_aggregation_config_serde_roundtrip() {
1111        let config = SecureAggregationConfig {
1112            num_clients: 8,
1113            gradient_dim: 1024,
1114            round_seed: 0xCAFE_BABE,
1115            quantization_scale: 1.0e5,
1116            modulus: 2_147_483_647,
1117            support_dropouts: false,
1118        };
1119        let json = serde_json::to_string(&config).expect("serialise");
1120        let back: SecureAggregationConfig = serde_json::from_str(&json).expect("deserialise");
1121        assert_eq!(back.num_clients, config.num_clients);
1122        assert_eq!(back.gradient_dim, config.gradient_dim);
1123        assert_eq!(back.round_seed, config.round_seed);
1124        assert_eq!(back.quantization_scale, config.quantization_scale);
1125        assert_eq!(back.modulus, config.modulus);
1126        assert_eq!(back.support_dropouts, config.support_dropouts);
1127    }
1128
1129    // ----- Resubmission semantics ----------------------------------------
1130
1131    #[test]
1132    fn test_resubmission_overwrites_previous() {
1133        let dim = 3;
1134        let config = make_config(2, dim);
1135        let clients = vec![1_u64, 2];
1136
1137        let g1_first = Array1::from(vec![100.0, 100.0, 100.0]);
1138        let g1_final = Array1::from(vec![1.0, 2.0, 3.0]);
1139        let g2 = Array1::from(vec![0.5, 0.5, 0.5]);
1140        let expected: Array1<f64> = &g1_final + &g2;
1141
1142        let mut agg = SecureAggregator::new(config.clone()).expect("aggregator");
1143
1144        let sub_first = submit_gradient(1, &g1_first, &clients, &config).expect("submit first");
1145        let sub_final = submit_gradient(1, &g1_final, &clients, &config).expect("submit final");
1146        let sub2 = submit_gradient(2, &g2, &clients, &config).expect("submit 2");
1147
1148        agg.receive(sub_first).expect("recv first");
1149        agg.receive(sub_final).expect("recv final");
1150        agg.receive(sub2).expect("recv 2");
1151
1152        assert_eq!(
1153            agg.received_count(),
1154            2,
1155            "resubmission must overwrite, not duplicate"
1156        );
1157
1158        let out = agg.aggregate().expect("aggregate");
1159        assert!(
1160            vec_close(&out, &expected, QUANT_TOL),
1161            "resubmission failed: expected {expected:?}, got {out:?}"
1162        );
1163    }
1164
1165    #[test]
1166    fn test_default_config_has_sane_round_seed_and_scale() {
1167        let cfg = SecureAggregationConfig::default();
1168        assert_eq!(cfg.round_seed, 42);
1169        assert_eq!(cfg.quantization_scale, 1.0e6);
1170        assert_eq!(cfg.modulus, 2_147_483_647);
1171        assert!(cfg.support_dropouts);
1172        // Caller must populate num_clients and gradient_dim explicitly.
1173        assert_eq!(cfg.num_clients, 0);
1174        assert_eq!(cfg.gradient_dim, 0);
1175    }
1176}