diff --git a/impl/e2e-tests/CONFIG.json b/impl/e2e-tests/CONFIG.json index 590787f..32d5e5c 100644 --- a/impl/e2e-tests/CONFIG.json +++ b/impl/e2e-tests/CONFIG.json @@ -8,6 +8,10 @@ }, "epochStart": 0.5, "fairlyAllocateCreditFraction": 0.5, + "$comment": "TODO", + "globalBudgetPerEpochMicroEpsilons": 9007199254740991, + "$comment": "TODO", + "impressionSiteQuotaPerEpochMicroEpsilons": 9007199254740991, "maxConversionSitesPerImpression": 3, "maxConversionCallersPerImpression": 3, "maxImpressionSitesForConversion": 3, diff --git a/impl/e2e-tests/single-epoch-budgeting.json b/impl/e2e-tests/single-epoch-budgeting.json index 74667fe..69f7bbb 100644 --- a/impl/e2e-tests/single-epoch-budgeting.json +++ b/impl/e2e-tests/single-epoch-budgeting.json @@ -73,11 +73,27 @@ "maxValue": 8, "credit": [3, 1] }, - "$comment": "The preceding case should have subtracted the remaining 1/4 even though it was insufficient for the 1/2 required", - "expected": [0, 0, 0] + "$comment": "The preceding case should not have subtracted the remaining 1/4 as it was insufficient for the 1/2 required", + "expected": [1, 3, 0] }, { "seconds": 7, + "site": "advertiser-1.example", + "event": "measureConversion", + "options": { + "aggregationService": "https://agg-service.example", + "epsilon": 1, + "histogramSize": 3, + "lookbackDays": 1, + "value": 4, + "maxValue": 8, + "credit": [3, 1] + }, + "$comment": "The preceding case should have subtracted the remaining 1/4", + "expected": [0, 0, 0] + }, + { + "seconds": 8, "site": "advertiser-2.example", "event": "measureConversion", "options": { diff --git a/impl/e2e.schema.json b/impl/e2e.schema.json index bdb7e5d..00811d0 100644 --- a/impl/e2e.schema.json +++ b/impl/e2e.schema.json @@ -27,6 +27,14 @@ "minimum": 0, "exclusiveMaximum": 1 }, + "globalBudgetPerEpochMicroEpsilons": { + "type": "integer", + "minimum": 1 + }, + "impressionSiteQuotaPerEpochMicroEpsilons": { + "type": "integer", + "minimum": 1 + }, "maxConversionCallersPerImpression": { "type": "integer", "minimum": 0 diff --git a/impl/src/backend.ts b/impl/src/backend.ts index 90238f6..5a97052 100644 --- a/impl/src/backend.ts +++ b/impl/src/backend.ts @@ -27,15 +27,35 @@ interface Impression { priority: number; } -interface PrivacyBudgetKey { - epoch: number; - site: string; +interface BudgetKey { + readonly epoch: number; + readonly site: string; } -interface PrivacyBudgetStoreEntry extends Readonly { +interface BudgetEntry extends BudgetKey { value: number; } +function lookup( + entries: readonly BudgetEntry[], + key: BudgetKey, +): BudgetEntry | undefined { + return entries.find((e) => e.epoch === key.epoch && e.site === key.site); +} + +function lookupOrInsert( + entries: BudgetEntry[], + key: BudgetKey, + valueIfNew: number, +): BudgetEntry { + let entry = lookup(entries, key); + if (entry === undefined) { + entry = { ...key, value: valueIfNew }; + entries.push(entry); + } + return entry; +} + interface ValidatedConversionOptions { aggregationService: Readonly; epsilon: number; @@ -107,6 +127,8 @@ export interface Delegate { readonly maxHistogramSize: number; readonly privacyBudgetMicroEpsilons: number; readonly privacyBudgetEpoch: Temporal.Duration; + readonly globalBudgetPerEpochMicroEpsilons: number; + readonly impressionSiteQuotaPerEpochMicroEpsilons: number; now(): Temporal.Instant; fairlyAllocateCreditFraction(): number; @@ -135,7 +157,9 @@ export class Backend { readonly #delegate: Delegate; #impressions: Readonly[] = []; readonly #epochStartStore: Map = new Map(); - #privacyBudgetStore: PrivacyBudgetStoreEntry[] = []; + #privacyBudgetStore: BudgetEntry[] = []; + #impressionSiteQuotaStore: BudgetEntry[] = []; + #globalPrivacyBudgetStore: Map = new Map(); #lastBrowsingHistoryClear: Temporal.Instant | null = null; @@ -147,7 +171,7 @@ export class Backend { return this.#epochStartStore; } - get privacyBudgetEntries(): ReadonlyArray> { + get privacyBudgetEntries(): ReadonlyArray> { return this.#privacyBudgetStore; } @@ -163,6 +187,8 @@ export class Backend { return this.#lastBrowsingHistoryClear; } + // This does not check permission policy or activation, as those are outside + // the scope of the simulator. saveImpression( impressionSite: string, intermediarySite: string | undefined, @@ -323,6 +349,8 @@ export class Backend { }; } + // This does not check permission policy or activation, as those are outside + // the scope of the simulator. measureConversion( topLevelSite: string, intermediarySite: string | undefined, @@ -459,14 +487,15 @@ export class Backend { ); if (impressions.size > 0) { const key = { epoch, site: topLevelSite }; - const budgetOk = this.#deductPrivacyBudget( + const budgetAndSafetyOk = this.#deductPrivacyAndSafetyBudgets( key, + impressions, options.epsilon, options.value, options.maxValue, /*l1Norm=*/ null, ); - if (budgetOk) { + if (budgetAndSafetyOk) { for (const i of impressions) { matchedImpressions.add(i); } @@ -495,15 +524,16 @@ export class Backend { epoch: currentEpoch, }; - const budgetOk = this.#deductPrivacyBudget( + const budgetAndSafetyOk = this.#deductPrivacyAndSafetyBudgets( key, + matchedImpressions, options.epsilon, options.value, options.maxValue, l1Norm, ); - if (!budgetOk) { + if (!budgetAndSafetyOk) { histogram = allZeroHistogram(options.histogramSize); } } @@ -511,36 +541,116 @@ export class Backend { return histogram; } - #deductPrivacyBudget( - key: PrivacyBudgetKey, + #deductPrivacyAndSafetyBudgets( + key: BudgetKey, + impressions: ReadonlySet>, epsilon: number, value: number, maxValue: number, l1Norm: number | null, ): boolean { - let entry = this.#privacyBudgetStore.find( - (e) => e.epoch === key.epoch && e.site === key.site, - ); - if (entry === undefined) { - entry = { - value: this.#delegate.privacyBudgetMicroEpsilons + 1000, - ...key, - }; - this.#privacyBudgetStore.push(entry); - } + const epoch = key.epoch; const sensitivity = l1Norm ?? 2 * value; const noiseScale = (2 * maxValue) / epsilon; const deductionFp = sensitivity / noiseScale; if (deductionFp < 0 || deductionFp > index.MAX_CONVERSION_EPSILON) { + const entry = lookupOrInsert(this.#privacyBudgetStore, key, 0); entry.value = 0; return false; } const deduction = Math.ceil(deductionFp * 1000000); - if (deduction > entry.value) { - entry.value = 0; + const impressionSites = new Set(); + for (const impression of impressions) { + impressionSites.add(impression.impressionSite); + } + const isSingleEpoch = l1Norm !== null; + const impressionSiteDeduction = this.#computeImpressionSiteDeductions( + impressionSites, + deduction, + value, + maxValue, + epsilon, + isSingleEpoch, + ); + if ( + !this.#checkForAvailablePrivacyBudget( + key, + deduction, + impressionSiteDeduction, + impressionSites, + ) + ) { return false; } - entry.value -= deduction; + { + const entry = lookupOrInsert( + this.#privacyBudgetStore, + key, + this.#delegate.privacyBudgetMicroEpsilons, + ); + entry.value -= deduction; + } + { + const currentValue = this.#globalPrivacyBudgetStore.get(epoch)!; + this.#globalPrivacyBudgetStore.set(epoch, currentValue - deduction); + } + for (const site of impressionSites) { + const entry = lookupOrInsert( + this.#impressionSiteQuotaStore, + { epoch, site }, + this.#delegate.impressionSiteQuotaPerEpochMicroEpsilons, + ); + entry.value -= impressionSiteDeduction; + } + return true; + } + + #computeImpressionSiteDeductions( + impressionSites: ReadonlySet, + deduction: number, + value: number, + maxValue: number, + epsilon: number, + isSingleEpoch: boolean, + ): number { + const sensitivity = 2 * value; + const noiseScale = (2 * maxValue) / epsilon; + const deductionFp = sensitivity / noiseScale; + const globalDeduction = Math.ceil(deductionFp * 1000000); + const numberImpressionSites = impressionSites.size; + if (isSingleEpoch && numberImpressionSites === 1) { + return deduction; + } + return globalDeduction; + } + + #checkForAvailablePrivacyBudget( + key: BudgetKey, + deduction: number, + impressionSiteDeduction: number, + impressionSites: ReadonlySet, + ): boolean { + const currentValue = + lookup(this.#privacyBudgetStore, key)?.value ?? + this.#delegate.privacyBudgetMicroEpsilons; + if (deduction > currentValue) { + return false; + } + const epoch = key.epoch; + const currentGlobalValue = + this.#globalPrivacyBudgetStore.get(epoch) ?? + this.#delegate.globalBudgetPerEpochMicroEpsilons; + if (deduction > currentGlobalValue) { + return false; + } + for (const site of impressionSites) { + const value = + lookup(this.#impressionSiteQuotaStore, { epoch, site })?.value ?? + this.#delegate.impressionSiteQuotaPerEpochMicroEpsilons; + if (impressionSiteDeduction > value) { + return false; + } + } return true; } @@ -662,18 +772,12 @@ export class Backend { const startEpoch = this.#getStartEpoch(site, now); const currentEpoch = this.#getCurrentEpoch(site, now); for (let epoch = startEpoch; epoch <= currentEpoch; ++epoch) { - const entry = this.#privacyBudgetStore.find( - (e) => e.epoch === epoch && e.site === site, + const entry = lookupOrInsert( + this.#privacyBudgetStore, + { epoch, site }, + 0, ); - if (entry === undefined) { - this.#privacyBudgetStore.push({ - site, - epoch, - value: 0, - }); - } else { - entry.value = 0; - } + entry.value = 0; } } } diff --git a/impl/src/e2e.test.ts b/impl/src/e2e.test.ts index cd9adfa..8796c4f 100644 --- a/impl/src/e2e.test.ts +++ b/impl/src/e2e.test.ts @@ -120,6 +120,9 @@ function runTest( maxHistogramSize: config.maxHistogramSize, privacyBudgetMicroEpsilons: config.privacyBudgetMicroEpsilons, privacyBudgetEpoch: days(config.privacyBudgetEpochDays), + globalBudgetPerEpochMicroEpsilons: config.globalBudgetPerEpochMicroEpsilons, + impressionSiteQuotaPerEpochMicroEpsilons: + config.impressionSiteQuotaPerEpochMicroEpsilons, now: () => now, fairlyAllocateCreditFraction: () => config.fairlyAllocateCreditFraction, diff --git a/impl/src/fixture.ts b/impl/src/fixture.ts index a48b0fe..b0b2737 100644 --- a/impl/src/fixture.ts +++ b/impl/src/fixture.ts @@ -20,6 +20,8 @@ export interface TestConfig { privacyBudgetEpochDays: number; epochStart: number; fairlyAllocateCreditFraction: number; + globalBudgetPerEpochMicroEpsilons: number; + impressionSiteQuotaPerEpochMicroEpsilons: number; } export function makeBackend( @@ -46,6 +48,9 @@ export function makeBackend( maxHistogramSize: config.maxHistogramSize, privacyBudgetMicroEpsilons: config.privacyBudgetMicroEpsilons, privacyBudgetEpoch: days(config.privacyBudgetEpochDays), + globalBudgetPerEpochMicroEpsilons: config.globalBudgetPerEpochMicroEpsilons, + impressionSiteQuotaPerEpochMicroEpsilons: + config.impressionSiteQuotaPerEpochMicroEpsilons, now: () => now, fairlyAllocateCreditFraction: () => config.fairlyAllocateCreditFraction, diff --git a/impl/src/simulator.ts b/impl/src/simulator.ts index 6679898..be6f445 100644 --- a/impl/src/simulator.ts +++ b/impl/src/simulator.ts @@ -16,6 +16,8 @@ const backend = new Backend({ includeUnencryptedHistogram: true, // TODO: Allow these values to be configured in the UI. + globalBudgetPerEpochMicroEpsilons: Infinity, // TODO + impressionSiteQuotaPerEpochMicroEpsilons: Infinity, // TODO maxConversionSitesPerImpression: 10, maxConversionCallersPerImpression: 10, maxImpressionSitesForConversion: 10,