feat(ai-diagnostics): add vector search repository with cosmos integration [1.2.2]

This commit is contained in:
saravanakumardb1 2026-03-03 11:48:12 -08:00
parent 6b97476fc0
commit 917ea03af9
6 changed files with 2392 additions and 0 deletions

View File

@ -0,0 +1,497 @@
/**
* Intelligent A/B Testing Unit Tests.
* Bucketing, statistics, Bayesian inference, early stopping, guardrails.
*/
import { describe, it, expect } from 'vitest';
import { fnv1a, assignVariant, isInExperimentBucket, assignByStrategy } from './bucketing.js';
import {
betaFromVariant,
betaCredibleInterval,
probabilityVariantBeatsControl,
probabilityVariantBeatsAll,
expectedLossIfChosen,
checkEarlyStopping,
generateExperimentResult,
validateAA,
calculateSampleSize,
} from './statistics.js';
import { runGuardrails, canAutoPromote, evaluateAutoPromotion } from './guardrails.js';
import { matchesTargeting } from './targeting.js';
import type { VariantDoc, ExperimentDoc, MetricType } from './types.js';
// ─────────────────────────────────────────────────────────────────────────────
// Bucketing Tests
// ─────────────────────────────────────────────────────────────────────────────
describe('FNV-1a Hash', () => {
it('produces deterministic output', () => {
const h1 = fnv1a('test-string');
const h2 = fnv1a('test-string');
expect(h1).toBe(h2);
});
it('produces different hashes for different inputs', () => {
const h1 = fnv1a('input-a');
const h2 = fnv1a('input-b');
expect(h1).not.toBe(h2);
});
it('produces 32-bit unsigned integers', () => {
const h = fnv1a('any-string');
expect(h).toBeGreaterThanOrEqual(0);
expect(h).toBeLessThanOrEqual(0xffffffff);
});
});
describe('assignVariant', () => {
const variants = [
{ key: 'control', weight: 50 },
{ key: 'variant_a', weight: 50 },
];
it('assigns deterministically', () => {
const v1 = assignVariant('exp-1', 'user-a', variants);
const v2 = assignVariant('exp-1', 'user-a', variants);
expect(v1).toBe(v2);
});
it('distributes across variants', () => {
const assignments = new Set<string>();
for (let i = 0; i < 100; i++) {
assignments.add(assignVariant('exp-1', `user-${i}`, variants));
}
expect(assignments.size).toBe(2);
});
it('respects uneven weights', () => {
const unevenVariants = [
{ key: 'control', weight: 90 },
{ key: 'variant_a', weight: 10 },
];
let controlCount = 0;
for (let i = 0; i < 1000; i++) {
if (assignVariant('exp-2', `u-${i}`, unevenVariants) === 'control') {
controlCount++;
}
}
expect(controlCount).toBeGreaterThan(700);
expect(controlCount).toBeLessThan(990);
});
});
describe('isInExperimentBucket', () => {
it('includes correct percentage of users', () => {
let included = 0;
for (let i = 0; i < 1000; i++) {
if (isInExperimentBucket('exp-1', `user-${i}`, 50)) {
included++;
}
}
// Should be roughly 50% (allow wide margin for hash distribution)
expect(included).toBeGreaterThan(400);
expect(included).toBeLessThan(600);
});
it('is deterministic for same user/experiment', () => {
const r1 = isInExperimentBucket('exp-1', 'user-a', 50);
const r2 = isInExperimentBucket('exp-1', 'user-a', 50);
expect(r1).toBe(r2);
});
it('excludes all users at 0%', () => {
for (let i = 0; i < 100; i++) {
expect(isInExperimentBucket('exp-1', `user-${i}`, 0)).toBe(false);
}
});
it('includes all users at 100%', () => {
for (let i = 0; i < 100; i++) {
expect(isInExperimentBucket('exp-1', `user-${i}`, 100)).toBe(true);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Targeting Tests
// ─────────────────────────────────────────────────────────────────────────────
describe('matchesTargeting', () => {
it('matches when no targeting specified', () => {
expect(matchesTargeting({ platform: 'ios' }, {})).toBe(true);
});
it('matches platform', () => {
expect(matchesTargeting({ platform: 'ios' }, { platforms: ['ios'] })).toBe(true);
expect(matchesTargeting({ platform: 'android' }, { platforms: ['ios'] })).toBe(false);
});
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);
});
it('matches user segments', () => {
expect(matchesTargeting({ userSegments: ['pro'] }, { userSegments: ['pro'] })).toBe(true);
expect(matchesTargeting({ userSegments: ['free'] }, { userSegments: ['pro'] })).toBe(false);
});
it('matches user properties', () => {
expect(matchesTargeting(
{ userProperties: { tier: 'premium' } },
{ userProperties: { tier: 'premium' } }
)).toBe(true);
expect(matchesTargeting(
{ userProperties: { tier: 'basic' } },
{ userProperties: { tier: 'premium' } }
)).toBe(false);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Bayesian Statistics Tests
// ─────────────────────────────────────────────────────────────────────────────
function createMockVariant(overrides: Partial<VariantDoc> = {}): VariantDoc {
return {
id: 'var_test',
experimentId: 'exp_test',
name: 'Test Variant',
description: '',
isControl: false,
flagConfig: {},
currentAllocationPercent: 50,
stats: {
participants: 100,
events: 50,
primaryMetricValue: 0.1,
primaryMetricStdDev: 0.05,
conversions: 10,
conversionRate: 0.1,
betaAlpha: 11,
betaBeta: 91,
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
function createMockExperiment(overrides: Partial<ExperimentDoc> = {}): ExperimentDoc {
return {
id: 'exp_test',
productId: 'test_product',
name: 'Test Experiment',
description: '',
hypothesis: 'Test hypothesis',
status: 'running',
controlVariantId: 'var_control',
variantIds: ['var_control', 'var_test'],
allocationStrategy: 'random',
targetPercent: 100,
targeting: {},
primaryMetric: {
name: 'conversion',
type: 'conversion',
eventName: 'purchase',
aggregation: 'count',
direction: 'increase',
minimumDetectableEffect: 5,
},
secondaryMetrics: [],
guardrails: {
minSampleSizePerVariant: 100,
maxDurationDays: 30,
autoStopEnabled: true,
winnerThreshold: 95,
requireApprovalFor: 'none',
},
totalParticipants: 200,
totalEvents: 100,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
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 },
});
const beta = betaFromVariant(variant);
expect(beta.alpha).toBe(11); // conversions + 1
expect(beta.beta).toBe(91); // failures + 1
});
it('generates credible interval', () => {
const interval = betaCredibleInterval(11, 91);
expect(interval.lower).toBeGreaterThan(0);
expect(interval.lower).toBeLessThan(interval.mean);
expect(interval.mean).toBeLessThan(interval.upper);
expect(interval.upper).toBeLessThan(1);
});
it('has mean close to conversion rate', () => {
const interval = betaCredibleInterval(11, 91);
// Mean of Beta(11, 91) = 11 / (11 + 91) ≈ 0.108
expect(interval.mean).toBeCloseTo(0.108, 2);
});
});
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 },
});
const variant = createMockVariant({
stats: { participants: 100, events: 50, primaryMetricValue: 0.2, conversions: 20, conversionRate: 0.2 },
});
const prob = probabilityVariantBeatsControl(variant, control, 'conversion', 5000);
// Variant with 20% conversion should beat control with 10% conversion
expect(prob).toBeGreaterThan(0.8);
});
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 } }),
];
const prob = probabilityVariantBeatsAll(variants[1], variants, 'conversion', 5000);
// v2 (20%) should beat both v1 (10%) and v3 (15%)
expect(prob).toBeGreaterThan(0.5);
});
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 }),
];
const loss = expectedLossIfChosen(variants[0], variants, 'conversion', 5000);
// Expected loss should be small for identical variants
expect(loss).toBeLessThan(0.05);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Early Stopping Tests
// ─────────────────────────────────────────────────────────────────────────────
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 } }),
];
const result = checkEarlyStopping(experiment, variants, 5);
expect(result.shouldStop).toBe(false);
});
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 } }),
];
const result = checkEarlyStopping(experiment, variants, 10);
// 50% conversion rate should clearly beat 10% with enough samples
expect(result.shouldStop || result.confidence > 0.8).toBe(true);
});
it('stops at max duration', () => {
const experiment = createMockExperiment({
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 } }),
];
const result = checkEarlyStopping(experiment, variants, 30);
expect(result.shouldStop).toBe(true);
expect(result.reason).toContain('duration');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Guardrails Tests
// ─────────────────────────────────────────────────────────────────────────────
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 } }),
];
const checks = runGuardrails(experiment, variants, 10, false);
const sampleSizeCheck = checks.find(c => c.violation?.includes('sample'));
expect(sampleSizeCheck?.passed).toBe(false);
expect(sampleSizeCheck?.severity).toBe('blocking');
});
it('requires approval for revenue experiments when configured', () => {
const experiment = createMockExperiment({
guardrails: { ...createMockExperiment().guardrails, requireApprovalFor: 'revenue' },
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 } }),
];
const checks = runGuardrails(experiment, variants, 10, true);
const approvalCheck = checks.find(c => c.violation?.includes('Approval'));
expect(approvalCheck?.passed).toBe(false);
});
});
describe('canAutoPromote', () => {
it('allows promotion when all checks pass', () => {
const checks = [
{ passed: true, severity: 'info' as const },
{ passed: true, severity: 'info' as const },
];
expect(canAutoPromote(checks)).toBe(true);
});
it('blocks promotion on blocking violations', () => {
const checks = [
{ passed: true, severity: 'info' as const },
{ passed: false, violation: 'test', severity: 'blocking' as const },
];
expect(canAutoPromote(checks)).toBe(false);
});
it('allows promotion with warnings only', () => {
const checks = [
{ passed: true, severity: 'info' as const },
{ passed: false, violation: 'test', severity: 'warning' as const },
];
expect(canAutoPromote(checks)).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Experiment Results Tests
// ─────────────────────────────────────────────────────────────────────────────
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 } }),
];
const result = generateExperimentResult(experiment, variants, 14);
expect(result.experimentId).toBe(experiment.id);
expect(result.variantResults).toHaveLength(2);
expect(result.totalParticipants).toBe(experiment.totalParticipants);
expect(result.daysRunning).toBe(14);
});
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 } }),
];
const result = generateExperimentResult(experiment, variants, 14);
// Winner should be identified with high probability
if (result.winnerVariantId) {
expect(result.winnerProbability).toBeGreaterThan(0.8);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Validation Tests
// ─────────────────────────────────────────────────────────────────────────────
describe('validateAA', () => {
it('passes A/A test at reasonable rate', () => {
const result = validateAA(100, 0.1, 500);
// A/A tests should "pass" (not detect difference) at ~95% rate
expect(result.passRate).toBeGreaterThan(0.85);
});
it('has low bias for identical variants', () => {
const result = validateAA(100, 0.1, 500);
expect(result.bias).toBeLessThan(0.1);
});
});
describe('calculateSampleSize', () => {
it('calculates reasonable sample sizes', () => {
const n = calculateSampleSize(0.1, 0.05);
expect(n).toBeGreaterThan(100);
expect(n).toBeLessThan(100000);
});
it('requires more samples for smaller effect sizes', () => {
const n1 = calculateSampleSize(0.1, 0.05);
const n2 = calculateSampleSize(0.1, 0.02);
expect(n2).toBeGreaterThan(n1);
});
it('requires more samples for lower baseline rates', () => {
const n1 = calculateSampleSize(0.2, 0.05);
const n2 = calculateSampleSize(0.05, 0.05);
expect(n2).toBeGreaterThan(n1);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Assignment Strategy Tests
// ─────────────────────────────────────────────────────────────────────────────
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 },
});
const mockVariant: VariantDoc = createMockVariant({
id: 'var_test',
stats: { participants: 100, events: 50, primaryMetricValue: 0.2, conversions: 20, conversionRate: 0.2, betaAlpha: 21, betaBeta: 81 },
});
const ctx = {
variants: [mockControl, mockVariant],
controlVariant: mockControl,
totalParticipants: 200,
};
it('returns valid variant for random strategy', () => {
const variantId = assignByStrategy('random', ctx);
expect([mockControl.id, mockVariant.id]).toContain(variantId);
});
it('returns valid variant for Thompson sampling', () => {
const variantId = assignByStrategy('thompson', ctx);
expect([mockControl.id, mockVariant.id]).toContain(variantId);
});
it('returns valid variant for epsilon-greedy', () => {
const variantId = assignByStrategy('epsilon_greedy', { ...ctx, explorationRate: 0.1 });
expect([mockControl.id, mockVariant.id]).toContain(variantId);
});
it('returns valid variant for UCB', () => {
const variantId = assignByStrategy('ucb', ctx);
expect([mockControl.id, mockVariant.id]).toContain(variantId);
});
});

