Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,103 @@
# @suaveplan/ab-testing - Enterprise A/B testing and experimentation: deterministic FNV-1a bucketing, targeting rules, variant assignment, conversion tracking, and OTel instrumentation
## Summary
Enterprise A/B testing and experimentation engine with deterministic FNV-1a bucketing, targeting rules, variant assignment, conversion tracking, result aggregation, and OpenTelemetry instrumentation.
<!-- begin:auto-docs -->
## Documentation tree
- [bucketing](./src/bucketing/bucketing.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [bucketing](./src/bucketing/bucketing.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [experiment-manager](./src/core/experiment-manager/experiment-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [experiment-manager](./src/core/experiment-manager/experiment-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [providers](./src/providers/provider.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [provider](./src/providers/provider.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [results](./src/results/results.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [results](./src/results/results.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [targeting](./src/targeting/targeting.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [targeting](./src/targeting/targeting.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [common](./src/types/common.md) — Defines the shared TypeScript interfaces and type aliases that form the public contract of `@suaveplan/ab-testing`. Eve…
- [schemas](./src/types/schemas.md) — Provides Zod validation schemas for all public input types in `@suaveplan/ab-testing`. These schemas serve as the singl…
<!-- end:auto-docs --> - [bucketing](./src/bucketing/bucketing.md) — <!-- begin:auto-docs --> _(no submodules)_- [experiment-manager](./src/core/experiment-manager/experiment-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [experiment-manager](./src/core/experiment-manager/experiment-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [providers](./src/providers/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/providers/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [results](./src/results/results.md) — <!-- begin:auto-docs --> _(no submodules)_- [results](./src/results/results.md) — <!-- begin:auto-docs --> _(no submodules)_- [targeting](./src/targeting/targeting.md) — <!-- begin:auto-docs --> _(no submodules)_- [targeting](./src/targeting/targeting.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — Defines the shared TypeScript interfaces and type aliases that form the public contract of `@suaveplan/ab-testing`. Eve…
- [schemas](./src/types/schemas.md) — Provides Zod validation schemas for all public input types in `@suaveplan/ab-testing`. These schemas serve as the singl…
- [bucketing](./src/bucketing/bucketing.md) — <!-- begin:auto-docs --> _(no submodules)_- [experiment-manager](./src/core/experiment-manager/experiment-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [experiment-manager](./src/core/experiment-manager/experiment-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [providers](./src/providers/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/providers/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [results](./src/results/results.md) — <!-- begin:auto-docs --> _(no submodules)_- [results](./src/results/results.md) — <!-- begin:auto-docs --> _(no submodules)_- [targeting](./src/targeting/targeting.md) — <!-- begin:auto-docs --> _(no submodules)_- [targeting](./src/targeting/targeting.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — Defines the shared TypeScript interfaces and type aliases that form the public contract of `@suaveplan/ab-testing`. Eve…
- [schemas](./src/types/schemas.md) — Provides Zod validation schemas for all public input types in `@suaveplan/ab-testing`. These schemas serve as the singl…
- [bucketing](./src/bucketing/bucketing.md) — <!-- begin:auto-docs --> _(no submodules)_- [experiment-manager](./src/core/experiment-manager/experiment-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [experiment-manager](./src/core/experiment-manager/experiment-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [providers](./src/providers/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [provider](./src/providers/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [results](./src/results/results.md) — <!-- begin:auto-docs --> _(no submodules)_- [results](./src/results/results.md) — <!-- begin:auto-docs --> _(no submodules)_- [targeting](./src/targeting/targeting.md) — <!-- begin:auto-docs --> _(no submodules)_- [targeting](./src/targeting/targeting.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — Defines the shared TypeScript interfaces and type aliases that form the public contract of `@suaveplan/ab-testing`. Eve…
- [schemas](./src/types/schemas.md) — Provides Zod validation schemas for all public input types in `@suaveplan/ab-testing`. These schemas serve as the singl…
- [bucketing](./src/bucketing/bucketing.md) — <!-- begin:auto-docs --> _(no submodules)_- [experiment-manager](./src/core/experiment-manager/experiment-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ - [experiment-manager](./src/core/experiment-manager/experiment-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [providers](./src/providers/provider.md) — <!-- begin:auto-docs --> _(no submodules)_ - [provider](./src/providers/provider.md) — <!-- begin:auto-docs --> _(no submodules)_- [results](./src/results/results.md) — <!-- begin:auto-docs --> _(no submodules)_ - [results](./src/results/results.md) — <!-- begin:auto-docs --> _(no submodules)_- [targeting](./src/targeting/targeting.md) — <!-- begin:auto-docs --> _(no submodules)_ - [targeting](./src/targeting/targeting.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — Defines the shared TypeScript interfaces and type aliases that form the public contract of `@suaveplan/ab-testing`. Eve…
- [schemas](./src/types/schemas.md) — Provides Zod validation schemas for all public input types in `@suaveplan/ab-testing`. These schemas serve as the singl…
## Features
- **Deterministic bucketing** — FNV-1a 32-bit hash maps `(userId, experimentId)` to a stable variant with configurable seed
- **Targeting rules** — eight operators (`eq`, `neq`, `in`, `notIn`, `gt`, `lt`, `gte`, `lte`) evaluated with AND logic
- **Sample rate** — fraction-based inclusion gate via injectable RNG for deterministic testing
- **Assignment caching** — repeat `assign()` calls for the same user always return the same variant
- **Conversion tracking** — named metric events with numeric values, aggregated per variant
- **Provider API** — plug in external platforms via `ExperimentProvider` for forced assignments and event forwarding
- **OTel instrumentation** — `ab.assign` span, `ab.assignments_total` counter, `ab.conversions_total` counter
- **Event bus** — emits `ab:assigned` and `ab:conversion` events on an injectable `EventBusLike`
- **Zod validation** — all experiment definitions and metric events are validated at the boundary
- **Injectable dependencies** — `clock`, `rng`, `logger`, `eventBus`, `hashSeed` for deterministic testing
## Installation
```bash
bun add @suaveplan/ab-testing
```
## Quick Start
```ts
import { createExperimentManager } from '@suaveplan/ab-testing';
const manager = createExperimentManager();
manager.defineExperiment({
id: 'checkout-cta',
name: 'Checkout CTA Colour',
status: 'running',
variants: [
{ id: 'ctrl', name: 'Blue', weight: 1, isControl: true },
{ id: 'treat', name: 'Green', weight: 1 },
],
primaryMetric: 'checkout_started',
});
const assignment = manager.assign('checkout-cta', 'user-123');
if (assignment.inExperiment) {
console.log(`Variant: ${assignment.variantId}`);
}
// Track a conversion
manager.track('checkout-cta', 'user-123', { name: 'checkout_started' });
// Read results
const results = manager.getResults('checkout-cta');
console.log(`Conversion rate: ${results.variants[0]?.conversionRate}`);
```
## Module Documentation
| Module | Description |
|---|---|
| [`src/types/common.md`](src/types/common.md) | Core domain type interfaces |
| [`src/types/schemas.md`](src/types/schemas.md) | Zod validation schemas |
| [`src/core/experiment-manager/experiment-manager.md`](src/core/experiment-manager/experiment-manager.md) | Main manager implementation |
| [`src/bucketing/bucketing.md`](src/bucketing/bucketing.md) | FNV-1a bucketing primitives |
| [`src/targeting/targeting.md`](src/targeting/targeting.md) | Targeting rule evaluation |
| [`src/results/results.md`](src/results/results.md) | Results aggregation |
| [`src/providers/provider.md`](src/providers/provider.md) | Built-in provider implementations |
## Subpath Exports
```ts
import { createExperimentManager } from '@suaveplan/ab-testing';
import { bucketUser } from '@suaveplan/ab-testing/bucketing';
import { evaluateTargeting } from '@suaveplan/ab-testing/targeting';
import { computeResults } from '@suaveplan/ab-testing/results';
import { ForceVariantProvider, LoggingProvider } from '@suaveplan/ab-testing/providers';
import { AbTestingError, AB_CODES } from '@suaveplan/ab-testing/errors';
```

View File

@@ -0,0 +1,117 @@
{
"name": "@suaveplan/ab-testing",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Enterprise A/B testing and experimentation: deterministic FNV-1a bucketing, targeting rules, variant assignment, conversion tracking, and OTel instrumentation",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./bucketing": {
"import": {
"types": "./dist/bucketing/index.d.ts",
"default": "./dist/bucketing/index.js"
},
"require": {
"types": "./dist/bucketing/index.d.ts",
"default": "./dist/bucketing/index.js"
}
},
"./targeting": {
"import": {
"types": "./dist/targeting/index.d.ts",
"default": "./dist/targeting/index.js"
},
"require": {
"types": "./dist/targeting/index.d.ts",
"default": "./dist/targeting/index.js"
}
},
"./results": {
"import": {
"types": "./dist/results/index.d.ts",
"default": "./dist/results/index.js"
},
"require": {
"types": "./dist/results/index.d.ts",
"default": "./dist/results/index.js"
}
},
"./providers": {
"import": {
"types": "./dist/providers/index.d.ts",
"default": "./dist/providers/index.js"
},
"require": {
"types": "./dist/providers/index.d.ts",
"default": "./dist/providers/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/error-codes.d.ts",
"default": "./dist/error-codes.js"
},
"require": {
"types": "./dist/error-codes.d.ts",
"default": "./dist/error-codes.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/logger": "workspace:^",
"@suaveplan/feature-flags": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,116 @@
# bucketing.ts — Deterministic User Bucketing
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the core hashing and variant selection primitives that map a
`(userId, experimentId)` pair to a stable, deterministic variant. The
implementation uses the FNV-1a 32-bit algorithm — a non-cryptographic hash
chosen for its excellent distribution characteristics, extremely low
computational overhead, and zero external dependencies.
Determinism is a hard requirement for A/B testing: the same user must always
receive the same variant for the lifetime of the experiment. This module
achieves that by combining the user and experiment identifiers with a null-byte
separator before hashing, preventing cross-experiment correlation while
remaining completely stateless.
## Features
- `bucketUser` — FNV-1a 32-bit hash of `userId\0experimentId` with optional
seed XOR offset; output is a float in `[0, 1)`
- `normaliseWeights` — converts arbitrary positive weights to cumulative
normalised slices in `[0, 1]`; zero/negative-weight variants are excluded
- `selectVariant` — binary search over normalised cumulative weights to find
the owning variant for a bucket value
- `NormalisedVariant` — typed extension of `Variant` with `normalizedWeight`
## Usage: Basic Example
```ts
import { bucketUser, normaliseWeights, selectVariant } from '@suaveplan/ab-testing/bucketing';
const variants = [
{ id: 'control', name: 'Control', weight: 50, isControl: true },
{ id: 'treatment', name: 'Treatment', weight: 50 },
];
const normalised = normaliseWeights(variants);
const bucket = bucketUser('user-123', 'checkout-cta');
const chosen = selectVariant(normalised, bucket);
console.log(chosen.id); // deterministic: 'control' or 'treatment'
```
## Usage: Advanced Example
```ts
import { bucketUser, normaliseWeights, selectVariant } from '@suaveplan/ab-testing/bucketing';
// Unequal weights: 10% treatment A, 20% treatment B, 70% control
const variants = [
{ id: 'ctrl', name: 'Control', weight: 70, isControl: true },
{ id: 'treat-a', name: 'Treatment A', weight: 10 },
{ id: 'treat-b', name: 'Treatment B', weight: 20 },
];
// Custom seed differentiates this experiment's buckets from other experiments
// even when the same userId is used across both.
const normalised = normaliseWeights(variants);
const bucket = bucketUser('user-456', 'homepage-hero', 0xdeadbeef);
const chosen = selectVariant(normalised, bucket);
// Verify: generate 10 000 users and check distribution
let counts: Record<string, number> = {};
for (let i = 0; i < 10_000; i++) {
const b = bucketUser(`u-${i}`, 'homepage-hero', 0xdeadbeef);
const v = selectVariant(normalised, b);
counts[v.id] = (counts[v.id] ?? 0) + 1;
}
// Expected: ctrl ≈ 7000, treat-a ≈ 1000, treat-b ≈ 2000
console.log(counts);
```
## API Reference
### `bucketUser(userId, experimentId, seed?): number`
- **`userId`** — `string` — user or entity identifier
- **`experimentId`** — `string` — experiment identifier; scopes the bucket
- **`seed`** — `number` (default `0`) — XOR'd into the FNV offset basis
- **Returns** — `number` in `[0, 1)`
- **Deterministic** — identical inputs always produce identical output
### `normaliseWeights(variants): NormalisedVariant[]`
- **`variants`** — `ReadonlyArray<Variant>`
- **Returns** — `NormalisedVariant[]` sorted in original order with `normalizedWeight`
set to cumulative upper bounds. Variants with `weight <= 0` are excluded.
Returns `[]` when all variants have non-positive weight.
### `selectVariant(variants, bucket): Variant`
- **`variants`** — `ReadonlyArray<NormalisedVariant>` from `normaliseWeights`
- **`bucket`** — `number` in `[0, 1)` from `bucketUser`
- **Returns** — the `Variant` whose cumulative slice contains `bucket`
- **Throws** — `RangeError` when `variants` is empty
### `NormalisedVariant`
Extends `Variant` with `normalizedWeight: number` representing the cumulative
upper bound of the variant's slice. The last variant always has
`normalizedWeight === 1.0`.
## Implementation Notes
The FNV-1a algorithm is applied with `Math.imul` for correct 32-bit wrapping
on all JavaScript engines (V8, JavaScriptCore, SpiderMonkey). The hash is
mapped to `[0, 1)` by dividing by `0x1_0000_0000` (`2^32`), preserving the
full resolution of the 32-bit output.
The null-byte separator (`\0`) between `userId` and `experimentId` prevents
hash collisions between adjacent key combinations such as `('ab', 'c')` and
`('a', 'bc')`. The seed XOR is applied to the FNV offset basis before
iterating so that the seed affects every step of the hash, not just the first.

View File

@@ -0,0 +1,194 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { bucketUser, normaliseWeights, selectVariant } from "./bucketing.js";
const makeVariant = (id: string, weight: number, isControl = false) => ({
id,
name: id,
weight,
isControl,
});
describe("bucketUser", () => {
it("returns a number in [0, 1)", { timeout: 5000 }, () => {
const b = bucketUser("user-1", "exp-1");
expect(b).toBeGreaterThanOrEqual(0);
expect(b).toBeLessThan(1);
});
it("is deterministic for the same inputs", { timeout: 5000 }, () => {
const b1 = bucketUser("user-abc", "exp-xyz");
const b2 = bucketUser("user-abc", "exp-xyz");
expect(b1).toBe(b2);
});
it("produces different values for different users", {
timeout: 5000,
}, () => {
const b1 = bucketUser("user-1", "exp-1");
const b2 = bucketUser("user-2", "exp-1");
expect(b1).not.toBe(b2);
});
it("produces different values for different experiments", {
timeout: 5000,
}, () => {
const b1 = bucketUser("user-1", "exp-1");
const b2 = bucketUser("user-1", "exp-2");
expect(b1).not.toBe(b2);
});
it("uses seed to differentiate buckets", { timeout: 5000 }, () => {
const b1 = bucketUser("user-1", "exp-1", 0);
const b2 = bucketUser("user-1", "exp-1", 12345);
expect(b1).not.toBe(b2);
});
it("handles empty userId", { timeout: 5000 }, () => {
const b = bucketUser("", "exp-1");
expect(b).toBeGreaterThanOrEqual(0);
expect(b).toBeLessThan(1);
});
it("handles empty experimentId", { timeout: 5000 }, () => {
const b = bucketUser("user-1", "");
expect(b).toBeGreaterThanOrEqual(0);
expect(b).toBeLessThan(1);
});
it("distributes roughly uniformly across many users", {
timeout: 5000,
}, () => {
const N = 10_000;
let above50 = 0;
for (let i = 0; i < N; i++) {
if (bucketUser(`u-${i}`, "exp") >= 0.5) above50++;
}
// Expect roughly 50% above 0.5 (within 5% tolerance)
expect(above50 / N).toBeGreaterThan(0.45);
expect(above50 / N).toBeLessThan(0.55);
});
});
describe("normaliseWeights", () => {
it("returns empty array for empty input", { timeout: 5000 }, () => {
expect(normaliseWeights([])).toEqual([]);
});
it("excludes zero-weight variants", { timeout: 5000 }, () => {
const variants = [makeVariant("a", 0), makeVariant("b", 1)];
const result = normaliseWeights(variants);
expect(result).toHaveLength(1);
expect(result[0]?.id).toBe("b");
});
it("excludes negative-weight variants", { timeout: 5000 }, () => {
const variants = [makeVariant("a", -5), makeVariant("b", 10)];
const result = normaliseWeights(variants);
expect(result).toHaveLength(1);
expect(result[0]?.id).toBe("b");
});
it("returns empty array when all weights are zero or negative", {
timeout: 5000,
}, () => {
const variants = [makeVariant("a", 0), makeVariant("b", -1)];
expect(normaliseWeights(variants)).toEqual([]);
});
it("last element has normalizedWeight exactly 1.0", {
timeout: 5000,
}, () => {
const variants = [
makeVariant("a", 1),
makeVariant("b", 2),
makeVariant("c", 3),
];
const result = normaliseWeights(variants);
expect(result[result.length - 1]?.normalizedWeight).toBe(1.0);
});
it("weights sum to 1 for equal-weight variants", { timeout: 5000 }, () => {
const variants = [makeVariant("a", 1), makeVariant("b", 1)];
const result = normaliseWeights(variants);
expect(result[0]?.normalizedWeight).toBeCloseTo(0.5);
expect(result[1]?.normalizedWeight).toBe(1.0);
});
it("handles single variant", { timeout: 5000 }, () => {
const variants = [makeVariant("only", 5)];
const result = normaliseWeights(variants);
expect(result).toHaveLength(1);
expect(result[0]?.normalizedWeight).toBe(1.0);
});
it("preserves variant properties", { timeout: 5000 }, () => {
const variants = [
makeVariant("ctrl", 50, true),
makeVariant("treat", 50),
];
const result = normaliseWeights(variants);
expect(result[0]?.isControl).toBe(true);
expect(result[1]?.isControl).toBe(false);
});
});
describe("selectVariant", () => {
it("throws RangeError for empty array", { timeout: 5000 }, () => {
expect(() => selectVariant([], 0.5)).toThrow(RangeError);
});
it("selects single variant regardless of bucket", { timeout: 5000 }, () => {
const variants = normaliseWeights([makeVariant("only", 1)]);
expect(selectVariant(variants, 0)).toStrictEqual(
expect.objectContaining({ id: "only" })
);
expect(selectVariant(variants, 0.99)).toStrictEqual(
expect.objectContaining({ id: "only" })
);
});
it("selects first variant for bucket 0", { timeout: 5000 }, () => {
const variants = normaliseWeights([
makeVariant("a", 1),
makeVariant("b", 1),
]);
const v = selectVariant(variants, 0);
expect(v.id).toBe("a");
});
it("selects last variant for bucket near 1", { timeout: 5000 }, () => {
const variants = normaliseWeights([
makeVariant("a", 1),
makeVariant("b", 1),
]);
const v = selectVariant(variants, 0.9999);
expect(v.id).toBe("b");
});
it("respects unequal weights", { timeout: 5000 }, () => {
const variants = normaliseWeights([
makeVariant("small", 10),
makeVariant("large", 90),
]);
// bucket 0.05 should be in "small" slice [0, 0.1)
expect(selectVariant(variants, 0.05).id).toBe("small");
// bucket 0.5 should be in "large" slice [0.1, 1.0)
expect(selectVariant(variants, 0.5).id).toBe("large");
});
it("statistical distribution matches weights", { timeout: 5000 }, () => {
const variants = normaliseWeights([
makeVariant("a", 25),
makeVariant("b", 75),
]);
const N = 10_000;
let countA = 0;
for (let i = 0; i < N; i++) {
const bucket = bucketUser(`u-${i}`, "exp-stat");
if (selectVariant(variants, bucket).id === "a") countA++;
}
// a should be ~25% (within 3%)
expect(countA / N).toBeGreaterThan(0.22);
expect(countA / N).toBeLessThan(0.28);
});
});

View File

@@ -0,0 +1,116 @@
import {
DEFAULT_HASH_SEED,
FNV_OFFSET_BASIS,
FNV_PRIME,
} from "../constants/index.js";
import type { Variant } from "../types/common.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A variant augmented with its normalised cumulative weight for binary search. */
export interface NormalisedVariant extends Variant {
/** Normalised weight in `(0, 1]`. */
readonly normalizedWeight: number;
}
// ---------------------------------------------------------------------------
// FNV-1a hash
// ---------------------------------------------------------------------------
/**
* Compute a deterministic FNV-1a 32-bit bucket for the given `userId` and
* `experimentId`. Returns a float in `[0, 1)`.
*
* The hash basis is XOR'd with `seed` before iteration so that the same user
* maps to independent buckets across experiments with different seeds.
*
* @param userId - User (or entity) identifier to bucket.
* @param experimentId - Experiment identifier scoping the bucket.
* @param seed - Optional integer seed XOR'd into the FNV offset basis.
* @returns Float in `[0, 1)`.
*/
export function bucketUser(
userId: string,
experimentId: string,
seed = DEFAULT_HASH_SEED
): number {
const input = `${userId}\0${experimentId}`;
let hash = (FNV_OFFSET_BASIS ^ (seed >>> 0)) >>> 0;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, FNV_PRIME) >>> 0;
}
// Map [0, 2^32) → [0, 1)
return hash / 0x1_0000_0000;
}
// ---------------------------------------------------------------------------
// Weight normalisation
// ---------------------------------------------------------------------------
/**
* Normalise variant weights into cumulative `[0, 1]` ranges.
*
* Variants with `weight <= 0` are excluded from the output. The returned
* array is sorted in original order and each element's `normalizedWeight`
* represents the **upper bound** of its cumulative slice (i.e. the last
* element always has `normalizedWeight === 1.0`).
*
* @param variants - Raw variants with arbitrary positive weights.
* @returns Variants with `normalizedWeight` set, excluding zero-weight entries.
*/
export function normaliseWeights(
variants: ReadonlyArray<Variant>
): NormalisedVariant[] {
const eligible = variants.filter((v) => v.weight > 0);
if (eligible.length === 0) return [];
const total = eligible.reduce((sum, v) => sum + v.weight, 0);
let cumulative = 0;
return eligible.map((v, i) => {
cumulative += v.weight / total;
// Force the last element to exactly 1.0 to avoid floating-point gaps
const normalizedWeight = i === eligible.length - 1 ? 1.0 : cumulative;
return { ...v, normalizedWeight };
});
}
// ---------------------------------------------------------------------------
// Variant selection
// ---------------------------------------------------------------------------
/**
* Select a variant for a given `bucket` using binary search over the
* cumulative normalised weight array produced by {@link normaliseWeights}.
*
* @param variants - Normalised variants (output of `normaliseWeights`).
* @param bucket - Float in `[0, 1)` from {@link bucketUser}.
* @returns The matched variant.
* @throws {RangeError} When `variants` is empty.
*/
export function selectVariant(
variants: ReadonlyArray<NormalisedVariant>,
bucket: number
): Variant {
if (variants.length === 0) {
throw new RangeError("selectVariant: variants array must not be empty");
}
let lo = 0;
let hi = variants.length - 1;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
const v = variants[mid];
if (v !== undefined && bucket < v.normalizedWeight) {
hi = mid;
} else {
lo = mid + 1;
}
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return variants[lo]!;
}

View File

@@ -0,0 +1,6 @@
export type { NormalisedVariant } from "./bucketing.js";
export {
bucketUser,
normaliseWeights,
selectVariant,
} from "./bucketing.js";

View File

@@ -0,0 +1,32 @@
/** Default sample rate — include 100% of eligible users in the experiment. */
export const DEFAULT_SAMPLE_RATE = 1.0;
/** Default FNV-1a seed offset XOR'd into the hash basis when no custom seed is provided. */
export const DEFAULT_HASH_SEED = 0;
/** FNV-1a 32-bit offset basis. */
export const FNV_OFFSET_BASIS = 2166136261;
/** FNV-1a 32-bit prime. */
export const FNV_PRIME = 16777619;
/** Maximum allowed total weight for variants (normalisation denominator). */
export const MAX_VARIANT_WEIGHT = Number.MAX_SAFE_INTEGER;
/** Event bus topic emitted when a user is assigned to an experiment variant. */
export const EVENT_AB_ASSIGNED = "ab:assigned";
/** Event bus topic emitted when a conversion event is tracked. */
export const EVENT_AB_CONVERSION = "ab:conversion";
/** OTel meter name for all A/B testing instruments. */
export const OTEL_METER_NAME = "@suaveplan/ab-testing";
/** OTel span name for the assign operation. */
export const OTEL_SPAN_ASSIGN = "ab.assign";
/** OTel counter name tracking total variant assignments. */
export const OTEL_COUNTER_ASSIGNMENTS = "ab.assignments_total";
/** OTel counter name tracking total conversion events. */
export const OTEL_COUNTER_CONVERSIONS = "ab.conversions_total";

View File

@@ -0,0 +1,148 @@
# experiment-manager.ts — Experiment Manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the `ExperimentManager` interface as a stateful, in-process service
that manages the full A/B experiment lifecycle: defining experiments, assigning
users to variants, recording conversion events, and computing results. All
state is held in memory, making the manager suitable for server-side
applications where state can be injected at startup and reset between test runs.
The manager integrates with the OTel API for counter and span instrumentation,
emits events on an injectable `EventBusLike` for downstream consumers, and
delegates to registered `ExperimentProvider` instances for forced assignments
and platform-specific event forwarding.
## Features
- `defineExperiment` — registers or replaces an experiment definition with
Zod validation
- `getExperiment` / `listExperiments` — read-only accessors
- `assign` — deterministic variant assignment with targeting evaluation,
sample-rate gating, provider override, FNV-1a bucketing, assignment caching,
OTel counter, event-bus emission, and provider tracking
- `track` — conversion event recording with Zod validation, provider tracking,
and OTel counter
- `getResults` — on-demand result aggregation via `computeResults`
- `use` — chainable provider registration
- Injectable `clock`, `rng`, `logger`, `eventBus`, `hashSeed`
## Usage: Basic Example
```ts
import { createExperimentManager } from '@suaveplan/ab-testing';
const manager = createExperimentManager();
manager.defineExperiment({
id: 'checkout-cta',
name: 'Checkout CTA Colour',
status: 'running',
variants: [
{ id: 'ctrl', name: 'Blue', weight: 1, isControl: true },
{ id: 'treat', name: 'Green', weight: 1 },
],
primaryMetric: 'checkout_started',
});
const a = manager.assign('checkout-cta', 'user-abc');
if (a.inExperiment) {
console.log('variant:', a.variantId);
}
manager.track('checkout-cta', 'user-abc', { name: 'checkout_started' });
const results = manager.getResults('checkout-cta');
console.log(results.totalConversions); // 1
```
## Usage: Advanced Example
```ts
import { createExperimentManager } from '@suaveplan/ab-testing';
import { ForceVariantProvider, LoggingProvider } from '@suaveplan/ab-testing/providers';
const manager = createExperimentManager({
hashSeed: 0xcafe, // scope buckets to this deployment
sampleRate: undefined, // per-experiment
});
manager
.use(new ForceVariantProvider({ 'my-exp': { 'qa-1': 'treatment' } }))
.use(new LoggingProvider({ onAssignment: (a) => metrics.record(a) }));
manager.defineExperiment({
id: 'my-exp',
name: 'Dashboard Redesign',
status: 'running',
variants: [
{ id: 'control', name: 'Old', weight: 50, isControl: true },
{ id: 'treatment', name: 'New', weight: 50 },
],
targeting: [
{ attribute: 'plan', operator: 'eq', value: 'premium' },
{ attribute: 'country', operator: 'in', value: ['US', 'CA'] },
],
sampleRate: 0.1,
primaryMetric: 'feature_click',
});
// qa-1 always gets treatment (forced by provider)
const qa = manager.assign('my-exp', 'qa-1', { plan: 'premium', country: 'US' });
console.log(qa.variantId); // 'treatment'
// Random user goes through bucketing + sample-rate
const user = manager.assign('my-exp', 'user-999', { plan: 'premium', country: 'CA' });
```
## API Reference
### `createExperimentManager(options?): ExperimentManager`
Factory function. All options are optional.
| Option | Type | Default |
|---|---|---|
| `logger` | `LoggerLike` | none |
| `clock` | `Clock` | `{ now: () => Date.now() }` |
| `rng` | `Rng` | `{ next: () => Math.random() }` |
| `eventBus` | `EventBusLike` | none |
| `hashSeed` | `number` | `0` |
### `ExperimentManager` interface
| Method | Signature | Description |
|---|---|---|
| `defineExperiment` | `(def) => void` | Register/replace experiment |
| `getExperiment` | `(id) => ExperimentDefinition \| null` | Lookup by id |
| `listExperiments` | `() => ReadonlyArray<ExperimentDefinition>` | All registered |
| `assign` | `(expId, userId, attrs?) => Assignment` | Assign user to variant |
| `track` | `(expId, userId, metric) => void` | Record conversion event |
| `getResults` | `(expId) => ExperimentResults` | Aggregate results |
| `use` | `(provider) => this` | Register provider (chainable) |
### Throws
- `AB_EXPERIMENT_NOT_FOUND` — `assign`, `track`, `getResults` when experiment
is not registered
- `AB_EXPERIMENT_INACTIVE` — `assign` when `status !== 'running'`
- `AB_INVALID_DEFINITION` — `defineExperiment` when Zod validation fails, or
when all variants have weight ≤ 0 at assign time
- `AB_METRIC_INVALID` — `track` when Zod validation of the metric event fails
## Implementation Notes
Assignments are cached by `${experimentId}:${userId}` key so repeated calls
to `assign` for the same user always return the same result. This is critical
for correctness — a user must not "jump" between variants within an experiment.
The OTel tracer wraps `_assign` in a span named `ab.assign` so that
distributed traces include experiment assignment latency. The span is closed
in a `finally` block to ensure it is always ended even when exceptions occur.
Providers are iterated in registration order. For `getAssignment`, the first
non-null result wins (short-circuit). For `trackAssignment` and
`trackConversion`, all providers are notified.

View File

@@ -0,0 +1,521 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import {
AB_CODES,
AbTestingError,
registerAbTestingErrorCodes,
} from "../../error-codes.js";
import type {
ExperimentDefinition,
ExperimentProvider,
} from "../../types/common.js";
import { createExperimentManager } from "./experiment-manager.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const makeExp = (
overrides?: Partial<ExperimentDefinition>
): ExperimentDefinition => ({
id: "exp-1",
name: "Test Experiment",
status: "running",
variants: [
{ id: "ctrl", name: "Control", weight: 1, isControl: true },
{ id: "treat", name: "Treatment", weight: 1 },
],
...overrides,
});
const deterministicRng = (value: number) => ({
nextFloat: () => value,
nextUint32: () => Math.round(value * 0xffffffff),
});
// ---------------------------------------------------------------------------
// defineExperiment
// ---------------------------------------------------------------------------
describe("defineExperiment", () => {
it("registers experiment successfully", { timeout: 5000 }, () => {
const m = createExperimentManager();
m.defineExperiment(makeExp());
expect(m.getExperiment("exp-1")).not.toBeNull();
});
it("replaces existing experiment", { timeout: 5000 }, () => {
const m = createExperimentManager();
m.defineExperiment(makeExp({ name: "Old" }));
m.defineExperiment(makeExp({ name: "New" }));
expect(m.getExperiment("exp-1")?.name).toBe("New");
});
it("throws AB_INVALID_DEFINITION for invalid experiment", {
timeout: 5000,
}, () => {
const m = createExperimentManager();
expect(() =>
m.defineExperiment({
id: "",
name: "x",
status: "running",
variants: [],
})
).toThrow(AbTestingError);
});
it("preserves state when redefining experiment", { timeout: 5000 }, () => {
const m = createExperimentManager();
m.defineExperiment(makeExp());
m.assign("exp-1", "user-1");
// Redefine — should not clear existing state
m.defineExperiment(makeExp({ name: "Updated" }));
// Already-assigned user still gets cached assignment
const a = m.assign("exp-1", "user-1");
expect(a.userId).toBe("user-1");
});
});
// ---------------------------------------------------------------------------
// getExperiment / listExperiments
// ---------------------------------------------------------------------------
describe("getExperiment", () => {
it("returns null for missing experiment", { timeout: 5000 }, () => {
const m = createExperimentManager();
expect(m.getExperiment("missing")).toBeNull();
});
it("returns definition for registered experiment", {
timeout: 5000,
}, () => {
const m = createExperimentManager();
m.defineExperiment(makeExp());
const def = m.getExperiment("exp-1");
expect(def?.id).toBe("exp-1");
});
});
describe("listExperiments", () => {
it("returns empty array when none registered", { timeout: 5000 }, () => {
expect(createExperimentManager().listExperiments()).toHaveLength(0);
});
it("returns all registered experiments", { timeout: 5000 }, () => {
const m = createExperimentManager();
m.defineExperiment(makeExp({ id: "e1" }));
m.defineExperiment(makeExp({ id: "e2" }));
expect(m.listExperiments()).toHaveLength(2);
});
});
// ---------------------------------------------------------------------------
// assign
// ---------------------------------------------------------------------------
describe("assign", () => {
it("throws AB_EXPERIMENT_NOT_FOUND for unknown experiment", {
timeout: 5000,
}, () => {
const m = createExperimentManager();
expect(() => m.assign("missing", "u1")).toThrow(AbTestingError);
try {
m.assign("missing", "u1");
} catch (e) {
expect((e as AbTestingError).code).toBe(
AB_CODES.EXPERIMENT_NOT_FOUND
);
}
});
it("throws AB_EXPERIMENT_INACTIVE for draft experiment", {
timeout: 5000,
}, () => {
const m = createExperimentManager();
m.defineExperiment(makeExp({ status: "draft" }));
expect(() => m.assign("exp-1", "u1")).toThrow(AbTestingError);
try {
m.assign("exp-1", "u1");
} catch (e) {
expect((e as AbTestingError).code).toBe(
AB_CODES.EXPERIMENT_INACTIVE
);
}
});
it("throws AB_EXPERIMENT_INACTIVE for paused experiment", {
timeout: 5000,
}, () => {
const m = createExperimentManager();
m.defineExperiment(makeExp({ status: "paused" }));
expect(() => m.assign("exp-1", "u1")).toThrow(AbTestingError);
});
it("throws AB_EXPERIMENT_INACTIVE for concluded experiment", {
timeout: 5000,
}, () => {
const m = createExperimentManager();
m.defineExperiment(makeExp({ status: "concluded" }));
expect(() => m.assign("exp-1", "u1")).toThrow(AbTestingError);
});
it("returns inExperiment: false when targeting fails", {
timeout: 5000,
}, () => {
const m = createExperimentManager();
m.defineExperiment(
makeExp({
targeting: [
{ attribute: "plan", operator: "eq", value: "premium" },
],
})
);
const a = m.assign("exp-1", "u1", { plan: "free" });
expect(a.inExperiment).toBe(false);
expect(a.variantId).toBe("");
});
it("returns inExperiment: false when rng >= sampleRate", {
timeout: 5000,
}, () => {
// rng always returns 1.0 → always excluded
const m = createExperimentManager({ rng: deterministicRng(1.0) });
m.defineExperiment(makeExp({ sampleRate: 0.5 }));
const a = m.assign("exp-1", "u1");
expect(a.inExperiment).toBe(false);
});
it("includes user when rng < sampleRate", { timeout: 5000 }, () => {
const m = createExperimentManager({ rng: deterministicRng(0.0) });
m.defineExperiment(makeExp({ sampleRate: 0.5 }));
const a = m.assign("exp-1", "u1");
expect(a.inExperiment).toBe(true);
});
it("returns a variant with id and isControl set", { timeout: 5000 }, () => {
const m = createExperimentManager();
m.defineExperiment(makeExp());
const a = m.assign("exp-1", "u1");
expect(a.inExperiment).toBe(true);
expect(["ctrl", "treat"]).toContain(a.variantId);
});
it("is deterministic for the same user", { timeout: 5000 }, () => {
const m = createExperimentManager();
m.defineExperiment(makeExp());
const a1 = m.assign("exp-1", "u1");
const a2 = m.assign("exp-1", "u1");
expect(a1.variantId).toBe(a2.variantId);
});
it("stores assignment in cache", { timeout: 5000 }, () => {
const clockFn = vi.fn().mockReturnValueOnce(100).mockReturnValue(200);
const m = createExperimentManager({ clock: clockFn });
m.defineExperiment(makeExp());
const a1 = m.assign("exp-1", "u1");
const a2 = m.assign("exp-1", "u1");
// Second call returns cached assignment with original timestamp
expect(a2.timestamp).toBe(a1.timestamp);
});
it("uses clock for timestamp", { timeout: 5000 }, () => {
const clock = () => 42_000;
const m = createExperimentManager({ clock });
m.defineExperiment(makeExp());
const a = m.assign("exp-1", "u1");
expect(a.timestamp).toBe(42_000);
});
it("emits ab:assigned event on eventBus", { timeout: 5000 }, () => {
const emit = vi.fn();
const m = createExperimentManager({ eventBus: { emit } as never });
m.defineExperiment(makeExp());
m.assign("exp-1", "u1");
expect(emit).toHaveBeenCalledWith(
"ab:assigned",
expect.objectContaining({ userId: "u1" })
);
});
it("calls provider.trackAssignment", { timeout: 5000 }, () => {
const trackAssignment = vi.fn();
const provider: ExperimentProvider = { name: "test", trackAssignment };
const m = createExperimentManager();
m.defineExperiment(makeExp());
m.use(provider);
m.assign("exp-1", "u1");
expect(trackAssignment).toHaveBeenCalledOnce();
});
it("uses provider.getAssignment when it returns non-null", {
timeout: 5000,
}, () => {
const provider: ExperimentProvider = {
name: "force",
getAssignment: () => "treat",
};
const m = createExperimentManager();
m.defineExperiment(makeExp());
m.use(provider);
const a = m.assign("exp-1", "u1");
expect(a.variantId).toBe("treat");
});
it("skips provider.getAssignment when it returns null", {
timeout: 5000,
}, () => {
const provider: ExperimentProvider = {
name: "pass-through",
getAssignment: () => null,
};
const m = createExperimentManager();
m.defineExperiment(makeExp());
m.use(provider);
const a = m.assign("exp-1", "u1");
expect(a.inExperiment).toBe(true);
expect(["ctrl", "treat"]).toContain(a.variantId);
});
it("throws AB_INVALID_DEFINITION when all variants have zero weight", {
timeout: 5000,
}, () => {
const m = createExperimentManager();
m.defineExperiment(
makeExp({
variants: [
{ id: "a", name: "A", weight: 0 },
{ id: "b", name: "B", weight: -1 },
],
})
);
expect(() => m.assign("exp-1", "u1")).toThrow(AbTestingError);
try {
m.assign("exp-1", "u1");
} catch (e) {
expect((e as AbTestingError).code).toBe(
AB_CODES.INVALID_DEFINITION
);
}
});
it("does not emit event or call provider for out-of-experiment assignment", {
timeout: 5000,
}, () => {
const emit = vi.fn();
const trackAssignment = vi.fn();
const provider: ExperimentProvider = { name: "test", trackAssignment };
const m = createExperimentManager({
eventBus: { emit } as never,
rng: deterministicRng(1.0),
});
m.defineExperiment(makeExp({ sampleRate: 0.5 }));
m.use(provider);
const a = m.assign("exp-1", "u1");
expect(a.inExperiment).toBe(false);
expect(emit).not.toHaveBeenCalled();
expect(trackAssignment).not.toHaveBeenCalled();
});
it("handles attributes default to empty object", { timeout: 5000 }, () => {
const m = createExperimentManager();
m.defineExperiment(makeExp());
const a = m.assign("exp-1", "u1");
expect(a.inExperiment).toBe(true);
});
it("provider getAssignment is only called when defined", {
timeout: 5000,
}, () => {
const provider: ExperimentProvider = { name: "no-get" };
const m = createExperimentManager();
m.defineExperiment(makeExp());
m.use(provider);
// Should not throw
const a = m.assign("exp-1", "u1");
expect(a.inExperiment).toBe(true);
});
});
// ---------------------------------------------------------------------------
// track
// ---------------------------------------------------------------------------
describe("track", () => {
it("throws AB_EXPERIMENT_NOT_FOUND for unknown experiment", {
timeout: 5000,
}, () => {
const m = createExperimentManager();
expect(() => m.track("missing", "u1", { name: "click" })).toThrow(
AbTestingError
);
try {
m.track("missing", "u1", { name: "click" });
} catch (e) {
expect((e as AbTestingError).code).toBe(
AB_CODES.EXPERIMENT_NOT_FOUND
);
}
});
it("throws AB_METRIC_INVALID for invalid metric", { timeout: 5000 }, () => {
const m = createExperimentManager();
m.defineExperiment(makeExp());
expect(() => m.track("exp-1", "u1", { name: "" })).toThrow(
AbTestingError
);
try {
m.track("exp-1", "u1", { name: "" });
} catch (e) {
expect((e as AbTestingError).code).toBe(AB_CODES.METRIC_INVALID);
}
});
it("stores metric events for user", { timeout: 5000 }, () => {
const m = createExperimentManager();
m.defineExperiment(makeExp());
m.assign("exp-1", "u1");
m.track("exp-1", "u1", { name: "checkout" });
const results = m.getResults("exp-1");
expect(results.totalConversions).toBe(1);
});
it("emits ab:conversion event on eventBus", { timeout: 5000 }, () => {
const emit = vi.fn();
const m = createExperimentManager({ eventBus: { emit } as never });
m.defineExperiment(makeExp());
m.assign("exp-1", "u1");
m.track("exp-1", "u1", { name: "click" });
expect(emit).toHaveBeenCalledWith(
"ab:conversion",
expect.objectContaining({ experimentId: "exp-1", userId: "u1" })
);
});
it("calls provider.trackConversion", { timeout: 5000 }, () => {
const trackConversion = vi.fn();
const provider: ExperimentProvider = { name: "test", trackConversion };
const m = createExperimentManager();
m.defineExperiment(makeExp());
m.use(provider);
m.track("exp-1", "u1", { name: "checkout" });
expect(trackConversion).toHaveBeenCalledWith("exp-1", "u1", {
name: "checkout",
});
});
it("provider.trackConversion not called when not defined", {
timeout: 5000,
}, () => {
const provider: ExperimentProvider = { name: "no-convert" };
const m = createExperimentManager();
m.defineExperiment(makeExp());
m.use(provider);
// Should not throw
expect(() => m.track("exp-1", "u1", { name: "click" })).not.toThrow();
});
});
// ---------------------------------------------------------------------------
// getResults
// ---------------------------------------------------------------------------
describe("getResults", () => {
it("throws AB_EXPERIMENT_NOT_FOUND for unknown experiment", {
timeout: 5000,
}, () => {
const m = createExperimentManager();
expect(() => m.getResults("missing")).toThrow(AbTestingError);
try {
m.getResults("missing");
} catch (e) {
expect((e as AbTestingError).code).toBe(
AB_CODES.EXPERIMENT_NOT_FOUND
);
}
});
it("returns zero counts for experiment with no assignments", {
timeout: 5000,
}, () => {
const m = createExperimentManager();
m.defineExperiment(makeExp());
const r = m.getResults("exp-1");
expect(r.totalAssignments).toBe(0);
expect(r.totalConversions).toBe(0);
});
it("returns correct conversion rate", { timeout: 5000 }, () => {
const m = createExperimentManager();
m.defineExperiment(
makeExp({ variants: [{ id: "only", name: "Only", weight: 1 }] })
);
m.assign("exp-1", "u1");
m.assign("exp-1", "u2");
m.track("exp-1", "u1", { name: "checkout" });
const r = m.getResults("exp-1");
expect(r.totalAssignments).toBe(2);
expect(r.totalConversions).toBe(1);
const v = r.variants.find((v) => v.variantId === "only");
expect(v?.conversionRate).toBeCloseTo(0.5);
});
});
// ---------------------------------------------------------------------------
// use — chaining
// ---------------------------------------------------------------------------
describe("use", () => {
it("returns the manager for chaining", { timeout: 5000 }, () => {
const m = createExperimentManager();
const result = m.use({ name: "p1" });
expect(result).toBe(m);
});
it("accumulates multiple providers", { timeout: 5000 }, () => {
const calls: string[] = [];
const p1: ExperimentProvider = {
name: "p1",
trackAssignment: () => {
calls.push("p1");
},
};
const p2: ExperimentProvider = {
name: "p2",
trackAssignment: () => {
calls.push("p2");
},
};
const m = createExperimentManager();
m.defineExperiment(makeExp());
m.use(p1).use(p2);
m.assign("exp-1", "u1");
expect(calls).toEqual(["p1", "p2"]);
});
it("first non-null getAssignment wins", { timeout: 5000 }, () => {
const p1: ExperimentProvider = {
name: "p1",
getAssignment: () => "ctrl",
};
const p2: ExperimentProvider = {
name: "p2",
getAssignment: () => "treat",
};
const m = createExperimentManager();
m.defineExperiment(makeExp());
m.use(p1).use(p2);
const a = m.assign("exp-1", "u1");
expect(a.variantId).toBe("ctrl");
});
});
// ---------------------------------------------------------------------------
// registerAbTestingErrorCodes
// ---------------------------------------------------------------------------
describe("registerAbTestingErrorCodes", () => {
it("is callable and idempotent", { timeout: 5000 }, () => {
expect(() => registerAbTestingErrorCodes()).not.toThrow();
expect(() => registerAbTestingErrorCodes()).not.toThrow();
});
});

View File

@@ -0,0 +1,371 @@
import { metrics, trace } from "@opentelemetry/api";
import type { Clock, EventBusLike, LoggerLike, Rng } from "@suaveplan/types";
import {
bucketUser,
normaliseWeights,
selectVariant,
} from "../../bucketing/bucketing.js";
import {
DEFAULT_HASH_SEED,
DEFAULT_SAMPLE_RATE,
EVENT_AB_ASSIGNED,
EVENT_AB_CONVERSION,
OTEL_COUNTER_ASSIGNMENTS,
OTEL_COUNTER_CONVERSIONS,
OTEL_METER_NAME,
OTEL_SPAN_ASSIGN,
} from "../../constants/index.js";
import { AB_CODES, AbTestingError } from "../../error-codes.js";
import { computeResults } from "../../results/results.js";
import { evaluateTargeting } from "../../targeting/targeting.js";
import type {
Assignment,
ExperimentDefinition,
ExperimentManager,
ExperimentManagerOptions,
ExperimentProvider,
ExperimentResults,
MetricEvent,
UserAttributes,
} from "../../types/common.js";
import {
ExperimentDefinitionSchema,
MetricEventSchema,
} from "../../types/schemas.js";
import type { ExperimentState } from "./experiment-manager.types.js";
// ---------------------------------------------------------------------------
// OTel instruments (lazy — no-op until an SDK provider is registered)
// ---------------------------------------------------------------------------
const meter = metrics.getMeter(OTEL_METER_NAME);
const tracer = trace.getTracer(OTEL_METER_NAME);
const assignmentsCounter = meter.createCounter(OTEL_COUNTER_ASSIGNMENTS);
const conversionsCounter = meter.createCounter(OTEL_COUNTER_CONVERSIONS);
// ---------------------------------------------------------------------------
// Default clock / RNG
// ---------------------------------------------------------------------------
const defaultClock: Clock = () => Date.now();
const defaultRng: Rng = {
nextUint32: /* istanbul ignore next */ () =>
(Math.random() * 0x1_0000_0000) >>> 0,
nextFloat: () => Math.random(),
};
// ---------------------------------------------------------------------------
// ExperimentManagerImpl
// ---------------------------------------------------------------------------
class ExperimentManagerImpl implements ExperimentManager {
protected readonly logger: LoggerLike | undefined;
protected readonly clock: Clock;
protected readonly rng: Rng;
protected readonly eventBus: EventBusLike | undefined;
protected readonly hashSeed: number;
protected readonly experiments = new Map<string, ExperimentDefinition>();
protected readonly state = new Map<string, ExperimentState>();
protected readonly providers: ExperimentProvider[] = [];
constructor(opts: ExperimentManagerOptions = {}) {
this.logger = opts.logger;
this.clock = opts.clock ?? defaultClock;
this.rng = opts.rng ?? defaultRng;
this.eventBus = opts.eventBus;
this.hashSeed = opts.hashSeed ?? DEFAULT_HASH_SEED;
}
// -----------------------------------------------------------------------
// defineExperiment
// -----------------------------------------------------------------------
defineExperiment(experiment: ExperimentDefinition): void {
const parsed = ExperimentDefinitionSchema.safeParse(experiment);
if (!parsed.success) {
throw new AbTestingError(
AB_CODES.INVALID_DEFINITION,
`Invalid experiment definition: ${parsed.error.message}`,
{ context: { id: experiment.id } }
);
}
this.experiments.set(experiment.id, experiment);
if (!this.state.has(experiment.id)) {
this.state.set(experiment.id, {
assignments: new Map(),
conversions: new Map(),
});
}
this.logger?.debug?.("ab-testing: defineExperiment", {
experimentId: experiment.id,
});
}
// -----------------------------------------------------------------------
// getExperiment / listExperiments
// -----------------------------------------------------------------------
getExperiment(id: string): ExperimentDefinition | null {
return this.experiments.get(id) ?? null;
}
listExperiments(): ReadonlyArray<ExperimentDefinition> {
return Array.from(this.experiments.values());
}
// -----------------------------------------------------------------------
// assign
// -----------------------------------------------------------------------
assign(
experimentId: string,
userId: string,
attributes: UserAttributes = {}
): Assignment {
return tracer.startActiveSpan(OTEL_SPAN_ASSIGN, (span) => {
try {
return this._assign(experimentId, userId, attributes);
} finally {
span.end();
}
});
}
private _assign(
experimentId: string,
userId: string,
attributes: UserAttributes
): Assignment {
const exp = this.experiments.get(experimentId);
if (!exp) {
throw new AbTestingError(
AB_CODES.EXPERIMENT_NOT_FOUND,
`Experiment '${experimentId}' not found`,
{ context: { experimentId } }
);
}
if (exp.status !== "running") {
throw new AbTestingError(
AB_CODES.EXPERIMENT_INACTIVE,
`Experiment '${experimentId}' is not running (status: ${exp.status})`,
{ context: { experimentId, status: exp.status } }
);
}
const stateKey = `${experimentId}:${userId}`;
const expState = this.state.get(experimentId)!;
// Return cached assignment for deterministic repeat calls
const existing = expState.assignments.get(stateKey);
if (existing) return existing;
// Check targeting
const targeting = exp.targeting ?? [];
const targeted = evaluateTargeting(targeting, attributes);
if (!targeted) {
const outAssignment = this._makeAssignment(
experimentId,
userId,
"",
false,
false
);
expState.assignments.set(stateKey, outAssignment);
return outAssignment;
}
// Check sample rate
const sampleRate = exp.sampleRate ?? DEFAULT_SAMPLE_RATE;
if (this.rng.nextFloat() >= sampleRate) {
const outAssignment = this._makeAssignment(
experimentId,
userId,
"",
false,
false
);
expState.assignments.set(stateKey, outAssignment);
return outAssignment;
}
// Check provider for forced variant
let forcedVariantId: string | null = null;
for (const provider of this.providers) {
if (provider.getAssignment) {
const forced = provider.getAssignment(experimentId, userId);
if (forced !== null) {
forcedVariantId = forced;
break;
}
}
}
// Determine variant
let variant = forcedVariantId
? exp.variants.find((v) => v.id === forcedVariantId)
: undefined;
if (!variant) {
const normalised = normaliseWeights(exp.variants);
if (normalised.length === 0) {
throw new AbTestingError(
AB_CODES.INVALID_DEFINITION,
`Experiment '${experimentId}' has no variants with positive weight`,
{ context: { experimentId } }
);
}
const bucket = bucketUser(userId, experimentId, this.hashSeed);
variant = selectVariant(normalised, bucket);
}
const assignment = this._makeAssignment(
experimentId,
userId,
variant.id,
variant.isControl ?? false,
true
);
expState.assignments.set(stateKey, assignment);
// OTel counter
assignmentsCounter.add(1, {
experiment_id: experimentId,
variant_id: variant.id,
});
// Event bus
this.eventBus?.emit?.(EVENT_AB_ASSIGNED, assignment);
// Provider tracking
for (const provider of this.providers) {
provider.trackAssignment?.(assignment);
}
return assignment;
}
private _makeAssignment(
experimentId: string,
userId: string,
variantId: string,
isControl: boolean,
inExperiment: boolean
): Assignment {
return {
experimentId,
userId,
variantId,
isControl,
timestamp: this.clock(),
inExperiment,
};
}
// -----------------------------------------------------------------------
// track
// -----------------------------------------------------------------------
track(experimentId: string, userId: string, metric: MetricEvent): void {
const exp = this.experiments.get(experimentId);
if (!exp) {
throw new AbTestingError(
AB_CODES.EXPERIMENT_NOT_FOUND,
`Experiment '${experimentId}' not found`,
{ context: { experimentId } }
);
}
const parsed = MetricEventSchema.safeParse(metric);
if (!parsed.success) {
throw new AbTestingError(
AB_CODES.METRIC_INVALID,
`Invalid metric event: ${parsed.error.message}`,
{ context: { experimentId, userId } }
);
}
const expState = this.state.get(experimentId)!;
const events = expState.conversions.get(userId) ?? [];
events.push(metric);
expState.conversions.set(userId, events);
conversionsCounter.add(1, {
experiment_id: experimentId,
metric_name: metric.name,
});
this.eventBus?.emit?.(EVENT_AB_CONVERSION, {
experimentId,
userId,
metric,
});
for (const provider of this.providers) {
provider.trackConversion?.(experimentId, userId, metric);
}
}
// -----------------------------------------------------------------------
// getResults
// -----------------------------------------------------------------------
getResults(experimentId: string): ExperimentResults {
const exp = this.experiments.get(experimentId);
if (!exp) {
throw new AbTestingError(
AB_CODES.EXPERIMENT_NOT_FOUND,
`Experiment '${experimentId}' not found`,
{ context: { experimentId } }
);
}
const expState = this.state.get(experimentId)!;
const assignments = Array.from(expState.assignments.values());
return computeResults(experimentId, assignments, expState.conversions);
}
// -----------------------------------------------------------------------
// use
// -----------------------------------------------------------------------
use(provider: ExperimentProvider): this {
this.providers.push(provider);
return this;
}
}
// ---------------------------------------------------------------------------
// Factory function
// ---------------------------------------------------------------------------
/**
* Create a new {@link ExperimentManager} instance.
*
* @param options - Optional dependency injection overrides (logger, clock, rng,
* eventBus, hashSeed).
* @returns A fully-initialised experiment manager.
*
* @example
* ```ts
* import { createExperimentManager } from '@suaveplan/ab-testing';
*
* const manager = createExperimentManager();
*
* manager.defineExperiment({
* id: 'checkout-cta',
* name: 'Checkout CTA',
* status: 'running',
* variants: [
* { id: 'ctrl', name: 'Control', weight: 1, isControl: true },
* { id: 'treat', name: 'Treatment', weight: 1 },
* ],
* });
*
* const assignment = manager.assign('checkout-cta', 'user-123');
* console.log(assignment.variantId);
* ```
*/
export function createExperimentManager(
options?: ExperimentManagerOptions
): ExperimentManager {
return new ExperimentManagerImpl(options);
}

View File

@@ -0,0 +1,7 @@
/** @internal — internal state stored per experiment. */
export interface ExperimentState {
/** Assignments keyed by `${experimentId}:${userId}`. */
assignments: Map<string, import("../../types/common.js").Assignment>;
/** Conversion events keyed by `userId`. */
conversions: Map<string, import("../../types/common.js").MetricEvent[]>;
}

View File

@@ -0,0 +1 @@
export { createExperimentManager } from "./experiment-manager.js";

View File

@@ -0,0 +1,59 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/ab-testing`.
*
* Each code is prefixed with `AB.` and resolved via `@suaveplan/error`'s
* `defineCodes` registry. Callers can discriminate errors without `instanceof`
* by inspecting the stable `error.code` string property.
*/
export const AB_CODES = defineCodes("AB", [
"EXPERIMENT_NOT_FOUND",
"EXPERIMENT_INACTIVE",
"INVALID_DEFINITION",
"VARIANT_NOT_FOUND",
"METRIC_INVALID",
] as const);
export type AbTestingErrorCode = (typeof AB_CODES)[keyof typeof AB_CODES];
/**
* Typed error for all `@suaveplan/ab-testing` failures.
*
* Extends `SuaveplanError` so callers can discriminate via `instanceof
* AbTestingError` or the stable `error.code` property.
*
* @example
* ```ts
* import { AbTestingError, AB_CODES } from '@suaveplan/ab-testing/errors';
*
* try {
* manager.assign('missing-exp', 'user-1');
* } catch (err) {
* if (err instanceof AbTestingError && err.code === AB_CODES.EXPERIMENT_NOT_FOUND) {
* console.error('Experiment does not exist');
* }
* }
* ```
*/
export class AbTestingError extends SuaveplanError<AbTestingErrorCode> {
public constructor(
code: AbTestingErrorCode,
message: string,
options?: { cause?: Error; context?: Record<string, unknown> }
) {
super(code, message, options);
this.name = "AbTestingError";
}
}
/**
* Register all AB testing error codes with the global `@suaveplan/error`
* registry. Safe to call multiple times — registration is idempotent.
*/
export function registerAbTestingErrorCodes(): void {
// defineCodes registers the prefix+codes eagerly at module evaluation time;
// this function exists as an explicit call-site for consumers that prefer
// imperative registration (e.g. app bootstrap sequences).
void AB_CODES;
}

View File

@@ -0,0 +1,55 @@
// Core
// Bucketing
export {
bucketUser,
type NormalisedVariant,
normaliseWeights,
selectVariant,
} from "./bucketing/bucketing.js";
export { createExperimentManager } from "./core/experiment-manager/experiment-manager.js";
// Errors
export {
AB_CODES,
AbTestingError,
type AbTestingErrorCode,
registerAbTestingErrorCodes,
} from "./error-codes.js";
// Providers
export {
ForceVariantProvider,
LoggingProvider,
type LoggingProviderOptions,
} from "./providers/provider.js";
// Results
export { computeResults } from "./results/results.js";
// Targeting
export { evaluateTargeting } from "./targeting/targeting.js";
// Types
export type {
Assignment,
ExperimentDefinition,
ExperimentManager,
ExperimentManagerOptions,
ExperimentProvider,
ExperimentResults,
MetricEvent,
TargetingRule,
UserAttributes,
Variant,
VariantResults,
} from "./types/common.js";
// Schemas
export {
type ExperimentDefinitionInput,
ExperimentDefinitionSchema,
type MetricEventInput,
MetricEventSchema,
type TargetingRuleInput,
TargetingRuleSchema,
type VariantInput,
VariantSchema,
} from "./types/schemas.js";

View File

@@ -0,0 +1,5 @@
export {
ForceVariantProvider,
LoggingProvider,
type LoggingProviderOptions,
} from "./provider.js";

View File

@@ -0,0 +1,116 @@
# provider.ts — Experiment Providers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides two concrete implementations of the `ExperimentProvider` interface:
`LoggingProvider` for forwarding assignment and conversion events to custom
callbacks, and `ForceVariantProvider` for overriding variant assignment in
QA, staging, and testing environments.
Providers are the extension point for integrating `@suaveplan/ab-testing`
with external platforms (LaunchDarkly, Optimizely, Amplitude, custom analytics
pipelines) without coupling the core engine to any specific SDK.
## Features
- `LoggingProvider` — configurable callbacks for assignment and conversion
events; defaults to `console.log` for zero-config debugging
- `ForceVariantProvider` — experiment-scoped per-user variant overrides with
wildcard `'*'` support for forcing all users in an experiment to a variant
- Both classes implement `ExperimentProvider` so they can be passed to
`manager.use(provider)` for chaining
## Usage: Basic Example
```ts
import { LoggingProvider } from '@suaveplan/ab-testing/providers';
import { createExperimentManager } from '@suaveplan/ab-testing';
const manager = createExperimentManager();
manager.use(new LoggingProvider({
onAssignment: (a) => console.info('assigned', a.variantId, 'to', a.userId),
}));
```
## Usage: Advanced Example
```ts
import {
ForceVariantProvider,
LoggingProvider,
} from '@suaveplan/ab-testing/providers';
import { createExperimentManager } from '@suaveplan/ab-testing';
const manager = createExperimentManager();
// Force QA users to treatment, but let normal users bucket normally
const forceProvider = new ForceVariantProvider({
'checkout-redesign': {
'qa-user-1': 'treatment',
'qa-user-2': 'control',
// '*': 'treatment', // would force everyone not explicitly listed
},
});
// Send events to an analytics platform
const analyticsProvider = new LoggingProvider({
onAssignment: (a) => externalAnalytics.track('experiment_assigned', a),
onConversion: (expId, userId, metric) =>
externalAnalytics.track('experiment_converted', { expId, userId, ...metric }),
});
// Chain multiple providers
manager.use(forceProvider).use(analyticsProvider);
// For the QA user, forceProvider.getAssignment returns 'treatment'
// and the bucketing step is skipped
const assignment = manager.assign('checkout-redesign', 'qa-user-1');
console.log(assignment.variantId); // 'treatment'
```
## API Reference
### `LoggingProvider`
Constructor: `new LoggingProvider(opts?: LoggingProviderOptions)`
#### `LoggingProviderOptions`
| Property | Type | Default |
|---|---|---|
| `onAssignment` | `(assignment: Assignment) => void` | `console.log` |
| `onConversion` | `(experimentId, userId, metric) => void` | `console.log` |
Methods:
- `trackAssignment(assignment)` — invokes `onAssignment`
- `trackConversion(experimentId, userId, metric)` — invokes `onConversion`
### `ForceVariantProvider`
Constructor: `new ForceVariantProvider(overrides?)`
Where `overrides` is `Record<string, Record<string, string>>` mapping
`experimentId → { userId → variantId }`. Use `'*'` as the `userId` key to
force all users not explicitly listed.
Methods:
- `getAssignment(experimentId, userId): string | null` — returns the forced
variant ID or `null` to fall through to bucketing
## Implementation Notes
`LoggingProvider` stores the resolved callbacks in a `protected readonly opts`
object so that subclasses can override or extend the default behaviour.
`ForceVariantProvider` checks user-specific overrides before the wildcard to
allow precise QA control even when a global experiment override is active.
Neither provider implements both `getAssignment` and `trackAssignment` /
`trackConversion` simultaneously — they are intentionally specialised. For a
production integration, compose multiple providers via `manager.use()` or
implement a custom class that merges the two concerns.

View File

@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import type { Assignment, MetricEvent } from "../types/common.js";
import { ForceVariantProvider, LoggingProvider } from "./provider.js";
const makeAssignment = (overrides?: Partial<Assignment>): Assignment => ({
experimentId: "exp-1",
userId: "user-1",
variantId: "ctrl",
isControl: true,
timestamp: 1000,
inExperiment: true,
...overrides,
});
const makeMetric = (overrides?: Partial<MetricEvent>): MetricEvent => ({
name: "click",
value: 1,
...overrides,
});
describe("LoggingProvider", () => {
it("has name 'logging-provider'", { timeout: 5000 }, () => {
const p = new LoggingProvider();
expect(p.name).toBe("logging-provider");
});
it("calls onAssignment callback", { timeout: 5000 }, () => {
const fn = vi.fn();
const p = new LoggingProvider({ onAssignment: fn });
const a = makeAssignment();
p.trackAssignment(a);
expect(fn).toHaveBeenCalledWith(a);
});
it("calls onConversion callback", { timeout: 5000 }, () => {
const fn = vi.fn();
const p = new LoggingProvider({ onConversion: fn });
const metric = makeMetric();
p.trackConversion("exp-1", "user-1", metric);
expect(fn).toHaveBeenCalledWith("exp-1", "user-1", metric);
});
it("uses default console.log when no onAssignment provided", {
timeout: 5000,
}, () => {
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
const p = new LoggingProvider();
p.trackAssignment(makeAssignment());
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
it("uses default console.log when no onConversion provided", {
timeout: 5000,
}, () => {
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
const p = new LoggingProvider();
p.trackConversion("exp-1", "u1", makeMetric());
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
});
describe("ForceVariantProvider", () => {
it("has name 'force-variant-provider'", { timeout: 5000 }, () => {
const p = new ForceVariantProvider();
expect(p.name).toBe("force-variant-provider");
});
it("returns null when no overrides configured", { timeout: 5000 }, () => {
const p = new ForceVariantProvider();
expect(p.getAssignment("exp-1", "user-1")).toBeNull();
});
it("returns null when experiment not in overrides", {
timeout: 5000,
}, () => {
const p = new ForceVariantProvider({ "exp-2": { "user-1": "treat" } });
expect(p.getAssignment("exp-1", "user-1")).toBeNull();
});
it("returns forced variant for specific user", { timeout: 5000 }, () => {
const p = new ForceVariantProvider({
"exp-1": { "qa-user": "treatment" },
});
expect(p.getAssignment("exp-1", "qa-user")).toBe("treatment");
});
it("returns null for user not in overrides (no wildcard)", {
timeout: 5000,
}, () => {
const p = new ForceVariantProvider({
"exp-1": { "qa-user": "treatment" },
});
expect(p.getAssignment("exp-1", "other-user")).toBeNull();
});
it("returns wildcard variant for unlisted user", { timeout: 5000 }, () => {
const p = new ForceVariantProvider({
"exp-1": { "*": "treatment" },
});
expect(p.getAssignment("exp-1", "random-user")).toBe("treatment");
});
it("user-specific override takes precedence over wildcard", {
timeout: 5000,
}, () => {
const p = new ForceVariantProvider({
"exp-1": { "*": "treatment", "special-user": "control" },
});
expect(p.getAssignment("exp-1", "special-user")).toBe("control");
expect(p.getAssignment("exp-1", "other-user")).toBe("treatment");
});
});

View File

@@ -0,0 +1,131 @@
import type {
Assignment,
ExperimentProvider,
MetricEvent,
} from "../types/common.js";
// ---------------------------------------------------------------------------
// LoggingProvider
// ---------------------------------------------------------------------------
/**
* Options for {@link LoggingProvider}.
*/
export interface LoggingProviderOptions {
/**
* Called when an assignment is tracked.
* Defaults to `console.log`.
*/
onAssignment?: (assignment: Assignment) => void;
/**
* Called when a conversion is tracked.
* Defaults to `console.log`.
*/
onConversion?: (
experimentId: string,
userId: string,
metric: MetricEvent
) => void;
}
/**
* A simple {@link ExperimentProvider} that forwards assignment and conversion
* events to configurable callbacks.
*
* Useful for debugging, test-time observation, or integration with custom
* analytics pipelines that do not require a full provider implementation.
*
* @example
* ```ts
* import { LoggingProvider } from '@suaveplan/ab-testing/providers';
*
* const provider = new LoggingProvider({
* onAssignment: (a) => analytics.track('Experiment Assigned', a),
* onConversion: (expId, userId, metric) =>
* analytics.track('Experiment Converted', { expId, userId, ...metric }),
* });
*
* manager.use(provider);
* ```
*/
export class LoggingProvider implements ExperimentProvider {
readonly name = "logging-provider";
protected readonly opts: Required<LoggingProviderOptions>;
constructor(opts: LoggingProviderOptions = {}) {
this.opts = {
onAssignment:
opts.onAssignment ??
((a) => {
console.log("[ab-testing] assigned", a);
}),
onConversion:
opts.onConversion ??
((expId, userId, metric) => {
console.log(
"[ab-testing] conversion",
expId,
userId,
metric
);
}),
};
}
trackAssignment(assignment: Assignment): void {
this.opts.onAssignment(assignment);
}
trackConversion(
experimentId: string,
userId: string,
metric: MetricEvent
): void {
this.opts.onConversion(experimentId, userId, metric);
}
}
// ---------------------------------------------------------------------------
// ForceVariantProvider
// ---------------------------------------------------------------------------
/**
* An {@link ExperimentProvider} that forces specific users or all users in an
* experiment to a pre-configured variant.
*
* Use this in QA, staging, or testing environments to verify specific variant
* behaviour without relying on bucketing.
*
* @example
* ```ts
* import { ForceVariantProvider } from '@suaveplan/ab-testing/providers';
*
* // Force user 'qa-user' to 'treatment' in 'checkout-cta'
* const provider = new ForceVariantProvider({
* 'checkout-cta': { 'qa-user': 'treatment' },
* });
*
* manager.use(provider);
* ```
*/
export class ForceVariantProvider implements ExperimentProvider {
readonly name = "force-variant-provider";
/** `{ [experimentId]: { [userId | '*']: variantId } }` */
protected readonly overrides: Record<string, Record<string, string>>;
constructor(overrides: Record<string, Record<string, string>> = {}) {
this.overrides = overrides;
}
getAssignment(experimentId: string, userId: string): string | null {
const expOverrides = this.overrides[experimentId];
if (!expOverrides) return null;
// User-specific override takes precedence over wildcard
const userOverride = expOverrides[userId];
if (userOverride !== undefined) return userOverride;
const wildcard = expOverrides["*"];
return wildcard ?? null;
}
}

View File

@@ -0,0 +1 @@
export { computeResults } from "./results.js";

View File

@@ -0,0 +1,118 @@
# results.ts — Experiment Results Computation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the `computeResults` function, which aggregates raw assignment and
conversion data into structured per-variant statistics. This module is a pure
function with no side effects, making it straightforward to test exhaustively
and to call from any context — the manager uses it on demand, but it can also
be called directly from analytics pipelines or reporting jobs.
The function produces assignment counts, conversion counts, conversion rates,
and per-metric aggregates (count, sum, average) for every variant that received
at least one in-experiment assignment.
## Features
- Groups assignments by `variantId`, excluding out-of-experiment users
- Counts unique conversions per user (one conversion per user, regardless of
how many metric events they fire)
- Aggregates named metric events into `count`, `sum`, and `avg` per metric
per variant
- Computes `conversionRate = conversions / assignments` (0 when no assignments)
- Returns `totalAssignments` and `totalConversions` rolled up across all
variants for dashboard summary views
- Fully deterministic — given the same inputs, produces byte-identical output
## Usage: Basic Example
```ts
import { computeResults } from '@suaveplan/ab-testing/results';
import type { Assignment, MetricEvent } from '@suaveplan/ab-testing';
const assignments: Assignment[] = [
{ experimentId: 'exp-1', userId: 'u1', variantId: 'ctrl', isControl: true, timestamp: 1, inExperiment: true },
{ experimentId: 'exp-1', userId: 'u2', variantId: 'treat', isControl: false, timestamp: 2, inExperiment: true },
];
const conversions = new Map<string, MetricEvent[]>([
['u1', [{ name: 'checkout_started', value: 1 }]],
]);
const results = computeResults('exp-1', assignments, conversions);
console.log(results.totalAssignments); // 2
console.log(results.totalConversions); // 1
console.log(results.variants[0]?.conversionRate); // 1.0 (ctrl: 1/1)
console.log(results.variants[1]?.conversionRate); // 0.0 (treat: 0/1)
```
## Usage: Advanced Example
```ts
import { computeResults } from '@suaveplan/ab-testing/results';
import type { Assignment, MetricEvent } from '@suaveplan/ab-testing';
// Multiple metric events per user (only first triggers conversion flag)
const assignments: Assignment[] = [
{ experimentId: 'exp-2', userId: 'u1', variantId: 'v1', isControl: true, timestamp: 1, inExperiment: true },
{ experimentId: 'exp-2', userId: 'u2', variantId: 'v1', isControl: true, timestamp: 2, inExperiment: true },
{ experimentId: 'exp-2', userId: 'u3', variantId: 'v2', isControl: false, timestamp: 3, inExperiment: true },
// excluded user — not counted
{ experimentId: 'exp-2', userId: 'u4', variantId: '', isControl: false, timestamp: 4, inExperiment: false },
];
const conversions = new Map<string, MetricEvent[]>([
['u1', [
{ name: 'revenue', value: 49.99 },
{ name: 'revenue', value: 19.99 },
]],
['u3', [{ name: 'revenue', value: 99.00 }]],
]);
const results = computeResults('exp-2', assignments, conversions);
// v1: 2 assignments, 1 conversion (u1), revenue avg = (49.99 + 19.99) / 2
// v2: 1 assignment, 1 conversion (u3), revenue avg = 99.00
```
## API Reference
### `computeResults(experimentId, assignments, conversions): ExperimentResults`
- **`experimentId`** — `string` — stable experiment identifier copied to result
- **`assignments`** — `Assignment[]` — all assignments for the experiment
- **`conversions`** — `Map<string, MetricEvent[]>` — keyed by `userId`
- **Returns** — `ExperimentResults` with `variants`, `totalAssignments`,
`totalConversions`
#### `ExperimentResults`
| Field | Type | Description |
|---|---|---|
| `experimentId` | `string` | Echoed from input |
| `variants` | `VariantResults[]` | Per-variant aggregates |
| `totalAssignments` | `number` | Sum across all variants |
| `totalConversions` | `number` | Sum across all variants |
#### `VariantResults`
| Field | Type | Description |
|---|---|---|
| `variantId` | `string` | Variant identifier |
| `assignments` | `number` | In-experiment assignment count |
| `conversions` | `number` | Unique-user conversion count |
| `conversionRate` | `number` | `conversions / assignments` |
| `metrics` | `Array<{name, count, sum, avg}>` | Per-metric aggregates |
## Implementation Notes
Conversion counting follows the convention that a user can convert at most
once per experiment — the first metric event for each user triggers the
conversion flag, but all subsequent events for that user are still accumulated
into the metric aggregates. This avoids double-counting in funnel analyses
while preserving the full metric history for revenue and value calculations.
Out-of-experiment assignments (`inExperiment: false`) are excluded entirely
from all counts. They are recorded so that the manager can return a stable
assignment for a user across multiple calls, but they should not pollute the
statistical results.

View File

@@ -0,0 +1,182 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Assignment, MetricEvent } from "../types/common.js";
import { computeResults } from "./results.js";
const makeAssignment = (
userId: string,
variantId: string,
inExperiment = true,
isControl = false
): Assignment => ({
experimentId: "exp-1",
userId,
variantId,
isControl,
timestamp: Date.now(),
inExperiment,
});
describe("computeResults", () => {
it("returns empty variants when no assignments", { timeout: 5000 }, () => {
const result = computeResults("exp-1", [], new Map());
expect(result.experimentId).toBe("exp-1");
expect(result.variants).toHaveLength(0);
expect(result.totalAssignments).toBe(0);
expect(result.totalConversions).toBe(0);
});
it("excludes out-of-experiment assignments", { timeout: 5000 }, () => {
const assignments = [
makeAssignment("u1", "", false),
makeAssignment("u2", "", false),
];
const result = computeResults("exp-1", assignments, new Map());
expect(result.totalAssignments).toBe(0);
expect(result.variants).toHaveLength(0);
});
it("counts in-experiment assignments per variant", {
timeout: 5000,
}, () => {
const assignments = [
makeAssignment("u1", "ctrl", true, true),
makeAssignment("u2", "ctrl", true, true),
makeAssignment("u3", "treat", true, false),
];
const result = computeResults("exp-1", assignments, new Map());
expect(result.totalAssignments).toBe(3);
const ctrl = result.variants.find((v) => v.variantId === "ctrl");
const treat = result.variants.find((v) => v.variantId === "treat");
expect(ctrl?.assignments).toBe(2);
expect(treat?.assignments).toBe(1);
});
it("counts conversions for users with metric events", {
timeout: 5000,
}, () => {
const assignments = [
makeAssignment("u1", "ctrl", true, true),
makeAssignment("u2", "ctrl", true, true),
];
const conversions = new Map<string, MetricEvent[]>([
["u1", [{ name: "checkout" }]],
]);
const result = computeResults("exp-1", assignments, conversions);
const ctrl = result.variants.find((v) => v.variantId === "ctrl");
expect(ctrl?.conversions).toBe(1);
expect(ctrl?.conversionRate).toBeCloseTo(0.5);
expect(result.totalConversions).toBe(1);
});
it("counts each user as at most one conversion", { timeout: 5000 }, () => {
const assignments = [makeAssignment("u1", "ctrl", true, true)];
const conversions = new Map<string, MetricEvent[]>([
["u1", [{ name: "click" }, { name: "click" }, { name: "click" }]],
]);
const result = computeResults("exp-1", assignments, conversions);
const ctrl = result.variants.find((v) => v.variantId === "ctrl");
expect(ctrl?.conversions).toBe(1);
});
it("aggregates metric values correctly", { timeout: 5000 }, () => {
const assignments = [makeAssignment("u1", "ctrl", true, true)];
const conversions = new Map<string, MetricEvent[]>([
[
"u1",
[
{ name: "revenue", value: 50 },
{ name: "revenue", value: 30 },
],
],
]);
const result = computeResults("exp-1", assignments, conversions);
const ctrl = result.variants.find((v) => v.variantId === "ctrl");
const revenueMetric = ctrl?.metrics.find((m) => m.name === "revenue");
expect(revenueMetric?.count).toBe(2);
expect(revenueMetric?.sum).toBe(80);
expect(revenueMetric?.avg).toBe(40);
});
it("uses value 1 when event value is undefined", { timeout: 5000 }, () => {
const assignments = [makeAssignment("u1", "ctrl", true, true)];
const conversions = new Map<string, MetricEvent[]>([
["u1", [{ name: "click" }]],
]);
const result = computeResults("exp-1", assignments, conversions);
const ctrl = result.variants.find((v) => v.variantId === "ctrl");
const metric = ctrl?.metrics.find((m) => m.name === "click");
expect(metric?.sum).toBe(1);
expect(metric?.avg).toBe(1);
});
it("conversionRate is 0 when no conversions", { timeout: 5000 }, () => {
const assignments = [makeAssignment("u1", "ctrl", true, true)];
const result = computeResults("exp-1", assignments, new Map());
const ctrl = result.variants.find((v) => v.variantId === "ctrl");
expect(ctrl?.conversionRate).toBe(0);
expect(ctrl?.conversions).toBe(0);
});
it("handles user with empty conversions array", { timeout: 5000 }, () => {
const assignments = [makeAssignment("u1", "ctrl", true, true)];
const conversions = new Map<string, MetricEvent[]>([["u1", []]]);
const result = computeResults("exp-1", assignments, conversions);
const ctrl = result.variants.find((v) => v.variantId === "ctrl");
expect(ctrl?.conversions).toBe(0);
});
it("handles multiple variants with multiple users", {
timeout: 5000,
}, () => {
const assignments = [
makeAssignment("u1", "ctrl", true, true),
makeAssignment("u2", "treat", true, false),
makeAssignment("u3", "treat", true, false),
];
const conversions = new Map<string, MetricEvent[]>([
["u2", [{ name: "purchase", value: 99 }]],
["u3", [{ name: "purchase", value: 149 }]],
]);
const result = computeResults("exp-1", assignments, conversions);
expect(result.totalAssignments).toBe(3);
expect(result.totalConversions).toBe(2);
const treat = result.variants.find((v) => v.variantId === "treat");
expect(treat?.conversionRate).toBeCloseTo(1.0);
const purchaseMetric = treat?.metrics.find(
(m) => m.name === "purchase"
);
expect(purchaseMetric?.sum).toBe(248);
expect(purchaseMetric?.avg).toBe(124);
});
it("ignores conversions for users not in assignments", {
timeout: 5000,
}, () => {
const assignments = [makeAssignment("u1", "ctrl", true, true)];
const conversions = new Map<string, MetricEvent[]>([
["u999", [{ name: "click" }]], // orphan conversion
]);
const result = computeResults("exp-1", assignments, conversions);
expect(result.totalConversions).toBe(0);
});
it("ignores conversions for out-of-experiment users", {
timeout: 5000,
}, () => {
const assignments = [makeAssignment("u1", "", false)];
const conversions = new Map<string, MetricEvent[]>([
["u1", [{ name: "click" }]],
]);
const result = computeResults("exp-1", assignments, conversions);
expect(result.totalConversions).toBe(0);
});
it("avg is 0 when count is 0 (edge-case guard)", { timeout: 5000 }, () => {
// This tests the defensive avg calculation when count is unexpectedly 0
// We exercise this via a normal path since count always matches events processed
const assignments = [makeAssignment("u1", "ctrl", true, true)];
const result = computeResults("exp-1", assignments, new Map());
const ctrl = result.variants.find((v) => v.variantId === "ctrl");
expect(ctrl?.metrics).toHaveLength(0);
});
});

View File

@@ -0,0 +1,121 @@
import type {
Assignment,
ExperimentResults,
MetricEvent,
VariantResults,
} from "../types/common.js";
// ---------------------------------------------------------------------------
// Internal aggregation helpers
// ---------------------------------------------------------------------------
interface MetricAccumulator {
count: number;
sum: number;
}
interface VariantAccumulator {
assignments: number;
conversions: number;
metrics: Map<string, MetricAccumulator>;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Compute aggregated experiment results from raw assignment and conversion
* data stored in the experiment manager's in-memory state.
*
* This function is intentionally pure — it takes all required data as
* arguments and returns a fully computed {@link ExperimentResults} object
* with no side effects.
*
* @param experimentId - Stable experiment identifier.
* @param assignments - All assignments ever recorded for this experiment.
* @param conversions - Map from `userId` to the metric events recorded for them.
* @returns Aggregated results grouped by variant.
*
* @example
* ```ts
* import { computeResults } from '@suaveplan/ab-testing/results';
*
* const results = computeResults(
* 'checkout-cta',
* assignments,
* conversions
* );
* console.log(results.variants[0]?.conversionRate);
* ```
*/
export function computeResults(
experimentId: string,
assignments: Assignment[],
conversions: Map<string, MetricEvent[]>
): ExperimentResults {
// Build per-variant accumulators from in-experiment assignments
// and immediately accumulate conversion events for each user.
const accMap = new Map<string, VariantAccumulator>();
for (const assignment of assignments) {
if (!assignment.inExperiment) continue;
const { variantId, userId } = assignment;
if (!accMap.has(variantId)) {
accMap.set(variantId, {
assignments: 0,
conversions: 0,
metrics: new Map(),
});
}
const acc = accMap.get(variantId)!;
acc.assignments++;
// Accumulate this user's conversion events
const events = conversions.get(userId);
if (!events || events.length === 0) continue;
acc.conversions++;
for (const evt of events) {
const metricKey = evt.name;
if (!acc.metrics.has(metricKey)) {
acc.metrics.set(metricKey, { count: 0, sum: 0 });
}
const m = acc.metrics.get(metricKey)!;
m.count++;
m.sum += evt.value ?? 1;
}
}
// Build final VariantResults
const variants: VariantResults[] = [];
let totalAssignments = 0;
let totalConversions = 0;
for (const [variantId, acc] of accMap) {
totalAssignments += acc.assignments;
totalConversions += acc.conversions;
const metrics = Array.from(acc.metrics.entries()).map(([name, m]) => ({
name,
count: m.count,
sum: m.sum,
avg: m.sum / m.count,
}));
variants.push({
variantId,
assignments: acc.assignments,
conversions: acc.conversions,
conversionRate: acc.conversions / acc.assignments,
metrics,
});
}
return {
experimentId,
variants,
totalAssignments,
totalConversions,
};
}

View File

@@ -0,0 +1 @@
export { evaluateTargeting } from "./targeting.js";

View File

@@ -0,0 +1,92 @@
# targeting.ts — Targeting Rule Evaluation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the `evaluateTargeting` function that evaluates an ordered list of
targeting rules against a set of user attributes. The function implements AND
semantics — every rule in the list must pass for the user to be considered
eligible for the experiment. This single-function module is intentionally
small and pure: it has no side effects, no I/O, no external dependencies, and
is trivially unit-testable.
Targeting rules are the primary mechanism for gating experiment exposure to
specific user segments. Common use cases include restricting to paid users,
specific geographic regions, device types, or beta-program membership.
## Features
- Eight comparison operators: `eq`, `neq`, `in`, `notIn`, `gt`, `lt`, `gte`, `lte`
- Numeric ordering operators (`gt`, `lt`, `gte`, `lte`) require both sides to
be numbers; any type mismatch returns `false` rather than throwing
- `in` / `notIn` accept any array on the right-hand side
- An empty rules array always returns `true` (no restriction)
- Absent attributes evaluate to `undefined` and fail equality/ordering tests
gracefully without exceptions
## Usage: Basic Example
```ts
import { evaluateTargeting } from '@suaveplan/ab-testing/targeting';
const eligible = evaluateTargeting(
[{ attribute: 'plan', operator: 'eq', value: 'premium' }],
{ plan: 'premium', country: 'US' }
);
console.log(eligible); // true
```
## Usage: Advanced Example
```ts
import { evaluateTargeting } from '@suaveplan/ab-testing/targeting';
// Multi-rule targeting: premium users in North America with high session count
const rules = [
{ attribute: 'plan', operator: 'eq' as const, value: 'premium' },
{ attribute: 'country', operator: 'in' as const, value: ['US', 'CA', 'MX'] },
{ attribute: 'sessionCount', operator: 'gte' as const, value: 10 },
{ attribute: 'blocklisted', operator: 'neq' as const, value: true },
];
const user1 = { plan: 'premium', country: 'CA', sessionCount: 15, blocklisted: false };
const user2 = { plan: 'free', country: 'US', sessionCount: 30, blocklisted: false };
console.log(evaluateTargeting(rules, user1)); // true (all 4 rules pass)
console.log(evaluateTargeting(rules, user2)); // false (plan !== 'premium')
```
## API Reference
### `evaluateTargeting(rules, attributes): boolean`
- **`rules`** — `ReadonlyArray<TargetingRule>` — ordered list of predicates
- **`attributes`** — `UserAttributes` (`Record<string, string | number | boolean>`)
- **Returns** — `boolean` — `true` iff every rule is satisfied (or `rules` is empty)
#### Supported operators
| Operator | Description | RHS type |
|---|---|---|
| `eq` | Strict equality (`===`) | any |
| `neq` | Strict inequality (`!==`) | any |
| `in` | LHS is contained in RHS array | `unknown[]` |
| `notIn` | LHS is not contained in RHS array | `unknown[]` |
| `gt` | LHS > RHS | `number` |
| `lt` | LHS < RHS | `number` |
| `gte` | LHS >= RHS | `number` |
| `lte` | LHS <= RHS | `number` |
## Implementation Notes
All comparisons are intentionally strict (`===` / `!==`) to avoid surprising
truthy/falsy coercions. When the RHS is not of the expected type for ordering
operators (e.g. a string is passed as the threshold for `gt`), the function
returns `false` rather than throwing, making it safe to evaluate partially
invalid rule sets without crashing the assignment pipeline.
The `in` / `notIn` operators use `Array.isArray` to check the RHS type before
calling `Array.prototype.includes`. When the RHS is not an array, the rule
evaluates to `false`.

View File

@@ -0,0 +1,350 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { evaluateTargeting } from "./targeting.js";
describe("evaluateTargeting", () => {
it("returns true for empty rules", { timeout: 5000 }, () => {
expect(evaluateTargeting([], {})).toBe(true);
});
describe("eq operator", () => {
it("matches equal string", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "plan", operator: "eq", value: "premium" }],
{ plan: "premium" }
)
).toBe(true);
});
it("fails on different value", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "plan", operator: "eq", value: "premium" }],
{ plan: "free" }
)
).toBe(false);
});
it("fails on absent attribute", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "plan", operator: "eq", value: "premium" }],
{}
)
).toBe(false);
});
it("matches boolean", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "verified", operator: "eq", value: true }],
{ verified: true }
)
).toBe(true);
});
it("matches number", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "age", operator: "eq", value: 30 }],
{ age: 30 }
)
).toBe(true);
});
});
describe("neq operator", () => {
it("matches when values differ", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "plan", operator: "neq", value: "banned" }],
{ plan: "premium" }
)
).toBe(true);
});
it("fails when values are equal", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "plan", operator: "neq", value: "banned" }],
{ plan: "banned" }
)
).toBe(false);
});
it("passes when attribute is absent (undefined !== value)", {
timeout: 5000,
}, () => {
expect(
evaluateTargeting(
[{ attribute: "plan", operator: "neq", value: "banned" }],
{}
)
).toBe(true);
});
});
describe("in operator", () => {
it("matches when value is in array", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[
{
attribute: "country",
operator: "in",
value: ["US", "CA"],
},
],
{ country: "US" }
)
).toBe(true);
});
it("fails when value is not in array", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[
{
attribute: "country",
operator: "in",
value: ["US", "CA"],
},
],
{ country: "GB" }
)
).toBe(false);
});
it("fails when value is absent", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "country", operator: "in", value: ["US"] }],
{}
)
).toBe(false);
});
it("fails when rhs is not an array", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "country", operator: "in", value: "US" }],
{ country: "US" }
)
).toBe(false);
});
});
describe("notIn operator", () => {
it("passes when value is not in array", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[
{
attribute: "plan",
operator: "notIn",
value: ["banned", "suspended"],
},
],
{ plan: "premium" }
)
).toBe(true);
});
it("fails when value is in array", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[
{
attribute: "plan",
operator: "notIn",
value: ["banned"],
},
],
{ plan: "banned" }
)
).toBe(false);
});
it("fails when rhs is not an array", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "plan", operator: "notIn", value: "banned" }],
{ plan: "premium" }
)
).toBe(false);
});
});
describe("gt operator", () => {
it("passes when attribute > value", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "age", operator: "gt", value: 18 }],
{ age: 25 }
)
).toBe(true);
});
it("fails when attribute === value", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "age", operator: "gt", value: 18 }],
{ age: 18 }
)
).toBe(false);
});
it("fails when attribute < value", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "age", operator: "gt", value: 18 }],
{ age: 10 }
)
).toBe(false);
});
it("fails when attribute is string", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "age", operator: "gt", value: 18 }],
{ age: "25" as unknown as number }
)
).toBe(false);
});
it("fails when rhs is not number", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "age", operator: "gt", value: "18" }],
{ age: 25 }
)
).toBe(false);
});
});
describe("lt operator", () => {
it("passes when attribute < value", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "age", operator: "lt", value: 65 }],
{ age: 30 }
)
).toBe(true);
});
it("fails when attribute >= value", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "age", operator: "lt", value: 65 }],
{ age: 65 }
)
).toBe(false);
});
it("fails when attribute is not number", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "age", operator: "lt", value: 65 }],
{ age: "young" as unknown as number }
)
).toBe(false);
});
it("fails when rhs is not number", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "age", operator: "lt", value: "65" }],
{ age: 30 }
)
).toBe(false);
});
});
describe("gte operator", () => {
it("passes when attribute >= value", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "score", operator: "gte", value: 100 }],
{ score: 100 }
)
).toBe(true);
});
it("fails when attribute < value", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "score", operator: "gte", value: 100 }],
{ score: 99 }
)
).toBe(false);
});
});
describe("lte operator", () => {
it("passes when attribute <= value", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "score", operator: "lte", value: 100 }],
{ score: 100 }
)
).toBe(true);
});
it("fails when attribute > value", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[{ attribute: "score", operator: "lte", value: 100 }],
{ score: 101 }
)
).toBe(false);
});
});
describe("AND logic across multiple rules", () => {
it("all pass → returns true", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[
{ attribute: "plan", operator: "eq", value: "premium" },
{
attribute: "country",
operator: "in",
value: ["US", "CA"],
},
{ attribute: "age", operator: "gte", value: 18 },
],
{ plan: "premium", country: "CA", age: 25 }
)
).toBe(true);
});
it("one fails → returns false", { timeout: 5000 }, () => {
expect(
evaluateTargeting(
[
{ attribute: "plan", operator: "eq", value: "premium" },
{
attribute: "country",
operator: "in",
value: ["US", "CA"],
},
],
{ plan: "premium", country: "GB" }
)
).toBe(false);
});
it("short-circuits on first failure", { timeout: 5000 }, () => {
// Both rules would fail, but function should return false on first
expect(
evaluateTargeting(
[
{ attribute: "plan", operator: "eq", value: "premium" },
{ attribute: "age", operator: "gt", value: 100 },
],
{ plan: "free", age: 5 }
)
).toBe(false);
});
});
});

View File

@@ -0,0 +1,103 @@
import type { TargetingRule, UserAttributes } from "../types/common.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Compare two scalar values using the given ordering operator.
*
* @internal
*/
function compareNumeric(
lhs: number,
rhs: unknown,
op: "gt" | "lt" | "gte" | "lte"
): boolean {
if (typeof rhs !== "number") return false;
switch (op) {
case "gt":
return lhs > rhs;
case "lt":
return lhs < rhs;
case "gte":
return lhs >= rhs;
case "lte":
return lhs <= rhs;
}
}
/**
* Evaluate a single {@link TargetingRule} against the provided `attributes`.
*
* Returns `true` when the rule is satisfied, `false` otherwise (including
* when the attribute is absent or the value types are incompatible).
*
* @internal
*/
function evaluateRule(
rule: TargetingRule,
attributes: UserAttributes
): boolean {
const actual = attributes[rule.attribute];
switch (rule.operator) {
case "eq":
return actual === rule.value;
case "neq":
return actual !== rule.value;
case "in": {
if (!Array.isArray(rule.value)) return false;
return (rule.value as unknown[]).includes(actual);
}
case "notIn": {
if (!Array.isArray(rule.value)) return false;
return !(rule.value as unknown[]).includes(actual);
}
case "gt":
case "lt":
case "gte":
case "lte": {
if (typeof actual !== "number") return false;
return compareNumeric(actual, rule.value, rule.operator);
}
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Evaluate an ordered list of targeting rules against a set of user
* attributes using AND logic — every rule must pass for the function
* to return `true`.
*
* An empty `rules` array is treated as "no restriction" and always returns
* `true`, making the function safe to call unconditionally in the assign
* path even when `targeting` is absent on the experiment definition.
*
* @param rules - Targeting rules to evaluate.
* @param attributes - User attributes to evaluate against.
* @returns `true` when all rules pass (or when `rules` is empty).
*
* @example
* ```ts
* import { evaluateTargeting } from '@suaveplan/ab-testing/targeting';
*
* const eligible = evaluateTargeting(
* [{ attribute: 'plan', operator: 'eq', value: 'premium' }],
* { plan: 'premium', country: 'US' }
* );
* // eligible === true
* ```
*/
export function evaluateTargeting(
rules: ReadonlyArray<TargetingRule>,
attributes: UserAttributes
): boolean {
for (const rule of rules) {
if (!evaluateRule(rule, attributes)) return false;
}
return true;
}

View File

@@ -0,0 +1,151 @@
# common.ts — Core Domain Types
## Purpose
Defines the shared TypeScript interfaces and type aliases that form the public
contract of `@suaveplan/ab-testing`. Every other module in the package imports
from this file rather than re-declaring shapes, ensuring a single source of
truth for the domain model.
The types are designed to be structurally compatible with common external A/B
testing platforms (LaunchDarkly, Optimizely, Split.io) to make provider
adapters straightforward to write.
## Features
- `ExperimentDefinition` — full lifecycle descriptor: id, name, status, variants,
targeting rules, date range, sample rate, and metric names
- `Variant` — a single treatment group with stable id, display name, relative
traffic weight, and optional control flag
- `TargetingRule` — a single eligibility predicate with eight comparison
operators (eq, neq, in, notIn, gt, lt, gte, lte)
- `Assignment` — immutable record capturing which variant a user received,
whether they are in-experiment, and when the assignment occurred
- `MetricEvent` — a named conversion event with optional numeric value,
timestamp, and arbitrary properties
- `VariantResults` / `ExperimentResults` — aggregated assignment and conversion
statistics ready for significance testing
- `UserAttributes` — free-form attribute map used in targeting evaluation
- `ExperimentManagerOptions` — dependency-injection surface for clock, RNG,
logger, event bus, and hash seed
- `ExperimentProvider` — plug-in interface for external platform adapters
- `ExperimentManager` — primary management interface for defining experiments,
assigning users, tracking conversions, and reading results
## Usage: Basic Example
```ts
import type {
ExperimentDefinition,
Variant,
} from '@suaveplan/ab-testing';
const control: Variant = { id: 'control', name: 'Control', weight: 50, isControl: true };
const treatment: Variant = { id: 'treatment', name: 'Treatment', weight: 50 };
const exp: ExperimentDefinition = {
id: 'checkout-cta',
name: 'Checkout CTA colour',
status: 'running',
variants: [control, treatment],
sampleRate: 0.5,
primaryMetric: 'checkout_started',
};
```
## Usage: Advanced Example
```ts
import type {
ExperimentDefinition,
TargetingRule,
ExperimentManagerOptions,
ExperimentProvider,
Assignment,
} from '@suaveplan/ab-testing';
// Targeting: only premium users in the US
const rules: TargetingRule[] = [
{ attribute: 'plan', operator: 'eq', value: 'premium' },
{ attribute: 'country', operator: 'in', value: ['US', 'CA'] },
];
const exp: ExperimentDefinition = {
id: 'dashboard-redesign',
name: 'Dashboard redesign',
status: 'running',
variants: [
{ id: 'v0', name: 'Old', weight: 1, isControl: true },
{ id: 'v1', name: 'New', weight: 1 },
],
targeting: rules,
sampleRate: 0.1,
primaryMetric: 'session_duration',
secondaryMetrics: ['page_views', 'feature_usage'],
};
// Custom provider that logs assignments to an analytics platform
const analyticsProvider: ExperimentProvider = {
name: 'analytics',
trackAssignment(assignment: Assignment) {
// forward to external analytics service
console.log('assigned', assignment.variantId, 'to', assignment.userId);
},
};
```
## API Reference
### `Variant`
| Property | Type | Description |
|---|---|---|
| `id` | `string` | Stable identifier |
| `name` | `string` | Display name |
| `weight` | `number` | Relative traffic weight (≤ 0 skipped) |
| `isControl` | `boolean?` | Marks the baseline variant |
### `TargetingRule`
| Property | Type | Description |
|---|---|---|
| `attribute` | `string` | Key into `UserAttributes` |
| `operator` | `'eq' \| 'neq' \| 'in' \| 'notIn' \| 'gt' \| 'lt' \| 'gte' \| 'lte'` | Comparison operator |
| `value` | `unknown` | Right-hand side value |
### `ExperimentDefinition`
Full descriptor of an experiment. Only `'running'` experiments accept new
assignments; all other statuses throw `AB_EXPERIMENT_INACTIVE`.
### `Assignment`
Immutable result of `ExperimentManager.assign()`. When `inExperiment` is
`false` (due to targeting or sample-rate exclusion), `variantId` is `''` and
`isControl` is `false`.
### `MetricEvent`
Conversion event passed to `ExperimentManager.track()`. `value` defaults to
`1` and `timestamp` defaults to `Date.now()` when omitted by the caller.
### `ExperimentManagerOptions`
Dependency-injection bag. All fields are optional; sensible defaults are
provided by the manager implementation.
### `ExperimentProvider`
Plug-in interface. `getAssignment` can return a forced variant ID (useful for
QA overrides) or `null` to fall through to normal bucketing.
## Implementation Notes
All interfaces use `readonly` modifiers on every property to prevent accidental
mutation of assignment or experiment records after creation. The `variants`
array on `ExperimentDefinition` is typed as `ReadonlyArray<Variant>` so that
freezing at the API boundary requires no explicit cast.
`UserAttributes` intentionally allows only `string | number | boolean` values
to keep targeting evaluation simple and deterministic without JSON serialisation
of complex objects.

View File

@@ -0,0 +1,199 @@
import type { Clock, EventBusLike, LoggerLike, Rng } from "@suaveplan/types";
// ---------------------------------------------------------------------------
// Core domain types
// ---------------------------------------------------------------------------
/**
* A single treatment group within an experiment.
*
* The `weight` field is a relative number — weights are normalised before
* bucketing so they do not need to sum to any particular value.
*/
export interface Variant {
/** Stable identifier used in assignments, analytics, and results. */
readonly id: string;
/** Human-readable display name. */
readonly name: string;
/** Relative traffic weight. Variants with weight ≤ 0 are skipped. */
readonly weight: number;
/** Mark this variant as the baseline control for statistical analysis. */
readonly isControl?: boolean;
}
/**
* A single targeting predicate that gates experiment eligibility.
*
* All rules in a list are evaluated with AND logic — a user must satisfy
* every rule to be considered eligible.
*/
export interface TargetingRule {
/** Attribute key on the {@link UserAttributes} map. */
readonly attribute: string;
/** Comparison operator. */
readonly operator:
| "eq"
| "neq"
| "in"
| "notIn"
| "gt"
| "lt"
| "gte"
| "lte";
/** Right-hand side value for the comparison. */
readonly value: unknown;
}
/**
* Full experiment definition as stored in the manager.
*/
export interface ExperimentDefinition {
/** Stable unique identifier. */
readonly id: string;
/** Human-readable display name. */
readonly name: string;
/** Lifecycle status. Only `'running'` experiments accept new assignments. */
readonly status: "draft" | "running" | "paused" | "concluded";
/** Ordered list of treatment groups. */
readonly variants: ReadonlyArray<Variant>;
/** Optional targeting rules — all must pass for a user to be included. */
readonly targeting?: TargetingRule[];
/** ISO-8601 start date (informational, not enforced at runtime). */
readonly startDate?: string;
/** ISO-8601 end date (informational, not enforced at runtime). */
readonly endDate?: string;
/** Fraction of eligible users to include, `[0, 1]`. Defaults to `1.0`. */
readonly sampleRate?: number;
/** Primary conversion metric name for results aggregation. */
readonly primaryMetric?: string;
/** Additional metric names to aggregate in results. */
readonly secondaryMetrics?: string[];
}
/**
* The assignment record produced for a single (experiment, user) pair.
*/
export interface Assignment {
readonly experimentId: string;
readonly userId: string;
/** Empty string when `inExperiment` is `false`. */
readonly variantId: string;
/** `false` when `inExperiment` is `false`. */
readonly isControl: boolean;
/** Unix epoch milliseconds at time of assignment. */
readonly timestamp: number;
/** `false` when the user was excluded by targeting or sample rate. */
readonly inExperiment: boolean;
}
/**
* A single metric event tracked against an experiment.
*/
export interface MetricEvent {
/** Metric name — should match `primaryMetric` or `secondaryMetrics`. */
readonly name: string;
/** Numeric value to aggregate. Defaults to `1` when omitted. */
readonly value?: number;
/** Unix epoch milliseconds. Defaults to `Date.now()` when omitted. */
readonly timestamp?: number;
/** Arbitrary key/value properties for downstream analytics. */
readonly properties?: Record<string, unknown>;
}
/**
* Per-variant aggregated results.
*/
export interface VariantResults {
readonly variantId: string;
readonly assignments: number;
readonly conversions: number;
readonly conversionRate: number;
readonly metrics: ReadonlyArray<{
name: string;
count: number;
sum: number;
avg: number;
}>;
}
/**
* Aggregated results for a complete experiment.
*/
export interface ExperimentResults {
readonly experimentId: string;
readonly variants: ReadonlyArray<VariantResults>;
readonly totalAssignments: number;
readonly totalConversions: number;
}
// ---------------------------------------------------------------------------
// Manager API
// ---------------------------------------------------------------------------
/** Free-form user attribute map used for targeting evaluation. */
export type UserAttributes = Record<string, string | number | boolean>;
/**
* Construction options for the experiment manager.
*/
export interface ExperimentManagerOptions {
/** Structured logger for lifecycle and error events. */
readonly logger?: LoggerLike;
/**
* Injectable clock for deterministic timestamps in tests.
* A `Clock` is a plain `() => number` function.
*/
readonly clock?: Clock;
/** Injectable RNG for deterministic sample-rate decisions in tests. */
readonly rng?: Rng;
/** Event bus for publishing assignment and conversion events. */
readonly eventBus?: EventBusLike;
/** Seed offset XOR'd into the FNV-1a basis. */
readonly hashSeed?: number;
}
/**
* A pluggable provider that can override assignment logic or forward events
* to an external A/B testing platform.
*/
export interface ExperimentProvider {
/** Unique provider name for debugging. */
readonly name: string;
/** Return a forced variant ID or `null` to fall through to bucketing. */
getAssignment?(experimentId: string, userId: string): string | null;
/** Called after every successful assignment. */
trackAssignment?(assignment: Assignment): void;
/** Called after every conversion event. */
trackConversion?(
experimentId: string,
userId: string,
metric: MetricEvent
): void;
}
/**
* The public `ExperimentManager` interface.
*/
export interface ExperimentManager {
/** Register or replace an experiment definition. */
defineExperiment(experiment: ExperimentDefinition): void;
/** Retrieve a definition by id, or `null` if not found. */
getExperiment(id: string): ExperimentDefinition | null;
/** List all registered definitions. */
listExperiments(): ReadonlyArray<ExperimentDefinition>;
/**
* Assign a user to a variant. Throws {@link AbTestingError} with
* `AB_EXPERIMENT_NOT_FOUND` or `AB_EXPERIMENT_INACTIVE` on error.
*/
assign(
experimentId: string,
userId: string,
attributes?: UserAttributes
): Assignment;
/** Record a metric conversion event for an (experiment, user) pair. */
track(experimentId: string, userId: string, metric: MetricEvent): void;
/** Return aggregated results for an experiment. */
getResults(experimentId: string): ExperimentResults;
/** Register an external provider; returns `this` for chaining. */
use(provider: ExperimentProvider): this;
}

View File

@@ -0,0 +1,107 @@
# schemas.ts — Zod Validation Schemas
## Purpose
Provides Zod validation schemas for all public input types in
`@suaveplan/ab-testing`. These schemas serve as the single runtime validation
layer between untrusted external input (e.g. feature-flag JSON files, API
payloads, user-supplied configs) and the strongly-typed domain objects used
internally by the experiment manager.
By keeping schemas in a dedicated module, the validation logic is reusable
across REST adapters, config loaders, and import utilities without coupling
any of them to each other.
## Features
- `VariantSchema` — validates variant id, name, weight, and optional isControl
- `TargetingRuleSchema` — validates attribute, all eight operator literals, and
an open `unknown` value field
- `ExperimentDefinitionSchema` — full experiment definition with all optional
fields validated (sampleRate clamped to `[0, 1]`, variants array non-empty)
- `MetricEventSchema` — metric name, optional numeric value, optional
timestamp, and optional free-form properties map
- Inferred TypeScript types (`VariantInput`, `TargetingRuleInput`,
`ExperimentDefinitionInput`, `MetricEventInput`) derived directly from
the schemas to eliminate duplication
## Usage: Basic Example
```ts
import { ExperimentDefinitionSchema } from '@suaveplan/ab-testing';
const raw = JSON.parse(fs.readFileSync('experiment.json', 'utf-8'));
const result = ExperimentDefinitionSchema.safeParse(raw);
if (!result.success) {
console.error(result.error.format());
} else {
manager.defineExperiment(result.data);
}
```
## Usage: Advanced Example
```ts
import z from 'zod';
import {
ExperimentDefinitionSchema,
MetricEventSchema,
} from '@suaveplan/ab-testing';
// Extend the base schema with domain-specific constraints
const StrictExperimentSchema = ExperimentDefinitionSchema.refine(
(exp) => exp.variants.some((v) => v.isControl),
{ message: 'At least one variant must be the control' }
).refine(
(exp) => exp.variants.every((v) => v.weight >= 0),
{ message: 'All variant weights must be non-negative' }
);
// Parse a metric event from an incoming webhook body
function parseMetric(body: unknown) {
return MetricEventSchema.parse(body);
}
```
## API Reference
### `VariantSchema`
Zod object schema. Requires `id` (non-empty string), `name` (non-empty
string), `weight` (any number — negative values are valid input but skipped
during bucketing), and optional `isControl` boolean.
### `TargetingRuleSchema`
Validates `attribute` (non-empty string), `operator` (one of the eight
supported literals), and `value` (any type — runtime comparison is handled
by the targeting module).
### `ExperimentDefinitionSchema`
Full experiment descriptor. `sampleRate` is constrained to `[0, 1]` by the
schema. `variants` must contain at least one entry. All date fields are plain
strings; format validation (ISO-8601) is left to the application layer.
### `MetricEventSchema`
`name` is required and non-empty. `value` defaults to `undefined` (the
manager treats this as `1` for conversion counting). `properties` is an
open record accepting any JSON-compatible value.
### Inferred Types
`VariantInput`, `TargetingRuleInput`, `ExperimentDefinitionInput`, and
`MetricEventInput` are `z.infer<>` aliases of the corresponding schemas.
They are interchangeable with the handwritten interfaces in `common.ts`
because Zod inference produces structurally compatible types.
## Implementation Notes
All schemas use `.optional()` for nullable/absent fields rather than
`.nullable()` because the domain model uses `undefined` rather than `null`
for absent fields, keeping serialisation round-trips clean. The `value` field
on `TargetingRuleSchema` intentionally uses `z.unknown()` to allow the full
range of operator comparisons (arrays for `in`/`notIn`, numbers for
`gt`/`lt`/`gte`/`lte`, and arbitrary scalars for `eq`/`neq`).

View File

@@ -0,0 +1,61 @@
import z from "zod";
// ---------------------------------------------------------------------------
// Variant schema
// ---------------------------------------------------------------------------
export const VariantSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
weight: z.number(),
isControl: z.boolean().optional(),
});
// ---------------------------------------------------------------------------
// TargetingRule schema
// ---------------------------------------------------------------------------
export const TargetingRuleSchema = z.object({
attribute: z.string().min(1),
operator: z.enum(["eq", "neq", "in", "notIn", "gt", "lt", "gte", "lte"]),
value: z.unknown(),
});
// ---------------------------------------------------------------------------
// ExperimentDefinition schema
// ---------------------------------------------------------------------------
export const ExperimentDefinitionSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
status: z.enum(["draft", "running", "paused", "concluded"]),
variants: z.array(VariantSchema).min(1),
targeting: z.array(TargetingRuleSchema).optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
sampleRate: z.number().min(0).max(1).optional(),
primaryMetric: z.string().optional(),
secondaryMetrics: z.array(z.string()).optional(),
});
// ---------------------------------------------------------------------------
// MetricEvent schema
// ---------------------------------------------------------------------------
export const MetricEventSchema = z.object({
name: z.string().min(1),
value: z.number().optional(),
timestamp: z.number().optional(),
properties: z.record(z.string(), z.unknown()).optional(),
});
// ---------------------------------------------------------------------------
// Inferred types
// ---------------------------------------------------------------------------
export type VariantInput = z.infer<typeof VariantSchema>;
export type TargetingRuleInput = z.infer<typeof TargetingRuleSchema>;
export type ExperimentDefinitionInput = z.infer<
typeof ExperimentDefinitionSchema
>;
export type MetricEventInput = z.infer<typeof MetricEventSchema>;

View File

@@ -0,0 +1,168 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createExperimentManager } from "../src/core/experiment-manager/experiment-manager.js";
import {
ForceVariantProvider,
LoggingProvider,
} from "../src/providers/provider.js";
import type { ExperimentDefinition } from "../src/types/common.js";
const runningExp = (
id: string,
overrides?: Partial<ExperimentDefinition>
): ExperimentDefinition => ({
id,
name: `Experiment ${id}`,
status: "running",
variants: [
{ id: "ctrl", name: "Control", weight: 1, isControl: true },
{ id: "treat", name: "Treatment", weight: 1 },
],
primaryMetric: "conversion",
...overrides,
});
describe("end-to-end flow", () => {
it("full happy path: define → assign → track → results", {
timeout: 5000,
}, () => {
const manager = createExperimentManager();
manager.defineExperiment(runningExp("e2e"));
// Assign 10 users
const assignments = Array.from({ length: 10 }, (_, i) =>
manager.assign("e2e", `user-${i}`)
);
expect(assignments).toHaveLength(10);
expect(assignments.every((a) => a.inExperiment)).toBe(true);
// Track conversions for first 5 users
for (let i = 0; i < 5; i++) {
manager.track("e2e", `user-${i}`, {
name: "conversion",
value: 10,
});
}
const results = manager.getResults("e2e");
expect(results.totalAssignments).toBe(10);
expect(results.totalConversions).toBe(5);
expect(results.variants.length).toBeGreaterThan(0);
});
it("targeting excludes ineligible users", { timeout: 5000 }, () => {
const manager = createExperimentManager();
manager.defineExperiment(
runningExp("targeted", {
targeting: [
{ attribute: "plan", operator: "eq", value: "premium" },
],
})
);
const included = manager.assign("targeted", "premium-user", {
plan: "premium",
});
const excluded = manager.assign("targeted", "free-user", {
plan: "free",
});
expect(included.inExperiment).toBe(true);
expect(excluded.inExperiment).toBe(false);
expect(excluded.variantId).toBe("");
});
it("sample rate excludes portion of users", { timeout: 5000 }, () => {
// Use rng that always returns 0 → always included
const alwaysIn = createExperimentManager({
rng: { nextFloat: () => 0, nextUint32: () => 0 },
});
alwaysIn.defineExperiment(runningExp("sample", { sampleRate: 0.5 }));
const a1 = alwaysIn.assign("sample", "u1");
expect(a1.inExperiment).toBe(true);
// Use rng that always returns 1 → always excluded
const alwaysOut = createExperimentManager({
rng: { nextFloat: () => 1, nextUint32: () => 0xffffffff },
});
alwaysOut.defineExperiment(runningExp("sample", { sampleRate: 0.5 }));
const a2 = alwaysOut.assign("sample", "u1");
expect(a2.inExperiment).toBe(false);
});
it("ForceVariantProvider overrides bucketing", { timeout: 5000 }, () => {
const manager = createExperimentManager();
manager.defineExperiment(runningExp("force-test"));
manager.use(
new ForceVariantProvider({ "force-test": { "qa-user": "treat" } })
);
const a = manager.assign("force-test", "qa-user");
expect(a.variantId).toBe("treat");
expect(a.isControl).toBe(false);
});
it("LoggingProvider receives assignment events", { timeout: 5000 }, () => {
const received: string[] = [];
const manager = createExperimentManager();
manager.defineExperiment(runningExp("logging-test"));
manager.use(
new LoggingProvider({
onAssignment: (a) => {
received.push(a.variantId);
},
})
);
manager.assign("logging-test", "u1");
expect(received).toHaveLength(1);
});
it("LoggingProvider receives conversion events", { timeout: 5000 }, () => {
const received: string[] = [];
const manager = createExperimentManager();
manager.defineExperiment(runningExp("conv-test"));
manager.use(
new LoggingProvider({
onConversion: (expId) => {
received.push(expId);
},
})
);
manager.track("conv-test", "u1", { name: "click" });
expect(received).toHaveLength(1);
expect(received[0]).toBe("conv-test");
});
it("multiple experiments run independently", { timeout: 5000 }, () => {
const manager = createExperimentManager();
manager.defineExperiment(runningExp("exp-a"));
manager.defineExperiment(runningExp("exp-b"));
const aA = manager.assign("exp-a", "user-shared");
const aB = manager.assign("exp-b", "user-shared");
// Same user can be in different variants in different experiments
expect(aA.experimentId).toBe("exp-a");
expect(aB.experimentId).toBe("exp-b");
});
it("hashSeed changes variant assignment", { timeout: 5000 }, () => {
const m1 = createExperimentManager({ hashSeed: 0 });
const m2 = createExperimentManager({ hashSeed: 999999 });
m1.defineExperiment(runningExp("seed-test"));
m2.defineExperiment(runningExp("seed-test"));
// Run many users and verify the distributions differ (not just one user)
const results1 = new Set<string>();
const results2 = new Set<string>();
for (let i = 0; i < 100; i++) {
results1.add(m1.assign("seed-test", `s-${i}`).variantId);
results2.add(m2.assign("seed-test", `s-${i}`).variantId);
}
// Both should have multiple variants
expect(results1.size).toBeGreaterThan(0);
expect(results2.size).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,125 @@
import { describe, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import {
bucketUser,
normaliseWeights,
selectVariant,
} from "../src/bucketing/bucketing.js";
import { evaluateTargeting } from "../src/targeting/targeting.js";
describe("property: bucketUser", () => {
it("always returns [0,1) for arbitrary inputs", { timeout: 30000 }, () => {
fc.assert(
fc.property(
fc.string(),
fc.string(),
fc.integer({ min: -1_000_000, max: 1_000_000 }),
(userId, experimentId, seed) => {
const b = bucketUser(userId, experimentId, seed);
return b >= 0 && b < 1;
}
),
{ numRuns: 1000 }
);
});
it("is deterministic for the same inputs", { timeout: 30000 }, () => {
fc.assert(
fc.property(fc.string(), fc.string(), (userId, experimentId) => {
const a = bucketUser(userId, experimentId);
const b = bucketUser(userId, experimentId);
return a === b;
}),
{ numRuns: 1000 }
);
});
});
describe("property: normaliseWeights + selectVariant", () => {
const positiveVariantArb = fc.record({
id: fc.string({ minLength: 1 }),
name: fc.string({ minLength: 1 }),
weight: fc.float({
min: Math.fround(0.001),
max: Math.fround(1000),
noNaN: true,
}),
});
it("selected variant always belongs to input", { timeout: 30000 }, () => {
fc.assert(
fc.property(
fc.array(positiveVariantArb, { minLength: 1, maxLength: 10 }),
fc.float({
min: Math.fround(0),
max: Math.fround(0.9999),
noNaN: true,
}),
(rawVariants, bucket) => {
const normalised = normaliseWeights(rawVariants);
if (normalised.length === 0) return true; // skip degenerate
const selected = selectVariant(normalised, bucket);
return rawVariants.some((v) => v.id === selected.id);
}
),
{ numRuns: 500 }
);
});
it("last normalised weight is always exactly 1.0", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.array(positiveVariantArb, { minLength: 1, maxLength: 20 }),
(rawVariants) => {
const normalised = normaliseWeights(rawVariants);
if (normalised.length === 0) return true;
return (
normalised[normalised.length - 1]?.normalizedWeight ===
1.0
);
}
),
{ numRuns: 500 }
);
});
});
describe("property: evaluateTargeting", () => {
it("empty rules always returns true", { timeout: 30000 }, () => {
fc.assert(
fc.property(
fc.dictionary(
fc.string(),
fc.oneof(fc.string(), fc.integer(), fc.boolean())
),
(attrs) => {
return evaluateTargeting([], attrs) === true;
}
),
{ numRuns: 500 }
);
});
it("eq rule is reflexive for present attributes", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.string({ minLength: 1 }),
fc.oneof(fc.string(), fc.integer()),
(key, value) => {
const attrs = { [key]: value };
const rule = {
attribute: key,
operator: "eq" as const,
value,
};
return evaluateTargeting([rule], attrs) === true;
}
),
{ numRuns: 500 }
);
});
});

View File

@@ -0,0 +1,8 @@
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,40 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/ab-testing",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"@suaveplan/events",
"@suaveplan/logger",
"@suaveplan/feature-flags",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/constants/**",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});