diff --git a/api.bs b/api.bs index 4d33b93..8f29ff2 100644 --- a/api.bs +++ b/api.bs @@ -1143,7 +1143,7 @@ given a [=privacy budget key=] |key|, [[WEBIDL#idl-double|double]] |epsilon|, integer |value|, integer |maxValue|, and -integer |attributedValueForSingleEpochOpt|: +nullable integer |attributedValueForSingleEpochOpt|: 1. If the [=privacy budget store=] does not [=map/contain=] |key|, [=map/set=] its value of |key| to be a [=user agent=]-defined value, @@ -1668,7 +1668,8 @@ To do attribution and fill a histogram, given |options|'s [=validated conversion options/max value=], and |l1Norm|. - 1. If |budgetOk| is false, set |histogram| to the [=create an all-zero histogram|all-zero histogram=]. + 1. If |budgetOk| is false, set |histogram| to the result of invoking + [=create an all-zero histogram=] with |options|' [=validated conversion options/histogram size=]. 1. Return |histogram|. diff --git a/impl/src/backend.ts b/impl/src/backend.ts index 00cfd55..dd8a218 100644 --- a/impl/src/backend.ts +++ b/impl/src/backend.ts @@ -422,51 +422,103 @@ export class Backend { now: Temporal.Instant, options: ValidatedConversionOptions, ): number[] { - const matchedImpressions = new Set(); + let matchedImpressions; const currentEpoch = this.#getCurrentEpoch(topLevelSite, now); const startEpoch = this.#getStartEpoch(topLevelSite); - for (let epoch = startEpoch; epoch <= currentEpoch; ++epoch) { - const impressions = this.#commonMatchingLogic( + const earliestEpoch = this.#getCurrentEpoch( + topLevelSite, + now.subtract(options.lookback), + ); + const singleEpoch = currentEpoch === earliestEpoch; + + if (singleEpoch) { + matchedImpressions = this.#commonMatchingLogic( topLevelSite, intermediarySite, - epoch, + currentEpoch, now, options, ); - if (impressions.size > 0) { - const key = { epoch, site: topLevelSite }; - const budgetOk = this.#deductPrivacyBudget( - key, - options.epsilon, - options.value, - options.maxValue, + } else { + matchedImpressions = new Set(); + for (let epoch = startEpoch; epoch <= currentEpoch; ++epoch) { + const impressions = this.#commonMatchingLogic( + topLevelSite, + intermediarySite, + epoch, + now, + options, ); - if (budgetOk) { - for (const i of impressions) { - matchedImpressions.add(i); + if (impressions.size > 0) { + const key = { epoch, site: topLevelSite }; + const budgetOk = this.#deductPrivacyBudget( + key, + options.epsilon, + options.value, + options.maxValue, + /*attributedValueForSingleEpochOpt=*/ null, + ); + if (budgetOk) { + for (const i of impressions) { + matchedImpressions.add(i); + } } } } } + if (matchedImpressions.size === 0) { return allZeroHistogram(options.histogramSize); } + + let histogram; switch (options.logic) { case "last-n-touch": - return this.#fillHistogramWithLastNTouchAttribution( + histogram = this.#fillHistogramWithLastNTouchAttribution( matchedImpressions, options.histogramSize, options.value, options.logicOptions.credit, ); + break; } + + if (singleEpoch) { + const l1Norm = histogram.reduce((a, b) => a + b); + if (l1Norm > options.value) { + throw new DOMException( + "l1Norm must be less than or equal to options.value", + "InvalidStateError", + ); + } + + const key = { + site: topLevelSite, + epoch: currentEpoch, + }; + + const budgetOk = this.#deductPrivacyBudget( + key, + options.epsilon, + options.value, + options.maxValue, + l1Norm, + ); + + if (!budgetOk) { + histogram = allZeroHistogram(options.histogramSize); + } + } + + return histogram; } #deductPrivacyBudget( key: PrivacyBudgetKey, epsilon: number, - sensitivity: number, - globalSensitivity: number, + value: number, + maxValue: number, + attributedValueForSingleEpochOpt: number | null, ): boolean { let entry = this.#privacyBudgetStore.find( (e) => e.epoch === key.epoch && e.site === key.site, @@ -478,7 +530,12 @@ export class Backend { }; this.#privacyBudgetStore.push(entry); } - const deductionFp = (epsilon * sensitivity) / globalSensitivity; + const singleEpochQuery = attributedValueForSingleEpochOpt !== null; + const halfReportGlobalSensitivity = singleEpochQuery + ? attributedValueForSingleEpochOpt / 2 + : value; + const noiseScale = (2 * maxValue) / epsilon; + const deductionFp = halfReportGlobalSensitivity / noiseScale; if (deductionFp < 0 || deductionFp > index.MAX_CONVERSION_EPSILON) { entry.value = 0; return false;