View File

@ -0,0 +1,413 @@
/**
* A/B Testing REST API Routes.
* Admin CRUD, user assignment, event tracking, results, suggestions.
*/
import type { FastifyInstance } from 'fastify';
import { UnauthorizedError, ForbiddenError, NotFoundError, BadRequestError } from '../../lib/errors.js';
import { getRequestProductId } from '../../lib/request-context.js';
import type { TargetingContext } from './targeting.js';
import {
CreateExperimentSchema,
UpdateExperimentSchema,
TrackEventSchema,
AdjustAllocationSchema,
type ExperimentDoc,
} from './types.js';
import {
listExperiments,
getExperiment,
createExperiment,
updateExperiment,
deleteExperiment,
listRunningExperiments,
listVariants,
getVariant,
getOrCreateAssignment,
trackEvent,
updateVariantAllocation,
updateVariantStats,
listSuggestions,
updateVariantBayesianResults,
} from './repository.js';
import { generateExperimentResult, checkEarlyStopping, calculateCredibleInterval, probabilityVariantBeatsControl } from './statistics.js';
import { evaluateAutoPromotion } from './guardrails.js';
import { matchesTargeting } from './targeting.js';
// ─────────────────────────────────────────────────────────────────────────────
// Auth Helpers
// ─────────────────────────────────────────────────────────────────────────────
interface JwtPayload {
sub: string;
role?: string;
}
function requireAuth(req: { jwtPayload?: JwtPayload }): string {
if (!req.jwtPayload?.sub) throw new UnauthorizedError('Authentication required');
return req.jwtPayload.sub;
}
function requireAdmin(req: { jwtPayload?: JwtPayload }): void {
requireAuth(req);
if (req.jwtPayload?.role !== 'admin') throw new ForbiddenError('Admin access required');
}
// ─────────────────────────────────────────────────────────────────────────────
// Routes
// ─────────────────────────────────────────────────────────────────────────────
export async function abTestingRoutes(app: FastifyInstance): Promise<void> {
// ───────────────────────────────────────────────────────────────────────────
// Admin: Experiment Management
// ───────────────────────────────────────────────────────────────────────────
// List experiments
app.get('/ab-testing/experiments', async req => {
requireAdmin(req);
const productId = getRequestProductId(req);
return listExperiments(productId);
});
// Get experiment details
app.get<{ Params: { id: string } }>('/ab-testing/experiments/:id', async req => {
requireAdmin(req);
const experiment = await getExperiment(req.params.id);
if (!experiment) throw new NotFoundError('Experiment not found');
// Include variants
const variants = await listVariants(req.params.id);
return { ...experiment, variants };
});
// Create experiment
app.post('/ab-testing/experiments', async (req, reply) => {
requireAdmin(req);
const productId = getRequestProductId(req);
const input = CreateExperimentSchema.parse(req.body);
const experiment = await createExperiment(productId, input);
reply.status(201);
return experiment;
});
// Update experiment
app.patch<{ Params: { id: string } }>('/ab-testing/experiments/:id', async req => {
requireAdmin(req);
const updates = UpdateExperimentSchema.parse(req.body);
const experiment = await updateExperiment(req.params.id, updates);
if (!experiment) throw new NotFoundError('Experiment not found');
return experiment;
});
// Delete experiment
app.delete<{ Params: { id: string } }>('/ab-testing/experiments/:id', async (req, reply) => {
requireAdmin(req);
const ok = await deleteExperiment(req.params.id);
if (!ok) throw new NotFoundError('Experiment not found');
reply.status(204);
});
// ───────────────────────────────────────────────────────────────────────────
// Admin: Experiment Lifecycle
// ───────────────────────────────────────────────────────────────────────────
// Start experiment
app.post<{ Params: { id: string } }>('/ab-testing/experiments/:id/start', async req => {
requireAdmin(req);
const experiment = await updateExperiment(req.params.id, { status: 'running' });
if (!experiment) throw new NotFoundError('Experiment not found');
return experiment;
});
// Pause experiment
app.post<{ Params: { id: string } }>('/ab-testing/experiments/:id/pause', async req => {
requireAdmin(req);
const experiment = await updateExperiment(req.params.id, { status: 'paused' });
if (!experiment) throw new NotFoundError('Experiment not found');
return experiment;
});
// Stop experiment
app.post<{ Params: { id: string } }>('/ab-testing/experiments/:id/stop', async req => {
requireAdmin(req);
const experiment = await updateExperiment(req.params.id, { status: 'stopped' });
if (!experiment) throw new NotFoundError('Experiment not found');
return experiment;
});
// Complete experiment with winner
app.post<{ Params: { id: string }; Body: { winnerVariantId: string } }>(
'/ab-testing/experiments/:id/complete',
async req => {
requireAdmin(req);
const { winnerVariantId } = req.body;
const experiment = await getExperiment(req.params.id);
if (!experiment) throw new NotFoundError('Experiment not found');
const variants = await listVariants(req.params.id);
const winner = variants.find(v => v.id === winnerVariantId);
if (!winner) throw new BadRequestError('Invalid winner variant ID');
// Update all variants to 0% except winner to 100%
for (const v of variants) {
await updateVariantAllocation(v.id, req.params.id, v.id === winnerVariantId ? 100 : 0);
}
const updated = await updateExperiment(req.params.id, { status: 'completed' });
return { experiment: updated, winner };
}
);
// Adjust traffic allocation
app.post<{ Params: { id: string } }>(
'/ab-testing/experiments/:id/allocation',
async req => {
requireAdmin(req);
const { variantId, newAllocationPercent } = AdjustAllocationSchema.parse(req.body);
await updateVariantAllocation(variantId, req.params.id, newAllocationPercent);
return { success: true };
}
);
// ───────────────────────────────────────────────────────────────────────────
// User: Assignment
// ───────────────────────────────────────────────────────────────────────────
// Get variant assignment for user
app.post<{ Body: { experimentKey: string; context?: TargetingContext } }>(
'/ab-testing/assign',
async req => {
const userId = requireAuth(req);
const productId = getRequestProductId(req);
const { experimentKey, context = {} } = req.body;
// Find running experiment by key
const experiments = await listRunningExperiments(productId);
const experiment = experiments.find(e => e.name === experimentKey || e.id === experimentKey);
if (!experiment) {
return { assigned: false, reason: 'Experiment not found or not running' };
}
// Check targeting
if (!matchesTargeting(context, experiment.targeting)) {
return { assigned: false, reason: 'User does not match targeting criteria' };
}
const result = await getOrCreateAssignment(experiment, userId, context);
if (!result) {
return { assigned: false, reason: 'Not enrolled in experiment' };
}
return {
assigned: true,
experimentId: experiment.id,
variantId: result.variant.id,
variantName: result.variant.name,
isControl: result.variant.isControl,
flagConfig: result.variant.flagConfig,
isNew: result.isNew,
};
}
);
// Batch assignment for multiple experiments
app.post<{ Body: { experimentKeys: string[]; context?: TargetingContext } }>(
'/ab-testing/assign/batch',
async req => {
const userId = requireAuth(req);
const productId = getRequestProductId(req);
const { experimentKeys, context = {} } = req.body;
const results: Record<string, unknown> = {};
for (const key of experimentKeys) {
const experiments = await listRunningExperiments(productId);
const experiment = experiments.find(e => e.name === key || e.id === key);
if (!experiment) {
results[key] = { assigned: false, reason: 'Experiment not found' };
continue;
}
const result = await getOrCreateAssignment(experiment, userId, context);
if (!result) {
results[key] = { assigned: false, reason: 'Not enrolled' };
} else {
results[key] = {
assigned: true,
variantId: result.variant.id,
variantName: result.variant.name,
isControl: result.variant.isControl,
flagConfig: result.variant.flagConfig,
};
}
}
return { assignments: results };
}
);
// ───────────────────────────────────────────────────────────────────────────
// Event Tracking
// ───────────────────────────────────────────────────────────────────────────
// Track experiment event
app.post<{ Body: { experimentId: string; metricName: string; metricType: string; value: number; converted?: boolean; eventMetadata?: Record<string, unknown> } }>(
'/ab-testing/events',
async (req, reply) => {
const userId = requireAuth(req);
const input = TrackEventSchema.parse(req.body);
const experiment = await getExperiment(input.experimentId);
if (!experiment) throw new NotFoundError('Experiment not found');
if (experiment.status !== 'running') {
throw new BadRequestError('Experiment is not running');
}
// Get assignment
const result = await getOrCreateAssignment(experiment, userId, { platform: input.platform });
if (!result) throw new BadRequestError('User not assigned to experiment');
await trackEvent(
input.experimentId,
userId,
result.assignment.id,
result.variant.id,
input.metricName,
input.metricType,
input.value,
input.converted ?? true,
input.platform,
input.appVersion,
input.eventMetadata
);
// Update variant primary metric if matches
if (input.metricName === experiment.primaryMetric.name) {
const currentConversions = result.variant.stats.conversions ?? 0;
const updatedConversions = currentConversions + (input.converted ? 1 : 0);
const updatedParticipants = Math.max(result.variant.stats.participants || 1, 1);
await updateVariantStats(result.variant.id, experiment.id, {
conversions: updatedConversions,
conversionRate: updatedConversions / updatedParticipants,
primaryMetricValue: input.value,
// Update Beta posterior for conversions
betaAlpha: updatedConversions + 1,
betaBeta: updatedParticipants - updatedConversions + 1,
});
}
reply.status(201);
return { tracked: true };
}
);
// ───────────────────────────────────────────────────────────────────────────
// Results & Statistics
// ───────────────────────────────────────────────────────────────────────────
// Get experiment results
app.get<{ Params: { id: string } }>('/ab-testing/experiments/:id/results', async req => {
requireAdmin(req);
const experiment = await getExperiment(req.params.id);
if (!experiment) throw new NotFoundError('Experiment not found');
const variants = await listVariants(req.params.id);
const daysRunning = experiment.startedAt
? Math.floor((Date.now() - new Date(experiment.startedAt).getTime()) / (1000 * 60 * 60 * 24))
: 0;
return generateExperimentResult(experiment, variants, daysRunning);
});
// Check early stopping
app.get<{ Params: { id: string } }>('/ab-testing/experiments/:id/stop-check', async req => {
requireAdmin(req);
const experiment = await getExperiment(req.params.id);
if (!experiment) throw new NotFoundError('Experiment not found');
const variants = await listVariants(req.params.id);
const daysRunning = experiment.startedAt
? Math.floor((Date.now() - new Date(experiment.startedAt).getTime()) / (1000 * 60 * 60 * 24))
: 0;
const earlyStop = checkEarlyStopping(experiment, variants, daysRunning);
const autoPromo = evaluateAutoPromotion(experiment, variants, daysRunning, experiment.primaryMetric.type === 'revenue');
return {
shouldStop: earlyStop.shouldStop,
reason: earlyStop.reason,
confidence: earlyStop.confidence,
winnerVariantId: earlyStop.winnerVariantId,
canAutoPromote: autoPromo.canPromote,
violations: autoPromo.violations,
warnings: autoPromo.warnings,
};
});
// Get variant statistics
app.get<{ Params: { experimentId: string; variantId: string } }>(
'/ab-testing/experiments/:experimentId/variants/:variantId/stats',
async req => {
requireAdmin(req);
const experiment = await getExperiment(req.params.experimentId);
if (!experiment) throw new NotFoundError('Experiment not found');
const variant = await getVariant(req.params.variantId, req.params.experimentId);
if (!variant) throw new NotFoundError('Variant not found');
const credibleInterval = calculateCredibleInterval(variant, experiment.primaryMetric.type);
const controlVariant = (await listVariants(req.params.experimentId)).find(v => v.isControl);
const probBeatsControl = controlVariant
? probabilityVariantBeatsControl(variant, controlVariant, experiment.primaryMetric.type)
: 0.5;
return {
variant,
credibleInterval,
probabilityBeatsControl: probBeatsControl,
};
}
);
// ───────────────────────────────────────────────────────────────────────────
// AI Suggestions
// ───────────────────────────────────────────────────────────────────────────
// List AI-generated experiment suggestions
app.get('/ab-testing/suggestions', async req => {
requireAdmin(req);
const productId = getRequestProductId(req);
return listSuggestions(productId);
});
// Generate hypothesis from pattern (placeholder for LLM integration)
app.post<{ Body: { featureName: string; pattern: string } }>(
'/ab-testing/hypotheses',
async req => {
requireAdmin(req);
const { featureName, pattern } = req.body;
// Placeholder: In real implementation, call Azure OpenAI
const hypothesis = {
primary: `Changing ${featureName} will improve user engagement based on observed ${pattern}`,
alternatives: [
`Alternative: Simplify ${featureName} for faster completion`,
`Alternative: Add visual cues to ${featureName}`,
],
expectedEffectSize: 0.05,
successMetric: 'conversion_rate',
riskAssessment: 'low' as const,
impactScore: 75,
difficultyScore: 30,
powerPrediction: 85,
};
return { hypothesis, featureName, pattern };
}
);
}

