@loic001/experiments
v0.4.0
Published
Experiment kernel: a stable arm per subject, exposure as the denominator, and a funnel verdict that refuses to speak too early. The registry serves; the data judges.
Maintainers
Readme
experiments
A stable arm per subject, exposure as the denominator, and a verdict that refuses to speak too early. No server round-trip, no flash, no vendor.
Laws: PRINCIPLES.md.
import { createExperiments, posthogSink } from '@loic001/experiments/browser';
export const experiments = createExperiments({
experiments: [
{
key: 'apply_landing',
enabled: true,
rollout: 0.5, // half the traffic enters the test
control: 'control',
variants: [{ id: 'control' }, { id: 'v2' }],
},
],
sinks: [posthogSink()],
});Serve it — React, or anything:
import { useExperiment } from '@loic001/experiments/react';
const arm = useExperiment(experiments, 'apply_landing');
if (arm === 'v2') return <Variant />;Carry it — the arm rides with whatever you already send:
payload.exp = experiments.wire(); // "apply_landing:v2", or undefinedJudge it — registry-free, from whatever your warehouse counted:
import { compareArms } from '@loic001/experiments';
compareArms([
{ id: 'control', n: 4_012, converted: 402 },
{ id: 'v2', n: 3_988, converted: 518 },
]);
// { verdict: 'winner', winner: 'v2',
// arms: [ …, { id: 'v2', ratePct: 13, deltaPts: 3, liftPct: 29.9, pValue: 0, significant: true } ] }Check the registry before it ships
import { validateExperiments } from '@loic001/experiments'
validateExperiments(EXPERIMENTS) // build only — throws, listing every problemThe wire is tolerant: it drops what it does not recognise. A variant named
"big blue" renders perfectly and never travels, so the experiment runs for
nobody and you find out by waiting for a result that cannot come. Validate at
build, where saying no costs a second; never during a render, where it would
cost the page (law 10).
Look at a variant without launching it
https://tagadapay.io/?exp=apply_landing:v2A forced arm wins even on a stopped experiment, and while it is stopped nothing is recorded, nothing travels, nobody else is moved. That is how you review a variant in production before turning it on.
The whole chain, not one rate
A change that doubles clicks can halve what a click is worth. One rate never shows it; a chain does.
import { compareFunnel } from '@loic001/experiments';
compareFunnel(
[
{ id: 'view', source: 'pixel' },
{ id: 'click', source: 'pixel' },
{ id: 'lead', source: 'pixel' }, // counted by both…
{ id: 'lead', source: 'ledger' }, // …so the two halves are pinned together
{ id: 'live', source: 'ledger' },
],
counts, // [{ step, arm, n }] — from a pixel, a warehouse, a CSV, anything
);
// click: v2 wins (+10 pts) · lead: v2 loses · live: same 250 merchants either wayThe kernel fetches nothing. You bring the counts; it judges them. Plugging a
new source in is writing one function that returns { step, arm, n }[].
Sources never divide into each other (law 8). A step counted by both
is a bridge, and it is what makes the chain readable end to end. Without one,
the ratio across the seam is still reported — hiding it would be worse — but
flagged as crossSource, never called a conversion.
Three entries
| Import | Contains | Needs |
|---|---|---|
| @loic001/experiments | assignment, wire format, verdict — pure | nothing |
| @loic001/experiments/browser | storage, URL overrides, exposure sinks | a browser (or injected fakes) |
| @loic001/experiments/react | the hook | React ≥ 18 |
The pure entry runs in a browser, a worker, a Node job or a SQL-fed dashboard
alike. Everything in /browser is injectable (storage, search, now,
sinks), so the whole adapter is testable without a DOM.
What it does not do
- No funnels. The arm is a dimension on your subject; your funnel tool
slices on it. Pairs naturally with
@loic001/funnels. - No identity. The subject id is a local random, 30 days, never sent
anywhere. Only
"key:variant"travels. - No registry on the reading side. A dashboard discovers arms from the data. Two registries to keep in sync is a bug waiting for a Friday.
- No sequential testing.
compareArmsis a two-proportion z test at a fixed horizon. Peeking daily and stopping at the first green inflates false positives — fix the sample size before you start.
Overrides
?exp=apply_landing:v2 forces an arm for QA and previews, and only accepts a
variant that actually exists. Change the parameter with overrideParam.
