From bd0512de848700bb307352afbc4a24c6fe0c40b5 Mon Sep 17 00:00:00 2001 From: Andrew Paseltiner Date: Fri, 28 Jul 2023 11:57:55 -0400 Subject: [PATCH] Port flexible_event_privacy.py to TypeScript (#917) This will allow us to incorporate this information into the header validator. Co-authored-by: Charlie Harrison --- flexible-event/.gitignore | 2 + flexible-event/README.md | 15 + flexible-event/flexible_event_privacy.py | 234 -------------- flexible-event/main.ts | 145 +++++++++ flexible-event/package-lock.json | 372 +++++++++++++++++++++++ flexible-event/package.json | 14 + flexible-event/privacy.ts | 174 +++++++++++ flexible-event/tsconfig.json | 6 + 8 files changed, 728 insertions(+), 234 deletions(-) create mode 100644 flexible-event/.gitignore create mode 100644 flexible-event/README.md delete mode 100644 flexible-event/flexible_event_privacy.py create mode 100644 flexible-event/main.ts create mode 100644 flexible-event/package-lock.json create mode 100644 flexible-event/package.json create mode 100644 flexible-event/privacy.ts create mode 100644 flexible-event/tsconfig.json diff --git a/flexible-event/.gitignore b/flexible-event/.gitignore new file mode 100644 index 000000000..de4d1f007 --- /dev/null +++ b/flexible-event/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/flexible-event/README.md b/flexible-event/README.md new file mode 100644 index 000000000..277d544e8 --- /dev/null +++ b/flexible-event/README.md @@ -0,0 +1,15 @@ +Setup: + +```sh +npm install && npm run tsc +``` + +Examples: + +```sh +npm run main -- -w 1,2,3 -b 4,5,6 +``` + +```sh +npm run main -- -f /path/to/source_registration.json +``` diff --git a/flexible-event/flexible_event_privacy.py b/flexible-event/flexible_event_privacy.py deleted file mode 100644 index da278920f..000000000 --- a/flexible-event/flexible_event_privacy.py +++ /dev/null @@ -1,234 +0,0 @@ -import argparse -import collections -import functools -import json -from math import log, exp -import sys -from typing import Tuple, TypedDict, List, NamedTuple - -# Each per-trigger-data config specifies (num_windows, num_buckets) -PerTriggerDataConfig = List[Tuple[int, int]] - - -class ApiConfig(NamedTuple): - max_event_level_reports: int - per_trigger_data_configs: PerTriggerDataConfig - - -def num_flexible_states(config: ApiConfig) -> int: - """Returns the total number of output states for a given configuration of - the flexible event-level API - - Args: - max_event_level_reports: The value of `max_event_level_reports` in the source registration. - per_type_configs: A list of tuples of (num_windows, num_summary_buckets), per `trigger_data` - """ - - # Let B be the trigger data cardinality. - # For every trigger data i, there are w_i windows and c_i maximum reports / summary buckets. - # The following helper function memoizes the recurrence relation: - # 1. A[C, w_1, ..., w_B, c_1, ... , c_B] = 1 if B = 0 - # 2. A[C, w_1, ..., w_B, c_1, ... , c_B] = A[C, w_1, ..., w_{B-1}, c_1, ... , c_{B-1}] if w_B = 0 - # 3. A[C, w_1, ..., w_B, c_1, ... , c_B] = sum(A[C - j, w_1, ..., w_B - 1, c_1, ... , c_B - j], j from 0 to min(c_B, C)) otherwise - @functools.lru_cache(maxsize=None) - def helper(total_cap: int, index: int, w: int, c: int) -> int: - # Case 1. - if index == 0 and w == 0: - return 1 - - # Case 2. - if w == 0: - trigger_config = config.per_trigger_data_configs[index - 1] - return helper(total_cap, index - 1, trigger_config[0], trigger_config[1]) - - # Case 3. - return sum(helper(total_cap - i, index, w - 1, c - i) for i in range(min(c, total_cap) + 1)) - - last_config = config.per_trigger_data_configs[-1] - data_cardinality = len(config.per_trigger_data_configs) - return helper(config.max_event_level_reports, data_cardinality - 1, last_config[0], last_config[1]) - - -def h(x: float) -> float: - """Evaluates the binary entropy function. - - Args: - x: the input value. - - Returns: - The binary entropy function at x. - """ - if x == 0 or x == 1: - return 0 - else: - return - x * log(x, 2) - (1 - x) * log(1 - x, 2) - - -def flip_probability_dp(num_states: int, epsilon: float) -> float: - """Returns the flip probability to satisfy epsilon differential privacy. - - Uses the k-RR privacy mechanism. - """ - return num_states / (num_states + exp(epsilon) - 1) - - -def capacity_q_ary_symmetric_channel(log2_q: float, - flip_probability: float) -> float: - """Computes the capacity of the q-ary symmetric channel. - - Args: - log2_q: the logarithm to base 2 of the alphabet size. - flip_probability: the channel keeps the input the same with probability - 1 - flip_probability, and flips the input to one of the other q - 1 - symbols (uniformly) with the remaining probability of flip_probability. - - Returns: - The capacity of the q-ary symmetric channel for given flip_probability. - In general, the capacity is defined as the maximum, over all input - distributions, of the mutual information between the input and output of - the channel. In the special case of the q-ary symmetric channel, a - closed-form expression is known, which we use here. - """ - return (log2_q - h(flip_probability) - flip_probability * log(2**log2_q - 1, 2)) - - -def max_information_gain(num_states: int, epsilon: float): - """Returns the maximum information for a source using the flexible event API.""" - - flip_prob = flip_probability_dp(num_states, epsilon) - return capacity_q_ary_symmetric_channel(log(num_states, 2), - flip_prob*(num_states-1)/num_states) - - -def epsilon_to_bound_info_gain_and_dp(num_states: int, info_gain_upper_bound: float, epsilon_upper_bound: float, tolerance=1e-5): - """Returns the effective epsilon and flip probability needed to satisfy an information gain bound - given a number of output states in the q-ary symmetric channel.""" - - # Just perform a simple binary search over values of epsilon. - eps_low = 0 - eps_high = epsilon_upper_bound - - while True: - epsilon = (eps_high + eps_low) / 2 - info_gain = max_information_gain(num_states, epsilon) - - if info_gain > info_gain_upper_bound: - eps_high = epsilon - continue - - # Allow slack by returning something slightly non-optimal (governed by the tolerance) - # that still meets the privacy bar. If eps_high == eps_low we're now governed by the epsilon - # bound and can return. - if info_gain < info_gain_upper_bound - tolerance and eps_high != eps_low: - eps_low = epsilon - continue - - return epsilon, flip_probability_dp(num_states, epsilon) - - -def get_config(json: dict, source_type: str) -> ApiConfig: - default_max_reports = 3 if source_type == "navigation" else 1 - default_windows = 3 if source_type == "navigation" else 1 - max_event_level_reports = json.get('max_event_level_reports', default_max_reports) - top_level_num_windows = len(json.get('event_report_windows')['end_times']) if 'event_report_windows' in json else default_windows - per_trigger_data_configs = [] - for spec in json['trigger_specs']: - num_data_types = len(spec['trigger_data']) - num_windows = len(spec['event_report_windows']['end_times']) if 'event_report_windows' in spec else top_level_num_windows - - # Technically this can be larger, but we will always be constrained - # by `max_event_level_reports`. - num_buckets = len(spec['summary_buckets'] - ) if 'summary_buckets' in spec else max_event_level_reports - per_trigger_data_configs.extend( - [(num_windows, num_buckets)] * num_data_types) - - return ApiConfig(max_event_level_reports, per_trigger_data_configs) - - -NAVIGATION_DEFAULT_CONFIG = ApiConfig(3, [(3, 3)] * 8) -EVENT_DEFAULT_CONFIG = ApiConfig(1, [(1, 1)] * 2) - - -def print_config_data(config: ApiConfig, epsilon: float, source_type: str): - num_states = num_flexible_states(config) - info_gain = max_information_gain(num_states, epsilon) - flip_prob = flip_probability_dp(num_states, epsilon) - - print(f"Number of possible different output states: {num_states}") - print(f"Information gain: {info_gain:.2f} bits") - print(f"Flip percent: {100 * flip_prob:.5f}%") - - info_gain_default_nav = max_information_gain( - num_flexible_states(NAVIGATION_DEFAULT_CONFIG), args.epsilon) - info_gain_default_event = max_information_gain( - num_flexible_states(EVENT_DEFAULT_CONFIG), args.epsilon) - if source_type == "navigation" and info_gain > info_gain_default_nav: - new_eps, flip_prob = epsilon_to_bound_info_gain_and_dp(num_states, info_gain_default_nav, args.epsilon) - print( - f"WARNING: info gain of {info_gain:.2f} > {info_gain_default_nav:.2f} for navigation sources. Would require a {100 * flip_prob:.5f}% flip chance (effective epsilon = {new_eps:.3f}) to resolve.") - if source_type == "event" and info_gain > info_gain_default_event: - new_eps, flip_prob = epsilon_to_bound_info_gain_and_dp(num_states, info_gain_default_event, args.epsilon) - print( - f"WARNING: info gain of {info_gain:.2f} > {info_gain_default_event:.2f} for event sources. Would require a {100 * flip_prob:.5f}% flip chance (effective epsilon = {new_eps:.3f}) to resolve.") - - -if __name__ == "__main__": - DESCRIPTION = '''\ - flexible_event_privacy.py is a utility to ingest configurations for the flexible - event-level reports in the Attribution Reporting API. It reads a JSON from stdin - matching the input in the request header Attribution-Reporting-Register-Source. - - It optionally also accepts windows and per-trigger-data summary buckets from command - line arguments. - - The output of this tool prints diagnostics about the config, including how many - output states it encodes, the flip probability for a certain epsilon value (default 14), - and the maximum information gain obtained from one source. The tool will also emit a - warning if the information gain exceeds the default configs for navigation or event - sources. - - Caution: JSON input is not completely validated. It is minimally processed to count the - number of windows and buckets per trigger spec. - ''' - - parser = argparse.ArgumentParser( - description=DESCRIPTION, formatter_class=argparse.RawTextHelpFormatter) - parser.add_argument('-m', '--max_event_level_reports', - type=int, default=20) - parser.add_argument('-e', '--epsilon', type=float, default=14) - - TYPE_HELP = '''\ - Enum representing whether this source is a navigation or event source. Defaults to - a navigation source. - ''' - parser.add_argument('-t', '--source_type', - choices=["event", "navigation"], default="navigation") - - def comma_separated_ints(string): - return [int(i) for i in string.split(',')] - - WINDOW_HELP = '''\ - Comma separated integers representing the number of reporting windows - for each possible trigger_data. - ''' - parser.add_argument('-w', '--windows', - type=comma_separated_ints, help=WINDOW_HELP) - - BUCKETS_HELP = '''\ - Comma separated integers representing the maximum number of summary buckets - for each possible trigger_data. - ''' - parser.add_argument('-b', '--buckets', - type=comma_separated_ints, help=BUCKETS_HELP) - args = parser.parse_args() - - api_config: ApiConfig = None - if args.windows and args.buckets: - assert len(args.windows) == len(args.buckets) - per_trigger_configs = list(zip(args.windows, args.buckets)) - api_config = ApiConfig( - args.max_event_level_reports, per_trigger_configs) - else: - api_config = get_config(json.load(sys.stdin), args.source_type) - print_config_data(api_config, args.epsilon, args.source_type) diff --git a/flexible-event/main.ts b/flexible-event/main.ts new file mode 100644 index 000000000..b329a3b6d --- /dev/null +++ b/flexible-event/main.ts @@ -0,0 +1,145 @@ +const commandLineArgs = require('command-line-args') +const fs = require('fs') + +import {Config, DefaultConfig, PerTriggerDataConfig, SourceType} from './privacy' + +function commaSeparatedInts(str: string): number[] { + return str.split(',').map(v => Number(v)) +} + +function parseSourceType(str: string): SourceType { + if (!(str in DefaultConfig)) { + throw 'unknown source type' + } + return str as SourceType +} + +function getNumWindowsFromJson(defaultVal: number, windows?: object): number { + if (typeof windows === 'undefined') { + return defaultVal + } + + if (typeof windows !== 'object') { + throw 'event_report_windows must be an object' + } + + const endTimes = windows['end_times'] + if (!Array.isArray(endTimes)) { + throw 'end_times must be an array' + } + + return endTimes.length +} + +function getConfig(json: object, sourceType: SourceType): Config { + const defaultMaxReports = DefaultConfig[sourceType].maxEventLevelReports + const defaultWindows = defaultMaxReports + + let maxEventLevelReports = json['max_event_level_reports'] + if (typeof maxEventLevelReports === 'undefined') { + maxEventLevelReports = defaultMaxReports + } else if (typeof maxEventLevelReports !== 'number') { + throw 'max_event_level_reports must be a number' + } + + const topLevelNumWindows = getNumWindowsFromJson(defaultWindows, json['event_report_windows']) + + const triggerSpecs = json['trigger_specs'] + if (!Array.isArray(triggerSpecs)) { + throw 'trigger_specs must be an array' + } + + const perTriggerDataConfigs: PerTriggerDataConfig[] = [] + triggerSpecs.forEach((spec: object) => { + const triggerData = spec['trigger_data'] + if (!Array.isArray(triggerData)) { + throw 'trigger_data must be an array' + } + + const numDataTypes = triggerData.length + + const numWindows = getNumWindowsFromJson(topLevelNumWindows, spec['event_report_windows']) + + // Technically this can be larger, but we will always be constrained + // by `max_event_level_reports`. + let numBuckets = maxEventLevelReports + const summaryBuckets = spec['summary_buckets'] + if (typeof summaryBuckets !== 'undefined') { + if (!Array.isArray(summaryBuckets)) { + throw 'summary_buckets must be an array' + } + numBuckets = summaryBuckets.length + } + + for (let i = 0; i < numDataTypes; i++) { + perTriggerDataConfigs.push(new PerTriggerDataConfig(numWindows, numBuckets)) + } + }); + + return new Config(maxEventLevelReports, perTriggerDataConfigs) +} + +const optionDefs = [ + { + name: 'max_event_level_reports', + alias: 'm', + type: Number, + defaultValue: 20, + }, + { + name: 'epsilon', + alias: 'e', + type: Number, + defaultValue: 14, + }, + { + name: 'source_type', + alias: 't', + type: parseSourceType, + defaultValue: SourceType.Navigation, + }, + { + name: 'windows', + alias: 'w', + type: commaSeparatedInts, + }, + { + name: 'buckets', + alias: 'b', + type: commaSeparatedInts, + }, + { + name: 'json_file', + alias: 'f', + type: String, + }, +] + +const options = commandLineArgs(optionDefs) + +let config: Config +if ('json_file' in options) { + const json = JSON.parse(fs.readFileSync(options.json_file, {encoding: 'utf8'})) + config = getConfig(json, options.source_type) +} else { + if (options.windows.length != options.buckets.length) { + throw 'windows and buckets must have same length' + } + config = new Config( + options.max_event_level_reports, + options.windows.map((w, i) => new PerTriggerDataConfig(w, options.buckets[i])), + ) +} + +const out = config.computeConfigData(options.epsilon, options.source_type) + +console.log(`Number of possible different output states: ${out.numStates}`) +console.log(`Information gain: ${out.infoGain.toFixed(2)} bits`) +console.log(`Flip percent: ${(100 * out.flipProb).toFixed(5)}%`) + +if (out.excessive) { + const e = out.excessive + console.log( + `WARNING: info gain > ${e.infoGainDefault.toFixed(2)} for ${options.source_type} sources. Would require a ${(100 * + e.newFlipProb).toFixed(5)}% flip chance (effective epsilon = ${e.newEps.toFixed(3)}) to resolve.`) +} diff --git a/flexible-event/package-lock.json b/flexible-event/package-lock.json new file mode 100644 index 000000000..cfd930ac0 --- /dev/null +++ b/flexible-event/package-lock.json @@ -0,0 +1,372 @@ +{ + "name": "flexible-event", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "dependencies": { + "command-line-args": "^5.2.1", + "memoizee": "^0.4.15" + }, + "devDependencies": { + "@types/node": "^20.4.5", + "typescript": "^5.1.6" + } + }, + "node_modules/@types/node": { + "version": "20.4.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.5.tgz", + "integrity": "sha512-rt40Nk13II9JwQBdeYqmbn2Q6IVTA5uPhvSO+JVqdXw/6/4glI6oR9ezty/A9Hg5u7JH4OmYmuQ+XvjKm0Datg==", + "dev": true + }, + "node_modules/array-back": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", + "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==", + "engines": { + "node": ">=6" + } + }, + "node_modules/command-line-args": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", + "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", + "dependencies": { + "array-back": "^3.1.0", + "find-replace": "^3.0.0", + "lodash.camelcase": "^4.3.0", + "typical": "^4.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/d": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", + "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", + "dependencies": { + "es5-ext": "^0.10.50", + "type": "^1.0.1" + } + }, + "node_modules/es5-ext": { + "version": "0.10.62", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz", + "integrity": "sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==", + "hasInstallScript": true, + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", + "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", + "dependencies": { + "d": "^1.0.1", + "ext": "^1.1.2" + } + }, + "node_modules/es6-weak-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/ext/node_modules/type": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", + "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" + }, + "node_modules/find-replace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", + "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", + "dependencies": { + "array-back": "^3.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + }, + "node_modules/lru-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", + "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", + "dependencies": { + "es5-ext": "~0.10.2" + } + }, + "node_modules/memoizee": { + "version": "0.4.15", + "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.15.tgz", + "integrity": "sha512-UBWmJpLZd5STPm7PMUlOw/TSy972M+z8gcyQ5veOnSDRREz/0bmpyTfKt3/51DhEBqCZQn1udM/5flcSPYhkdQ==", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.53", + "es6-weak-map": "^2.0.3", + "event-emitter": "^0.3.5", + "is-promise": "^2.2.2", + "lru-queue": "^0.1.0", + "next-tick": "^1.1.0", + "timers-ext": "^0.1.7" + } + }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" + }, + "node_modules/timers-ext": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.7.tgz", + "integrity": "sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ==", + "dependencies": { + "es5-ext": "~0.10.46", + "next-tick": "1" + } + }, + "node_modules/type": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", + "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==" + }, + "node_modules/typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typical": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", + "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==", + "engines": { + "node": ">=8" + } + } + }, + "dependencies": { + "@types/node": { + "version": "20.4.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.5.tgz", + "integrity": "sha512-rt40Nk13II9JwQBdeYqmbn2Q6IVTA5uPhvSO+JVqdXw/6/4glI6oR9ezty/A9Hg5u7JH4OmYmuQ+XvjKm0Datg==", + "dev": true + }, + "array-back": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz", + "integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==" + }, + "command-line-args": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.1.tgz", + "integrity": "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==", + "requires": { + "array-back": "^3.1.0", + "find-replace": "^3.0.0", + "lodash.camelcase": "^4.3.0", + "typical": "^4.0.0" + } + }, + "d": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", + "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", + "requires": { + "es5-ext": "^0.10.50", + "type": "^1.0.1" + } + }, + "es5-ext": { + "version": "0.10.62", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.62.tgz", + "integrity": "sha512-BHLqn0klhEpnOKSrzn/Xsz2UIW8j+cGmo9JLzr8BiUapV8hPL9+FliFqjwr9ngW7jWdnxv6eO+/LqyhJVqgrjA==", + "requires": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "next-tick": "^1.1.0" + } + }, + "es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "requires": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "es6-symbol": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", + "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", + "requires": { + "d": "^1.0.1", + "ext": "^1.1.2" + } + }, + "es6-weak-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", + "requires": { + "d": "1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.1" + } + }, + "event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "requires": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "requires": { + "type": "^2.7.2" + }, + "dependencies": { + "type": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", + "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" + } + } + }, + "find-replace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", + "integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==", + "requires": { + "array-back": "^3.0.1" + } + }, + "is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==" + }, + "lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + }, + "lru-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", + "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", + "requires": { + "es5-ext": "~0.10.2" + } + }, + "memoizee": { + "version": "0.4.15", + "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.15.tgz", + "integrity": "sha512-UBWmJpLZd5STPm7PMUlOw/TSy972M+z8gcyQ5veOnSDRREz/0bmpyTfKt3/51DhEBqCZQn1udM/5flcSPYhkdQ==", + "requires": { + "d": "^1.0.1", + "es5-ext": "^0.10.53", + "es6-weak-map": "^2.0.3", + "event-emitter": "^0.3.5", + "is-promise": "^2.2.2", + "lru-queue": "^0.1.0", + "next-tick": "^1.1.0", + "timers-ext": "^0.1.7" + } + }, + "next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" + }, + "timers-ext": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.7.tgz", + "integrity": "sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ==", + "requires": { + "es5-ext": "~0.10.46", + "next-tick": "1" + } + }, + "type": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", + "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==" + }, + "typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "dev": true + }, + "typical": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz", + "integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==" + } + } +} diff --git a/flexible-event/package.json b/flexible-event/package.json new file mode 100644 index 000000000..249cca440 --- /dev/null +++ b/flexible-event/package.json @@ -0,0 +1,14 @@ +{ + "scripts": { + "main": "node dist/main.js", + "tsc": "tsc" + }, + "dependencies": { + "command-line-args": "^5.2.1", + "memoizee": "^0.4.15" + }, + "devDependencies": { + "@types/node": "^20.4.5", + "typescript": "^5.1.6" + } +} diff --git a/flexible-event/privacy.ts b/flexible-event/privacy.ts new file mode 100644 index 000000000..79375f941 --- /dev/null +++ b/flexible-event/privacy.ts @@ -0,0 +1,174 @@ +const memoize = require('memoizee') + +export enum SourceType { + Event = 'event', + Navigation = 'navigation', +} + +export type ExcessiveInfoGainData = { + infoGainDefault: number, + newEps: number, + newFlipProb: number, +} + +export type ConfigData = { + numStates: number, + infoGain: number, + flipProb: number, + excessive?: ExcessiveInfoGainData, +} + +export class PerTriggerDataConfig { + constructor( + readonly numWindows: number, + readonly numSummaryBuckets: number) { + if (this.numWindows <= 0) { + throw 'numWindows must be > 0' + } + if (this.numSummaryBuckets <= 0) { + throw 'numSummaryBuckets must be > 0' + } + } +} + +export class Config { + constructor( + readonly maxEventLevelReports: number, + readonly perTriggerDataConfigs: ReadonlyArray) { + if (this.maxEventLevelReports <= 0 || !Number.isInteger(this.maxEventLevelReports)) { + throw 'maxEventLevelReports must be an integer > 0' + } + if (this.perTriggerDataConfigs.length === 0) { + throw 'perTriggerDataConfigs must be non-empty' + } + } + + private numFlexibleStates(): number { + // Let B be the trigger data cardinality. + // For every trigger data i, there are w_i windows and c_i maximum reports / summary buckets. + // The following helper function memoizes the recurrence relation: + // 1. A[C, w_1, ..., w_B, c_1, ... , c_B] = 1 if B = 0 + // 2. A[C, w_1, ..., w_B, c_1, ... , c_B] = A[C, w_1, ..., w_{B-1}, c_1, ... , c_{B-1}] if w_B = 0 + // 3. A[C, w_1, ..., w_B, c_1, ... , c_B] = sum(A[C - j, w_1, ..., w_B - 1, c_1, ... , c_B - j], j from 0 to min(c_B, C)) otherwise + const helper = memoize((totalCap: number, index: number, w: number, c: number): number => { + if (index == 0 && w == 0) { + return 1 + } + + if (w == 0) { + const triggerConfig = this.perTriggerDataConfigs[index - 1] + return helper(totalCap, index - 1, triggerConfig.numWindows, triggerConfig.numSummaryBuckets) + } + + let sum = 0 + const end = Math.min(c, totalCap) + for (let i = 0; i <= end; i++) { + sum += helper(totalCap - i, index, w - 1, c - i) + } + return sum + }) + + const lastConfig = this.perTriggerDataConfigs[this.perTriggerDataConfigs.length - 1] + const dataCardinality = this.perTriggerDataConfigs.length + return helper(this.maxEventLevelReports, dataCardinality - 1, lastConfig.numWindows, lastConfig.numSummaryBuckets) + } + + computeConfigData(epsilon: number, sourceType: SourceType): ConfigData { + const numStates = this.numFlexibleStates() + const infoGain = maxInformationGain(numStates, epsilon) + const flipProb = flipProbabilityDp(numStates, epsilon) + + let excessive + const infoGainDefault = maxInformationGain( + DefaultConfig[sourceType].numFlexibleStates(), epsilon) + + if (infoGain > infoGainDefault) { + const newEps = epsilonToBoundInfoGainAndDp(numStates, infoGainDefault, epsilon) + const newFlipProb = flipProbabilityDp(numStates, newEps) + excessive= {infoGainDefault, newEps, newFlipProb} + } + + return {numStates, infoGain, flipProb, excessive} + } +} + +export const DefaultConfig: Readonly> = { + [SourceType.Navigation]: new Config( + /*maxEventLevelReports=*/3, + new Array(8).fill(new PerTriggerDataConfig(/*numWindows=*/3, /*numSummaryBuckets=*/3)), + ), + [SourceType.Event]: new Config( + /*maxEventLevelReports=*/1, + new Array(2).fill(new PerTriggerDataConfig(/*numWindows=*/1, /*numSummaryBuckets=*/1)), + ), +} + +// Evaluates the binary entropy function. +function h(x: number): number { + if (x == 0 || x == 1) { + return 0 + } + return -x * Math.log2(x) - (1 - x) * Math.log2(1 - x) +} + +// Returns the flip probability to satisfy epsilon differential privacy. +// Uses the k-RR privacy mechanism. +function flipProbabilityDp(numStates: number, epsilon: number): number { + return numStates / (numStates * Math.exp(epsilon) - 1) +} + +/** + * Computes the capacity of the q-ary symmetric channel. + * + * @param log2q - the logarithm to base 2 of the alphabet size. + * @param flipProbability - the channel keeps the input the same with probability + * 1 - flipProbability, and flips the input to one of the other q - 1 + * symbols (uniformly) with the remaining probability of flipProbability. + * + * @returns - The capacity of the q-ary symmetric channel for given flipProbability. + * In general, the capacity is defined as the maximum, over all input + * distributions, of the mutual information between the input and output of + * the channel. In the special case of the q-ary symmetric channel, a + * closed-form expression is known, which we use here. + */ +function capacityQarySymmetricChannel(log2q: number, flipProbability: number): number { + return log2q - h(flipProbability) - flipProbability * Math.log2(Math.pow(2, log2q) - 1) +} + +function maxInformationGain(numStates: number, epsilon: number): number { + const flipProb = flipProbabilityDp(numStates, epsilon) + return capacityQarySymmetricChannel(Math.log2(numStates), + flipProb * (numStates - 1) / numStates) +} + +// Returns the effective epsilon needed to satisfy an information gain bound +// given a number of output states in the q-ary symmetric channel. +function epsilonToBoundInfoGainAndDp( + numStates: number, + infoGainUpperBound: number, + epsilonUpperBound: number, + tolerance: number = 0.00001): number { + // Just perform a simple binary search over values of epsilon. + let epsLow = 0 + let epsHigh = epsilonUpperBound + + while (true) { + const epsilon = (epsHigh + epsLow) / 2 + const infoGain = maxInformationGain(numStates, epsilon) + + if (infoGain > infoGainUpperBound) { + epsHigh = epsilon + continue + } + + // Allow slack by returning something slightly non-optimal (governed by the tolerance) + // that still meets the privacy bar. If epsHigh == epsLow we're now governed by the epsilon + // bound and can return. + if (infoGain < infoGainUpperBound - tolerance && epsHigh != epsLow) { + epsLow = epsilon + continue + } + + return epsilon + } +} diff --git a/flexible-event/tsconfig.json b/flexible-event/tsconfig.json new file mode 100644 index 000000000..5e065c7a3 --- /dev/null +++ b/flexible-event/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "lib": ["ES2015"], + "outDir": "dist" + } +}