Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions impl/e2e-tests/CONFIG.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
},
"epochStart": 0.5,
"fairlyAllocateCreditFraction": 0.5,
"$comment": "TODO",
"globalBudgetPerEpochMicroEpsilons": 9007199254740991,
"$comment": "TODO",
"impressionSiteQuotaPerEpochMicroEpsilons": 9007199254740991,
"maxConversionSitesPerImpression": 3,
"maxConversionCallersPerImpression": 3,
"maxImpressionSitesForConversion": 3,
Expand Down
20 changes: 18 additions & 2 deletions impl/e2e-tests/single-epoch-budgeting.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 8 additions & 0 deletions impl/e2e.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@
"minimum": 0,
"exclusiveMaximum": 1
},
"globalBudgetPerEpochMicroEpsilons": {
"type": "integer",
"minimum": 1
},
"impressionSiteQuotaPerEpochMicroEpsilons": {
"type": "integer",
"minimum": 1
},
"maxConversionCallersPerImpression": {
"type": "integer",
"minimum": 0
Expand Down
176 changes: 140 additions & 36 deletions impl/src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PrivacyBudgetKey> {
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<AttributionAggregationService>;
epsilon: number;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -135,7 +157,9 @@ export class Backend {
readonly #delegate: Delegate;
#impressions: Readonly<Impression>[] = [];
readonly #epochStartStore: Map<string, Temporal.Instant> = new Map();
#privacyBudgetStore: PrivacyBudgetStoreEntry[] = [];
#privacyBudgetStore: BudgetEntry[] = [];
#impressionSiteQuotaStore: BudgetEntry[] = [];
#globalPrivacyBudgetStore: Map<number, number> = new Map();

#lastBrowsingHistoryClear: Temporal.Instant | null = null;

Expand All @@ -147,7 +171,7 @@ export class Backend {
return this.#epochStartStore;
}

get privacyBudgetEntries(): ReadonlyArray<Readonly<PrivacyBudgetStoreEntry>> {
get privacyBudgetEntries(): ReadonlyArray<Readonly<BudgetEntry>> {
return this.#privacyBudgetStore;
}

Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -495,52 +524,133 @@ 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);
}
}

return histogram;
}

#deductPrivacyBudget(
key: PrivacyBudgetKey,
#deductPrivacyAndSafetyBudgets(
key: BudgetKey,
impressions: ReadonlySet<Readonly<Impression>>,
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<string>();
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<string>,
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<string>,
): 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;
}

Expand Down Expand Up @@ -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;
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions impl/src/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions impl/src/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface TestConfig {
privacyBudgetEpochDays: number;
epochStart: number;
fairlyAllocateCreditFraction: number;
globalBudgetPerEpochMicroEpsilons: number;
impressionSiteQuotaPerEpochMicroEpsilons: number;
}

export function makeBackend(
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions impl/src/simulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down