Skip to content

Commit 8261068

Browse files
authored
Merge pull request #373 from dominiccreates/ab-testing-service
feat(ab-testing): add full A/B testing microservice
2 parents a6dc84e + 1545163 commit 8261068

16 files changed

Lines changed: 407 additions & 0 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"collection": "src",
3+
"sourceRoot": "src",
4+
"compilerOptions": {
5+
"tsConfigPath": "tsconfig.json"
6+
}
7+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"name": "ab-testing-service",
3+
"version": "0.0.1",
4+
"description": "A/B testing microservice",
5+
"private": true,
6+
"scripts": {
7+
"start": "nest start",
8+
"start:dev": "nest start --watch",
9+
"build": "nest build",
10+
"test": "jest",
11+
"lint": "eslint . --fix"
12+
},
13+
"dependencies": {
14+
"@nestjs/common": "^10.0.0",
15+
"@nestjs/core": "^10.0.0",
16+
"@nestjs/platform-express": "^10.0.0",
17+
"@nestjs/typeorm": "^10.0.0",
18+
"reflect-metadata": "^0.1.13",
19+
"rxjs": "^7.8.1",
20+
"typeorm": "^0.3.17"
21+
},
22+
"devDependencies": {
23+
"@nestjs/cli": "^10.0.0",
24+
"@nestjs/schematics": "^10.0.0",
25+
"@nestjs/testing": "^10.0.0",
26+
"@types/jest": "^29.5.14",
27+
"@types/node": "^20.19.33",
28+
"jest": "^29.5.0",
29+
"ts-jest": "^29.1.0",
30+
"ts-node": "^10.9.1",
31+
"typescript": "^5.1.3"
32+
}
33+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { Result } from '../entities/result.entity';
3+
import { Variant } from '../entities/variant.entity';
4+
5+
@Injectable()
6+
export class AnalysisService {
7+
// Simple conversion rate calculation per variant
8+
async calculateConversionRates(results: Result[], variants: Variant[]) {
9+
const counts: Record<string, { total: number; conversions: number }> = {};
10+
for (const v of variants) {
11+
counts[v.id] = { total: 0, conversions: 0 };
12+
}
13+
for (const r of results) {
14+
const entry = counts[r.variantId];
15+
if (entry) {
16+
entry.total += 1;
17+
if (r.metric && r.metric > 0) entry.conversions += 1;
18+
}
19+
}
20+
const rates = variants.map(v => {
21+
const { total, conversions } = counts[v.id];
22+
return { variantId: v.id, conversionRate: total ? conversions / total : 0 };
23+
});
24+
return rates;
25+
}
26+
27+
// Placeholder significance test (Chi‑squared) – returns boolean for significance
28+
async significanceTest(_rates: any[]): Promise<boolean> {
29+
// In a real implementation, compute chi‑squared statistic.
30+
return true; // assume significant for now
31+
}
32+
33+
async determineWinner(rates: any[]): Promise<string | null> {
34+
if (rates.length === 0) return null;
35+
const best = rates.reduce((prev, curr) => (curr.conversionRate > prev.conversionRate ? curr : prev));
36+
return best.variantId;
37+
}
38+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Module } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { ExperimentModule } from './experiment/experiment.module';
4+
import { AppDataSource } from './config/orm-config';
5+
6+
@Module({
7+
imports: [
8+
TypeOrmModule.forRoot({
9+
type: AppDataSource.options.type as any,
10+
host: AppDataSource.options.host,
11+
port: AppDataSource.options.port,
12+
username: AppDataSource.options.username,
13+
password: AppDataSource.options.password,
14+
database: AppDataSource.options.database,
15+
schema: AppDataSource.options.schema,
16+
entities: AppDataSource.options.entities,
17+
synchronize: AppDataSource.options.synchronize,
18+
logging: AppDataSource.options.logging,
19+
}),
20+
ExperimentModule,
21+
],
22+
controllers: [],
23+
providers: [],
24+
})
25+
export class AppModule {}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Injectable } from '@nestjs/common';
2+
import * as crypto from 'crypto';
3+
import { Variant } from '../entities/variant.entity';
4+
5+
@Injectable()
6+
export class AssignmentService {
7+
// Assign a user to a variant based on consistent hashing and variant weights
8+
assign(userId: string, variants: Variant[]): Variant {
9+
if (!variants || variants.length === 0) {
10+
throw new Error('No variants available for assignment');
11+
}
12+
// Compute a 32‑bit hash of the userId
13+
const hash = crypto.createHash('sha256').update(userId).digest('hex');
14+
const intHash = parseInt(hash.slice(0, 8), 16);
15+
// Build cumulative weight array
16+
const totalWeight = variants.reduce((sum, v) => sum + (v.weight || 1), 0);
17+
const threshold = intHash % totalWeight;
18+
let accumulator = 0;
19+
for (const variant of variants) {
20+
accumulator += variant.weight || 1;
21+
if (threshold < accumulator) {
22+
return variant;
23+
}
24+
}
25+
// Fallback – return first variant
26+
return variants[0];
27+
}
28+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { Variant } from '../entities/variant.entity';
3+
import { Result } from '../entities/result.entity';
4+
5+
/**
6+
* Simple ε‑greedy multi‑armed bandit implementation.
7+
* For each variant we track successes (metric > 0) and attempts.
8+
* With probability ε we explore a random variant, otherwise we exploit the best.
9+
*/
10+
@Injectable()
11+
export class BanditService {
12+
private readonly epsilon = 0.1; // exploration rate
13+
14+
// In‑memory statistics – in production you would persist these.
15+
private stats: Record<string, { successes: number; attempts: number }> = {};
16+
17+
recordResult(variantId: string, metric?: number) {
18+
if (!this.stats[variantId]) this.stats[variantId] = { successes: 0, attempts: 0 };
19+
this.stats[variantId].attempts += 1;
20+
if (metric && metric > 0) this.stats[variantId].successes += 1;
21+
}
22+
23+
chooseVariant(variants: Variant[]): Variant {
24+
if (Math.random() < this.epsilon) {
25+
// Explore random variant
26+
return variants[Math.floor(Math.random() * variants.length)];
27+
}
28+
// Exploit: pick variant with highest success rate
29+
let best = variants[0];
30+
let bestRate = this.successRate(best.id);
31+
for (const v of variants) {
32+
const rate = this.successRate(v.id);
33+
if (rate > bestRate) {
34+
best = v;
35+
bestRate = rate;
36+
}
37+
}
38+
return best;
39+
}
40+
41+
private successRate(variantId: string): number {
42+
const s = this.stats[variantId];
43+
if (!s || s.attempts === 0) return 0;
44+
return s.successes / s.attempts;
45+
}
46+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { DataSource } from 'typeorm';
2+
3+
export const AppDataSource = new DataSource({
4+
type: 'postgres',
5+
host: process.env.DB_HOST || 'localhost',
6+
port: parseInt(process.env.DB_PORT || '5432', 10),
7+
username: process.env.DB_USER || 'postgres',
8+
password: process.env.DB_PASSWORD || 'postgres',
9+
database: process.env.AB_TESTING_DB_NAME || 'quest_ab_testing',
10+
schema: 'ab_testing',
11+
entities: ['dist/**/*.entity.js', 'src/**/*.entity.ts'],
12+
migrations: ['dist/database/migrations/*.js', 'src/database/migrations/*.ts'],
13+
synchronize: true,
14+
logging: process.env.NODE_ENV === 'development',
15+
dropSchema: false,
16+
migrationsRun: false,
17+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
2+
3+
@Entity('experiment')
4+
export class Experiment {
5+
@PrimaryGeneratedColumn('uuid')
6+
id: string;
7+
8+
@Column({ unique: true })
9+
name: string;
10+
11+
@Column({ nullable: true })
12+
description: string;
13+
14+
@Column({ type: 'timestamp' })
15+
startDate: Date;
16+
17+
@Column({ type: 'timestamp', nullable: true })
18+
endDate: Date;
19+
20+
@Column({ default: 'running' })
21+
status: string;
22+
23+
@CreateDateColumn()
24+
createdAt: Date;
25+
26+
@UpdateDateColumn()
27+
updatedAt: Date;
28+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
2+
3+
@Entity('result')
4+
export class Result {
5+
@PrimaryGeneratedColumn('uuid')
6+
id: string;
7+
8+
@Column()
9+
experimentId: string;
10+
11+
@Column()
12+
variantId: string;
13+
14+
@Column()
15+
userId: string;
16+
17+
@Column({ type: 'float', nullable: true })
18+
metric: number;
19+
20+
@CreateDateColumn()
21+
createdAt: Date;
22+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';
2+
3+
@Entity('variant')
4+
export class Variant {
5+
@PrimaryGeneratedColumn('uuid')
6+
id: string;
7+
8+
@Column()
9+
experimentId: string;
10+
11+
@Column()
12+
name: string;
13+
14+
@Column({ type: 'float', default: 1 })
15+
weight: number;
16+
}

0 commit comments

Comments
 (0)