feat(ab-testing): bucketing, statistics, guardrails, routes [1.2][2.1][2.2]
This commit is contained in:
parent
917ea03af9
commit
783067e17d
@ -16,9 +16,9 @@ import {
|
||||
validateAA,
|
||||
calculateSampleSize,
|
||||
} from './statistics.js';
|
||||
import { runGuardrails, canAutoPromote, evaluateAutoPromotion } from './guardrails.js';
|
||||
import { runGuardrails, canAutoPromote } from './guardrails.js';
|
||||
import { matchesTargeting } from './targeting.js';
|
||||
import type { VariantDoc, ExperimentDoc, MetricType } from './types.js';
|
||||
import type { VariantDoc, ExperimentDoc } from './types.js';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Bucketing Tests
|
||||
@ -128,7 +128,9 @@ describe('matchesTargeting', () => {
|
||||
|
||||
it('matches version range', () => {
|
||||
expect(matchesTargeting({ appVersion: '1.5.0' }, { appVersions: { min: '1.0.0' } })).toBe(true);
|
||||
expect(matchesTargeting({ appVersion: '0.5.0' }, { appVersions: { min: '1.0.0' } })).toBe(false);
|
||||
expect(matchesTargeting({ appVersion: '0.5.0' }, { appVersions: { min: '1.0.0' } })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('matches user segments', () => {
|
||||
@ -137,14 +139,18 @@ describe('matchesTargeting', () => {
|
||||
});
|
||||
|
||||
it('matches user properties', () => {
|
||||
expect(matchesTargeting(
|
||||
{ userProperties: { tier: 'premium' } },
|
||||
{ userProperties: { tier: 'premium' } }
|
||||
)).toBe(true);
|
||||
expect(matchesTargeting(
|
||||
{ userProperties: { tier: 'basic' } },
|
||||
{ userProperties: { tier: 'premium' } }
|
||||
)).toBe(false);
|
||||
expect(
|
||||
matchesTargeting(
|
||||
{ userProperties: { tier: 'premium' } },
|
||||
{ userProperties: { tier: 'premium' } }
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesTargeting(
|
||||
{ userProperties: { tier: 'basic' } },
|
||||
{ userProperties: { tier: 'premium' } }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -217,11 +223,17 @@ function createMockExperiment(overrides: Partial<ExperimentDoc> = {}): Experimen
|
||||
describe('Beta Distribution', () => {
|
||||
it('computes correct parameters from variant', () => {
|
||||
const variant = createMockVariant({
|
||||
stats: { participants: 100, events: 50, primaryMetricValue: 0.1, conversions: 10, conversionRate: 0.1 },
|
||||
stats: {
|
||||
participants: 100,
|
||||
events: 50,
|
||||
primaryMetricValue: 0.1,
|
||||
conversions: 10,
|
||||
conversionRate: 0.1,
|
||||
},
|
||||
});
|
||||
const beta = betaFromVariant(variant);
|
||||
expect(beta.alpha).toBe(11); // conversions + 1
|
||||
expect(beta.beta).toBe(91); // failures + 1
|
||||
expect(beta.beta).toBe(91); // failures + 1
|
||||
});
|
||||
|
||||
it('generates credible interval', () => {
|
||||
@ -242,10 +254,22 @@ describe('Beta Distribution', () => {
|
||||
describe('Probability Calculations', () => {
|
||||
it('calculates probability variant beats control', () => {
|
||||
const control = createMockVariant({
|
||||
stats: { participants: 100, events: 50, primaryMetricValue: 0.1, conversions: 10, conversionRate: 0.1 },
|
||||
stats: {
|
||||
participants: 100,
|
||||
events: 50,
|
||||
primaryMetricValue: 0.1,
|
||||
conversions: 10,
|
||||
conversionRate: 0.1,
|
||||
},
|
||||
});
|
||||
const variant = createMockVariant({
|
||||
stats: { participants: 100, events: 50, primaryMetricValue: 0.2, conversions: 20, conversionRate: 0.2 },
|
||||
stats: {
|
||||
participants: 100,
|
||||
events: 50,
|
||||
primaryMetricValue: 0.2,
|
||||
conversions: 20,
|
||||
conversionRate: 0.2,
|
||||
},
|
||||
});
|
||||
|
||||
const prob = probabilityVariantBeatsControl(variant, control, 'conversion', 5000);
|
||||
@ -255,9 +279,36 @@ describe('Probability Calculations', () => {
|
||||
|
||||
it('calculates probability variant beats all', () => {
|
||||
const variants = [
|
||||
createMockVariant({ id: 'v1', stats: { participants: 100, events: 50, primaryMetricValue: 0.1, conversions: 10, conversionRate: 0.1 } }),
|
||||
createMockVariant({ id: 'v2', stats: { participants: 100, events: 50, primaryMetricValue: 0.2, conversions: 20, conversionRate: 0.2 } }),
|
||||
createMockVariant({ id: 'v3', stats: { participants: 100, events: 50, primaryMetricValue: 0.15, conversions: 15, conversionRate: 0.15 } }),
|
||||
createMockVariant({
|
||||
id: 'v1',
|
||||
stats: {
|
||||
participants: 100,
|
||||
events: 50,
|
||||
primaryMetricValue: 0.1,
|
||||
conversions: 10,
|
||||
conversionRate: 0.1,
|
||||
},
|
||||
}),
|
||||
createMockVariant({
|
||||
id: 'v2',
|
||||
stats: {
|
||||
participants: 100,
|
||||
events: 50,
|
||||
primaryMetricValue: 0.2,
|
||||
conversions: 20,
|
||||
conversionRate: 0.2,
|
||||
},
|
||||
}),
|
||||
createMockVariant({
|
||||
id: 'v3',
|
||||
stats: {
|
||||
participants: 100,
|
||||
events: 50,
|
||||
primaryMetricValue: 0.15,
|
||||
conversions: 15,
|
||||
conversionRate: 0.15,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const prob = probabilityVariantBeatsAll(variants[1], variants, 'conversion', 5000);
|
||||
@ -267,8 +318,24 @@ describe('Probability Calculations', () => {
|
||||
|
||||
it('calculates expected loss', () => {
|
||||
const variants = [
|
||||
createMockVariant({ stats: { participants: 100, events: 50, primaryMetricValue: 0.1, conversions: 10, conversionRate: 0.1 } }),
|
||||
createMockVariant({ stats: { participants: 100, events: 50, primaryMetricValue: 0.1, conversions: 10, conversionRate: 0.1 }),
|
||||
createMockVariant({
|
||||
stats: {
|
||||
participants: 100,
|
||||
events: 50,
|
||||
primaryMetricValue: 0.1,
|
||||
conversions: 10,
|
||||
conversionRate: 0.1,
|
||||
},
|
||||
}),
|
||||
createMockVariant({
|
||||
stats: {
|
||||
participants: 100,
|
||||
events: 50,
|
||||
primaryMetricValue: 0.1,
|
||||
conversions: 10,
|
||||
conversionRate: 0.1,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const loss = expectedLossIfChosen(variants[0], variants, 'conversion', 5000);
|
||||
@ -285,8 +352,25 @@ describe('checkEarlyStopping', () => {
|
||||
it('does not stop before minimum sample size', () => {
|
||||
const experiment = createMockExperiment();
|
||||
const variants = [
|
||||
createMockVariant({ isControl: true, stats: { participants: 50, events: 25, primaryMetricValue: 0.1, conversions: 5, conversionRate: 0.1 } }),
|
||||
createMockVariant({ stats: { participants: 50, events: 25, primaryMetricValue: 0.5, conversions: 25, conversionRate: 0.5 } }),
|
||||
createMockVariant({
|
||||
isControl: true,
|
||||
stats: {
|
||||
participants: 50,
|
||||
events: 25,
|
||||
primaryMetricValue: 0.1,
|
||||
conversions: 5,
|
||||
conversionRate: 0.1,
|
||||
},
|
||||
}),
|
||||
createMockVariant({
|
||||
stats: {
|
||||
participants: 50,
|
||||
events: 25,
|
||||
primaryMetricValue: 0.5,
|
||||
conversions: 25,
|
||||
conversionRate: 0.5,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const result = checkEarlyStopping(experiment, variants, 5);
|
||||
@ -296,8 +380,29 @@ describe('checkEarlyStopping', () => {
|
||||
it('stops when winner is clear', () => {
|
||||
const experiment = createMockExperiment();
|
||||
const variants = [
|
||||
createMockVariant({ isControl: true, stats: { participants: 1000, events: 500, primaryMetricValue: 0.1, conversions: 100, conversionRate: 0.1, betaAlpha: 101, betaBeta: 901 } }),
|
||||
createMockVariant({ stats: { participants: 1000, events: 500, primaryMetricValue: 0.5, conversions: 500, conversionRate: 0.5, betaAlpha: 501, betaBeta: 501 } }),
|
||||
createMockVariant({
|
||||
isControl: true,
|
||||
stats: {
|
||||
participants: 1000,
|
||||
events: 500,
|
||||
primaryMetricValue: 0.1,
|
||||
conversions: 100,
|
||||
conversionRate: 0.1,
|
||||
betaAlpha: 101,
|
||||
betaBeta: 901,
|
||||
},
|
||||
}),
|
||||
createMockVariant({
|
||||
stats: {
|
||||
participants: 1000,
|
||||
events: 500,
|
||||
primaryMetricValue: 0.5,
|
||||
conversions: 500,
|
||||
conversionRate: 0.5,
|
||||
betaAlpha: 501,
|
||||
betaBeta: 501,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const result = checkEarlyStopping(experiment, variants, 10);
|
||||
@ -310,8 +415,25 @@ describe('checkEarlyStopping', () => {
|
||||
guardrails: { ...createMockExperiment().guardrails, maxDurationDays: 14 },
|
||||
});
|
||||
const variants = [
|
||||
createMockVariant({ isControl: true, stats: { participants: 200, events: 100, primaryMetricValue: 0.1, conversions: 20, conversionRate: 0.1 } }),
|
||||
createMockVariant({ stats: { participants: 200, events: 100, primaryMetricValue: 0.11, conversions: 22, conversionRate: 0.11 } }),
|
||||
createMockVariant({
|
||||
isControl: true,
|
||||
stats: {
|
||||
participants: 200,
|
||||
events: 100,
|
||||
primaryMetricValue: 0.1,
|
||||
conversions: 20,
|
||||
conversionRate: 0.1,
|
||||
},
|
||||
}),
|
||||
createMockVariant({
|
||||
stats: {
|
||||
participants: 200,
|
||||
events: 100,
|
||||
primaryMetricValue: 0.11,
|
||||
conversions: 22,
|
||||
conversionRate: 0.11,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const result = checkEarlyStopping(experiment, variants, 30);
|
||||
@ -328,8 +450,25 @@ describe('runGuardrails', () => {
|
||||
it('requires minimum sample size', () => {
|
||||
const experiment = createMockExperiment();
|
||||
const variants = [
|
||||
createMockVariant({ isControl: true, stats: { participants: 50, events: 25, primaryMetricValue: 0.1, conversions: 5, conversionRate: 0.1 } }),
|
||||
createMockVariant({ stats: { participants: 50, events: 25, primaryMetricValue: 0.5, conversions: 25, conversionRate: 0.5 } }),
|
||||
createMockVariant({
|
||||
isControl: true,
|
||||
stats: {
|
||||
participants: 50,
|
||||
events: 25,
|
||||
primaryMetricValue: 0.1,
|
||||
conversions: 5,
|
||||
conversionRate: 0.1,
|
||||
},
|
||||
}),
|
||||
createMockVariant({
|
||||
stats: {
|
||||
participants: 50,
|
||||
events: 25,
|
||||
primaryMetricValue: 0.5,
|
||||
conversions: 25,
|
||||
conversionRate: 0.5,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const checks = runGuardrails(experiment, variants, 10, false);
|
||||
@ -344,8 +483,25 @@ describe('runGuardrails', () => {
|
||||
primaryMetric: { ...createMockExperiment().primaryMetric, type: 'revenue' },
|
||||
});
|
||||
const variants = [
|
||||
createMockVariant({ isControl: true, stats: { participants: 200, events: 100, primaryMetricValue: 10, conversions: 50, conversionRate: 0.25 } }),
|
||||
createMockVariant({ stats: { participants: 200, events: 100, primaryMetricValue: 15, conversions: 75, conversionRate: 0.375 } }),
|
||||
createMockVariant({
|
||||
isControl: true,
|
||||
stats: {
|
||||
participants: 200,
|
||||
events: 100,
|
||||
primaryMetricValue: 10,
|
||||
conversions: 50,
|
||||
conversionRate: 0.25,
|
||||
},
|
||||
}),
|
||||
createMockVariant({
|
||||
stats: {
|
||||
participants: 200,
|
||||
events: 100,
|
||||
primaryMetricValue: 15,
|
||||
conversions: 75,
|
||||
conversionRate: 0.375,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const checks = runGuardrails(experiment, variants, 10, true);
|
||||
@ -388,8 +544,29 @@ describe('generateExperimentResult', () => {
|
||||
it('generates results with variant comparisons', () => {
|
||||
const experiment = createMockExperiment();
|
||||
const variants = [
|
||||
createMockVariant({ id: 'var_control', isControl: true, name: 'Control', stats: { participants: 200, events: 100, primaryMetricValue: 0.1, conversions: 20, conversionRate: 0.1 } }),
|
||||
createMockVariant({ id: 'var_test', name: 'Test', stats: { participants: 200, events: 100, primaryMetricValue: 0.15, conversions: 30, conversionRate: 0.15 } }),
|
||||
createMockVariant({
|
||||
id: 'var_control',
|
||||
isControl: true,
|
||||
name: 'Control',
|
||||
stats: {
|
||||
participants: 200,
|
||||
events: 100,
|
||||
primaryMetricValue: 0.1,
|
||||
conversions: 20,
|
||||
conversionRate: 0.1,
|
||||
},
|
||||
}),
|
||||
createMockVariant({
|
||||
id: 'var_test',
|
||||
name: 'Test',
|
||||
stats: {
|
||||
participants: 200,
|
||||
events: 100,
|
||||
primaryMetricValue: 0.15,
|
||||
conversions: 30,
|
||||
conversionRate: 0.15,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const result = generateExperimentResult(experiment, variants, 14);
|
||||
@ -403,8 +580,32 @@ describe('generateExperimentResult', () => {
|
||||
it('identifies winner when statistically significant', () => {
|
||||
const experiment = createMockExperiment();
|
||||
const variants = [
|
||||
createMockVariant({ id: 'var_control', isControl: true, stats: { participants: 1000, events: 500, primaryMetricValue: 0.1, conversions: 100, conversionRate: 0.1, betaAlpha: 101, betaBeta: 901 } }),
|
||||
createMockVariant({ id: 'var_test', name: 'Test', stats: { participants: 1000, events: 500, primaryMetricValue: 0.5, conversions: 500, conversionRate: 0.5, betaAlpha: 501, betaBeta: 501 } }),
|
||||
createMockVariant({
|
||||
id: 'var_control',
|
||||
isControl: true,
|
||||
stats: {
|
||||
participants: 1000,
|
||||
events: 500,
|
||||
primaryMetricValue: 0.1,
|
||||
conversions: 100,
|
||||
conversionRate: 0.1,
|
||||
betaAlpha: 101,
|
||||
betaBeta: 901,
|
||||
},
|
||||
}),
|
||||
createMockVariant({
|
||||
id: 'var_test',
|
||||
name: 'Test',
|
||||
stats: {
|
||||
participants: 1000,
|
||||
events: 500,
|
||||
primaryMetricValue: 0.5,
|
||||
conversions: 500,
|
||||
conversionRate: 0.5,
|
||||
betaAlpha: 501,
|
||||
betaBeta: 501,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const result = generateExperimentResult(experiment, variants, 14);
|
||||
@ -461,12 +662,28 @@ describe('assignByStrategy', () => {
|
||||
const mockControl: VariantDoc = createMockVariant({
|
||||
id: 'var_control',
|
||||
isControl: true,
|
||||
stats: { participants: 100, events: 50, primaryMetricValue: 0.1, conversions: 10, conversionRate: 0.1, betaAlpha: 11, betaBeta: 91 },
|
||||
stats: {
|
||||
participants: 100,
|
||||
events: 50,
|
||||
primaryMetricValue: 0.1,
|
||||
conversions: 10,
|
||||
conversionRate: 0.1,
|
||||
betaAlpha: 11,
|
||||
betaBeta: 91,
|
||||
},
|
||||
});
|
||||
|
||||
const mockVariant: VariantDoc = createMockVariant({
|
||||
id: 'var_test',
|
||||
stats: { participants: 100, events: 50, primaryMetricValue: 0.2, conversions: 20, conversionRate: 0.2, betaAlpha: 21, betaBeta: 81 },
|
||||
stats: {
|
||||
participants: 100,
|
||||
events: 50,
|
||||
primaryMetricValue: 0.2,
|
||||
conversions: 20,
|
||||
conversionRate: 0.2,
|
||||
betaAlpha: 21,
|
||||
betaBeta: 81,
|
||||
},
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
|
||||
@ -58,6 +58,7 @@ import { maintenanceRoutes } from './modules/maintenance/routes.js';
|
||||
import { exportRoutes } from './modules/exports/routes.js';
|
||||
import { ipRuleRoutes } from './modules/ip-rules/routes.js';
|
||||
import { experimentRoutes } from './modules/experiments/routes.js';
|
||||
import { abTestingRoutes } from './modules/ab-testing/routes.js';
|
||||
import { analyticsRoutes } from './modules/analytics/routes.js';
|
||||
import { feedbackRoutes } from './modules/feedback/routes.js';
|
||||
import { impersonationRoutes } from './modules/impersonation/routes.js';
|
||||
@ -166,6 +167,7 @@ await app.register(exportRoutes, { prefix: '/api' });
|
||||
await app.register(ipRuleRoutes, { prefix: '/api' });
|
||||
// P2 — Product Intelligence
|
||||
await app.register(experimentRoutes, { prefix: '/api' });
|
||||
await app.register(abTestingRoutes, { prefix: '/api' });
|
||||
await app.register(analyticsRoutes, { prefix: '/api' });
|
||||
await app.register(feedbackRoutes, { prefix: '/api' });
|
||||
await app.register(impersonationRoutes, { prefix: '/api' });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user