Skip to content

Commit ef55eef

Browse files
authored
Merge pull request #17 from samply/develop
limiting the domain
2 parents 5244d90 + e47bd5b commit ef55eef

File tree

5 files changed

+108
-15
lines changed

5 files changed

+108
-15
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Samply.Laplace v0.6.0 2025-11-26
2+
3+
## Major changes
4+
5+
* Option to limit the domain of the distribution used for obfuscation

Cargo.toml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
[package]
22
name = "laplace_rs"
3-
version = "0.5.0"
3+
version = "0.6.0"
44
edition = "2021"
55
license = "Apache-2.0"
66

77
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
88

99
[dependencies]
1010
thiserror = "2.0.3"
11-
12-
1311
statrs = "0.18.0"
1412
rand = "0.8.5"
1513
anyhow = "1.0.69"

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ The values are obfuscated by perturbing them with random values sampled from a l
55

66
Optionally, true zero values can be returned unperturbed. While lowering the privacy level slightly, this can vastly improve subsequent processes for data access control.
77

8+
If the optional parameter limiting the domain of the Laplace distribution is used, restricting the perturbation to the interval `[-domain_limit, domain_limit]`, the security properties change: the mechanism is no longer $`(\epsilon,0)`$-differentially private but instead only fulfills $`(\epsilon,\delta)`$-differential privacy. The parameter can be calculated as $`\delta(\epsilon,\Delta)=\sup_S\left(Pr[Z\in S]-e^\epsilon Pr[Z\in S-\Delta]\right)_+`$.
9+
810
## Dependencies
911

1012
The dependencies Samply.Laplace Rust library requires are:
@@ -36,6 +38,7 @@ const DELTA: f64 = 1.;
3638
const EPSILON: f64 = 0.1;
3739
const MU: f64 = 0.;
3840
const ROUNDING_STEP: usize = 10;
41+
const domain_limit = None;
3942

4043

