Skip to content

Commit bdea09e

Browse files
authored
Merge pull request #238 from apasel422/tidy
Miscellaneous simulator improvements
2 parents fecce15 + bca39a1 commit bdea09e

File tree

5 files changed

+219
-35
lines changed

5 files changed

+219
-35
lines changed

impl/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ Usage:
33
```sh
44
npm install && npm run pack && npm run serve-local
55
```
6+
7+
A live version of the simulator can be found at
8+
https://w3c.github.io/ppa/simulator.html.

impl/package-lock.json

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

impl/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"html-webpack-plugin": "^5.6.3",
1818
"http-server": "^14.1.1",
1919
"prettier": "^3.6.2",
20+
"simple-statistics": "^7.8.8",
2021
"ts-loader": "^9.5.2",
2122
"typescript": "^5.9.2",
2223
"typescript-eslint": "^8.39.0",

impl/src/backend.test.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { fairlyAllocateCredit } from "./backend";
2+
3+
import { strict as assert } from "assert";
4+
import test from "node:test";
5+
import { inverseErrorFunction } from "simple-statistics";
6+
7+
interface FairlyAllocateCreditTestCase {
8+
name: string;
9+
credit: number[];
10+
value: number;
11+
needsRand?: boolean;
12+
}
13+
14+
function noRand(): number {
15+
throw new Error("no rand expected");
16+
}
17+
18+
type Interval = [min: number, max: number];
19+
20+
// https://en.wikipedia.org/wiki/Probit
21+
function normalPpf(q: number, stdDev: number): number {
22+
return stdDev * Math.sqrt(2) * inverseErrorFunction(2 * q - 1);
23+
}
24+
25+
const minNForIntervalApprox = 1000;
26+
27+
function getIntervalApprox(n: number, p: number, alpha: number): Interval {
28+
if (n < minNForIntervalApprox) {
29+
throw new RangeError(`n must be >= ${minNForIntervalApprox}`);
30+
}
31+
32+
// Approximates a binomial distribution with a normal distribution which is a bit
33+
// simpler as it is symmetric.
34+
const mean = n * p;
35+
const variance = mean * (1 - p);
36+
const diff = normalPpf(1 - alpha / 2, Math.sqrt(variance));
37+
return [mean - diff, mean + diff];
38+
}
39+
40+
function getAllIntervals(
41+
n: number,
42+
creditFractions: readonly number[],
43+
alphaTotal: number,
44+
): Interval[] {
45+
// We are testing one hypothesis per dimension, so divide `alphaTotal` by
46+
// the number of dimensions: https://en.wikipedia.org/wiki/Bonferroni_correction
47+
const alpha = alphaTotal / creditFractions.length;
48+
return creditFractions.map((cf) => getIntervalApprox(n, cf, alpha));
49+
}
50+
51+
function runFairlyAllocateCreditTest(
52+
tc: Readonly<FairlyAllocateCreditTestCase>,
53+
): void {
54+
// TODO: replace with precise sum
55+
const sumCredit = tc.credit.reduce((a, b) => a + b, 0);
56+
const normalizedFloatCredit = tc.credit.map((item) => item / sumCredit);
57+
58+
const [rand, k] = tc.needsRand ? [Math.random, 1000] : [noRand, 1];
59+
60+
const totals = new Array<number>(tc.credit.length).fill(0);
61+
62+
for (let n = 0; n < k; ++n) {
63+
const actualCredit = fairlyAllocateCredit(tc.credit, tc.value, rand);
64+
65+
assert.equal(actualCredit.length, tc.credit.length);
66+
67+
for (const [j, actual] of actualCredit.entries()) {
68+
assert.ok(Number.isInteger(actual));
69+
70+
const normalized = normalizedFloatCredit[j]! * tc.value;
71+
const diff = Math.abs(actual - normalized);
72+
assert.ok(
73+
diff < 1,
74+
`credit error >= 1: actual=${actual}, normalized=${normalized}`,
75+
);
76+
77+
totals[j]! += actual / tc.value;
78+
}
79+
80+
assert.equal(
81+
// TODO: replace with precise sum
82+
actualCredit.reduce((a, b) => a + b, 0),
83+
tc.value,
84+
`actual credit does not sum to value: ${actualCredit.join(", ")}`,
85+
);
86+
}
87+
88+
const alpha = 0.00001; // Probability of test failing at random.
89+
90+
const intervals: Interval[] =
91+
k > 1
92+
? getAllIntervals(
93+
k,
94+
normalizedFloatCredit.map((c) => c - Math.floor(c)),
95+
alpha,
96+
)
97+
: normalizedFloatCredit.map((c) => [c, c]);
98+
99+
for (const [j, total] of totals.entries()) {
100+
const [min, max] = intervals[j]!;
101+
assert.ok(
102+
total >= min && total <= max,
103+
`total for credit[${j}] ${total} not in ${1 - alpha} confidence interval [${min}, ${max}]`,
104+
);
105+
}
106+
}
107+
108+
const testCases: FairlyAllocateCreditTestCase[] = [
109+
{
110+
name: "credit-equal-to-value",
111+
credit: [1],
112+
value: 1,
113+
needsRand: false,
114+
},
115+
{
116+
name: "credit-less-than-value",
117+
credit: [2],
118+
value: 3,
119+
needsRand: false,
120+
},
121+
{
122+
name: "credit-less-than-1",
123+
credit: [0.25],
124+
value: 4,
125+
needsRand: false,
126+
},
127+
{
128+
name: "2-credit-divides-value-evenly",
129+
credit: [3, 1],
130+
value: 8,
131+
needsRand: false,
132+
},
133+
{
134+
name: "3-credit-divides-value-evenly",
135+
credit: [2, 1, 1],
136+
value: 8,
137+
needsRand: false,
138+
},
139+
{
140+
name: "2-credit-divides-value-unevenly",
141+
credit: [1, 1],
142+
value: 5,
143+
needsRand: true,
144+
},
145+
{
146+
name: "3-credit-divides-value-unevenly",
147+
credit: [2, 1, 1],
148+
value: 5,
149+
needsRand: true,
150+
},
151+
];
152+
153+
void test("fairlyAllocateCredit", async (t) => {
154+
await Promise.all(
155+
testCases.map((tc) =>
156+
t.test(tc.name, () => runFairlyAllocateCreditTest(tc)),
157+
),
158+
);
159+
});