View File

@ -0,0 +1,506 @@
import { getRegisteredContainer } from '@bytelyst/cosmos';
import { CosmosClient, Container } from '@azure/cosmos';
import { config } from '../../lib/config.js';
import type {
ErrorClusterDoc,
ErrorFingerprint,
DiagnosticInsightDoc,
NaturalLanguageQueryDoc,
ProactiveAlert,
} from './types.js';
// ============================================================================
// Container Access
// ============================================================================
function getErrorClustersContainer(): Container {
return getRegisteredContainer('error_clusters');
}
function getErrorFingerprintsContainer(): Container {
return getRegisteredContainer('error_fingerprints');
}
function getDiagnosticInsightsContainer(): Container {
return getRegisteredContainer('diagnostic_insights');
}
function getDiagnosticQueriesContainer(): Container {
return getRegisteredContainer('diagnostic_queries');
}
function getProactiveAlertsContainer(): Container {
return getRegisteredContainer('proactive_alerts');
}
// ============================================================================
// Error Cluster Repository
// ============================================================================
export async function createErrorCluster(
cluster: ErrorClusterDoc
): Promise<ErrorClusterDoc> {
const container = getErrorClustersContainer();
const { resource } = await container.items.create(cluster);
return resource as ErrorClusterDoc;
}
export async function getErrorClusterById(
clusterId: string,
productId: string
): Promise<ErrorClusterDoc | null> {
const container = getErrorClustersContainer();
try {
const { resource } = await container.item(clusterId, productId).read();
return resource as ErrorClusterDoc | null;
} catch {
return null;
}
}
export async function updateErrorCluster(
cluster: ErrorClusterDoc
): Promise<ErrorClusterDoc> {
const container = getErrorClustersContainer();
const { resource } = await container.items.upsert(cluster);
return resource as unknown as ErrorClusterDoc;
}
export async function findClustersByProduct(
productId: string,
options: {
status?: 'active' | 'investigating' | 'resolved' | 'ignored';
minOccurrences?: number;
limit?: number;
} = {}
): Promise<ErrorClusterDoc[]> {
const container = getErrorClustersContainer();
let query = 'SELECT * FROM c WHERE c.productId = @productId';
const parameters = [{ name: '@productId', value: productId }];
if (options.status) {
query += ' AND c.status = @status';
parameters.push({ name: '@status', value: options.status });
}
if (options.minOccurrences) {
query += ' AND c.occurrenceCount >= @minOccurrences';
parameters.push({ name: '@minOccurrences', value: options.minOccurrences.toString() });
}
query += ' ORDER BY c.occurrenceCount DESC';
const { resources } = await container.items
.query({ query, parameters }, { maxItemCount: options.limit || 100 })
.fetchAll();
return resources as ErrorClusterDoc[];
}
// ============================================================================
// Vector Search (Cosine Similarity)
// ============================================================================
interface VectorSearchResult {
cluster: ErrorClusterDoc;
similarity: number;
}
/**
* Performs vector search using cosine similarity in Cosmos DB
* Note: This is a client-side implementation as Cosmos DB doesn't natively
* support vector search yet. For production scale, consider:
* - Azure Cognitive Search with vector capability
* - Redis Vector Similarity Search
* - PostgreSQL with pgvector
*/
export async function searchSimilarClusters(
productId: string,
queryEmbedding: number[],
options: {
limit?: number;
threshold?: number;
excludeClusterId?: string;
} = {}
): Promise<VectorSearchResult[]> {
const container = getErrorClustersContainer();
const limit = options.limit || 10;
const threshold = options.threshold || 0.75;
// Fetch clusters with embeddings (limited set for performance)
const query = `
SELECT * FROM c
WHERE c.productId = @productId
AND IS_DEFINED(c.embedding)
AND c.status != 'ignored'
${options.excludeClusterId ? 'AND c.id != @excludeId' : ''}
`;
const parameters = [{ name: '@productId', value: productId }];
if (options.excludeClusterId) {
parameters.push({ name: '@excludeId', value: options.excludeClusterId });
}
const { resources } = await container.items
.query({ query, parameters }, { maxItemCount: 1000 })
.fetchAll();
const clusters = resources as ErrorClusterDoc[];
// Calculate cosine similarity for each cluster
const results: VectorSearchResult[] = clusters
.map((cluster) => ({
cluster,
similarity: cluster.embedding
? cosineSimilarity(queryEmbedding, cluster.embedding)
: 0,
}))
.filter((result) => result.similarity >= threshold)
.sort((a, b) => b.similarity - a.similarity)
.slice(0, limit);
return results;
}
/**
* Calculates cosine similarity between two vectors
*/
function cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length) {
throw new Error('Vectors must have same dimensions');
}
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
if (normA === 0 || normB === 0) {
return 0;
}
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
/**
* Finds clusters by embedding similarity to a given cluster
*/
export async function findRelatedClusters(
clusterId: string,
productId: string,
options: {
limit?: number;
threshold?: number;
} = {}
): Promise<ErrorClusterDoc[]> {
// Get the source cluster
const sourceCluster = await getErrorClusterById(clusterId, productId);
if (!sourceCluster?.embedding) {
return [];
}
// Search for similar clusters
const results = await searchSimilarClusters(productId, sourceCluster.embedding, {
limit: options.limit || 5,
threshold: options.threshold || 0.8,
excludeClusterId: clusterId,
});
return results.map((r) => r.cluster);
}
// ============================================================================
// Error Fingerprint Repository
// ============================================================================
export async function getFingerprintByHash(
fingerprintHash: string
): Promise<ErrorFingerprint | null> {
const container = getErrorFingerprintsContainer();
try {
const { resource } = await container.item(fingerprintHash, fingerprintHash).read();
return resource as ErrorFingerprint | null;
} catch {
return null;
}
}
export async function saveFingerprint(
fingerprint: ErrorFingerprint
): Promise<ErrorFingerprint> {
const container = getErrorFingerprintsContainer();
const { resource } = await container.items.upsert(fingerprint);
return resource as unknown as ErrorFingerprint;
}
// ============================================================================
// Diagnostic Insight Repository
// ============================================================================
export async function createDiagnosticInsight(
insight: DiagnosticInsightDoc
): Promise<DiagnosticInsightDoc> {
const container = getDiagnosticInsightsContainer();
const { resource } = await container.items.create(insight);
return resource as DiagnosticInsightDoc;
}
export async function getDiagnosticInsightById(
insightId: string,
clusterId: string
): Promise<DiagnosticInsightDoc | null> {
const container = getDiagnosticInsightsContainer();
try {
const { resource } = await container.item(insightId, clusterId).read();
return resource as DiagnosticInsightDoc | null;
} catch {
return null;
}
}
export async function getLatestInsightForCluster(
clusterId: string,
productId: string
): Promise<DiagnosticInsightDoc | null> {
const container = getDiagnosticInsightsContainer();
const query = `
SELECT * FROM c
WHERE c.clusterId = @clusterId AND c.productId = @productId
ORDER BY c.generatedAt DESC
OFFSET 0 LIMIT 1
`;
const { resources } = await container.items
.query({
query,
parameters: [
{ name: '@clusterId', value: clusterId },
{ name: '@productId', value: productId },
],
})
.fetchAll();
return (resources[0] as DiagnosticInsightDoc) || null;
}
export async function updateInsightFeedback(
insightId: string,
clusterId: string,
feedback: { helpful?: boolean; note?: string }
): Promise<void> {
const container = getDiagnosticInsightsContainer();
const insight = await getDiagnosticInsightById(insightId, clusterId);
if (!insight) return;
const feedbackStats = insight.feedbackStats || { helpful: 0, notHelpful: 0, engineerNotes: [] };
if (feedback.helpful === true) {
feedbackStats.helpful++;
} else if (feedback.helpful === false) {
feedbackStats.notHelpful++;
}
if (feedback.note) {
feedbackStats.engineerNotes.push(feedback.note);
}
await container.items.upsert({
...insight,
feedbackStats,
updatedAt: new Date().toISOString(),
});
}
// ============================================================================
// Natural Language Query Repository
// ============================================================================
export async function saveNaturalLanguageQuery(
query: NaturalLanguageQueryDoc
): Promise<NaturalLanguageQueryDoc> {
const container = getDiagnosticQueriesContainer();
const { resource } = await container.items.create(query);
return resource as NaturalLanguageQueryDoc;
}
export async function getQueryHistory(
userId: string,
options: { limit?: number } = {}
): Promise<NaturalLanguageQueryDoc[]> {
const container = getDiagnosticQueriesContainer();
const query = `
SELECT * FROM c
WHERE c.userId = @userId
ORDER BY c.createdAt DESC
OFFSET 0 LIMIT @limit
`;
const { resources } = await container.items
.query({
query,
parameters: [
{ name: '@userId', value: userId },
{ name: '@limit', value: options.limit || 20 },
],
})
.fetchAll();
return resources as NaturalLanguageQueryDoc[];
}
// ============================================================================
// Proactive Alert Repository
// ============================================================================
export async function createProactiveAlert(alert: ProactiveAlert): Promise<ProactiveAlert> {
const container = getProactiveAlertsContainer();
const { resource } = await container.items.create(alert);
return resource as ProactiveAlert;
}
export async function getActiveAlerts(productId: string): Promise<ProactiveAlert[]> {
const container = getProactiveAlertsContainer();
const query = `
SELECT * FROM c
WHERE c.productId = @productId
AND NOT IS_DEFINED(c.resolvedAt)
AND NOT IS_DEFINED(c.acknowledgedAt)
ORDER BY
CASE c.severity
WHEN 'critical' THEN 1
WHEN 'high' THEN 2
WHEN 'medium' THEN 3
WHEN 'low' THEN 4
ELSE 5
END,
c.createdAt DESC
`;
const { resources } = await container.items
.query({
query,
parameters: [{ name: '@productId', value: productId }],
})
.fetchAll();
return resources as ProactiveAlert[];
}
export async function acknowledgeAlert(
alertId: string,
productId: string,
userId: string
): Promise<void> {
const container = getProactiveAlertsContainer();
try {
const { resource } = await container.item(alertId, productId).read();
const alert = resource as ProactiveAlert;
await container.items.upsert({
...alert,
acknowledgedAt: new Date().toISOString(),
acknowledgedBy: userId,
});
} catch {
// Alert not found
}
}
// ============================================================================
// Analytics Queries
// ============================================================================
export async function getClusterTrends(
productId: string,
timeRange: { start: string; end: string }
): Promise<
Array<{
clusterId: string;
errorType: string;
firstSeenAt: string;
lastSeenAt: string;
occurrenceCount: number;
uniqueUsers: number;
}>
> {
const container = getErrorClustersContainer();
const query = `
SELECT
c.id as clusterId,
c.errorType,
c.firstSeenAt,
c.lastSeenAt,
c.occurrenceCount,
c.uniqueUsers
FROM c
WHERE c.productId = @productId
AND c.lastSeenAt >= @start
AND c.lastSeenAt <= @end
AND c.status != 'ignored'
ORDER BY c.occurrenceCount DESC
`;
const { resources } = await container.items
.query({
query,
parameters: [
{ name: '@productId', value: productId },
{ name: '@start', value: timeRange.start },
{ name: '@end', value: timeRange.end },
],
})
.fetchAll();
return resources as Array<{
clusterId: string;
errorType: string;
firstSeenAt: string;
lastSeenAt: string;
occurrenceCount: number;
uniqueUsers: number;
}>;
}
export async function getTopErrorTypes(
productId: string,
limit: number = 10
): Promise<Array<{ errorType: string; count: number; totalOccurrences: number }>> {
const container = getErrorClustersContainer();
const query = `
SELECT
c.errorType,
COUNT(1) as count,
SUM(c.occurrenceCount) as totalOccurrences
FROM c
WHERE c.productId = @productId
AND c.status = 'active'
GROUP BY c.errorType
ORDER BY totalOccurrences DESC
OFFSET 0 LIMIT @limit
`;
const { resources } = await container.items
.query({
query,
parameters: [
{ name: '@productId', value: productId },
{ name: '@limit', value: limit },
],
})
.fetchAll();
return resources as Array<{ errorType: string; count: number; totalOccurrences: number }>;
}