4144
fn obfuscate -> Result<u64, LaplaceError> {
@@ -53,6 +56,7 @@ fn obfuscate -> Result<u64, LaplaceError> {
5356
false, // A flag indicating whether zero counts should be obfuscated.
5457
ObfuscateBelow10Mode::Ten, // 0 - return 0, 1 - return 10, 2 - obfuscate using Laplace distribution and rounding
5558
ROUNDING_STEP, // The granularity of the rounding.
59+
domain_limit, // Optional limitation to the domain of the Laplace distributions
5660
&mut rng, // A secure random generator for seeded randomness.
5761
)?;
5862

src/errors.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ use thiserror::Error;
55
pub enum LaplaceError {
66
#[error("Unable to create Laplace distribution: {0}")]
77
DistributionCreationError(StatsError),
8+
#[error("Invalid domain limit. Must be None or a positive non-zero number")]
9+
InvalidDomain,
810
#[error("Rounding step zero not allowed")]
911
InvalidArgRoundingStepZero,
1012
#[error("Rounding step error: {0}")]

src/lib.rs

Lines changed: 96 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ pub enum ObfuscateBelow10Mode {
3939
/// * obfuscate_zero - A flag indicating whether zero counts should be obfuscated.
4040
/// * below_10_obfuscation_mode: 0 - return 0, 1 - return 10, 2 - obfuscate using Laplace distribution and rounding
4141
/// * rounding_step - The granularity of the rounding.
42+
/// * domain_limit - optional parameter limiting the distribution to [-domain_limit, domain_limit]
4243
/// * rng - A secure random generator for seeded randomness.
4344
///
4445
/// # Returns
@@ -54,10 +55,11 @@ pub fn get_from_cache_or_privatize(
5455
obfuscate_zero: bool,
5556
obfuscate_below_10_mode: ObfuscateBelow10Mode,
5657
rounding_step: usize,
58+
domain_limit: Option<f64>,
5759
rng: &mut rand::rngs::ThreadRng,
5860
) -> Result<u64, LaplaceError> {
5961
let obfuscated: u64 = match obf_cache_option {
60-
None => privatize(value, delta, epsilon, rounding_step, rng).unwrap(),
62+
None => privatize(value, delta, epsilon, rounding_step, domain_limit, rng)?,
6163
Some(obf_cache) => {
6264
if !obfuscate_zero && value == 0 {
6365
return Ok(0);
@@ -78,7 +80,7 @@ pub fn get_from_cache_or_privatize(
7880
Some(obfuscated_reference) => *obfuscated_reference,
7981
None => {
8082
let obfuscated_value =
81-
privatize(value, delta, epsilon, rounding_step, rng).unwrap();
83+
privatize(value, delta, epsilon, rounding_step, domain_limit, rng)?;
8284

8385
obf_cache
8486
.cache
@@ -101,6 +103,7 @@ pub fn get_from_cache_or_privatize(
101103
/// * `sensitivity` - Sensitivity of query.
102104
/// * `epsilon` - Privacy budget parameter.
103105
/// * `rounding_step` - Rounding to the given number is performed.
106+
/// * `domain_limit` - optional parameter limiting the distribution to [-domain_limit, domain_limit]
104107
/// * rng - A secure random generator for seeded randomness.
105108
///
106109
/// # Returns
@@ -111,9 +114,25 @@ pub fn privatize(
111114
sensitivity: f64,
112115
epsilon: f64,
113116
rounding_step: usize,
117+
domain_limit: Option<f64>,
114118
rng: &mut rand::rngs::ThreadRng,
115119
) -> Result<u64, LaplaceError> {
116-
let obfuscated_value = value as f64 + laplace(0.0, sensitivity / epsilon, rng).unwrap();
120+
let permutation = match domain_limit {
121+
Some(f64::INFINITY) | None => laplace(0.0, sensitivity / epsilon, rng)?,
122+
Some(boundary) if boundary <= 0. => Err(LaplaceError::InvalidDomain)?,
123+
// Resample, if clamped to a specific domain
124+
Some(boundary) => {
125+
let mut sample: f64;
126+
loop {
127+
sample = laplace(0.0, sensitivity / epsilon, rng)?;
128+
if sample >= -boundary && sample <= boundary {
129+
break;
130+
}
131+
}
132+
sample
133+
}
134+
};
135+
let obfuscated_value = value as f64 + permutation;
117136
round_parametric(obfuscated_value, rounding_step)
118137
}
119138

@@ -140,14 +159,13 @@ fn round_parametric(value: f64, step_parameter: usize) -> Result<u64, LaplaceErr
140159
///
141160
/// * `mu` - the mean of the distribution.
142161
/// * `b` - the scale parameter of the distribution, often equal to `sensitivity`/`epsilon`.
143-
/// /// * `rng` - random generator.
162+
/// * `rng` - random generator.
144163
///
145164
/// # Returns
146165
///
147166
/// Returns a random sample from the Laplace distribution with the given `mu` and `b`, or an error if the distribution creation failed.
148167
fn laplace(mu: f64, b: f64, rng: &mut rand::rngs::ThreadRng) -> Result<f64, LaplaceError> {
149-
let dist =
150-
Laplace::new(mu, b).map_err(|e| LaplaceError::DistributionCreationError(e))?;
168+
let dist = Laplace::new(mu, b).map_err(LaplaceError::DistributionCreationError)?;
151169
Ok(dist.sample(rng))
152170
}
153171

@@ -219,28 +237,74 @@ mod test {
219237
let sensitivity = 10.0;
220238
let epsilon = 0.5;
221239
let rounding_step = 10;
240+
let domain_limit = None;
222241
let result = privatize(
223242
value,
224243
sensitivity,
225244
epsilon,
226245
rounding_step,
246+
domain_limit,
227247
&mut rng,
228248
);
229249
assert!(result.is_ok());
230250
}
231251

252+
#[test]
253+
fn test_privatize_within_domain() {
254+
let mut rng = rand::thread_rng();
255+
let value = 27;
256+
let sensitivity = 10.0;
257+
let epsilon = 0.5;
258+
let rounding_step = 1;
259+
let domain_limit = 10;
260+
for _ in 0..10000 {
261+
let result = privatize(
262+
value,
263+
sensitivity,
264+
epsilon,
265+
rounding_step,
266+
Some(domain_limit as f64),
267+
&mut rng,
268+
)
269+
.unwrap();
270+
assert!(result <= (value + domain_limit) && result >= (value - domain_limit));
271+
}
272+
}
273+
232274
#[test]
233275
fn test_obfuscate_value_zero() {
234276
let mut rng = rand::thread_rng();
235-
let result = get_from_cache_or_privatize(0, 1.0, 1.0, 1, None, true, ObfuscateBelow10Mode::Obfuscate, 1, &mut rng);
277+
let result = get_from_cache_or_privatize(
278+
0,
279+
1.0,
280+
1.0,
281+
1,
282+
None,
283+
true,
284+
ObfuscateBelow10Mode::Obfuscate,
285+
1,
286+
None,
287+
&mut rng,
288+
);
236289

237290
assert!(result.is_ok());
238291
}
239292

240293
#[test]
241294
fn test_obfuscate_value_non_zero() {
242295
let mut rng = rand::thread_rng();
243-
let result = get_from_cache_or_privatize(10, 1.0, 1.0, 1, None, true, ObfuscateBelow10Mode::Obfuscate, 1, &mut rng);
296+
let result = get_from_cache_or_privatize(
297+
10,
298+
1.0,
299+
1.0,
300+
1,
301+
None,
302+
true,
303+
ObfuscateBelow10Mode::Obfuscate,
304+
1,
305+
None,
306+
&mut rng,
307+
);
244308

245309
assert!(result.is_ok());
246310
}
@@ -252,17 +316,37 @@ mod test {
252316
cache: HashMap::new(),
253317
};
254318

255-
let result =
256-
get_from_cache_or_privatize(10, 1.0, 1.0, 1, Some(&mut obf_cache), true, ObfuscateBelow10Mode::Obfuscate, 1, &mut rng);
319+
let result = get_from_cache_or_privatize(
320+
10,
321+
1.0,
322+
1.0,
323+
1,
324+
Some(&mut obf_cache),
325+
true,
326+
ObfuscateBelow10Mode::Obfuscate,
327+
1,
328+
None,
329+
&mut rng,
330+
);
257331
assert!(result.is_ok());
258332

259333
let obfuscated_value = obf_cache.cache.get(&(1, 10, 1));
260334
assert!(obfuscated_value.is_some());
261335
let result_ok = result.unwrap();
262336
assert_eq!(result_ok.clone(), *obfuscated_value.unwrap());
263337

264-
let result2 =
265-
get_from_cache_or_privatize(10, 1.0, 1.0, 1, Some(&mut obf_cache), true, ObfuscateBelow10Mode::Obfuscate, 1, &mut rng);
338+
let result2 = get_from_cache_or_privatize(
339+
10,
340+
1.0,
341+
1.0,
342+
1,
343+
Some(&mut obf_cache),
344+
true,
345+
ObfuscateBelow10Mode::Obfuscate,
346+
1,
347+
None,
348+
&mut rng,
349+
);
266350
assert!(result2.is_ok());
267351
assert_eq!(result_ok, result2.unwrap());
268352
}

0 commit comments

Comments
 (0)