impl/src/backend.ts

Lines changed: 45 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ interface ValidatedConversionOptions {
5050
}
5151

5252
interface ValidatedLogicOptions {
53-
credit: number[];
53+
credit: readonly number[];
5454
}
5555

5656
export function days(days: number): Temporal.Duration {
@@ -551,7 +551,7 @@ export class Backend {
551551
matchedImpressions: Set<Impression>,
552552
histogramSize: number,
553553
value: number,
554-
credit: number[],
554+
credit: readonly number[],
555555
): number[] {
556556
if (matchedImpressions.size === 0) {
557557
throw new DOMException(
@@ -576,12 +576,13 @@ export class Backend {
576576

577577
const lastNImpressions = sortedImpressions.slice(0, N);
578578

579-
const normalizedCredit = this.#fairlyAllocateCredit(credit, value);
579+
const normalizedCredit = fairlyAllocateCredit(credit, value, () =>
580+
this.#delegate.random(),
581+
);
580582

581583
const histogram = allZeroHistogram(histogramSize);
582584

583-
for (let i = 0; i < lastNImpressions.length; ++i) {
584-
const impression = lastNImpressions[i]!;
585+
for (const [i, impression] of lastNImpressions.entries()) {
585586
const value = normalizedCredit[i];
586587
const index = impression.histogramIndex;
587588
if (index < histogram.length) {
@@ -591,7 +592,7 @@ export class Backend {
591592
return histogram;
592593
}
593594

594-
#encryptReport(report: number[]): Uint8Array {
595+
#encryptReport(report: readonly number[]): Uint8Array {
595596
void report;
596597
return new Uint8Array(0); // TODO
597598
}
@@ -600,10 +601,7 @@ export class Backend {
600601
const period = this.#delegate.privacyBudgetEpoch.total("seconds");
601602
let start = this.#epochStartStore.get(site);
602603
if (start === undefined) {
603-
const p = this.#delegate.random();
604-
if (!(p >= 0 && p < 1)) {
605-
throw new RangeError("random must be in the range [0, 1)");
606-
}
604+
const p = checkRandom(this.#delegate.random());
607605
const dur = Temporal.Duration.from({
608606
seconds: p * period,
609607
});
@@ -661,42 +659,54 @@ export class Backend {
661659
);
662660
});
663661
}
662+
}
664663

665-
#fairlyAllocateCredit(credit: number[], value: number): number[] {
666-
const sumCredit = credit.reduce((a, b) => a + b, 0);
664+
function checkRandom(p: number): number {
665+
if (!(p >= 0 && p < 1)) {
666+
throw new RangeError("random must be in the range [0, 1)");
667+
}
668+
return p;
669+
}
667670

668-
const roundedCredit = credit.map((item) => (value * item) / sumCredit);
671+
export function fairlyAllocateCredit(
672+
credit: readonly number[],
673+
value: number,
674+
rand: () => number,
675+
): number[] {
676+
// TODO: replace with precise sum
677+
const sumCredit = credit.reduce((a, b) => a + b, 0);
669678

670-
let idx1 = 0;
679+
const roundedCredit = credit.map((item) => (value * item) / sumCredit);
671680

672-
for (let n = 1; n < roundedCredit.length; ++n) {
673-
let idx2 = n;
681+
let idx1 = 0;
674682

675-
const frac1 = roundedCredit[idx1]! - Math.floor(roundedCredit[idx1]!);
676-
const frac2 = roundedCredit[idx2]! - Math.floor(roundedCredit[idx2]!);
677-
if (frac1 === 0 && frac2 === 0) {
678-
continue;
679-
}
683+
for (let n = 1; n < roundedCredit.length; ++n) {
684+
let idx2 = n;
680685

681-
const [incr1, incr2] =
682-
frac1 + frac2 > 1 ? [1 - frac1, 1 - frac2] : [-frac1, -frac2];
686+
const frac1 = roundedCredit[idx1]! - Math.floor(roundedCredit[idx1]!);
687+
const frac2 = roundedCredit[idx2]! - Math.floor(roundedCredit[idx2]!);
688+
if (frac1 === 0 && frac2 === 0) {
689+
continue;
690+
}
683691

684-
const p1 = incr2 / (incr1 + incr2);
692+
const [incr1, incr2] =
693+
frac1 + frac2 > 1 ? [1 - frac1, 1 - frac2] : [-frac1, -frac2];
685694

686-
const r = this.#delegate.random();
695+
const p1 = incr2 / (incr1 + incr2);
687696

688-
let incr;
689-
if (r < p1) {
690-
incr = incr1;
691-
[idx1, idx2] = [idx2, idx1];
692-
} else {
693-
incr = incr2;
694-
}
697+
const r = checkRandom(rand());
695698

696-
roundedCredit[idx2]! += incr;
697-
roundedCredit[idx1]! -= incr;
699+
let incr;
700+
if (r < p1) {
701+
incr = incr1;
702+
[idx1, idx2] = [idx2, idx1];
703+
} else {
704+
incr = incr2;
698705
}
699706

700-
return roundedCredit.map((item) => Math.round(item));
707+
roundedCredit[idx2]! += incr;
708+
roundedCredit[idx1]! -= incr;
701709
}
710+
711+
return roundedCredit.map((item) => Math.round(item));
702712
}

0 commit comments

Comments
 (0)