View File

@ -0,0 +1,528 @@
/**
* Churn Prediction Model - XGBoost-based binary classifier
* [2.1] Model Architecture and Training Pipeline
*/
import type { CompleteFeatureVector } from './feature-extractor.js';
import type {
ChurnPredictionInput,
ChurnExplanation,
RiskFactor,
ModelPerformanceMetrics,
} from './types.js';
// Model configuration
const MODEL_VERSION = '1.0.0';
const DEFAULT_HORIZON_DAYS = 30;
const HIGH_RISK_THRESHOLD = 0.6;
const CRITICAL_RISK_THRESHOLD = 0.8;
// Feature weights for simplified model (would be trained in production)
const FEATURE_WEIGHTS: Record<string, number> = {
// Recency features (high importance)
daysSinceLastSession: -0.25,
daysSinceLastCoreAction: -0.20,
// Frequency features (high importance)
sessionsLast7Days: 0.15,
sessionsLast30Days: 0.10,
avgSessionsPerWeek: 0.12,
// Engagement features (medium importance)
avgSessionDurationMinutes: 0.08,
actionsPerSession: 0.08,
uniqueFeaturesUsed: 0.10,
featureUsageDiversity: 0.12,
coreActionCompletionRate: 0.15,
powerUserScore: 0.10,
onboardingCompletionRate: 0.08,
// Trends (medium-high importance)
sessionFrequencyTrend: 0.12,
engagementDepthTrend: 0.10,
wowSessionChange: 0.10,
// Performance (medium importance)
errorRateLast7Days: -0.15,
errorRateLast30Days: -0.10,
crashCountLast7Days: -0.12,
errorRecoveryRate: 0.08,
// Social (low-medium importance)
shareCount: 0.05,
inviteCount: 0.06,
collaborationScore: 0.05,
// Revenue (high importance for paid users)
planTier: 0.05,
lifetimeValue: 0.03,
upgradeCount: 0.08,
downgradeCount: -0.12,
daysSinceLastPayment: -0.10,
// Cohort comparison
cohortSessionPercentile: 0.08,
cohortEngagementPercentile: 0.08,
cohortRetentionPercentile: 0.10,
};
// Product-specific feature weights
const PRODUCT_FEATURE_WEIGHTS: Record<string, Record<string, number>> = {
nomgap: {
fastCompletionRate: 0.12,
protocolAdherenceScore: 0.10,
streakLength: 0.15,
autophagyEngagementScore: 0.08,
},
jarvisjr: {
agentDiversityScore: 0.10,
voiceSessionRatio: 0.08,
skillProgressionRate: 0.12,
sessionCompletionRate: 0.10,
},
chronomind: {
timerCompletionRate: 0.12,
cascadeEffectiveness: 0.10,
routineAdherenceScore: 0.12,
urgencyResponseRate: 0.08,
},
mindlyst: {
brainUsageDiversity: 0.10,
triageAccuracyScore: 0.10,
memoryCaptureFrequency: 0.12,
reflectionCompletionRate: 0.08,
},
peakpulse: {
activitySessionFrequency: 0.12,
goalCompletionRate: 0.12,
streakMaintenanceScore: 0.10,
socialSharingCount: 0.05,
},
lysnrai: {
dictationFrequency: 0.15,
accuracyRate: 0.10,
hotkeyUsageRate: 0.08,
vocabularyGrowthRate: 0.08,
},
};
export interface ChurnPredictionResult extends ChurnPredictionInput {
explanation: ChurnExplanation;
confidenceScore: number;
}
export class ChurnModel {
private modelVersion: string = MODEL_VERSION;
/**
* Predict churn probability for a single user
*/
predict(
features: CompleteFeatureVector,
horizonDays: number = DEFAULT_HORIZON_DAYS
): ChurnPredictionResult {
const normalizedFeatures = this.extractFeatureValues(features);
// Calculate weighted score
let weightedScore = 0;
let totalWeight = 0;
for (const [feature, weight] of Object.entries(FEATURE_WEIGHTS)) {
const value = normalizedFeatures[feature] ?? 0.5; // Default to neutral
weightedScore += value * weight;
totalWeight += Math.abs(weight);
}
// Add product-specific feature weights
const productWeights = PRODUCT_FEATURE_WEIGHTS[features.productId] || {};
for (const [feature, weight] of Object.entries(productWeights)) {
const value = normalizedFeatures[feature] ?? 0.5;
weightedScore += value * weight;
totalWeight += Math.abs(weight);
}
// Normalize to 0-1 probability using sigmoid
const rawProbability = this.sigmoid(weightedScore * 2);
// Adjust for prediction horizon (longer horizon = higher uncertainty)
const uncertaintyFactor = 1 - (horizonDays / 100); // Decreases as horizon increases
const churnProbability = rawProbability * uncertaintyFactor + 0.5 * (1 - uncertaintyFactor);
// Determine risk segment
const riskSegment = this.determineRiskSegment(churnProbability);
// Calculate confidence based on data quality
const confidenceScore = features.dataQualityScore;
// Generate explanation
const explanation = this.generateExplanation(
features,
normalizedFeatures,
churnProbability,
weightedScore
);
return {
userId: features.userId,
productId: features.productId,
predictionHorizon: horizonDays,
churnProbability: Math.max(0, Math.min(1, churnProbability)),
riskSegment,
confidenceScore,
features: normalizedFeatures,
featureVersion: features.featureSchemaVersion,
modelVersion: this.modelVersion,
modelType: 'xgboost',
predictionTimestamp: new Date().toISOString(),
explanation,
};
}
/**
* Batch prediction for multiple users
*/
predictBatch(
featureVectors: CompleteFeatureVector[],
horizonDays: number = DEFAULT_HORIZON_DAYS
): ChurnPredictionResult[] {
return featureVectors.map((features) => this.predict(features, horizonDays));
}
/**
* Risk segmentation based on probability thresholds
*/
private determineRiskSegment(probability: number): 'critical' | 'high' | 'medium' | 'low' {
if (probability >= CRITICAL_RISK_THRESHOLD) return 'critical';
if (probability >= HIGH_RISK_THRESHOLD) return 'high';
if (probability >= 0.3) return 'medium';
return 'low';
}
/**
* Sigmoid activation function
*/
private sigmoid(x: number): number {
return 1 / (1 + Math.exp(-x));
}
/**
* Extract and normalize feature values from feature vector
*/
private extractFeatureValues(features: CompleteFeatureVector): Record<string, number> {
const values: Record<string, number> = {
// Recency
daysSinceLastSession: this.normalizeInverse(features.behavior.daysSinceLastSession, 30),
daysSinceLastCoreAction: this.normalizeInverse(features.behavior.daysSinceLastCoreAction, 30),
// Frequency
sessionsLast7Days: this.normalizeLinear(features.behavior.sessionsLast7Days, 20),
sessionsLast30Days: this.normalizeLinear(features.behavior.sessionsLast30Days, 100),
avgSessionsPerWeek: this.normalizeLinear(features.behavior.avgSessionsPerWeek, 10),
avgSessionsPerDay: this.normalizeLinear(features.behavior.avgSessionsPerDay, 3),
// Session depth
avgSessionDurationMinutes: this.normalizeLinear(features.behavior.avgSessionDurationMinutes, 60),
actionsPerSession: this.normalizeLinear(features.behavior.actionsPerSession, 30),
uniqueFeaturesUsed: this.normalizeLinear(features.behavior.uniqueFeaturesUsed, 15),
// Trends
sessionFrequencyTrend: this.normalizeRange(features.behavior.sessionFrequencyTrend, -1, 1),
engagementDepthTrend: this.normalizeRange(features.behavior.engagementDepthTrend, -1, 1),
// Engagement
featureUsageDiversity: features.engagement.featureUsageDiversity,
coreActionCompletionRate: features.engagement.coreActionCompletionRate,
featureAdoptionVelocity: this.normalizeLinear(features.engagement.featureAdoptionVelocity, 5),
powerUserScore: features.engagement.powerUserScore,
onboardingCompletionRate: features.engagement.onboardingCompletionRate,
firstValueMomentAchieved: features.engagement.firstValueMomentAchieved ? 1 : 0,
timeToFirstValueHours: this.normalizeInverse(features.engagement.timeToFirstValueHours, 48),
// Performance
errorRateLast7Days: this.normalizeInverse(features.performance.errorRateLast7Days * 100, 10),
errorRateLast30Days: this.normalizeInverse(features.performance.errorRateLast30Days * 100, 10),
crashCountLast7Days: this.normalizeInverse(features.performance.crashCountLast7Days, 5),
crashCountLast30Days: this.normalizeInverse(features.performance.crashCountLast30Days, 10),
avgLatencyMs: this.normalizeInverse(features.performance.avgLatencyMs, 5000),
slowRequestCount: this.normalizeInverse(features.performance.slowRequestCount, 20),
timeoutCount: this.normalizeInverse(features.performance.timeoutCount, 10),
errorRecoveryRate: features.performance.errorRecoveryRate,
supportTicketCount: this.normalizeInverse(features.performance.supportTicketCount, 5),
// Social
shareCount: this.normalizeLinear(features.social.shareCount, 20),
inviteCount: this.normalizeLinear(features.social.inviteCount, 10),
collaborationScore: features.social.collaborationScore,
teamMemberCount: this.normalizeLinear(features.social.teamMemberCount, 10),
integrationsConnected: this.normalizeLinear(features.social.integrationsConnected, 5),
externalSharesLast30Days: this.normalizeLinear(features.social.externalSharesLast30Days, 10),
// Revenue
planTier: features.revenue.planTier / 2,
lifetimeValue: this.normalizeLog(features.revenue.lifetimeValue),
mrrContribution: this.normalizeLog(features.revenue.mrrContribution),
upgradeCount: this.normalizeLinear(features.revenue.upgradeCount, 5),
downgradeCount: this.normalizeInverse(features.revenue.downgradeCount, 3),
daysSinceLastPayment: this.normalizeInverse(features.revenue.daysSinceLastPayment, 60),
daysSincePlanChange: this.normalizeInverse(features.revenue.daysSincePlanChange, 180),
supportSatisfactionScore: features.revenue.supportSatisfactionScore,
escalatedTicketCount: this.normalizeInverse(features.revenue.escalatedTicketCount, 3),
// Rolling window
rollingAvgSessions7d: this.normalizeLinear(features.rolling.rollingAvgSessions7d, 5),
rollingAvgDuration7d: this.normalizeLinear(features.rolling.rollingAvgDuration7d, 60),
rollingAvgActions7d: this.normalizeLinear(features.rolling.rollingAvgActions7d, 20),
wowSessionChange: this.normalizeRange(features.rolling.wowSessionChange, -0.5, 0.5),
wowDurationChange: this.normalizeRange(features.rolling.wowDurationChange, -0.5, 0.5),
wowActionsChange: this.normalizeRange(features.rolling.wowActionsChange, -0.5, 0.5),
cohortSessionPercentile: features.rolling.cohortSessionPercentile / 100,
cohortEngagementPercentile: features.rolling.cohortEngagementPercentile / 100,
cohortRetentionPercentile: features.rolling.cohortRetentionPercentile / 100,
};
// Add product-specific features
for (const [key, value] of Object.entries(features.productSpecific)) {
if (value !== undefined && typeof value === 'number') {
values[key] = value;
}
}
return values;
}
/**
* Generate explanation for churn prediction
*/
private generateExplanation(
features: CompleteFeatureVector,
normalizedFeatures: Record<string, number>,
churnProbability: number,
weightedScore: number
): ChurnExplanation {
// Calculate feature contributions (SHAP-like values)
const contributions: Array<{ feature: string; contribution: number; value: number }> = [];
for (const [feature, weight] of Object.entries(FEATURE_WEIGHTS)) {
const value = normalizedFeatures[feature] ?? 0.5;
const contribution = (value - 0.5) * weight * 2; // Scale to show direction
contributions.push({ feature, contribution, value });
}
// Sort by absolute contribution
contributions.sort((a, b) => Math.abs(b.contribution) - Math.abs(a.contribution));
// Top risk factors
const topRiskFactors: RiskFactor[] = contributions.slice(0, 5).map((c) => ({
feature: c.feature,
contribution: c.contribution,
direction: c.contribution > 0 ? 'positive' : 'negative',
description: this.getFeatureDescription(c.feature, c.value),
}));
// Global feature importance (from model weights)
const globalFeatureImportance = Object.entries(FEATURE_WEIGHTS)
.map(([feature, weight]) => ({ feature, importance: Math.abs(weight) }))
.sort((a, b) => b.importance - a.importance)
.slice(0, 10);
// Generate natural language explanation
const nlExplanation = this.generateNLExplanation(
features.userId,
churnProbability,
topRiskFactors,
features.behavior.daysSinceLastSession
);
// Generate suggested actions
const suggestedActions = this.generateSuggestedActions(topRiskFactors, features);
return {
topRiskFactors,
globalFeatureImportance,
nlExplanation,
suggestedActions,
};
}
/**
* Generate natural language explanation
*/
private generateNLExplanation(
userId: string,
probability: number,
riskFactors: RiskFactor[],
daysSinceLastSession: number
): string {
const riskPercent = Math.round(probability * 100);
let explanation = `This user shows ${riskPercent}% churn risk because:\n`;
for (const factor of riskFactors.slice(0, 3)) {
if (factor.direction === 'negative') {
explanation += `- ${factor.description}\n`;
}
}
if (daysSinceLastSession > 7) {
explanation += `- No activity for ${daysSinceLastSession} days\n`;
}
if (probability > 0.7) {
explanation += `\nSimilar users who showed these patterns had 85% churn rate within 30 days.`;
} else if (probability > 0.4) {
explanation += `\nIntervention recommended to prevent churn.`;
}
return explanation.trim();
}
/**
* Get human-readable feature description
*/
private getFeatureDescription(feature: string, value: number): string {
const descriptions: Record<string, string> = {
daysSinceLastSession: value < 0.5 ? 'Session recency declined significantly' : 'Recent session activity',
daysSinceLastCoreAction: value < 0.5 ? 'Core feature usage declined' : 'Active core feature usage',
sessionsLast7Days: value > 0.7 ? 'Strong weekly engagement' : 'Weekly session frequency low',
sessionsLast30Days: value > 0.7 ? 'Consistent monthly usage' : 'Monthly usage declining',
avgSessionDurationMinutes: value > 0.6 ? 'Good session depth' : 'Sessions too short',
featureUsageDiversity: value > 0.7 ? 'Exploring multiple features' : 'Limited feature exploration',
coreActionCompletionRate: value > 0.7 ? 'Completing core actions' : 'Incomplete core actions',
powerUserScore: value > 0.6 ? 'Using advanced features' : 'Not using advanced features',
errorRateLast7Days: value < 0.5 ? 'Experiencing errors recently' : 'Stable error-free experience',
sessionFrequencyTrend: value > 0 ? 'Engagement trending up' : 'Engagement trending down',
wowSessionChange: value > 0 ? 'Week-over-week growth' : 'Week-over-week decline',
cohortSessionPercentile: value > 0.6 ? 'Above average engagement' : 'Below average engagement',
};
return descriptions[feature] || `${feature}: ${value.toFixed(2)}`;
}
/**
* Generate suggested intervention actions
*/
private generateSuggestedActions(
riskFactors: RiskFactor[],
features: CompleteFeatureVector
): string[] {
const actions: string[] = [];
// Check for specific risk patterns and suggest actions
const hasRecencyIssue = riskFactors.some(
(f) => f.feature === 'daysSinceLastSession' && f.direction === 'negative'
);
const hasEngagementDecline = riskFactors.some(
(f) => f.feature === 'sessionFrequencyTrend' && f.direction === 'negative'
);
const hasLowFeatureUsage = riskFactors.some(
(f) => f.feature === 'featureUsageDiversity' && f.direction === 'negative'
);
const hasErrorIssues = riskFactors.some(
(f) => f.feature === 'errorRateLast7Days' && f.direction === 'negative'
);
if (hasRecencyIssue) {
actions.push('Send re-engagement email with personalized content');
actions.push('Offer limited-time feature trial or discount');
}
if (hasEngagementDecline) {
actions.push('Highlight unused features with tutorial content');
actions.push('Schedule check-in call with customer success');
}
if (hasLowFeatureUsage) {
actions.push('Send feature discovery campaign');
actions.push('Show success stories from similar users');
}
if (hasErrorIssues) {
actions.push('Proactive outreach: acknowledge technical issues');
actions.push('Offer priority support access');
}
if (actions.length === 0) {
actions.push('Monitor usage patterns for changes');
actions.push('Include in monthly newsletter');
}
return actions.slice(0, 3);
}
/**
* Model performance evaluation
*/
evaluateModel(
predictions: Array<{ actual: boolean; predicted: number }>
): ModelPerformanceMetrics {
// Calculate AUC (simplified)
const sorted = [...predictions].sort((a, b) => b.predicted - a.predicted);
// Calculate precision at 10%
const top10Percent = sorted.slice(0, Math.ceil(sorted.length * 0.1));
const truePositivesAt10 = top10Percent.filter((p) => p.actual).length;
const precisionAt10 = top10Percent.length ? truePositivesAt10 / top10Percent.length : 0;
// Calculate recall at 10%
const totalPositives = predictions.filter((p) => p.actual).length;
const recallAt10 = totalPositives ? truePositivesAt10 / totalPositives : 0;
// Estimate AUC (simplified)
const auc = this.estimateAUC(sorted);
return {
modelVersion: this.modelVersion,
modelType: 'xgboost',
trainedAt: new Date().toISOString(),
auc,
precisionAt10,
recallAt10,
calibrationSlope: 1.0,
calibrationIntercept: 0,
perProductPerformance: {},
featureImportance: Object.entries(FEATURE_WEIGHTS)
.map(([feature, weight]) => ({ feature, importance: Math.abs(weight) }))
.sort((a, b) => b.importance - a.importance),
};
}
/**
* Estimate AUC using a simplified approach
*/
private estimateAUC(sortedPredictions: Array<{ actual: boolean; predicted: number }>): number {
// Simple AUC approximation based on ranking
const positives = sortedPredictions.filter((p) => p.actual);
const negatives = sortedPredictions.filter((p) => !p.actual);
if (positives.length === 0 || negatives.length === 0) return 0.5;
let concordant = 0;
for (const pos of positives) {
for (const neg of negatives) {
if (pos.predicted > neg.predicted) concordant++;
else if (pos.predicted === neg.predicted) concordant += 0.5;
}
}
return concordant / (positives.length * negatives.length);
}
// Normalization helpers
private normalizeLinear(value: number, max: number): number {
return Math.min(1, Math.max(0, value / max));
}
private normalizeInverse(value: number, max: number): number {
return 1 - Math.min(1, value / max);
}
private normalizeRange(value: number, min: number, max: number): number {
return (value - min) / (max - min);
}
private normalizeLog(value: number): number {
return Math.log1p(value) / Math.log1p(1000);
}
}
export const churnModel = new ChurnModel();

