@loic001/experiments 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PRINCIPLES.md +59 -0
- package/README.md +80 -0
- package/dist/browser/index.d.ts +50 -0
- package/dist/browser/index.js +153 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/chunk-WBS3SDIK.js +64 -0
- package/dist/chunk-WBS3SDIK.js.map +1 -0
- package/dist/index.d.ts +117 -0
- package/dist/index.js +84 -0
- package/dist/index.js.map +1 -0
- package/dist/react/index.d.ts +6 -0
- package/dist/react/index.js +15 -0
- package/dist/react/index.js.map +1 -0
- package/dist/types-BWOV7ULO.d.ts +29 -0
- package/package.json +66 -0
package/PRINCIPLES.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# experiments — laws
|
|
2
|
+
|
|
3
|
+
An experiment shows different people different things and asks which worked.
|
|
4
|
+
Everything below exists because one of these is easy to break by accident.
|
|
5
|
+
|
|
6
|
+
## 1. An arm is stable, or it is not an experiment
|
|
7
|
+
|
|
8
|
+
The same subject sees the same arm on every load, every page, every day, with
|
|
9
|
+
no server round-trip and no flash. The arm is a pure function of
|
|
10
|
+
`(subject id × experiment key)` — a hash, not a coin toss. A subject who
|
|
11
|
+
changes arm mid-test pollutes both.
|
|
12
|
+
|
|
13
|
+
## 2. Assigning is not exposing
|
|
14
|
+
|
|
15
|
+
Computing someone's arm costs nothing and means nothing. The denominator is
|
|
16
|
+
the people who **saw** the thing. A visitor who never reached the tested
|
|
17
|
+
component must never enter the count — including them dilutes the very
|
|
18
|
+
difference you are trying to measure. So assignment is pure and silent;
|
|
19
|
+
only `expose()` records.
|
|
20
|
+
|
|
21
|
+
## 3. The registry serves; the data judges
|
|
22
|
+
|
|
23
|
+
The registry lives with the app that **serves** variants. A reader —
|
|
24
|
+
dashboard, warehouse, notebook — discovers the arms from what was actually
|
|
25
|
+
served, and never needs a mirrored copy. Two registries to keep in sync is a
|
|
26
|
+
bug waiting for a Friday: one of them drifts, and the numbers quietly describe
|
|
27
|
+
an experiment nobody is running.
|
|
28
|
+
|
|
29
|
+
## 4. A variant is what someone SAW, never what they became
|
|
30
|
+
|
|
31
|
+
The arm is a **dimension** of the subject, alongside channel or country — not
|
|
32
|
+
a fact, not a step, not an outcome. It rides with the subject into whatever
|
|
33
|
+
funnel or ledger the host already has. This package computes no funnels: it
|
|
34
|
+
hands over a string.
|
|
35
|
+
|
|
36
|
+
## 5. Widening a rollout never moves anyone
|
|
37
|
+
|
|
38
|
+
Entering the test and choosing an arm are two independent draws. Going from
|
|
39
|
+
10% to 50% lets new subjects in; it does not reshuffle the ones already there,
|
|
40
|
+
so the data collected before the widening stays usable.
|
|
41
|
+
|
|
42
|
+
## 6. Stopping is one line, and history keeps its arms
|
|
43
|
+
|
|
44
|
+
`enabled: false` → everyone sees the control, nothing new is recorded. What
|
|
45
|
+
was already served keeps its arm forever: a past experiment is history, not a
|
|
46
|
+
mistake to erase. Variant ids are append-only — renaming one rewrites the
|
|
47
|
+
meaning of rows already written.
|
|
48
|
+
|
|
49
|
+
## 7. No verdict without enough people
|
|
50
|
+
|
|
51
|
+
A p-value on twelve subjects is decoration. A verdict requires **both** a
|
|
52
|
+
minimum arm size and a significance threshold, and when either is missing the
|
|
53
|
+
honest output is "not yet", never a number that will flip next week. Fixing
|
|
54
|
+
the goal before starting is the host's job; refusing to speak early is ours.
|
|
55
|
+
|
|
56
|
+
## 8. Analytics never breaks the page
|
|
57
|
+
|
|
58
|
+
Every sink is best-effort and wrapped. A blocked pixel, a full quota, a
|
|
59
|
+
private window: the experiment still serves an arm and the page still renders.
|
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# experiments
|
|
2
|
+
|
|
3
|
+
A stable arm per subject, exposure as the denominator, and a verdict that
|
|
4
|
+
refuses to speak too early. No server round-trip, no flash, no vendor.
|
|
5
|
+
|
|
6
|
+
Laws: [PRINCIPLES.md](./PRINCIPLES.md).
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { createExperiments, posthogSink } from '@loic001/experiments/browser';
|
|
10
|
+
|
|
11
|
+
export const experiments = createExperiments({
|
|
12
|
+
experiments: [
|
|
13
|
+
{
|
|
14
|
+
key: 'apply_landing',
|
|
15
|
+
enabled: true,
|
|
16
|
+
rollout: 0.5, // half the traffic enters the test
|
|
17
|
+
control: 'control',
|
|
18
|
+
variants: [{ id: 'control' }, { id: 'v2' }],
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
sinks: [posthogSink()],
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Serve it — React, or anything:
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
import { useExperiment } from '@loic001/experiments/react';
|
|
29
|
+
|
|
30
|
+
const arm = useExperiment(experiments, 'apply_landing');
|
|
31
|
+
if (arm === 'v2') return <Variant />;
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Carry it — the arm rides with whatever you already send:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
payload.exp = experiments.wire(); // "apply_landing:v2", or undefined
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Judge it — registry-free, from whatever your warehouse counted:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { compareArms } from '@loic001/experiments';
|
|
44
|
+
|
|
45
|
+
compareArms([
|
|
46
|
+
{ id: 'control', n: 4_012, converted: 402 },
|
|
47
|
+
{ id: 'v2', n: 3_988, converted: 518 },
|
|
48
|
+
]);
|
|
49
|
+
// { verdict: 'winner', winner: 'v2',
|
|
50
|
+
// arms: [ …, { id: 'v2', ratePct: 13, deltaPts: 3, liftPct: 29.9, pValue: 0, significant: true } ] }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Three entries
|
|
54
|
+
|
|
55
|
+
| Import | Contains | Needs |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `@loic001/experiments` | assignment, wire format, verdict — **pure** | nothing |
|
|
58
|
+
| `@loic001/experiments/browser` | storage, URL overrides, exposure sinks | a browser (or injected fakes) |
|
|
59
|
+
| `@loic001/experiments/react` | the hook | React ≥ 18 |
|
|
60
|
+
|
|
61
|
+
The pure entry runs in a browser, a worker, a Node job or a SQL-fed dashboard
|
|
62
|
+
alike. Everything in `/browser` is injectable (`storage`, `search`, `now`,
|
|
63
|
+
`sinks`), so the whole adapter is testable without a DOM.
|
|
64
|
+
|
|
65
|
+
## What it does not do
|
|
66
|
+
|
|
67
|
+
- **No funnels.** The arm is a dimension on your subject; your funnel tool
|
|
68
|
+
slices on it. Pairs naturally with [`@loic001/funnels`](https://github.com/loic001/funnels).
|
|
69
|
+
- **No identity.** The subject id is a local random, 30 days, never sent
|
|
70
|
+
anywhere. Only `"key:variant"` travels.
|
|
71
|
+
- **No registry on the reading side.** A dashboard discovers arms from the
|
|
72
|
+
data. Two registries to keep in sync is a bug waiting for a Friday.
|
|
73
|
+
- **No sequential testing.** `compareArms` is a two-proportion z test at a
|
|
74
|
+
fixed horizon. Peeking daily and stopping at the first green inflates false
|
|
75
|
+
positives — fix the sample size before you start.
|
|
76
|
+
|
|
77
|
+
## Overrides
|
|
78
|
+
|
|
79
|
+
`?exp=apply_landing:v2` forces an arm for QA and previews, and only accepts a
|
|
80
|
+
variant that actually exists. Change the parameter with `overrideParam`.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { E as Experiment, A as Assignments } from '../types-BWOV7ULO.js';
|
|
2
|
+
|
|
3
|
+
type StorageLike = {
|
|
4
|
+
getItem(key: string): string | null;
|
|
5
|
+
setItem(key: string, value: string): void;
|
|
6
|
+
removeItem(key: string): void;
|
|
7
|
+
};
|
|
8
|
+
type Exposure = {
|
|
9
|
+
key: string;
|
|
10
|
+
variant: string;
|
|
11
|
+
subject: string;
|
|
12
|
+
};
|
|
13
|
+
/** Where exposures go. Every sink is best-effort — it never breaks the page (law 8). */
|
|
14
|
+
type Sink = {
|
|
15
|
+
onExposure(e: Exposure): void;
|
|
16
|
+
};
|
|
17
|
+
type CreateOptions = {
|
|
18
|
+
experiments: readonly Experiment[];
|
|
19
|
+
/** Default: `localStorage`, or an in-memory map when unavailable (SSR, private mode). */
|
|
20
|
+
storage?: StorageLike;
|
|
21
|
+
/** Query string used for overrides. Default: `location.search`. */
|
|
22
|
+
search?: () => string;
|
|
23
|
+
now?: () => number;
|
|
24
|
+
/** How long an assignment and a subject id live. Default 30 days. */
|
|
25
|
+
ttlMs?: number;
|
|
26
|
+
/** Storage key prefix. Default `exp`. */
|
|
27
|
+
namespace?: string;
|
|
28
|
+
/** URL parameter that forces arms — `?exp=key:variant`. Default `exp`. */
|
|
29
|
+
overrideParam?: string;
|
|
30
|
+
sinks?: readonly Sink[];
|
|
31
|
+
};
|
|
32
|
+
type Experiments = {
|
|
33
|
+
/** Stable anonymous id for this browser. Not an identity; never leaves the device. */
|
|
34
|
+
subjectId(): string;
|
|
35
|
+
/** The arm this subject WOULD see. Pure: records nothing, fires nothing (law 2). */
|
|
36
|
+
variantOf(key: string): string;
|
|
37
|
+
/** Declare the subject saw it: records the arm, fires the sinks once, returns it. */
|
|
38
|
+
expose(key: string): string;
|
|
39
|
+
/** Arms actually shown (from storage) — the only ones that may travel (law 2). */
|
|
40
|
+
shown(): Assignments;
|
|
41
|
+
/** `shown()` serialized — drop it in your lead/event payload. */
|
|
42
|
+
wire(): string | undefined;
|
|
43
|
+
/** Test seam: forget assignments and fired exposures. */
|
|
44
|
+
reset(): void;
|
|
45
|
+
};
|
|
46
|
+
/** PostHog sink: a persistent `exp_<key>` property plus one `experiment_exposure` event. */
|
|
47
|
+
declare function posthogSink(): Sink;
|
|
48
|
+
declare function createExperiments(opts: CreateOptions): Experiments;
|
|
49
|
+
|
|
50
|
+
export { type CreateOptions, type Experiments, type Exposure, type Sink, type StorageLike, createExperiments, posthogSink };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assignVariant,
|
|
3
|
+
parseAssignments,
|
|
4
|
+
serializeAssignments
|
|
5
|
+
} from "../chunk-WBS3SDIK.js";
|
|
6
|
+
|
|
7
|
+
// browser/index.ts
|
|
8
|
+
var DAY = 864e5;
|
|
9
|
+
function memoryStorage() {
|
|
10
|
+
const m = /* @__PURE__ */ new Map();
|
|
11
|
+
return {
|
|
12
|
+
getItem: (k) => m.get(k) ?? null,
|
|
13
|
+
setItem: (k, v) => void m.set(k, v),
|
|
14
|
+
removeItem: (k) => void m.delete(k)
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function safeStorage(given) {
|
|
18
|
+
if (given) return given;
|
|
19
|
+
try {
|
|
20
|
+
const ls = globalThis.localStorage;
|
|
21
|
+
if (ls) {
|
|
22
|
+
const probe = "__exp_probe__";
|
|
23
|
+
ls.setItem(probe, "1");
|
|
24
|
+
ls.removeItem(probe);
|
|
25
|
+
return ls;
|
|
26
|
+
}
|
|
27
|
+
} catch {
|
|
28
|
+
}
|
|
29
|
+
return memoryStorage();
|
|
30
|
+
}
|
|
31
|
+
function posthogSink() {
|
|
32
|
+
return {
|
|
33
|
+
onExposure({ key, variant }) {
|
|
34
|
+
const ph = globalThis.posthog;
|
|
35
|
+
ph?.register?.({ [`exp_${key}`]: variant });
|
|
36
|
+
ph?.capture?.("experiment_exposure", { exp_key: key, exp_variant: variant });
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function createExperiments(opts) {
|
|
41
|
+
const store = safeStorage(opts.storage);
|
|
42
|
+
const now = opts.now ?? (() => Date.now());
|
|
43
|
+
const ttl = opts.ttlMs ?? 30 * DAY;
|
|
44
|
+
const ns = opts.namespace ?? "exp";
|
|
45
|
+
const param = opts.overrideParam ?? "exp";
|
|
46
|
+
const sinks = opts.sinks ?? [];
|
|
47
|
+
const subjectKey = `${ns}:subject`;
|
|
48
|
+
const assignKey = `${ns}:assigned`;
|
|
49
|
+
const fired = /* @__PURE__ */ new Set();
|
|
50
|
+
const find = (key) => opts.experiments.find((e) => e.key === key);
|
|
51
|
+
const readEnvelope = (k) => {
|
|
52
|
+
try {
|
|
53
|
+
const raw = store.getItem(k);
|
|
54
|
+
if (!raw) return {};
|
|
55
|
+
const parsed = JSON.parse(raw);
|
|
56
|
+
if (!parsed || typeof parsed.savedAt !== "number" || typeof parsed.data !== "object" || parsed.data == null) {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
if (now() - parsed.savedAt > ttl) {
|
|
60
|
+
store.removeItem(k);
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
return parsed.data;
|
|
64
|
+
} catch {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const writeEnvelope = (k, data) => {
|
|
69
|
+
try {
|
|
70
|
+
store.setItem(k, JSON.stringify({ savedAt: now(), data }));
|
|
71
|
+
} catch {
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const overrides = () => {
|
|
75
|
+
let raw = null;
|
|
76
|
+
try {
|
|
77
|
+
const search = opts.search ? opts.search() : globalThis.location?.search;
|
|
78
|
+
if (search) raw = new URLSearchParams(search).get(param);
|
|
79
|
+
} catch {
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
const parsed = parseAssignments(raw);
|
|
83
|
+
const out = {};
|
|
84
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
85
|
+
if (find(k)?.variants.some((x) => x.id === v)) out[k] = v;
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
};
|
|
89
|
+
const subjectId = () => {
|
|
90
|
+
const stored = readEnvelope(subjectKey).id;
|
|
91
|
+
if (stored) return stored;
|
|
92
|
+
let id;
|
|
93
|
+
try {
|
|
94
|
+
id = globalThis.crypto?.randomUUID?.();
|
|
95
|
+
} catch {
|
|
96
|
+
id = void 0;
|
|
97
|
+
}
|
|
98
|
+
id ??= `s_${Math.random().toString(16).slice(2)}${now().toString(16)}`;
|
|
99
|
+
writeEnvelope(subjectKey, { id });
|
|
100
|
+
return id;
|
|
101
|
+
};
|
|
102
|
+
const variantOf = (key) => {
|
|
103
|
+
const exp = find(key);
|
|
104
|
+
if (!exp) return "control";
|
|
105
|
+
if (!exp.enabled) return exp.control;
|
|
106
|
+
return overrides()[key] ?? readEnvelope(assignKey)[key] ?? assignVariant(exp, subjectId());
|
|
107
|
+
};
|
|
108
|
+
const expose = (key) => {
|
|
109
|
+
const exp = find(key);
|
|
110
|
+
if (!exp) return "control";
|
|
111
|
+
const variant = variantOf(key);
|
|
112
|
+
if (!exp.enabled) return variant;
|
|
113
|
+
const stored = readEnvelope(assignKey);
|
|
114
|
+
if (stored[key] !== variant) writeEnvelope(assignKey, { ...stored, [key]: variant });
|
|
115
|
+
const token = `${key}:${variant}`;
|
|
116
|
+
if (!fired.has(token)) {
|
|
117
|
+
fired.add(token);
|
|
118
|
+
const e = { key, variant, subject: subjectId() };
|
|
119
|
+
for (const s of sinks) {
|
|
120
|
+
try {
|
|
121
|
+
s.onExposure(e);
|
|
122
|
+
} catch {
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return variant;
|
|
127
|
+
};
|
|
128
|
+
const shown = () => {
|
|
129
|
+
const stored = readEnvelope(assignKey);
|
|
130
|
+
const out = {};
|
|
131
|
+
for (const e of opts.experiments) if (e.enabled && stored[e.key]) out[e.key] = stored[e.key];
|
|
132
|
+
return out;
|
|
133
|
+
};
|
|
134
|
+
return {
|
|
135
|
+
subjectId,
|
|
136
|
+
variantOf,
|
|
137
|
+
expose,
|
|
138
|
+
shown,
|
|
139
|
+
wire: () => serializeAssignments(shown()),
|
|
140
|
+
reset: () => {
|
|
141
|
+
fired.clear();
|
|
142
|
+
try {
|
|
143
|
+
store.removeItem(assignKey);
|
|
144
|
+
} catch {
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
export {
|
|
150
|
+
createExperiments,
|
|
151
|
+
posthogSink
|
|
152
|
+
};
|
|
153
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../browser/index.ts"],"sourcesContent":["/**\n * Browser plumbing — the only part that touches storage, the URL and pixels.\n *\n * Everything here is injectable, so the whole adapter is testable in Node\n * without a DOM: pass your own `storage`, `search`, `now` and sinks.\n *\n * const exp = createExperiments({ experiments: MY_EXPERIMENTS, sinks: [posthogSink()] });\n * const arm = exp.expose('apply_landing'); // assigns, records, fires sinks\n * exp.wire(); // \"apply_landing:v2\" → your lead payload\n */\nimport { assignVariant } from '../src/assign.js';\nimport type { Assignments, Experiment } from '../src/types.js';\nimport { parseAssignments, serializeAssignments } from '../src/wire.js';\n\nexport type StorageLike = {\n getItem(key: string): string | null;\n setItem(key: string, value: string): void;\n removeItem(key: string): void;\n};\n\nexport type Exposure = { key: string; variant: string; subject: string };\n\n/** Where exposures go. Every sink is best-effort — it never breaks the page (law 8). */\nexport type Sink = { onExposure(e: Exposure): void };\n\nexport type CreateOptions = {\n experiments: readonly Experiment[];\n /** Default: `localStorage`, or an in-memory map when unavailable (SSR, private mode). */\n storage?: StorageLike;\n /** Query string used for overrides. Default: `location.search`. */\n search?: () => string;\n now?: () => number;\n /** How long an assignment and a subject id live. Default 30 days. */\n ttlMs?: number;\n /** Storage key prefix. Default `exp`. */\n namespace?: string;\n /** URL parameter that forces arms — `?exp=key:variant`. Default `exp`. */\n overrideParam?: string;\n sinks?: readonly Sink[];\n};\n\nexport type Experiments = {\n /** Stable anonymous id for this browser. Not an identity; never leaves the device. */\n subjectId(): string;\n /** The arm this subject WOULD see. Pure: records nothing, fires nothing (law 2). */\n variantOf(key: string): string;\n /** Declare the subject saw it: records the arm, fires the sinks once, returns it. */\n expose(key: string): string;\n /** Arms actually shown (from storage) — the only ones that may travel (law 2). */\n shown(): Assignments;\n /** `shown()` serialized — drop it in your lead/event payload. */\n wire(): string | undefined;\n /** Test seam: forget assignments and fired exposures. */\n reset(): void;\n};\n\nconst DAY = 86_400_000;\n\nfunction memoryStorage(): StorageLike {\n const m = new Map<string, string>();\n return {\n getItem: (k) => m.get(k) ?? null,\n setItem: (k, v) => void m.set(k, v),\n removeItem: (k) => void m.delete(k),\n };\n}\n\n/** localStorage, but never throwing: private mode and quota are normal, not exceptional. */\nfunction safeStorage(given?: StorageLike): StorageLike {\n if (given) return given;\n try {\n const ls = (globalThis as { localStorage?: StorageLike }).localStorage;\n if (ls) {\n const probe = '__exp_probe__';\n ls.setItem(probe, '1');\n ls.removeItem(probe);\n return ls;\n }\n } catch {\n /* falls through to memory */\n }\n return memoryStorage();\n}\n\ntype Envelope = { savedAt: number; data: Record<string, string> };\n\n/** PostHog sink: a persistent `exp_<key>` property plus one `experiment_exposure` event. */\nexport function posthogSink(): Sink {\n return {\n onExposure({ key, variant }) {\n const ph = (globalThis as { posthog?: { register?(p: Record<string, unknown>): void; capture?(e: string, p?: Record<string, unknown>): void } }).posthog;\n ph?.register?.({ [`exp_${key}`]: variant });\n ph?.capture?.('experiment_exposure', { exp_key: key, exp_variant: variant });\n },\n };\n}\n\nexport function createExperiments(opts: CreateOptions): Experiments {\n const store = safeStorage(opts.storage);\n const now = opts.now ?? (() => Date.now());\n const ttl = opts.ttlMs ?? 30 * DAY;\n const ns = opts.namespace ?? 'exp';\n const param = opts.overrideParam ?? 'exp';\n const sinks = opts.sinks ?? [];\n const subjectKey = `${ns}:subject`;\n const assignKey = `${ns}:assigned`;\n const fired = new Set<string>();\n\n const find = (key: string): Experiment | undefined => opts.experiments.find((e) => e.key === key);\n\n const readEnvelope = (k: string): Record<string, string> => {\n try {\n const raw = store.getItem(k);\n if (!raw) return {};\n const parsed = JSON.parse(raw) as Envelope;\n if (!parsed || typeof parsed.savedAt !== 'number' || typeof parsed.data !== 'object' || parsed.data == null) {\n return {};\n }\n if (now() - parsed.savedAt > ttl) {\n store.removeItem(k);\n return {};\n }\n return parsed.data;\n } catch {\n return {};\n }\n };\n\n const writeEnvelope = (k: string, data: Record<string, string>): void => {\n try {\n store.setItem(k, JSON.stringify({ savedAt: now(), data } satisfies Envelope));\n } catch {\n /* best-effort (law 8) */\n }\n };\n\n const overrides = (): Record<string, string> => {\n let raw: string | null = null;\n try {\n const search = opts.search ? opts.search() : (globalThis as { location?: { search?: string } }).location?.search;\n if (search) raw = new URLSearchParams(search).get(param);\n } catch {\n return {};\n }\n const parsed = parseAssignments(raw);\n // A forced arm must be a REAL arm, or a dashboard would grow a column for\n // an experiment nobody is running.\n const out: Record<string, string> = {};\n for (const [k, v] of Object.entries(parsed)) {\n if (find(k)?.variants.some((x) => x.id === v)) out[k] = v;\n }\n return out;\n };\n\n const subjectId = (): string => {\n const stored = readEnvelope(subjectKey).id;\n if (stored) return stored;\n let id: string | undefined;\n try {\n // Locked-down browsers expose `crypto` but throw on use — a random id is\n // always better than a broken page (law 8).\n id = (globalThis as { crypto?: { randomUUID?(): string } }).crypto?.randomUUID?.();\n } catch {\n id = undefined;\n }\n id ??= `s_${Math.random().toString(16).slice(2)}${now().toString(16)}`;\n writeEnvelope(subjectKey, { id });\n return id;\n };\n\n const variantOf = (key: string): string => {\n const exp = find(key);\n if (!exp) return 'control';\n if (!exp.enabled) return exp.control;\n return overrides()[key] ?? readEnvelope(assignKey)[key] ?? assignVariant(exp, subjectId());\n };\n\n const expose = (key: string): string => {\n const exp = find(key);\n if (!exp) return 'control';\n const variant = variantOf(key);\n if (!exp.enabled) return variant;\n\n // Exposure is the ONLY writer: what is stored is what was shown, and that\n // is exactly what may travel with the subject (law 2).\n const stored = readEnvelope(assignKey);\n if (stored[key] !== variant) writeEnvelope(assignKey, { ...stored, [key]: variant });\n\n const token = `${key}:${variant}`;\n if (!fired.has(token)) {\n fired.add(token);\n const e: Exposure = { key, variant, subject: subjectId() };\n for (const s of sinks) {\n try {\n s.onExposure(e);\n } catch {\n /* law 8 */\n }\n }\n }\n return variant;\n };\n\n const shown = (): Assignments => {\n const stored = readEnvelope(assignKey);\n const out: Record<string, string> = {};\n // A stopped experiment stops travelling, even if the browser still remembers it (law 6).\n for (const e of opts.experiments) if (e.enabled && stored[e.key]) out[e.key] = stored[e.key];\n return out;\n };\n\n return {\n subjectId,\n variantOf,\n expose,\n shown,\n wire: () => serializeAssignments(shown()),\n reset: () => {\n fired.clear();\n try {\n store.removeItem(assignKey);\n } catch {\n /* law 8 */\n }\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// src/hash.ts
|
|
2
|
+
function hash32(input) {
|
|
3
|
+
let h = 2166136261;
|
|
4
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
5
|
+
h ^= input.charCodeAt(i);
|
|
6
|
+
h = Math.imul(h, 16777619);
|
|
7
|
+
}
|
|
8
|
+
return h >>> 0;
|
|
9
|
+
}
|
|
10
|
+
var bucketOf = (subject, salt) => hash32(`${subject}:${salt}`) / 4294967296;
|
|
11
|
+
|
|
12
|
+
// src/assign.ts
|
|
13
|
+
function assignVariant(exp, subject) {
|
|
14
|
+
if (!exp.enabled || !subject) return exp.control;
|
|
15
|
+
const rollout = exp.rollout ?? 1;
|
|
16
|
+
if (rollout < 1 && bucketOf(subject, `${exp.key}:rollout`) >= rollout) return exp.control;
|
|
17
|
+
const total = exp.variants.reduce((s, v) => s + (v.weight ?? 1), 0);
|
|
18
|
+
if (total <= 0) return exp.control;
|
|
19
|
+
const target = bucketOf(subject, exp.key) * total;
|
|
20
|
+
let acc = 0;
|
|
21
|
+
let chosen = exp.control;
|
|
22
|
+
for (const v of exp.variants) {
|
|
23
|
+
acc += v.weight ?? 1;
|
|
24
|
+
if (target < acc) {
|
|
25
|
+
chosen = v.id;
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return chosen;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/wire.ts
|
|
33
|
+
var KEY_RE = /^[a-z0-9_]{1,40}$/;
|
|
34
|
+
var VARIANT_RE = /^[a-z0-9_-]{1,40}$/;
|
|
35
|
+
var WIRE_MAX = 200;
|
|
36
|
+
function parseAssignments(raw) {
|
|
37
|
+
const out = {};
|
|
38
|
+
if (!raw) return out;
|
|
39
|
+
for (const part of String(raw).split(",")) {
|
|
40
|
+
const [k, v] = part.split(":").map((s) => s.trim().toLowerCase());
|
|
41
|
+
if (k && v && KEY_RE.test(k) && VARIANT_RE.test(v)) out[k] = v;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
function serializeAssignments(a) {
|
|
46
|
+
const parts = Object.entries(a).filter(([k, v]) => k && v).sort(([x], [y]) => x.localeCompare(y)).map(([k, v]) => `${k}:${v}`);
|
|
47
|
+
if (parts.length === 0) return void 0;
|
|
48
|
+
const joined = parts.join(",");
|
|
49
|
+
return joined.length <= WIRE_MAX ? joined : joined.slice(0, joined.lastIndexOf(",", WIRE_MAX));
|
|
50
|
+
}
|
|
51
|
+
var dimIdOf = (key) => `exp_${key}`;
|
|
52
|
+
var NOT_EXPOSED = "none";
|
|
53
|
+
|
|
54
|
+
export {
|
|
55
|
+
hash32,
|
|
56
|
+
bucketOf,
|
|
57
|
+
assignVariant,
|
|
58
|
+
WIRE_MAX,
|
|
59
|
+
parseAssignments,
|
|
60
|
+
serializeAssignments,
|
|
61
|
+
dimIdOf,
|
|
62
|
+
NOT_EXPOSED
|
|
63
|
+
};
|
|
64
|
+
//# sourceMappingURL=chunk-WBS3SDIK.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/hash.ts","../src/assign.ts","../src/wire.ts"],"sourcesContent":["/**\n * FNV-1a, 32 bits. Small, dependency-free, and well-spread enough to split\n * traffic: the point is a stable bucket per subject, not cryptography.\n */\nexport function hash32(input: string): number {\n let h = 0x811c9dc5;\n for (let i = 0; i < input.length; i += 1) {\n h ^= input.charCodeAt(i);\n h = Math.imul(h, 0x01000193);\n }\n return h >>> 0;\n}\n\n/**\n * A stable float in [0,1) for a `(subject, salt)` pair. Different salts give\n * independent draws from the same subject — which is what lets a rollout widen\n * without reshuffling anyone (law 5).\n */\nexport const bucketOf = (subject: string, salt: string): number =>\n hash32(`${subject}:${salt}`) / 0x100000000;\n","import { bucketOf } from './hash.js';\nimport type { Experiment } from './types.js';\n\n/**\n * The arm a subject gets — PURE, silent, no I/O (law 2). Two independent\n * draws: one decides whether the subject enters the test at all (`rollout`),\n * the other which arm they see. Separating them is what makes widening a\n * rollout safe (law 5).\n *\n * An empty subject id, a disabled experiment, or weights that sum to zero all\n * fall back to the control — serving something is always better than throwing\n * on a page.\n */\nexport function assignVariant(exp: Experiment, subject: string): string {\n if (!exp.enabled || !subject) return exp.control;\n\n const rollout = exp.rollout ?? 1;\n if (rollout < 1 && bucketOf(subject, `${exp.key}:rollout`) >= rollout) return exp.control;\n\n const total = exp.variants.reduce((s, v) => s + (v.weight ?? 1), 0);\n if (total <= 0) return exp.control;\n\n // Une seule sortie : le point tombe dans le premier intervalle cumulé qui le\n // dépasse. `bucketOf` est strictement < 1, donc il tombe toujours.\n const target = bucketOf(subject, exp.key) * total;\n let acc = 0;\n let chosen = exp.control;\n for (const v of exp.variants) {\n acc += v.weight ?? 1;\n if (target < acc) {\n chosen = v.id;\n break;\n }\n }\n return chosen;\n}\n","import type { Assignments } from './types.js';\n\nconst KEY_RE = /^[a-z0-9_]{1,40}$/;\nconst VARIANT_RE = /^[a-z0-9_-]{1,40}$/;\n\n/** Longest wire string we ever emit — it rides inside other people's columns. */\nexport const WIRE_MAX = 200;\n\n/**\n * `\"apply_landing:v2,hero:b\"` → `{ apply_landing: 'v2', hero: 'b' }`.\n * Tolerant by design: this string crosses domains, query strings and third\n * party columns, so it parses what it recognises and silently drops the rest.\n * Never throws.\n */\nexport function parseAssignments(raw: string | null | undefined): Record<string, string> {\n const out: Record<string, string> = {};\n if (!raw) return out;\n for (const part of String(raw).split(',')) {\n const [k, v] = part.split(':').map((s) => s.trim().toLowerCase());\n if (k && v && KEY_RE.test(k) && VARIANT_RE.test(v)) out[k] = v;\n }\n return out;\n}\n\n/**\n * `{ apply_landing: 'v2' }` → `\"apply_landing:v2\"`. Sorted, so the same\n * assignments always produce the same string (it ends up in dedupe keys and\n * cache keys). `undefined` when there is nothing to say.\n */\nexport function serializeAssignments(a: Assignments): string | undefined {\n const parts = Object.entries(a)\n .filter(([k, v]) => k && v)\n .sort(([x], [y]) => x.localeCompare(y))\n .map(([k, v]) => `${k}:${v}`);\n if (parts.length === 0) return undefined;\n const joined = parts.join(',');\n return joined.length <= WIRE_MAX ? joined : joined.slice(0, joined.lastIndexOf(',', WIRE_MAX));\n}\n\n/** Dimension id for a key — the name the funnel/warehouse sees. */\nexport const dimIdOf = (key: string): string => `exp_${key}`;\n\n/** Value used for subjects who were never exposed. Never an empty string: absence must be countable. */\nexport const NOT_EXPOSED = 'none';\n"],"mappings":";AAIO,SAAS,OAAO,OAAuB;AAC5C,MAAI,IAAI;AACR,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK,GAAG;AACxC,SAAK,MAAM,WAAW,CAAC;AACvB,QAAI,KAAK,KAAK,GAAG,QAAU;AAAA,EAC7B;AACA,SAAO,MAAM;AACf;AAOO,IAAM,WAAW,CAAC,SAAiB,SACxC,OAAO,GAAG,OAAO,IAAI,IAAI,EAAE,IAAI;;;ACN1B,SAAS,cAAc,KAAiB,SAAyB;AACtE,MAAI,CAAC,IAAI,WAAW,CAAC,QAAS,QAAO,IAAI;AAEzC,QAAM,UAAU,IAAI,WAAW;AAC/B,MAAI,UAAU,KAAK,SAAS,SAAS,GAAG,IAAI,GAAG,UAAU,KAAK,QAAS,QAAO,IAAI;AAElF,QAAM,QAAQ,IAAI,SAAS,OAAO,CAAC,GAAG,MAAM,KAAK,EAAE,UAAU,IAAI,CAAC;AAClE,MAAI,SAAS,EAAG,QAAO,IAAI;AAI3B,QAAM,SAAS,SAAS,SAAS,IAAI,GAAG,IAAI;AAC5C,MAAI,MAAM;AACV,MAAI,SAAS,IAAI;AACjB,aAAW,KAAK,IAAI,UAAU;AAC5B,WAAO,EAAE,UAAU;AACnB,QAAI,SAAS,KAAK;AAChB,eAAS,EAAE;AACX;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;;;ACjCA,IAAM,SAAS;AACf,IAAM,aAAa;AAGZ,IAAM,WAAW;AAQjB,SAAS,iBAAiB,KAAwD;AACvF,QAAM,MAA8B,CAAC;AACrC,MAAI,CAAC,IAAK,QAAO;AACjB,aAAW,QAAQ,OAAO,GAAG,EAAE,MAAM,GAAG,GAAG;AACzC,UAAM,CAAC,GAAG,CAAC,IAAI,KAAK,MAAM,GAAG,EAAE,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,YAAY,CAAC;AAChE,QAAI,KAAK,KAAK,OAAO,KAAK,CAAC,KAAK,WAAW,KAAK,CAAC,EAAG,KAAI,CAAC,IAAI;AAAA,EAC/D;AACA,SAAO;AACT;AAOO,SAAS,qBAAqB,GAAoC;AACvE,QAAM,QAAQ,OAAO,QAAQ,CAAC,EAC3B,OAAO,CAAC,CAAC,GAAG,CAAC,MAAM,KAAK,CAAC,EACzB,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC,EACrC,IAAI,CAAC,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,EAAE;AAC9B,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,QAAM,SAAS,MAAM,KAAK,GAAG;AAC7B,SAAO,OAAO,UAAU,WAAW,SAAS,OAAO,MAAM,GAAG,OAAO,YAAY,KAAK,QAAQ,CAAC;AAC/F;AAGO,IAAM,UAAU,CAAC,QAAwB,OAAO,GAAG;AAGnD,IAAM,cAAc;","names":[]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { E as Experiment, A as Assignments } from './types-BWOV7ULO.js';
|
|
2
|
+
export { V as Variant } from './types-BWOV7ULO.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The arm a subject gets — PURE, silent, no I/O (law 2). Two independent
|
|
6
|
+
* draws: one decides whether the subject enters the test at all (`rollout`),
|
|
7
|
+
* the other which arm they see. Separating them is what makes widening a
|
|
8
|
+
* rollout safe (law 5).
|
|
9
|
+
*
|
|
10
|
+
* An empty subject id, a disabled experiment, or weights that sum to zero all
|
|
11
|
+
* fall back to the control — serving something is always better than throwing
|
|
12
|
+
* on a page.
|
|
13
|
+
*/
|
|
14
|
+
declare function assignVariant(exp: Experiment, subject: string): string;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* FNV-1a, 32 bits. Small, dependency-free, and well-spread enough to split
|
|
18
|
+
* traffic: the point is a stable bucket per subject, not cryptography.
|
|
19
|
+
*/
|
|
20
|
+
declare function hash32(input: string): number;
|
|
21
|
+
/**
|
|
22
|
+
* A stable float in [0,1) for a `(subject, salt)` pair. Different salts give
|
|
23
|
+
* independent draws from the same subject — which is what lets a rollout widen
|
|
24
|
+
* without reshuffling anyone (law 5).
|
|
25
|
+
*/
|
|
26
|
+
declare const bucketOf: (subject: string, salt: string) => number;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The verdict — generic over arms, registry-free (law 3). Give it what each
|
|
30
|
+
* arm was shown and how many converted; it says whether the difference is
|
|
31
|
+
* real, or refuses to say (law 7).
|
|
32
|
+
*/
|
|
33
|
+
/** What a reader knows about one arm, whatever its source. */
|
|
34
|
+
type Arm = {
|
|
35
|
+
id: string;
|
|
36
|
+
label?: string;
|
|
37
|
+
/** Subjects EXPOSED to this arm — the denominator (law 2). */
|
|
38
|
+
n: number;
|
|
39
|
+
/** Of those, how many reached the goal. */
|
|
40
|
+
converted: number;
|
|
41
|
+
};
|
|
42
|
+
type CompareOptions = {
|
|
43
|
+
/** Arm every other one is measured against. Default: the first. */
|
|
44
|
+
control?: string;
|
|
45
|
+
/** Minimum exposed per arm before any verdict is allowed. Default 30. */
|
|
46
|
+
minArmN?: number;
|
|
47
|
+
/** Two-sided significance threshold. Default 0.05. */
|
|
48
|
+
alpha?: number;
|
|
49
|
+
};
|
|
50
|
+
type ArmResult = {
|
|
51
|
+
id: string;
|
|
52
|
+
label: string;
|
|
53
|
+
n: number;
|
|
54
|
+
converted: number;
|
|
55
|
+
/** Conversion, in percent. null when nobody was exposed. */
|
|
56
|
+
ratePct: number | null;
|
|
57
|
+
/** Difference with the control, in percentage points. null for the control itself. */
|
|
58
|
+
deltaPts: number | null;
|
|
59
|
+
/** Relative lift vs the control, in percent. null when the control converts nobody. */
|
|
60
|
+
liftPct: number | null;
|
|
61
|
+
z: number | null;
|
|
62
|
+
pValue: number | null;
|
|
63
|
+
/** Both conditions met: p < alpha AND both arms above `minArmN`. */
|
|
64
|
+
significant: boolean;
|
|
65
|
+
/** This arm alone has enough people. */
|
|
66
|
+
enough: boolean;
|
|
67
|
+
isControl: boolean;
|
|
68
|
+
};
|
|
69
|
+
type Comparison = {
|
|
70
|
+
control: string;
|
|
71
|
+
arms: ArmResult[];
|
|
72
|
+
/** Total exposed across arms. */
|
|
73
|
+
exposed: number;
|
|
74
|
+
/**
|
|
75
|
+
* What a human should read. `not_enough` while any arm is below the floor,
|
|
76
|
+
* `no_difference` when nothing is significant, else the winning arm id.
|
|
77
|
+
*/
|
|
78
|
+
verdict: 'not_enough' | 'no_difference' | 'winner' | 'loser';
|
|
79
|
+
winner: string | null;
|
|
80
|
+
};
|
|
81
|
+
declare const DEFAULT_MIN_ARM_N = 30;
|
|
82
|
+
declare const DEFAULT_ALPHA = 0.05;
|
|
83
|
+
/**
|
|
84
|
+
* Abramowitz & Stegun 7.1.26 — |error| < 1.5e-7. A p-value is displayed to
|
|
85
|
+
* three decimals; a series is honest here and keeps the package dependency-free.
|
|
86
|
+
*/
|
|
87
|
+
declare function erf(x: number): number;
|
|
88
|
+
type ProportionTest = {
|
|
89
|
+
z: number;
|
|
90
|
+
pValue: number;
|
|
91
|
+
};
|
|
92
|
+
/** Two-proportion z test (pooled), two-sided. null when either arm is empty. */
|
|
93
|
+
declare function twoProportionTest(kA: number, nA: number, kB: number, nB: number): ProportionTest | null;
|
|
94
|
+
/** Compare arms against the control. Order in = order out, control first. */
|
|
95
|
+
declare function compareArms(arms: readonly Arm[], opts?: CompareOptions): Comparison;
|
|
96
|
+
|
|
97
|
+
/** Longest wire string we ever emit — it rides inside other people's columns. */
|
|
98
|
+
declare const WIRE_MAX = 200;
|
|
99
|
+
/**
|
|
100
|
+
* `"apply_landing:v2,hero:b"` → `{ apply_landing: 'v2', hero: 'b' }`.
|
|
101
|
+
* Tolerant by design: this string crosses domains, query strings and third
|
|
102
|
+
* party columns, so it parses what it recognises and silently drops the rest.
|
|
103
|
+
* Never throws.
|
|
104
|
+
*/
|
|
105
|
+
declare function parseAssignments(raw: string | null | undefined): Record<string, string>;
|
|
106
|
+
/**
|
|
107
|
+
* `{ apply_landing: 'v2' }` → `"apply_landing:v2"`. Sorted, so the same
|
|
108
|
+
* assignments always produce the same string (it ends up in dedupe keys and
|
|
109
|
+
* cache keys). `undefined` when there is nothing to say.
|
|
110
|
+
*/
|
|
111
|
+
declare function serializeAssignments(a: Assignments): string | undefined;
|
|
112
|
+
/** Dimension id for a key — the name the funnel/warehouse sees. */
|
|
113
|
+
declare const dimIdOf: (key: string) => string;
|
|
114
|
+
/** Value used for subjects who were never exposed. Never an empty string: absence must be countable. */
|
|
115
|
+
declare const NOT_EXPOSED = "none";
|
|
116
|
+
|
|
117
|
+
export { type Arm, type ArmResult, Assignments, type CompareOptions, type Comparison, DEFAULT_ALPHA, DEFAULT_MIN_ARM_N, Experiment, NOT_EXPOSED, type ProportionTest, WIRE_MAX, assignVariant, bucketOf, compareArms, dimIdOf, erf, hash32, parseAssignments, serializeAssignments, twoProportionTest };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NOT_EXPOSED,
|
|
3
|
+
WIRE_MAX,
|
|
4
|
+
assignVariant,
|
|
5
|
+
bucketOf,
|
|
6
|
+
dimIdOf,
|
|
7
|
+
hash32,
|
|
8
|
+
parseAssignments,
|
|
9
|
+
serializeAssignments
|
|
10
|
+
} from "./chunk-WBS3SDIK.js";
|
|
11
|
+
|
|
12
|
+
// src/analyze.ts
|
|
13
|
+
var DEFAULT_MIN_ARM_N = 30;
|
|
14
|
+
var DEFAULT_ALPHA = 0.05;
|
|
15
|
+
function erf(x) {
|
|
16
|
+
const sign = x < 0 ? -1 : 1;
|
|
17
|
+
const ax = Math.abs(x);
|
|
18
|
+
const t = 1 / (1 + 0.3275911 * ax);
|
|
19
|
+
const y = 1 - ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-ax * ax);
|
|
20
|
+
return sign * y;
|
|
21
|
+
}
|
|
22
|
+
var normalCdf = (z) => 0.5 * (1 + erf(z / Math.SQRT2));
|
|
23
|
+
function twoProportionTest(kA, nA, kB, nB) {
|
|
24
|
+
if (nA <= 0 || nB <= 0) return null;
|
|
25
|
+
const p = (kA + kB) / (nA + nB);
|
|
26
|
+
const se = Math.sqrt(p * (1 - p) * (1 / nA + 1 / nB));
|
|
27
|
+
const z = se === 0 ? 0 : (kB / nB - kA / nA) / se;
|
|
28
|
+
return { z: Math.round(z * 100) / 100, pValue: Math.round(2 * (1 - normalCdf(Math.abs(z))) * 1e3) / 1e3 };
|
|
29
|
+
}
|
|
30
|
+
var round1 = (v) => Math.round(v * 10) / 10;
|
|
31
|
+
function compareArms(arms, opts = {}) {
|
|
32
|
+
const minArmN = opts.minArmN ?? DEFAULT_MIN_ARM_N;
|
|
33
|
+
const alpha = opts.alpha ?? DEFAULT_ALPHA;
|
|
34
|
+
const controlId = opts.control ?? arms[0]?.id ?? "";
|
|
35
|
+
const control = arms.find((a) => a.id === controlId);
|
|
36
|
+
const controlN = control?.n ?? 0;
|
|
37
|
+
const results = arms.map((a) => {
|
|
38
|
+
const isControl = a.id === controlId;
|
|
39
|
+
const test = isControl || !control ? null : twoProportionTest(control.converted, control.n, a.converted, a.n);
|
|
40
|
+
const rate = a.n > 0 ? 100 * a.converted / a.n : null;
|
|
41
|
+
const controlRate = control && control.n > 0 ? 100 * control.converted / control.n : null;
|
|
42
|
+
return {
|
|
43
|
+
id: a.id,
|
|
44
|
+
label: a.label ?? a.id,
|
|
45
|
+
n: a.n,
|
|
46
|
+
converted: a.converted,
|
|
47
|
+
ratePct: rate == null ? null : round1(rate),
|
|
48
|
+
deltaPts: rate == null || controlRate == null || isControl ? null : round1(rate - controlRate),
|
|
49
|
+
liftPct: rate == null || controlRate == null || controlRate === 0 || isControl ? null : round1((rate - controlRate) / controlRate * 100),
|
|
50
|
+
z: test?.z ?? null,
|
|
51
|
+
pValue: test?.pValue ?? null,
|
|
52
|
+
significant: test != null && test.pValue < alpha && a.n >= minArmN && controlN >= minArmN,
|
|
53
|
+
enough: a.n >= minArmN,
|
|
54
|
+
isControl
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
const ordered = [...results].sort((a, b) => Number(b.isControl) - Number(a.isControl));
|
|
58
|
+
const decided = ordered.filter((r) => r.significant && r.deltaPts != null);
|
|
59
|
+
const best = decided.reduce((acc, r) => acc == null || r.deltaPts > acc.deltaPts ? r : acc, null);
|
|
60
|
+
const verdict = ordered.some((r) => !r.enough) ? "not_enough" : best == null ? "no_difference" : best.deltaPts > 0 ? "winner" : "loser";
|
|
61
|
+
return {
|
|
62
|
+
control: controlId,
|
|
63
|
+
arms: ordered,
|
|
64
|
+
exposed: arms.reduce((s, a) => s + a.n, 0),
|
|
65
|
+
verdict,
|
|
66
|
+
winner: verdict === "winner" && best ? best.id : null
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export {
|
|
70
|
+
DEFAULT_ALPHA,
|
|
71
|
+
DEFAULT_MIN_ARM_N,
|
|
72
|
+
NOT_EXPOSED,
|
|
73
|
+
WIRE_MAX,
|
|
74
|
+
assignVariant,
|
|
75
|
+
bucketOf,
|
|
76
|
+
compareArms,
|
|
77
|
+
dimIdOf,
|
|
78
|
+
erf,
|
|
79
|
+
hash32,
|
|
80
|
+
parseAssignments,
|
|
81
|
+
serializeAssignments,
|
|
82
|
+
twoProportionTest
|
|
83
|
+
};
|
|
84
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/analyze.ts"],"sourcesContent":["/**\n * The verdict — generic over arms, registry-free (law 3). Give it what each\n * arm was shown and how many converted; it says whether the difference is\n * real, or refuses to say (law 7).\n */\n\n/** What a reader knows about one arm, whatever its source. */\nexport type Arm = {\n id: string;\n label?: string;\n /** Subjects EXPOSED to this arm — the denominator (law 2). */\n n: number;\n /** Of those, how many reached the goal. */\n converted: number;\n};\n\nexport type CompareOptions = {\n /** Arm every other one is measured against. Default: the first. */\n control?: string;\n /** Minimum exposed per arm before any verdict is allowed. Default 30. */\n minArmN?: number;\n /** Two-sided significance threshold. Default 0.05. */\n alpha?: number;\n};\n\nexport type ArmResult = {\n id: string;\n label: string;\n n: number;\n converted: number;\n /** Conversion, in percent. null when nobody was exposed. */\n ratePct: number | null;\n /** Difference with the control, in percentage points. null for the control itself. */\n deltaPts: number | null;\n /** Relative lift vs the control, in percent. null when the control converts nobody. */\n liftPct: number | null;\n z: number | null;\n pValue: number | null;\n /** Both conditions met: p < alpha AND both arms above `minArmN`. */\n significant: boolean;\n /** This arm alone has enough people. */\n enough: boolean;\n isControl: boolean;\n};\n\nexport type Comparison = {\n control: string;\n arms: ArmResult[];\n /** Total exposed across arms. */\n exposed: number;\n /**\n * What a human should read. `not_enough` while any arm is below the floor,\n * `no_difference` when nothing is significant, else the winning arm id.\n */\n verdict: 'not_enough' | 'no_difference' | 'winner' | 'loser';\n winner: string | null;\n};\n\nexport const DEFAULT_MIN_ARM_N = 30;\nexport const DEFAULT_ALPHA = 0.05;\n\n/**\n * Abramowitz & Stegun 7.1.26 — |error| < 1.5e-7. A p-value is displayed to\n * three decimals; a series is honest here and keeps the package dependency-free.\n */\nexport function erf(x: number): number {\n const sign = x < 0 ? -1 : 1;\n const ax = Math.abs(x);\n const t = 1 / (1 + 0.3275911 * ax);\n const y =\n 1 -\n ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) *\n t *\n Math.exp(-ax * ax);\n return sign * y;\n}\n\nconst normalCdf = (z: number): number => 0.5 * (1 + erf(z / Math.SQRT2));\n\nexport type ProportionTest = { z: number; pValue: number };\n\n/** Two-proportion z test (pooled), two-sided. null when either arm is empty. */\nexport function twoProportionTest(\n kA: number,\n nA: number,\n kB: number,\n nB: number,\n): ProportionTest | null {\n if (nA <= 0 || nB <= 0) return null;\n const p = (kA + kB) / (nA + nB);\n const se = Math.sqrt(p * (1 - p) * (1 / nA + 1 / nB));\n const z = se === 0 ? 0 : (kB / nB - kA / nA) / se;\n return { z: Math.round(z * 100) / 100, pValue: Math.round(2 * (1 - normalCdf(Math.abs(z))) * 1000) / 1000 };\n}\n\nconst round1 = (v: number): number => Math.round(v * 10) / 10;\n\n/** Compare arms against the control. Order in = order out, control first. */\nexport function compareArms(arms: readonly Arm[], opts: CompareOptions = {}): Comparison {\n const minArmN = opts.minArmN ?? DEFAULT_MIN_ARM_N;\n const alpha = opts.alpha ?? DEFAULT_ALPHA;\n const controlId = opts.control ?? arms[0]?.id ?? '';\n const control = arms.find((a) => a.id === controlId);\n\n const controlN = control?.n ?? 0;\n\n const results: ArmResult[] = arms.map((a) => {\n const isControl = a.id === controlId;\n const test = isControl || !control ? null : twoProportionTest(control.converted, control.n, a.converted, a.n);\n const rate = a.n > 0 ? (100 * a.converted) / a.n : null;\n const controlRate = control && control.n > 0 ? (100 * control.converted) / control.n : null;\n return {\n id: a.id,\n label: a.label ?? a.id,\n n: a.n,\n converted: a.converted,\n ratePct: rate == null ? null : round1(rate),\n deltaPts: rate == null || controlRate == null || isControl ? null : round1(rate - controlRate),\n liftPct:\n rate == null || controlRate == null || controlRate === 0 || isControl\n ? null\n : round1(((rate - controlRate) / controlRate) * 100),\n z: test?.z ?? null,\n pValue: test?.pValue ?? null,\n significant: test != null && test.pValue < alpha && a.n >= minArmN && controlN >= minArmN,\n enough: a.n >= minArmN,\n isControl,\n };\n });\n\n const ordered = [...results].sort((a, b) => Number(b.isControl) - Number(a.isControl));\n // Le contrôle n'est jamais « significatif » (il n'est comparé à rien), donc\n // tout ce qui reste ici porte un `deltaPts` chiffré.\n type Decided = ArmResult & { deltaPts: number };\n const decided = ordered.filter((r): r is Decided => r.significant && r.deltaPts != null);\n const best = decided.reduce<Decided | null>((acc, r) => (acc == null || r.deltaPts > acc.deltaPts ? r : acc), null);\n\n const verdict: Comparison['verdict'] = ordered.some((r) => !r.enough)\n ? 'not_enough'\n : best == null\n ? 'no_difference'\n : best.deltaPts > 0\n ? 'winner'\n : 'loser';\n\n return {\n control: controlId,\n arms: ordered,\n exposed: arms.reduce((s, a) => s + a.n, 0),\n verdict,\n winner: verdict === 'winner' && best ? best.id : null,\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// react/index.ts
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
function useExperiment(experiments, key, control = "control") {
|
|
6
|
+
const [variant, setVariant] = useState(control);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
setVariant(experiments.expose(key));
|
|
9
|
+
}, [experiments, key]);
|
|
10
|
+
return variant;
|
|
11
|
+
}
|
|
12
|
+
export {
|
|
13
|
+
useExperiment
|
|
14
|
+
};
|
|
15
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../react/index.ts"],"sourcesContent":["'use client';\n\n/**\n * The React seam — one hook, no context, no provider.\n *\n * const arm = useExperiment(exp, 'apply_landing');\n * if (arm === 'v2') return <Variant />;\n *\n * The first render (server, and hydration) always returns the CONTROL: the arm\n * lives in storage, which does not exist on the server, and diverging would\n * be a hydration error. The real arm arrives on mount, and THAT is when the\n * exposure is declared — so the denominator never counts someone who saw\n * nothing (law 2). For an above-the-fold change this costs one frame; it is\n * the honest price of no server round-trip and no blocking screen.\n */\nimport { useEffect, useState } from 'react';\nimport type { Experiments } from '../browser/index.js';\n\nexport function useExperiment(experiments: Experiments, key: string, control = 'control'): string {\n const [variant, setVariant] = useState<string>(control);\n useEffect(() => {\n setVariant(experiments.expose(key));\n }, [experiments, key]);\n return variant;\n}\n"],"mappings":";;;AAeA,SAAS,WAAW,gBAAgB;AAG7B,SAAS,cAAc,aAA0B,KAAa,UAAU,WAAmB;AAChG,QAAM,CAAC,SAAS,UAAU,IAAI,SAAiB,OAAO;AACtD,YAAU,MAAM;AACd,eAAW,YAAY,OAAO,GAAG,CAAC;AAAA,EACpC,GAAG,CAAC,aAAa,GAAG,CAAC;AACrB,SAAO;AACT;","names":[]}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** One arm. `weight` is relative to its siblings (default 1). */
|
|
2
|
+
type Variant = {
|
|
3
|
+
id: string;
|
|
4
|
+
label?: string;
|
|
5
|
+
weight?: number;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* One experiment, as declared by the app that SERVES it (law 3). Readers do
|
|
9
|
+
* not need this — they discover arms from the data.
|
|
10
|
+
*/
|
|
11
|
+
type Experiment = {
|
|
12
|
+
/** Stable key. Becomes the dimension id downstream (`exp_<key>`). */
|
|
13
|
+
key: string;
|
|
14
|
+
label?: string;
|
|
15
|
+
/** false → everyone gets the control and nothing is recorded (law 6). */
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
/** Share of subjects allowed INTO the test, 0..1 (default 1). */
|
|
18
|
+
rollout?: number;
|
|
19
|
+
/** Arm served to everyone outside the test. Must be one of `variants`. */
|
|
20
|
+
control: string;
|
|
21
|
+
variants: readonly Variant[];
|
|
22
|
+
description?: string;
|
|
23
|
+
/** ISO date the experiment started serving — the marker on a funnel's timeline. */
|
|
24
|
+
since?: string;
|
|
25
|
+
};
|
|
26
|
+
/** `{ [key]: variantId }` — what a subject was shown. */
|
|
27
|
+
type Assignments = Readonly<Record<string, string>>;
|
|
28
|
+
|
|
29
|
+
export type { Assignments as A, Experiment as E, Variant as V };
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@loic001/experiments",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Experiment kernel: a stable arm per subject, exposure as the denominator, and a verdict that refuses to speak too early. The registry serves; the data judges.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./browser": {
|
|
14
|
+
"types": "./dist/browser/index.d.ts",
|
|
15
|
+
"default": "./dist/browser/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./react": {
|
|
18
|
+
"types": "./dist/react/index.d.ts",
|
|
19
|
+
"default": "./dist/react/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"PRINCIPLES.md"
|
|
25
|
+
],
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"author": "loic001",
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"react": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"peerDependenciesMeta": {
|
|
36
|
+
"react": {
|
|
37
|
+
"optional": true
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"experiment",
|
|
42
|
+
"ab-test",
|
|
43
|
+
"feature-flag",
|
|
44
|
+
"funnel",
|
|
45
|
+
"tracking"
|
|
46
|
+
],
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/loic001/experiments.git"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"test": "vitest run",
|
|
53
|
+
"coverage": "vitest run --coverage",
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"build": "tsup",
|
|
56
|
+
"prepublishOnly": "npm run typecheck && npm run coverage && npm run build"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/react": "^19.2.0",
|
|
60
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
61
|
+
"react": "^19.2.0",
|
|
62
|
+
"tsup": "^8.5.1",
|
|
63
|
+
"typescript": "^5.5.0",
|
|
64
|
+
"vitest": "^3.2.4"
|
|
65
|
+
}
|
|
66
|
+
}
|