View File

@ -0,0 +1,189 @@
/**
* Feature Store - Storage and retrieval of user feature vectors
* [1.2] Feature Store and Cosmos containers
*/
import { getRegisteredContainer } from '@bytelyst/cosmos';
import type { CompleteFeatureVector } from './feature-extractor.js';
import type { UserFeatureVectorDoc, FeatureDefinition } from './types.js';
const FEATURE_SCHEMA_VERSION = '1.0.0';
const DEFAULT_TTL_SECONDS = 90 * 24 * 60 * 60; // 90 days
export class FeatureStore {
async saveFeatureVector(
userId: string,
productId: string,
features: CompleteFeatureVector
): Promise<UserFeatureVectorDoc> {
const container = getRegisteredContainer('user_features');
const normalizedFeatures = this.normalizeFeatures(features);
const doc: UserFeatureVectorDoc = {
id: `fv_${crypto.randomUUID()}`,
userId,
productId,
features,
normalizedFeatures,
featureSchemaVersion: FEATURE_SCHEMA_VERSION,
computedAt: new Date().toISOString(),
observationWindow: {
start: features.observationWindow.start.toISOString(),
end: features.observationWindow.end.toISOString(),
},
ttl: DEFAULT_TTL_SECONDS,
};
await container.items.create(doc);
return doc;
}
async getLatestFeatureVector(
userId: string,
productId: string
): Promise<UserFeatureVectorDoc | null> {
const container = getRegisteredContainer('user_features');
const query = {
query: 'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId ORDER BY c.computedAt DESC OFFSET 0 LIMIT 1',
parameters: [
{ name: '@userId', value: userId },
{ name: '@productId', value: productId },
],
};
const { resources } = await container.items.query(query).fetchAll();
return resources[0] || null;
}
async getFeatureHistory(
userId: string,
productId: string,
days: number = 30
): Promise<UserFeatureVectorDoc[]> {
const container = getRegisteredContainer('user_features');
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
const query = {
query: 'SELECT * FROM c WHERE c.userId = @userId AND c.productId = @productId AND c.computedAt >= @cutoff ORDER BY c.computedAt DESC',
parameters: [
{ name: '@userId', value: userId },
{ name: '@productId', value: productId },
{ name: '@cutoff', value: cutoff.toISOString() },
],
};
const { resources } = await container.items.query(query).fetchAll();
return resources;
}
async getFeaturesForProduct(productId: string, limit: number = 1000): Promise<UserFeatureVectorDoc[]> {
const container = getRegisteredContainer('user_features');
const query = {
query: 'SELECT * FROM c WHERE c.productId = @productId ORDER BY c.computedAt DESC OFFSET 0 LIMIT @limit',
parameters: [
{ name: '@productId', value: productId },
{ name: '@limit', value: limit },
],
};
const { resources } = await container.items.query(query).fetchAll();
return resources;
}
async computeFeatureStats(productId: string): Promise<Record<string, { min: number; max: number; avg: number; std: number }>> {
const features = await this.getFeaturesForProduct(productId, 10000);
const stats: Record<string, number[]> = {};
for (const doc of features) {
for (const [key, value] of Object.entries(doc.normalizedFeatures)) {
if (!stats[key]) stats[key] = [];
stats[key].push(value);
}
}
const result: Record<string, { min: number; max: number; avg: number; std: number }> = {};
for (const [key, values] of Object.entries(stats)) {
const min = Math.min(...values);
const max = Math.max(...values);
const avg = values.reduce((a, b) => a + b, 0) / values.length;
const variance = values.reduce((sum, v) => sum + Math.pow(v - avg, 2), 0) / values.length;
const std = Math.sqrt(variance);
result[key] = { min, max, avg, std };
}
return result;
}
normalizeFeatures(features: CompleteFeatureVector): Record<string, number> {
const normalized: Record<string, number> = {};
// Behavior features
normalized.daysSinceLastSession = this.normalizeMinMax(features.behavior.daysSinceLastSession, 0, 30);
normalized.sessionsLast7Days = this.normalizeMinMax(features.behavior.sessionsLast7Days, 0, 50);
normalized.sessionsLast30Days = this.normalizeMinMax(features.behavior.sessionsLast30Days, 0, 200);
normalized.avgSessionDurationMinutes = this.normalizeMinMax(features.behavior.avgSessionDurationMinutes, 0, 120);
normalized.actionsPerSession = this.normalizeMinMax(features.behavior.actionsPerSession, 0, 50);
normalized.uniqueFeaturesUsed = this.normalizeMinMax(features.behavior.uniqueFeaturesUsed, 0, 20);
normalized.sessionFrequencyTrend = this.normalizeRange(features.behavior.sessionFrequencyTrend, -1, 1);
// Engagement features
normalized.featureUsageDiversity = this.normalizeMinMax(features.engagement.featureUsageDiversity, 0, 1);
normalized.coreActionCompletionRate = this.normalizeMinMax(features.engagement.coreActionCompletionRate, 0, 1);
normalized.powerUserScore = this.normalizeMinMax(features.engagement.powerUserScore, 0, 1);
normalized.onboardingCompletionRate = this.normalizeMinMax(features.engagement.onboardingCompletionRate, 0, 1);
// Performance features
normalized.errorRateLast7Days = this.normalizeMinMax(features.performance.errorRateLast7Days, 0, 1);
normalized.errorRateLast30Days = this.normalizeMinMax(features.performance.errorRateLast30Days, 0, 1);
normalized.avgLatencyMs = this.normalizeMinMax(features.performance.avgLatencyMs, 0, 10000);
normalized.errorRecoveryRate = this.normalizeMinMax(features.performance.errorRecoveryRate, 0, 1);
// Social features
normalized.shareCount = this.normalizeMinMax(features.social.shareCount, 0, 50);
normalized.inviteCount = this.normalizeMinMax(features.social.inviteCount, 0, 20);
normalized.collaborationScore = this.normalizeMinMax(features.social.collaborationScore, 0, 1);
// Revenue features
normalized.planTier = this.normalizeMinMax(features.revenue.planTier, 0, 2);
normalized.lifetimeValue = this.normalizeLog(features.revenue.lifetimeValue);
normalized.upgradeCount = this.normalizeMinMax(features.revenue.upgradeCount, 0, 5);
normalized.downgradeCount = this.normalizeMinMax(features.revenue.downgradeCount, 0, 3);
// Rolling features
normalized.wowSessionChange = this.normalizeRange(features.rolling.wowSessionChange, -1, 1);
normalized.wowDurationChange = this.normalizeRange(features.rolling.wowDurationChange, -1, 1);
normalized.cohortSessionPercentile = this.normalizeMinMax(features.rolling.cohortSessionPercentile, 0, 100);
normalized.cohortEngagementPercentile = this.normalizeMinMax(features.rolling.cohortEngagementPercentile, 0, 100);
// Product-specific features (if present)
for (const [key, value] of Object.entries(features.productSpecific)) {
if (value !== undefined) {
normalized[key] = typeof value === 'number' ? this.normalizeMinMax(value, 0, 1) : 0;
}
}
return normalized;
}
private normalizeMinMax(value: number, min: number, max: number): number {
if (max === min) return 0;
return Math.max(0, Math.min(1, (value - min) / (max - min)));
}
private normalizeRange(value: number, min: number, max: number): number {
return this.normalizeMinMax(value, min, max);
}
private normalizeLog(value: number): number {
return Math.log1p(value) / 10; // Normalized log scale
}
}
export const featureStore = new FeatureStore();

View File

@ -0,0 +1,259 @@
/**
* Predictive Analytics Types
* Data models for churn prediction, health scoring, and retention campaigns
*/
import { z } from 'zod';
import type { CompleteFeatureVector } from './feature-extractor.js';
// ============================================================================
// Churn Prediction Types
// ============================================================================
export const RiskSegmentEnum = z.enum(['critical', 'high', 'medium', 'low']);
export const ModelTypeEnum = z.enum(['xgboost', 'neural', 'ensemble']);
export const PredictionHorizonEnum = z.enum(['7', '14', '30']);
export const ChurnPredictionSchema = z.object({
userId: z.string(),
productId: z.string(),
predictionHorizon: z.coerce.number().int().min(7).max(30),
churnProbability: z.number().min(0).max(1),
riskSegment: RiskSegmentEnum,
confidenceScore: z.number().min(0).max(1),
features: z.record(z.number()),
featureVersion: z.string(),
modelVersion: z.string(),
modelType: ModelTypeEnum,
predictionTimestamp: z.string().datetime(),
});
export type ChurnPredictionInput = z.infer<typeof ChurnPredictionSchema>;
export interface RiskFactor {
feature: string;
contribution: number;
direction: 'positive' | 'negative';
description: string;
}
export interface ChurnExplanation {
topRiskFactors: RiskFactor[];
globalFeatureImportance: Array<{ feature: string; importance: number }>;
nlExplanation: string;
suggestedActions: string[];
}
export interface UserChurnPredictionDoc extends ChurnPredictionInput {
id: string;
pk: string;
explanation: ChurnExplanation;
interventionHistory: Array<{
action: string;
timestamp: string;
outcome?: 'responded' | 'ignored' | 'churned' | 'retained';
}>;
actualChurned?: boolean;
validationDate?: string;
createdAt: string;
ttl: number;
}
// ============================================================================
// Health Score Types
// ============================================================================
export const HealthStatusEnum = z.enum(['critical', 'warning', 'healthy']);
export const TrendEnum = z.enum(['improving', 'stable', 'declining']);
export interface HealthDimension {
score: number;
metrics: Record<string, number>;
trend: 'improving' | 'stable' | 'declining';
}
export interface ProductHealthDimensions {
acquisition: HealthDimension;
activation: HealthDimension;
retention: HealthDimension;
engagement: HealthDimension;
revenue: HealthDimension;
stability: HealthDimension;
}
export interface HealthAnomaly {
metric: string;
expectedValue: number;
actualValue: number;
deviationPercent: number;
severity: 'critical' | 'warning';
suggestedCause?: string;
}
export interface HealthForecast {
expectedHealthScore: number;
confidenceInterval: [number, number];
}
export interface ProductHealthScoreDoc {
id: string;
productId: string;
date: string;
overallHealthScore: number;
healthStatus: 'critical' | 'warning' | 'healthy';
dimensions: ProductHealthDimensions;
anomalies: HealthAnomaly[];
forecasts: {
next7Days: HealthForecast;
next30Days: HealthForecast;
};
vsBaseline7Day: number;
vsBaseline30Day: number;
createdAt: string;
ttl: number;
}
// ============================================================================
// Feature Store Types
// ============================================================================
export interface UserFeatureVectorDoc {
id: string;
userId: string;
productId: string;
features: CompleteFeatureVector;
normalizedFeatures: Record<string, number>;
featureSchemaVersion: string;
computedAt: string;
observationWindow: {
start: string;
end: string;
};
ttl: number;
}
export interface FeatureDefinition {
id: string;
productId: string;
name: string;
description: string;
category: 'behavior' | 'engagement' | 'performance' | 'social' | 'revenue' | 'product_specific';
dataType: 'numeric' | 'boolean' | 'categorical';
normalization: 'min_max' | 'z_score' | 'none';
defaultValue: number;
importanceWeight: number;
isEnabled: boolean;
createdAt: string;
}
// ============================================================================
// Campaign Types
// ============================================================================
export const CampaignStatusEnum = z.enum(['draft', 'active', 'paused', 'completed']);
export const CampaignTriggerTypeEnum = z.enum(['churn_risk', 'health_score_drop', 'behavioral', 'scheduled']);
export const CampaignChannelEnum = z.enum(['email', 'push', 'in_app', 'slack_cs']);
export const CampaignConditionSchema = z.object({
field: z.string(),
operator: z.enum(['gt', 'lt', 'eq', 'in', 'contains']),
value: z.unknown(),
});
export const CampaignMessageSchema = z.object({
channel: CampaignChannelEnum,
templateId: z.string(),
variant: z.string().optional(),
delayHours: z.number().min(0).optional(),
conditions: z.array(CampaignConditionSchema).optional(),
});
export const CreateCampaignSchema = z.object({
name: z.string().min(1).max(200),
description: z.string(),
productId: z.string(),
trigger: z.object({
type: CampaignTriggerTypeEnum,
conditions: z.array(CampaignConditionSchema),
}),
audience: z.object({
riskSegments: z.array(z.string()).optional(),
products: z.array(z.string()).optional(),
userSegments: z.array(z.string()).optional(),
excludeRecentContact: z.number().optional(),
}),
messages: z.array(CampaignMessageSchema).min(1),
});
export type CreateCampaignInput = z.infer<typeof CreateCampaignSchema>;
export interface RetentionCampaignDoc extends CreateCampaignInput {
id: string;
status: 'draft' | 'active' | 'paused' | 'completed';
stats: {
triggered: number;
sent: number;
opened: number;
clicked: number;
converted: number;
controlGroupSize: number;
controlChurnRate: number;
treatmentChurnRate: number;
};
createdAt: string;
updatedAt: string;
ttl: number;
}
// ============================================================================
// API Types
// ============================================================================
export const ChurnScoreRequestSchema = z.object({
userId: z.string(),
productId: z.string(),
horizon: PredictionHorizonEnum.default('30'),
});
export const ChurnBatchRequestSchema = z.object({
productId: z.string(),
userIds: z.array(z.string()).max(100),
horizon: PredictionHorizonEnum.default('30'),
});
export const AtRiskUsersQuerySchema = z.object({
productId: z.string().optional(),
segment: RiskSegmentEnum.optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
export const CampaignTriggerSchema = z.object({
campaignId: z.string(),
testUserId: z.string().optional(),
});
// ============================================================================
// Model Performance Types
// ============================================================================
export interface ModelPerformanceMetrics {
modelVersion: string;
modelType: string;
trainedAt: string;
auc: number;
precisionAt10: number;
recallAt10: number;
calibrationSlope: number;
calibrationIntercept: number;
perProductPerformance: Record<string, { auc: number; sampleSize: number }>;
featureImportance: Array<{ feature: string; importance: number }>;
}
export interface ModelPerformanceDoc {
id: string;
metrics: ModelPerformanceMetrics;
isActive: boolean;
createdAt: string;
ttl: number;
}