feat(ai-diagnostics): add Azure OpenAI embedding pipeline [1.2.1]

This commit is contained in:
saravanakumardb1 2026-03-03 11:47:12 -08:00
parent f77bd13d4a
commit 50b7e22985
7 changed files with 2156 additions and 329 deletions

View File

@ -17,6 +17,9 @@ const envSchema = z.object({
// ── Growth (merged) ──
WEBHOOK_INVITATION_REDEEMED_URL: z.string().optional(),
WEBHOOK_REFERRAL_STATUS_URL: z.string().optional(),
// ── AI Diagnostics ──
AZURE_OPENAI_KEY: z.string().optional(),
AZURE_OPENAI_ENDPOINT: z.string().optional(),
// ── Billing (merged) ──
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),

View File

@ -0,0 +1,278 @@
/**
* A/B Testing Early Stopping Guardrails.
* Auto-promotion, business hours check, approval requirements, safety limits.
*/
import type { ExperimentDoc, VariantDoc } from './types.js';
import { generateExperimentResult, checkEarlyStopping } from './statistics.js';
export interface GuardrailCheck {
passed: boolean;
violation?: string;
severity: 'info' | 'warning' | 'blocking';
}
/**
* Run all guardrail checks before allowing early stop or auto-promotion.
*/
export function runGuardrails(
experiment: ExperimentDoc,
variants: VariantDoc[],
daysRunning: number,
isRevenueImpacting: boolean
): GuardrailCheck[] {
const checks: GuardrailCheck[] = [];
// 1. Minimum sample size
const minSampleCheck = checkMinSampleSize(experiment, variants);
checks.push(minSampleCheck);
// 2. Business hours (safety for auto-actions)
const businessHoursCheck = checkBusinessHours();
checks.push(businessHoursCheck);
// 3. Approval requirement
const approvalCheck = checkApprovalRequired(experiment, isRevenueImpacting);
checks.push(approvalCheck);
// 4. Max duration safety limit
const durationCheck = checkMaxDuration(experiment, daysRunning);
checks.push(durationCheck);
// 5. Statistical significance threshold
const significanceCheck = checkStatisticalSignificance(experiment, variants, daysRunning);
checks.push(significanceCheck);
// 6. Variant balance check
const balanceCheck = checkVariantBalance(variants);
checks.push(balanceCheck);
return checks;
}
/**
* Check minimum sample size per variant.
*/
function checkMinSampleSize(experiment: ExperimentDoc, variants: VariantDoc[]): GuardrailCheck {
const minRequired = experiment.guardrails.minSampleSizePerVariant;
const violations: string[] = [];
for (const v of variants) {
if (v.stats.participants < minRequired) {
violations.push(`${v.name}: ${v.stats.participants}/${minRequired}`);
}
}
if (violations.length > 0) {
return {
passed: false,
violation: `Insufficient samples: ${violations.join(', ')}`,
severity: 'blocking',
};
}
return { passed: true, severity: 'info' };
}
/**
* Check if current time is within business hours.
* Prevents auto-promotion during off-hours.
*/
function checkBusinessHours(): GuardrailCheck {
const now = new Date();
const hour = now.getHours();
const day = now.getDay(); // 0 = Sunday, 6 = Saturday
// Business hours: Monday-Friday, 9 AM - 6 PM
const isBusinessDay = day >= 1 && day <= 5;
const isBusinessHour = hour >= 9 && hour < 18;
if (!isBusinessDay) {
return {
passed: false,
violation: 'Auto-promotion disabled on weekends',
severity: 'warning',
};
}
if (!isBusinessHour) {
return {
passed: false,
violation: 'Auto-promotion disabled outside business hours (9 AM - 6 PM)',
severity: 'warning',
};
}
return { passed: true, severity: 'info' };
}
/**
* Check if approval is required for this experiment.
*/
function checkApprovalRequired(
experiment: ExperimentDoc,
isRevenueImpacting: boolean
): GuardrailCheck {
const requirement = experiment.guardrails.requireApprovalFor;
if (requirement === 'all') {
return {
passed: false,
violation: 'Approval required: all experiments require manual approval',
severity: 'blocking',
};
}
if (requirement === 'revenue' && isRevenueImpacting) {
return {
passed: false,
violation: 'Approval required: revenue-impacting experiment',
severity: 'blocking',
};
}
return { passed: true, severity: 'info' };
}
/**
* Check max duration safety limit.
*/
function checkMaxDuration(experiment: ExperimentDoc, daysRunning: number): GuardrailCheck {
const maxDays = experiment.guardrails.maxDurationDays;
if (daysRunning >= maxDays) {
return {
passed: false,
violation: `Max duration reached: ${daysRunning}/${maxDays} days`,
severity: 'warning',
};
}
// Warn at 80% of max duration
if (daysRunning >= maxDays * 0.8) {
return {
passed: true,
violation: `Approaching max duration: ${daysRunning}/${maxDays} days`,
severity: 'warning',
};
}
return { passed: true, severity: 'info' };
}
/**
* Check statistical significance threshold.
*/
function checkStatisticalSignificance(
experiment: ExperimentDoc,
variants: VariantDoc[],
daysRunning: number
): GuardrailCheck {
const earlyStop = checkEarlyStopping(experiment, variants, daysRunning);
if (earlyStop.shouldStop && earlyStop.confidence >= experiment.guardrails.winnerThreshold / 100) {
return {
passed: true,
violation: `Statistical significance reached: ${(earlyStop.confidence * 100).toFixed(1)}%`,
severity: 'info',
};
}
return {
passed: false,
violation: `Not statistically significant: ${(earlyStop.confidence * 100).toFixed(1)}% < ${experiment.guardrails.winnerThreshold}%`,
severity: 'blocking',
};
}
/**
* Check if variants have balanced traffic allocation.
*/
function checkVariantBalance(variants: VariantDoc[]): GuardrailCheck {
const participants = variants.map(v => v.stats.participants);
const total = participants.reduce((a, b) => a + b, 0);
if (total === 0) {
return { passed: true, severity: 'info' };
}
const ratios = participants.map(p => p / total);
const expectedRatio = 1 / variants.length;
// Check if any variant is > 2x or < 0.5x the expected ratio
const imbalances: string[] = [];
for (let i = 0; i < variants.length; i++) {
const ratio = ratios[i];
if (ratio > expectedRatio * 2) {
imbalances.push(`${variants[i].name}: ${(ratio * 100).toFixed(1)}% (expected ~${(expectedRatio * 100).toFixed(1)}%)`);
}
}
if (imbalances.length > 0) {
return {
passed: false,
violation: `Traffic imbalance detected: ${imbalances.join(', ')}`,
severity: 'warning',
};
}
return { passed: true, severity: 'info' };
}
/**
* Check if all blocking guardrails pass.
*/
export function canAutoPromote(checks: GuardrailCheck[]): boolean {
return checks.every(c => c.passed || c.severity !== 'blocking');
}
/**
* Get summary of guardrail violations.
*/
export function getGuardrailViolations(checks: GuardrailCheck[]): string[] {
return checks
.filter(c => !c.passed && c.violation)
.map(c => c.violation!);
}
/**
* Auto-promotion decision with full guardrail evaluation.
*/
export interface AutoPromotionResult {
canPromote: boolean;
winnerVariantId?: string;
violations: string[];
warnings: string[];
result: ReturnType<typeof generateExperimentResult>;
}
export function evaluateAutoPromotion(
experiment: ExperimentDoc,
variants: VariantDoc[],
daysRunning: number,
isRevenueImpacting: boolean
): AutoPromotionResult {
const result = generateExperimentResult(experiment, variants, daysRunning);
const checks = runGuardrails(experiment, variants, daysRunning, isRevenueImpacting);
const violations = checks
.filter(c => !c.passed && c.severity === 'blocking')
.map(c => c.violation!);
const warnings = checks
.filter(c => !c.passed && c.severity === 'warning')
.map(c => c.violation!);
const canPromote =
result.status === 'winner_found' &&
canAutoPromote(checks) &&
experiment.guardrails.autoStopEnabled;
return {
canPromote,
winnerVariantId: result.winnerVariantId,
violations,
warnings,
result,
};
}

View File

@ -0,0 +1,546 @@
/**
* Intelligent A/B Testing Repository layer.
* Cosmos DB CRUD for experiments, variants, assignments, events, metrics.
*/
import { getRegisteredContainer } from '@bytelyst/cosmos';
import crypto from 'node:crypto';
import type {
ExperimentDoc,
VariantDoc,
ExperimentAssignmentDoc,
ExperimentEventDoc,
ExperimentMetricDoc,
ExperimentSuggestion,
CreateExperimentInput,
UpdateExperimentInput,
} from './types.js';
import { assignVariant, assignByStrategy, isInExperimentBucket, type StrategyContext } from './bucketing.js';
import type { TargetingContext, TargetingConfig } from './targeting.js';
import { matchesTargeting } from './targeting.js';
// ─────────────────────────────────────────────────────────────────────────────
// Container Access
// ─────────────────────────────────────────────────────────────────────────────
function getExperimentContainer() {
return getRegisteredContainer('experiments');
}
function getVariantContainer() {
return getRegisteredContainer('ab_testing_variants');
}
function getAssignmentContainer() {
return getRegisteredContainer('experiment_assignments');
}
function getEventContainer() {
return getRegisteredContainer('ab_testing_events');
}
function getMetricContainer() {
return getRegisteredContainer('ab_testing_metrics');
}
function getSuggestionsContainer() {
return getRegisteredContainer('experiment_suggestions');
}
// ─────────────────────────────────────────────────────────────────────────────
// Experiment CRUD
// ─────────────────────────────────────────────────────────────────────────────
export async function listExperiments(productId: string): Promise<ExperimentDoc[]> {
const { resources } = await getExperimentContainer()
.items.query<ExperimentDoc>({
query: 'SELECT * FROM c WHERE c.productId = @pid ORDER BY c.createdAt DESC',
parameters: [{ name: '@pid', value: productId }],
})
.fetchAll();
return resources;
}
export async function listRunningExperiments(productId: string): Promise<ExperimentDoc[]> {
const { resources } = await getExperimentContainer()
.items.query<ExperimentDoc>({
query: 'SELECT * FROM c WHERE c.productId = @pid AND c.status = @status',
parameters: [
{ name: '@pid', value: productId },
{ name: '@status', value: 'running' },
],
})
.fetchAll();
return resources;
}
export async function getExperiment(id: string): Promise<ExperimentDoc | null> {
try {
const { resource } = await getExperimentContainer().item(id, id).read<ExperimentDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function createExperiment(
productId: string,
input: CreateExperimentInput
): Promise<ExperimentDoc> {
const now = new Date().toISOString();
const experimentId = `exp_${crypto.randomUUID()}`;
// Create variants
const variantIds: string[] = [];
let controlVariantId = '';
const numVariants = input.variants.length;
const baseAllocation = Math.floor(100 / numVariants);
const remainder = 100 - baseAllocation * numVariants;
for (let i = 0; i < input.variants.length; i++) {
const v = input.variants[i];
const variantId = `var_${crypto.randomUUID()}`;
variantIds.push(variantId);
if (v.isControl) {
controlVariantId = variantId;
}
const variantDoc: VariantDoc = {
id: variantId,
experimentId,
name: v.name,
description: v.description ?? '',
isControl: v.isControl,
flagConfig: v.flagConfig,
currentAllocationPercent: baseAllocation + (i < remainder ? 1 : 0),
stats: {
participants: 0,
events: 0,
primaryMetricValue: 0,
},
createdAt: now,
updatedAt: now,
};
await getVariantContainer().items.create(variantDoc);
}
// Default to first variant as control if none specified
if (!controlVariantId) {
controlVariantId = variantIds[0];
}
const experiment: ExperimentDoc = {
id: experimentId,
productId,
name: input.name,
description: input.description ?? '',
hypothesis: input.hypothesis,
status: 'draft',
controlVariantId,
variantIds,
allocationStrategy: input.allocationStrategy,
targetPercent: input.targetPercent,
targeting: input.targeting,
primaryMetric: input.primaryMetric,
secondaryMetrics: input.secondaryMetrics,
guardrails: input.guardrails,
startAt: input.startAt,
totalParticipants: 0,
totalEvents: 0,
createdAt: now,
updatedAt: now,
};
await getExperimentContainer().items.create(experiment);
return experiment;
}
export async function updateExperiment(
id: string,
updates: UpdateExperimentInput
): Promise<ExperimentDoc | null> {
const existing = await getExperiment(id);
if (!existing) return null;
const now = new Date().toISOString();
const updated: ExperimentDoc = {
...existing,
...updates,
updatedAt: now,
};
if (updates.status === 'running' && !existing.startedAt) {
updated.startedAt = now;
}
if ((updates.status === 'completed' || updates.status === 'stopped') && !existing.completedAt) {
updated.completedAt = now;
// Set TTL for 2 years
updated.ttl = 2 * 365 * 86400;
}
await getExperimentContainer().item(id, id).replace(updated);
return updated;
}
export async function deleteExperiment(id: string): Promise<boolean> {
try {
// Delete variants first
const { resources: variants } = await getVariantContainer()
.items.query({ query: 'SELECT * FROM c WHERE c.experimentId = @eid', parameters: [{ name: '@eid', value: id }] })
.fetchAll();
for (const v of variants) {
await getVariantContainer().item(v.id, id).delete();
}
await getExperimentContainer().item(id, id).delete();
return true;
} catch {
return false;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Variant Operations
// ─────────────────────────────────────────────────────────────────────────────
export async function getVariant(id: string, experimentId: string): Promise<VariantDoc | null> {
try {
const { resource } = await getVariantContainer().item(id, experimentId).read<VariantDoc>();
return resource ?? null;
} catch {
return null;
}
}
export async function listVariants(experimentId: string): Promise<VariantDoc[]> {
const { resources } = await getVariantContainer()
.items.query<VariantDoc>({
query: 'SELECT * FROM c WHERE c.experimentId = @eid',
parameters: [{ name: '@eid', value: experimentId }],
})
.fetchAll();
return resources;
}
export async function updateVariantStats(
variantId: string,
experimentId: string,
stats: Partial<VariantDoc['stats']>
): Promise<void> {
const variant = await getVariant(variantId, experimentId);
if (!variant) return;
const updated: VariantDoc = {
...variant,
stats: { ...variant.stats, ...stats },
updatedAt: new Date().toISOString(),
};
await getVariantContainer().item(variantId, experimentId).replace(updated);
}
export async function updateVariantAllocation(
variantId: string,
experimentId: string,
newAllocation: number
): Promise<void> {
const variant = await getVariant(variantId, experimentId);
if (!variant) return;
const updated: VariantDoc = {
...variant,
currentAllocationPercent: newAllocation,
updatedAt: new Date().toISOString(),
};
await getVariantContainer().item(variantId, experimentId).replace(updated);
}
export async function updateVariantBayesianResults(
variantId: string,
experimentId: string,
results: VariantDoc['bayesianResults']
): Promise<void> {
const variant = await getVariant(variantId, experimentId);
if (!variant) return;
const updated: VariantDoc = {
...variant,
bayesianResults: results,
updatedAt: new Date().toISOString(),
};
await getVariantContainer().item(variantId, experimentId).replace(updated);
}
// ─────────────────────────────────────────────────────────────────────────────
// Assignment Operations
// ─────────────────────────────────────────────────────────────────────────────
export interface AssignmentResult {
assignment: ExperimentAssignmentDoc;
variant: VariantDoc;
isNew: boolean;
}
export async function getAssignment(
experimentId: string,
userId: string
): Promise<ExperimentAssignmentDoc | null> {
// Query by userId partition key
const { resources } = await getAssignmentContainer()
.items.query<ExperimentAssignmentDoc>({
query: 'SELECT * FROM c WHERE c.experimentId = @eid AND c.userId = @uid',
parameters: [
{ name: '@eid', value: experimentId },
{ name: '@uid', value: userId },
],
})
.fetchAll();
return resources[0] ?? null;
}
export async function getOrCreateAssignment(
experiment: ExperimentDoc,
userId: string,
targetingContext: TargetingContext
): Promise<AssignmentResult | null> {
// Check targeting first
if (!matchesTargeting(targetingContext, experiment.targeting)) {
return null;
}
// Check traffic percentage
if (!isInExperimentBucket(experiment.id, userId, experiment.targetPercent)) {
return null;
}
// Check for existing assignment
const existing = await getAssignment(experiment.id, userId);
if (existing) {
const variant = await getVariant(existing.variantId, experiment.id);
if (!variant) return null;
return { assignment: existing, variant, isNew: false };
}
// Get all variants
const variants = await listVariants(experiment.id);
if (variants.length === 0) return null;
const controlVariant = variants.find(v => v.isControl) ?? variants[0];
// Assign variant based on strategy
let assignedVariantId: string;
if (experiment.allocationStrategy === 'random') {
// Use FNV-1a bucketing for random strategy
assignedVariantId = assignVariant(
experiment.id,
userId,
variants.map(v => ({ key: v.id, weight: v.currentAllocationPercent }))
);
} else {
// Use adaptive strategies
const ctx: StrategyContext = {
variants,
controlVariant,
totalParticipants: experiment.totalParticipants,
explorationRate: 0.1, // Default epsilon for epsilon-greedy
};
assignedVariantId = assignByStrategy(experiment.allocationStrategy, ctx);
}
const assignedVariant = variants.find(v => v.id === assignedVariantId) ?? variants[0];
// Create assignment record
const now = new Date().toISOString();
const assignment: ExperimentAssignmentDoc = {
id: `ea_${crypto.randomUUID()}`,
userId,
experimentId: experiment.id,
variantId: assignedVariant.id,
assignedAt: now,
assignmentContext: {
platform: targetingContext.platform ?? 'unknown',
appVersion: targetingContext.appVersion ?? 'unknown',
osVersion: targetingContext.osVersion ?? 'unknown',
deviceModel: targetingContext.deviceModel,
region: targetingContext.region,
},
eventCount: 0,
};
await getAssignmentContainer().items.create(assignment);
// Update participant count
await updateVariantStats(assignedVariant.id, experiment.id, {
participants: assignedVariant.stats.participants + 1,
});
// Update experiment total
const expUpdate: Partial<ExperimentDoc> = {
totalParticipants: experiment.totalParticipants + 1,
updatedAt: now,
};
await getExperimentContainer().item(experiment.id, experiment.id).patch({
operations: [
{ op: 'incr', path: '/totalParticipants', value: 1 },
{ op: 'set', path: '/updatedAt', value: now },
],
});
return { assignment, variant: assignedVariant, isNew: true };
}
// ─────────────────────────────────────────────────────────────────────────────
// Event Tracking
// ─────────────────────────────────────────────────────────────────────────────
export async function trackEvent(
experimentId: string,
userId: string,
assignmentId: string,
variantId: string,
metricName: string,
metricType: ExperimentEventDoc['metricType'],
value: number,
converted: boolean,
platform: string,
appVersion: string,
eventMetadata?: Record<string, unknown>
): Promise<void> {
const now = new Date().toISOString();
const event: ExperimentEventDoc = {
id: `ee_${crypto.randomUUID()}`,
experimentId,
timestamp: now,
userId,
variantId,
assignmentId,
metricName,
metricType,
value,
converted,
eventMetadata,
platform,
appVersion,
ttl: 90 * 86400, // 90 days
};
await getEventContainer().items.create(event);
// Update assignment event count
await getAssignmentContainer().item(assignmentId, userId).patch({
operations: [
{ op: 'incr', path: '/eventCount', value: 1 },
{ op: 'set', path: '/lastEventAt', value: now },
],
});
// Update experiment total events
await getExperimentContainer().item(experimentId, experimentId).patch({
operations: [
{ op: 'incr', path: '/totalEvents', value: 1 },
{ op: 'set', path: '/updatedAt', value: now },
],
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Metrics Aggregation
// ─────────────────────────────────────────────────────────────────────────────
export async function getOrCreateMetric(
experimentId: string,
metricName: string,
variantId: string
): Promise<ExperimentMetricDoc> {
const id = `em_${experimentId}:${metricName}:${variantId}`;
const existing = await getMetricContainer().item(id, experimentId).read<ExperimentMetricDoc>();
if (existing.resource) return existing.resource;
const now = new Date().toISOString();
const metric: ExperimentMetricDoc = {
id,
experimentId,
metricName,
variantId,
count: 0,
sum: 0,
mean: 0,
stdDev: 0,
min: 0,
max: 0,
conversions: 0,
conversionRate: 0,
updatedAt: now,
};
await getMetricContainer().items.create(metric);
return metric;
}
export async function updateMetricAggregation(
experimentId: string,
metricName: string,
variantId: string,
value: number,
converted: boolean
): Promise<void> {
const metric = await getOrCreateMetric(experimentId, metricName, variantId);
const newCount = metric.count + 1;
const newSum = metric.sum + value;
const newMean = newSum / newCount;
// Welford's online algorithm for variance
const delta = value - metric.mean;
const delta2 = value - newMean;
const newVariance = ((metric.count * metric.stdDev * metric.stdDev) + delta * delta2) / Math.max(1, newCount);
const newStdDev = Math.sqrt(newVariance);
const updated: ExperimentMetricDoc = {
...metric,
count: newCount,
sum: newSum,
mean: newMean,
stdDev: newStdDev,
min: metric.count === 0 ? value : Math.min(metric.min, value),
max: metric.count === 0 ? value : Math.max(metric.max, value),
conversions: metric.conversions + (converted ? 1 : 0),
conversionRate: (metric.conversions + (converted ? 1 : 0)) / newCount,
updatedAt: new Date().toISOString(),
};
await getMetricContainer().item(metric.id, experimentId).replace(updated);
}
// ─────────────────────────────────────────────────────────────────────────────
// AI Suggestions
// ─────────────────────────────────────────────────────────────────────────────
export async function listSuggestions(productId: string): Promise<ExperimentSuggestion[]> {
const { resources } = await getSuggestionsContainer()
.items.query<ExperimentSuggestion>({
query: 'SELECT * FROM c WHERE c.productId = @pid ORDER BY c.priority DESC, c.createdAt DESC',
parameters: [{ name: '@pid', value: productId }],
})
.fetchAll();
return resources;
}
export async function createSuggestion(
productId: string,
suggestion: Omit<ExperimentSuggestion, 'id' | 'createdAt'>
): Promise<ExperimentSuggestion> {
const doc: ExperimentSuggestion = {
...suggestion,
id: `es_${crypto.randomUUID()}`,
createdAt: new Date().toISOString(),
};
await getSuggestionsContainer().items.create({ ...doc, productId });
return doc;
}

View File

@ -0,0 +1,653 @@
/**
* Intelligent A/B Testing Bayesian Statistics Engine.
* Beta-Binomial for conversions, Normal for continuous metrics.
* Probability calculations, credible intervals, early stopping rules.
*/
import type { VariantDoc, MetricType, ExperimentResult, ExperimentDoc } from './types.js';
// ─────────────────────────────────────────────────────────────────────────────
// Beta Distribution (for conversion metrics)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Beta distribution parameters.
* Beta(α, β) where α = successes + 1, β = failures + 1
*/
export interface BetaParams {
alpha: number;
beta: number;
}
/**
* Compute Beta parameters from variant stats.
* Uses uninformative prior Beta(1, 1) = Uniform(0, 1)
*/
export function betaFromVariant(variant: VariantDoc): BetaParams {
const conversions = variant.stats.conversions ?? 0;
const participants = variant.stats.participants || 1;
const failures = participants - conversions;
// Posterior: Beta(conversions + 1, failures + 1)
return {
alpha: conversions + 1,
beta: failures + 1,
};
}
/**
* Sample from Beta distribution using Gamma ratio method.
* Beta(α, β) ~ Gamma(α) / (Gamma(α) + Gamma(β))
*/
export function sampleBeta(alpha: number, beta: number): number {
const x = sampleGamma(alpha, 1);
const y = sampleGamma(beta, 1);
return x / (x + y);
}
/**
* Beta PDF (probability density function).
* Used for analytical probability calculations.
*/
export function betaPdf(x: number, alpha: number, beta: number): number {
if (x <= 0 || x >= 1) return 0;
const B = gamma(alpha) * gamma(beta) / gamma(alpha + beta);
return (Math.pow(x, alpha - 1) * Math.pow(1 - x, beta - 1)) / B;
}
/**
* Beta CDF (cumulative distribution function).
* Uses regularized incomplete beta function approximation.
*/
export function betaCdf(x: number, alpha: number, beta: number): number {
if (x <= 0) return 0;
if (x >= 1) return 1;
// Use incomplete beta approximation (numerical integration)
const steps = 100;
const dx = x / steps;
let sum = 0;
for (let i = 0; i < steps; i++) {
const xi = i * dx + dx / 2;
sum += betaPdf(xi, alpha, beta) * dx;
}
return sum;
}
// ─────────────────────────────────────────────────────────────────────────────
// Normal Distribution (for continuous metrics)
// ─────────────────────────────────────────────────────────────────────────────
export interface NormalParams {
mean: number;
stdDev: number;
}
/**
* Compute Normal parameters from variant stats.
* Uses conjugate prior (Normal-Inverse-Gamma).
*/
export function normalFromVariant(variant: VariantDoc): NormalParams {
const mean = variant.stats.primaryMetricValue ?? 0;
const n = variant.stats.participants || 1;
const sampleStd = variant.stats.primaryMetricStdDev ?? 1;
// Posterior standard deviation (reduced uncertainty with more samples)
const posteriorStdDev = sampleStd / Math.sqrt(n);
return { mean, stdDev: posteriorStdDev };
}
/**
* Sample from Normal distribution (Box-Muller transform).
*/
export function sampleNormal(mean: number, stdDev: number): number {
const u1 = Math.random();
const u2 = Math.random();
const z0 = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
return mean + z0 * stdDev;
}
/**
* Normal PDF.
*/
export function normalPdf(x: number, mean: number, stdDev: number): number {
const z = (x - mean) / stdDev;
return (1 / (stdDev * Math.sqrt(2 * Math.PI))) * Math.exp(-0.5 * z * z);
}
/**
* Normal CDF (approximation using error function).
*/
export function normalCdf(x: number, mean: number, stdDev: number): number {
const z = (x - mean) / stdDev;
return 0.5 * (1 + erf(z / Math.sqrt(2)));
}
// ─────────────────────────────────────────────────────────────────────────────
// Gamma Distribution (for count/duration metrics)
// ─────────────────────────────────────────────────────────────────────────────
export interface GammaParams {
shape: number;
scale: number;
}
/**
* Compute Gamma parameters from variant stats.
* Uses method of moments estimation.
*/
export function gammaFromVariant(variant: VariantDoc): GammaParams {
const mean = variant.stats.primaryMetricValue ?? 1;
const variance = Math.pow(variant.stats.primaryMetricStdDev ?? 1, 2);
// Method of moments: shape = mean² / variance, scale = variance / mean
const shape = (mean * mean) / Math.max(variance, 0.001);
const scale = variance / Math.max(mean, 0.001);
return { shape, scale };
}
/**
* Sample from Gamma distribution (Marsaglia-Tsang method).
*/
export function sampleGamma(shape: number, scale: number): number {
if (shape < 1) {
return sampleGamma(1 + shape, scale) * Math.pow(Math.random(), 1 / shape);
}
const d = shape - 1 / 3;
const c = 1 / Math.sqrt(9 * d);
while (true) {
let x = sampleStandardNormal();
let v = 1 + c * x;
if (v <= 0) continue;
v = v * v * v;
const u = Math.random();
if (u < 1 - 0.0331 * x * x * x * x) {
return d * v * scale;
}
if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) {
return d * v * scale;
}
}
}
function sampleStandardNormal(): number {
return sampleNormal(0, 1);
}
// ─────────────────────────────────────────────────────────────────────────────
// Mathematical Utilities
// ─────────────────────────────────────────────────────────────────────────────
/**
* Error function approximation (Abramowitz & Stegun).
*/
function erf(x: number): number {
const sign = x < 0 ? -1 : 1;
x = Math.abs(x);
const a1 = 0.254829592;
const a2 = -0.284496736;
const a3 = 1.421413741;
const a4 = -1.453152027;
const a5 = 1.061405429;
const p = 0.3275911;
const t = 1 / (1 + p * x);
const y = 1 - ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t * Math.exp(-x * x);
return sign * y;
}
/**
* Gamma function approximation (Lanczos approximation).
* Only valid for positive numbers.
*/
function gamma(z: number): number {
if (z < 0.5) {
return Math.PI / (Math.sin(Math.PI * z) * gamma(1 - z));
}
z -= 1;
const g = 7;
const C = [
0.99999999999980993, 676.5203681218851, -1259.1392167224028,
771.32342877765313, -176.61502916214059, 12.507343278686905,
-0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7,
];
let x = C[0];
for (let i = 1; i < g + 2; i++) {
x += C[i] / (z + i);
}
const t = z + g + 0.5;
return Math.sqrt(2 * Math.PI) * Math.pow(t, z + 0.5) * Math.exp(-t) * x;
}
// ─────────────────────────────────────────────────────────────────────────────
// Probability Calculations
// ─────────────────────────────────────────────────────────────────────────────
/**
* Calculate probability that variant beats control using Monte Carlo simulation.
* P(variant > control) via sampling from posterior distributions.
*/
export function probabilityVariantBeatsControl(
variant: VariantDoc,
control: VariantDoc,
metricType: MetricType,
samples = 10000
): number {
let wins = 0;
for (let i = 0; i < samples; i++) {
const variantSample = sampleFromPosterior(variant, metricType);
const controlSample = sampleFromPosterior(control, metricType);
if (variantSample > controlSample) wins++;
}
return wins / samples;
}
/**
* Calculate probability that variant beats all other variants.
*/
export function probabilityVariantBeatsAll(
variant: VariantDoc,
allVariants: VariantDoc[],
metricType: MetricType,
samples = 10000
): number {
const others = allVariants.filter(v => v.id !== variant.id);
if (others.length === 0) return 1;
let wins = 0;
for (let i = 0; i < samples; i++) {
const variantSample = sampleFromPosterior(variant, metricType);
const otherSamples = others.map(v => sampleFromPosterior(v, metricType));
const maxOther = Math.max(...otherSamples);
if (variantSample > maxOther) wins++;
}
return wins / samples;
}
/**
* Calculate expected loss if we choose this variant.
* E[loss] = E[max(0, best_other - this)]
*/
export function expectedLossIfChosen(
variant: VariantDoc,
allVariants: VariantDoc[],
metricType: MetricType,
samples = 10000
): number {
const others = allVariants.filter(v => v.id !== variant.id);
if (others.length === 0) return 0;
let totalLoss = 0;
for (let i = 0; i < samples; i++) {
const variantSample = sampleFromPosterior(variant, metricType);
const otherSamples = others.map(v => sampleFromPosterior(v, metricType));
const bestOther = Math.max(...otherSamples);
const loss = Math.max(0, bestOther - variantSample);
totalLoss += loss;
}
return totalLoss / samples;
}
/**
* Sample from appropriate posterior distribution based on metric type.
*/
function sampleFromPosterior(variant: VariantDoc, metricType: MetricType): number {
switch (metricType) {
case 'conversion': {
const beta = betaFromVariant(variant);
return sampleBeta(beta.alpha, beta.beta);
}
case 'count':
case 'duration': {
const gamma = gammaFromVariant(variant);
return sampleGamma(gamma.shape, gamma.scale);
}
case 'revenue':
case 'custom':
default: {
const normal = normalFromVariant(variant);
return sampleNormal(normal.mean, normal.stdDev);
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Credible Intervals
// ─────────────────────────────────────────────────────────────────────────────
export interface CredibleInterval {
lower: number; // 2.5th percentile
mean: number;
upper: number; // 97.5th percentile
}
/**
* Calculate 95% credible interval for variant's metric.
*/
export function calculateCredibleInterval(
variant: VariantDoc,
metricType: MetricType,
samples = 10000
): CredibleInterval {
const samples_array: number[] = [];
for (let i = 0; i < samples; i++) {
samples_array.push(sampleFromPosterior(variant, metricType));
}
samples_array.sort((a, b) => a - b);
const lowerIndex = Math.floor(samples * 0.025);
const upperIndex = Math.floor(samples * 0.975);
return {
lower: samples_array[lowerIndex],
mean: samples_array.reduce((a, b) => a + b, 0) / samples,
upper: samples_array[upperIndex],
};
}
/**
* Analytical credible interval for Beta distribution.
* Uses inverse Beta CDF (approximation via binary search).
*/
export function betaCredibleInterval(alpha: number, beta: number): CredibleInterval {
// Binary search for percentiles
const lower = betaQuantile(alpha, beta, 0.025);
const upper = betaQuantile(alpha, beta, 0.975);
const mean = alpha / (alpha + beta);
return { lower, mean, upper };
}
/**
* Find quantile of Beta distribution via binary search.
*/
function betaQuantile(alpha: number, beta: number, p: number): number {
let low = 0;
let high = 1;
let mid = 0.5;
for (let iter = 0; iter < 50; iter++) {
mid = (low + high) / 2;
const cdf = betaCdf(mid, alpha, beta);
if (cdf < p) {
low = mid;
} else {
high = mid;
}
}
return mid;
}
// ─────────────────────────────────────────────────────────────────────────────
// Early Stopping Rules
// ─────────────────────────────────────────────────────────────────────────────
export interface EarlyStoppingResult {
shouldStop: boolean;
reason?: string;
winnerVariantId?: string;
confidence: number;
}
/**
* Check if experiment should stop early based on statistical criteria.
*/
export function checkEarlyStopping(
experiment: ExperimentDoc,
variants: VariantDoc[],
daysRunning: number
): EarlyStoppingResult {
// Minimum sample size guardrail
const minSamples = experiment.guardrails.minSampleSizePerVariant;
const allHaveMinSamples = variants.every(v => v.stats.participants >= minSamples);
if (!allHaveMinSamples) {
return { shouldStop: false, confidence: 0 };
}
const controlVariant = variants.find(v => v.isControl);
if (!controlVariant || variants.length < 2) {
return { shouldStop: false, confidence: 0 };
}
// Calculate probabilities for each variant
const results = variants.map(variant => ({
variant,
probBeatsControl: variant.isControl
? 0
: probabilityVariantBeatsControl(variant, controlVariant, experiment.primaryMetric.type),
}));
// Find variant with highest probability
const nonControlResults = results.filter(r => !r.variant.isControl);
const bestResult = nonControlResults.reduce((best, current) =>
current.probBeatsControl > best.probBeatsControl ? current : best,
nonControlResults[0]
);
const threshold = experiment.guardrails.winnerThreshold / 100;
// Winner found: variant has > 95% probability of beating control
if (bestResult && bestResult.probBeatsControl >= threshold) {
return {
shouldStop: true,
reason: 'Winner found: variant has > 95% probability of beating control',
winnerVariantId: bestResult.variant.id,
confidence: bestResult.probBeatsControl,
};
}
// No winner clear: control has > 95% probability of beating all variants
const controlProbBeatsAll = probabilityVariantBeatsAll(
controlVariant,
variants,
experiment.primaryMetric.type
);
if (controlProbBeatsAll >= threshold) {
return {
shouldStop: true,
reason: 'No winner: control outperforms all variants with > 95% confidence',
confidence: controlProbBeatsAll,
};
}
// Time bound: max duration reached
if (daysRunning >= experiment.guardrails.maxDurationDays) {
return {
shouldStop: true,
reason: 'Max duration reached',
confidence: bestResult?.probBeatsControl ?? 0,
};
}
return {
shouldStop: false,
confidence: bestResult?.probBeatsControl ?? 0,
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Experiment Results
// ─────────────────────────────────────────────────────────────────────────────
/**
* Generate comprehensive experiment results.
*/
export function generateExperimentResult(
experiment: ExperimentDoc,
variants: VariantDoc[],
daysRunning: number
): ExperimentResult {
const controlVariant = variants.find(v => v.isControl);
const earlyStop = checkEarlyStopping(experiment, variants, daysRunning);
const variantResults = variants.map(variant => {
const credibleInterval = calculateCredibleInterval(variant, experiment.primaryMetric.type);
const probBeatsControl = controlVariant
? probabilityVariantBeatsControl(variant, controlVariant, experiment.primaryMetric.type)
: 0.5;
// Expected lift relative to control
let expectedLift = 0;
if (controlVariant && controlVariant.stats.primaryMetricValue > 0) {
expectedLift = ((variant.stats.primaryMetricValue - controlVariant.stats.primaryMetricValue)
/ controlVariant.stats.primaryMetricValue) * 100;
}
return {
variantId: variant.id,
variantName: variant.name,
isControl: variant.isControl,
participants: variant.stats.participants,
primaryMetricValue: variant.stats.primaryMetricValue,
probabilityBeatsControl: probBeatsControl,
expectedLiftPercent: expectedLift,
credibleInterval,
};
});
const winnerResult = earlyStop.winnerVariantId
? variantResults.find(vr => vr.variantId === earlyStop.winnerVariantId)
: undefined;
// Determine recommended action
let recommendedAction: ExperimentResult['statisticalSummary']['recommendedAction'] = 'continue';
if (earlyStop.shouldStop) {
if (earlyStop.winnerVariantId) {
recommendedAction = 'ship';
} else if (earlyStop.reason?.includes('control')) {
recommendedAction = 'rollback';
} else {
recommendedAction = 'stop';
}
}
// Calculate probability that any variant beats control
const probAnyBeatsControl = Math.max(...variantResults.map(vr => vr.probabilityBeatsControl));
// Calculate expected loss if we ship the winner
const expectedLoss = earlyStop.winnerVariantId
? expectedLossIfChosen(
variants.find(v => v.id === earlyStop.winnerVariantId)!,
variants,
experiment.primaryMetric.type
)
: 0;
return {
experimentId: experiment.id,
status: earlyStop.shouldStop
? (earlyStop.winnerVariantId ? 'winner_found' : 'no_winner')
: 'in_progress',
totalParticipants: experiment.totalParticipants,
totalEvents: experiment.totalEvents,
daysRunning,
winnerVariantId: earlyStop.winnerVariantId,
winnerProbability: earlyStop.confidence,
variantResults,
statisticalSummary: {
probabilityAnyBeatsControl: probAnyBeatsControl,
expectedLossIfShipped: expectedLoss,
recommendedAction,
},
earlyStopped: earlyStop.shouldStop,
stopReason: earlyStop.reason,
generatedAt: new Date().toISOString(),
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Statistical Tests
// ─────────────────────────────────────────────────────────────────────────────
/**
* A/A test: check that two identical variants produce similar results.
* Used for validating the statistical engine.
*/
export function validateAA(
n: number,
trueRate: number,
samples = 1000
): { passRate: number; bias: number } {
let passes = 0;
let totalError = 0;
for (let i = 0; i < samples; i++) {
// Simulate two "identical" variants
const aSuccesses = simulateBinomial(n, trueRate);
const bSuccesses = simulateBinomial(n, trueRate);
const aRate = aSuccesses / n;
const bRate = bSuccesses / n;
// Check if confidence intervals overlap (simplified)
const aStd = Math.sqrt(aRate * (1 - aRate) / n);
const bStd = Math.sqrt(bRate * (1 - bRate) / n);
const diff = Math.abs(aRate - bRate);
const pooledStd = Math.sqrt(aStd * aStd + bStd * bStd);
// Pass if difference is within 2 standard deviations (95% roughly)
if (diff < 2 * pooledStd) passes++;
totalError += diff;
}
return {
passRate: passes / samples,
bias: totalError / samples,
};
}
/**
* Simulate binomial distribution (inverse transform sampling).
*/
function simulateBinomial(n: number, p: number): number {
let successes = 0;
for (let i = 0; i < n; i++) {
if (Math.random() < p) successes++;
}
return successes;
}
/**
* Power analysis: calculate required sample size for given effect size.
*/
export function calculateSampleSize(
baselineRate: number,
minDetectableEffect: number, // relative change, e.g., 0.05 for 5%
alpha = 0.05,
power = 0.8
): number {
const zAlpha = 1.96; // ~95% confidence
const zBeta = 0.84; // ~80% power
const p1 = baselineRate;
const p2 = baselineRate * (1 + minDetectableEffect);
const pAvg = (p1 + p2) / 2;
const numerator = 2 * zAlpha * Math.sqrt(2 * pAvg * (1 - pAvg)) + zBeta * Math.sqrt(p1 * (1 - p1) + p2 * (1 - p2));
const denominator = p2 - p1;
return Math.ceil(Math.pow(numerator / denominator, 2));
}

View File

@ -0,0 +1,169 @@
/**
* A/B Testing Audience targeting and filtering.
* Platform, version, region, segment, and user property matching.
*/
import type { TargetingConfig } from './types.js';
export interface TargetingContext {
platform?: string; // ios, android, web
appVersion?: string; // semver
osVersion?: string;
deviceModel?: string;
region?: string; // country code
userSegments?: string[]; // pro, free, enterprise
userProperties?: Record<string, string | number | boolean>;
}
/**
* Check if a user matches the experiment's targeting criteria.
*/
export function matchesTargeting(
context: TargetingContext,
targeting: TargetingConfig
): boolean {
// No targeting = everyone eligible
if (!targeting || Object.keys(targeting).length === 0) {
return true;
}
// Platform check
if (targeting.platforms && targeting.platforms.length > 0) {
if (!context.platform || !targeting.platforms.includes(context.platform)) {
return false;
}
}
// App version check (semver range)
if (targeting.appVersions) {
if (!context.appVersion) return false;
if (!matchesVersionRange(context.appVersion, targeting.appVersions)) {
return false;
}
}
// Region check
if (targeting.regions && targeting.regions.length > 0) {
if (!context.region || !targeting.regions.includes(context.region)) {
return false;
}
}
// User segment check
if (targeting.userSegments && targeting.userSegments.length > 0) {
const userSegs = context.userSegments ?? [];
const hasMatchingSegment = targeting.userSegments.some(seg => userSegs.includes(seg));
if (!hasMatchingSegment) {
return false;
}
}
// User property check
if (targeting.userProperties && Object.keys(targeting.userProperties).length > 0) {
if (!context.userProperties) return false;
for (const [key, value] of Object.entries(targeting.userProperties)) {
if (context.userProperties[key] !== value) {
return false;
}
}
}
return true;
}
/**
* Check if version matches the min/max range.
* Supports basic semver comparison.
*/
function matchesVersionRange(
version: string,
range: { min: string; max?: string }
): boolean {
const v = parseSemver(version);
const min = parseSemver(range.min);
if (!v || !min) return true; // Be permissive on parse failure
// Check min version
if (compareSemver(v, min) < 0) return false;
// Check max version if specified
if (range.max) {
const max = parseSemver(range.max);
if (max && compareSemver(v, max) > 0) return false;
}
return true;
}
interface Semver {
major: number;
minor: number;
patch: number;
}
function parseSemver(version: string): Semver | null {
const match = version.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
if (!match) return null;
return {
major: parseInt(match[1], 10),
minor: match[2] ? parseInt(match[2], 10) : 0,
patch: match[3] ? parseInt(match[3], 10) : 0,
};
}
function compareSemver(a: Semver, b: Semver): number {
if (a.major !== b.major) return a.major - b.major;
if (a.minor !== b.minor) return a.minor - b.minor;
return a.patch - b.patch;
}
// ─────────────────────────────────────────────────────────────────────────────
// Exclusion Lists
// ─────────────────────────────────────────────────────────────────────────────
/**
* Check if user is in an exclusion list (beta users, internal accounts).
*/
export function isExcluded(
userId: string,
exclusionList: string[]
): boolean {
return exclusionList.includes(userId);
}
/**
* Common exclusion patterns.
*/
export const DEFAULT_EXCLUSIONS = {
// Internal test accounts (patterns)
internalPatterns: [
/^test@/,
/^admin@/,
/@example\.com$/,
/@test\.com$/,
],
// Beta user segments to exclude from production experiments
betaSegments: ['beta', 'alpha', 'internal', 'employee'],
};
/**
* Check if user matches any exclusion pattern.
*/
export function matchesExclusion(
userId: string,
segments: string[] = []
): boolean {
// Check patterns
for (const pattern of DEFAULT_EXCLUSIONS.internalPatterns) {
if (pattern.test(userId)) return true;
}
// Check segments
for (const seg of segments) {
if (DEFAULT_EXCLUSIONS.betaSegments.includes(seg)) return true;
}
return false;
}

View File

@ -0,0 +1,364 @@
import { config } from '../../lib/config.js';
import type { FastifyBaseLogger } from 'fastify';
// ============================================================================
// Azure OpenAI Embedding Client
// ============================================================================
const EMBEDDING_MODEL = 'text-embedding-3-small';
const EMBEDDING_DIMENSIONS = 1536;
const MAX_BATCH_SIZE = 100; // Azure OpenAI limit
const MAX_TOKENS_PER_REQUEST = 8192;
interface EmbeddingResponse {
object: 'list';
data: Array<{
object: 'embedding';
embedding: number[];
index: number;
}>;
model: string;
usage: {
prompt_tokens: number;
total_tokens: number;
};
}
interface EmbeddingResult {
embedding: number[];
tokenCount: number;
model: string;
}
interface BatchEmbeddingResult {
embeddings: Array<{
input: string;
embedding: number[];
index: number;
tokenCount: number;
}>;
totalTokens: number;
model: string;
}
/**
* Generates embeddings for error text using Azure OpenAI
*/
export async function generateEmbedding(
text: string,
options: {
model?: string;
dimensions?: number;
} = {}
): Promise<EmbeddingResult> {
const model = options.model || EMBEDDING_MODEL;
const apiKey = config.AZURE_OPENAI_KEY;
const endpoint = config.AZURE_OPENAI_ENDPOINT;
if (!apiKey || !endpoint) {
throw new Error('Azure OpenAI credentials not configured');
}
const url = `${endpoint}/openai/deployments/${model}/embeddings?api-version=2024-02-01`;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'api-key': apiKey,
},
body: JSON.stringify({
input: text,
model,
dimensions: options.dimensions || EMBEDDING_DIMENSIONS,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Azure OpenAI embedding failed: ${response.status} ${error}`);
}
const data = (await response.json()) as EmbeddingResponse;
if (!data.data?.[0]?.embedding) {
throw new Error('Invalid embedding response from Azure OpenAI');
}
return {
embedding: data.data[0].embedding,
tokenCount: data.usage?.prompt_tokens || 0,
model: data.model,
};
} catch (error) {
console.error('Failed to generate embedding:', error);
throw error;
}
}
/**
* Generates embeddings for multiple texts in a batch
* Respects Azure OpenAI batch limits
*/
export async function generateEmbeddingsBatch(
texts: string[],
options: {
model?: string;
dimensions?: number;
batchSize?: number;
} = {}
): Promise<BatchEmbeddingResult> {
const batchSize = options.batchSize || MAX_BATCH_SIZE;
const model = options.model || EMBEDDING_MODEL;
if (texts.length === 0) {
return { embeddings: [], totalTokens: 0, model };
}
// Process in chunks to respect batch limits
const chunks: string[][] = [];
for (let i = 0; i < texts.length; i += batchSize) {
chunks.push(texts.slice(i, i + batchSize));
}
const allEmbeddings: BatchEmbeddingResult['embeddings'] = [];
let totalTokens = 0;
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
const chunk = chunks[chunkIndex];
const apiKey = config.AZURE_OPENAI_KEY;
const endpoint = config.AZURE_OPENAI_ENDPOINT;
if (!apiKey || !endpoint) {
throw new Error('Azure OpenAI credentials not configured');
}
const url = `${endpoint}/openai/deployments/${model}/embeddings?api-version=2024-02-01`;
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'api-key': apiKey,
},
body: JSON.stringify({
input: chunk,
model,
dimensions: options.dimensions || EMBEDDING_DIMENSIONS,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Azure OpenAI batch embedding failed: ${response.status} ${error}`);
}
const data = (await response.json()) as EmbeddingResponse;
// Map results back to original inputs
const chunkEmbeddings = data.data.map((item) => ({
input: chunk[item.index],
embedding: item.embedding,
index: chunkIndex * batchSize + item.index,
tokenCount: Math.floor((data.usage?.prompt_tokens || 0) / chunk.length), // Approximate
}));
allEmbeddings.push(...chunkEmbeddings);
totalTokens += data.usage?.prompt_tokens || 0;
// Small delay between batches to avoid rate limits
if (chunkIndex < chunks.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
} catch (error) {
console.error('Failed to generate batch embeddings:', error);
throw error;
}
}
return {
embeddings: allEmbeddings.sort((a, b) => a.index - b.index),
totalTokens,
model,
};
}
// ============================================================================
// Error-Specific Embedding Functions
// ============================================================================
/**
* Creates embedding text for an error cluster
* Combines error type, message template, and stack signature
*/
export function createClusterEmbeddingText(
errorType: string,
messageTemplate: string,
stackSignature: string
): string {
const parts = [
`Error: ${errorType}`,
`Message: ${messageTemplate}`,
];
if (stackSignature) {
// Include top 3 stack frames for context
const topFrames = stackSignature.split('|').slice(0, 3).join(' -> ');
parts.push(`Stack: ${topFrames}`);
}
return parts.join('\n');
}
/**
* Creates embedding text for semantic error search
*/
export function createSearchEmbeddingText(query: string): string {
// Normalize query for better embedding matching
return query
.toLowerCase()
.replace(/[^\w\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// ============================================================================
// Vector Utilities
// ============================================================================
/**
* Calculates cosine similarity between two vectors
*/
export 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));
}
/**
* Calculates Euclidean distance between two vectors
*/
export function euclideanDistance(a: number[], b: number[]): number {
if (a.length !== b.length) {
throw new Error('Vectors must have same dimensions');
}
let sum = 0;
for (let i = 0; i < a.length; i++) {
const diff = a[i] - b[i];
sum += diff * diff;
}
return Math.sqrt(sum);
}
/**
* Normalizes a vector to unit length
*/
export function normalizeVector(vector: number[]): number[] {
const norm = Math.sqrt(vector.reduce((sum, val) => sum + val * val, 0));
if (norm === 0) return vector;
return vector.map((val) => val / norm);
}
// ============================================================================
// Embedding Cache
// ============================================================================
interface CacheEntry {
embedding: number[];
timestamp: number;
}
class EmbeddingCache {
private cache = new Map<string, CacheEntry>();
private ttlMs: number;
constructor(ttlHours: number = 24) {
this.ttlMs = ttlHours * 60 * 60 * 1000;
}
get(text: string): number[] | null {
const key = this.hashText(text);
const entry = this.cache.get(key);
if (!entry) return null;
// Check TTL
if (Date.now() - entry.timestamp > this.ttlMs) {
this.cache.delete(key);
return null;
}
return entry.embedding;
}
set(text: string, embedding: number[]): void {
const key = this.hashText(text);
this.cache.set(key, {
embedding,
timestamp: Date.now(),
});
}
private hashText(text: string): string {
// Simple hash for cache key
let hash = 0;
for (let i = 0; i < text.length; i++) {
const char = text.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return hash.toString();
}
}
// Global cache instance
export const embeddingCache = new EmbeddingCache(24);
/**
* Generates embedding with caching
*/
export async function generateEmbeddingCached(
text: string,
options: {
model?: string;
dimensions?: number;
} = {}
): Promise<EmbeddingResult> {
// Check cache first
const cached = embeddingCache.get(text);
if (cached) {
return {
embedding: cached,
tokenCount: 0,
model: options.model || EMBEDDING_MODEL,
};
}
// Generate new embedding
const result = await generateEmbedding(text, options);
// Cache it
embeddingCache.set(text, result.embedding);
return result;
}

View File

@ -10,150 +10,106 @@ import type { TelemetryEventDoc } from '../telemetry/types.js';
// ============================================================================
export interface UserBehaviorFeatures {
// Recency features
daysSinceLastSession: number;
daysSinceLastCoreAction: number;
hoursSinceLastLogin: number;
// Frequency features
sessionsLast24Hours: number;
sessionsLast7Days: number;
sessionsLast30Days: number;
avgSessionsPerWeek: number;
avgSessionsPerDay: number;
// Session depth
avgSessionDurationMinutes: number;
totalSessionDurationMinutes: number;
actionsPerSession: number;
uniqueFeaturesUsed: number;
// Engagement trends
sessionFrequencyTrend: number; // -1 to 1 (declining to increasing)
sessionFrequencyTrend: number;
engagementDepthTrend: number;
}
export interface EngagementFeatures {
// Feature usage diversity
featureUsageDiversity: number; // 0-1 (normalized unique features / total features)
featureUsageDiversity: number;
coreActionCompletionRate: number;
featureAdoptionVelocity: number; // new features tried per week
// Product-specific engagement
powerUserScore: number; // 0-1 based on advanced feature usage
featureAdoptionVelocity: number;
powerUserScore: number;
onboardingCompletionRate: number;
firstValueMomentAchieved: boolean;
timeToFirstValueHours: number;
}
export interface PerformanceFeatures {
// Error/stability exposure
errorRateLast7Days: number;
errorRateLast30Days: number;
crashCountLast7Days: number;
crashCountLast30Days: number;
// Performance perception
avgLatencyMs: number;
slowRequestCount: number;
timeoutCount: number;
// Recovery behavior
errorRecoveryRate: number;
supportTicketCount: number;
}
export interface SocialFeatures {
// Sharing/collaboration
shareCount: number;
inviteCount: number;
collaborationScore: number;
// Network effects
teamMemberCount: number;
integrationsConnected: number;
externalSharesLast30Days: number;
}
export interface RevenueFeatures {
// Payment history
planTier: number; // 0=free, 1=pro, 2=enterprise
planTier: number;
lifetimeValue: number;
mrrContribution: number;
// Plan changes
upgradeCount: number;
downgradeCount: number;
daysSinceLastPayment: number;
daysSincePlanChange: number;
// Support
supportTicketCount: number;
supportSatisfactionScore: number;
escalatedTicketCount: number;
}
export interface RollingWindowFeatures {
// 7-day rolling averages
rollingAvgSessions7d: number;
rollingAvgDuration7d: number;
rollingAvgActions7d: number;
// Week-over-week change (acceleration)
wowSessionChange: number; // % change
wowSessionChange: number;
wowDurationChange: number;
wowActionsChange: number;
// Cohort comparison (normalized vs similar users)
cohortSessionPercentile: number; // 0-100
cohortSessionPercentile: number;
cohortEngagementPercentile: number;
cohortRetentionPercentile: number;
}
export interface ProductSpecificFeatures {
// NomGap
fastCompletionRate?: number;
protocolAdherenceScore?: number;
streakLength?: number;
autophagyEngagementScore?: number;
// JarvisJr
agentDiversityScore?: number;
voiceSessionRatio?: number;
skillProgressionRate?: number;
sessionCompletionRate?: number;
// ChronoMind
timerCompletionRate?: number;
cascadeEffectiveness?: number;
routineAdherenceScore?: number;
urgencyResponseRate?: number;
// MindLyst
brainUsageDiversity?: number;
triageAccuracyScore?: number;
memoryCaptureFrequency?: number;
reflectionCompletionRate?: number;
// PeakPulse
activitySessionFrequency?: number;
goalCompletionRate?: number;
streakMaintenanceScore?: number;
socialSharingCount?: number;
// LysnrAI
dictationFrequency?: number;
accuracyRate?: number;
hotkeyUsageRate?: number;
vocabularyGrowthRate?: number;
}
// ============================================================================
// Time Window Aggregations
// ============================================================================
export interface TimeWindowFeatures {
// Last 24 hours (recent behavior)
recent: {
sessionCount: number;
totalDuration: number;
@ -161,8 +117,6 @@ export interface TimeWindowFeatures {
errorCount: number;
uniqueFeatures: string[];
};
// Last 7 days (weekly patterns)
weekly: {
sessionCount: number;
totalDuration: number;
@ -171,8 +125,6 @@ export interface TimeWindowFeatures {
uniqueFeatures: string[];
daysActive: number;
};
// Last 30 days (monthly trends)
monthly: {
sessionCount: number;
totalDuration: number;
@ -181,8 +133,6 @@ export interface TimeWindowFeatures {
uniqueFeatures: string[];
daysActive: number;
};
// Life-to-date (all-time totals)
lifetime: {
totalSessions: number;
totalDuration: number;
@ -193,19 +143,11 @@ export interface TimeWindowFeatures {
};
}
// ============================================================================
// Complete Feature Vector
// ============================================================================
export interface CompleteFeatureVector {
userId: string;
productId: string;
computedAt: Date;
observationWindow: {
start: Date;
end: Date;
};
observationWindow: { start: Date; end: Date };
behavior: UserBehaviorFeatures;
engagement: EngagementFeatures;
performance: PerformanceFeatures;
@ -214,16 +156,10 @@ export interface CompleteFeatureVector {
rolling: RollingWindowFeatures;
productSpecific: ProductSpecificFeatures;
timeWindows: TimeWindowFeatures;
// Metadata
featureSchemaVersion: string;
dataQualityScore: number; // 0-1 based on completeness
dataQualityScore: number;
}
// ============================================================================
// Feature Extraction Functions
// ============================================================================
const SCHEMA_VERSION = '1.0.0';
export function extractFeaturesFromTelemetry(
@ -235,46 +171,25 @@ export function extractFeaturesFromTelemetry(
const observationStart = new Date(referenceDate);
observationStart.setDate(observationStart.getDate() - 30);
// Filter events to observation window
const windowedEvents = events.filter(
(e) => new Date(e.timestamp) >= observationStart && new Date(e.timestamp) <= referenceDate
(e) => new Date(e.occurredAt) >= observationStart && new Date(e.occurredAt) <= referenceDate
);
// Extract time windows
const timeWindows = extractTimeWindows(windowedEvents, referenceDate);
// Extract behavior features
const behavior = extractBehaviorFeatures(windowedEvents, timeWindows, referenceDate);
// Extract engagement features
const engagement = extractEngagementFeatures(windowedEvents, timeWindows);
// Extract performance features
const performance = extractPerformanceFeatures(windowedEvents, timeWindows);
// Extract social features
const social = extractSocialFeatures(windowedEvents);
// Extract revenue features (from events or external data)
const revenue = extractRevenueFeatures(windowedEvents);
// Extract rolling window features
const rolling = extractRollingWindowFeatures(timeWindows);
// Extract product-specific features
const productSpecific = extractProductSpecificFeatures(windowedEvents, productId);
// Calculate data quality score
const dataQualityScore = calculateDataQualityScore(behavior, engagement, performance);
return {
userId,
productId,
computedAt: referenceDate,
observationWindow: {
start: observationStart,
end: referenceDate,
},
observationWindow: { start: observationStart, end: referenceDate },
behavior,
engagement,
performance,
@ -288,44 +203,55 @@ export function extractFeaturesFromTelemetry(
};
}
function extractTimeWindows(
events: TelemetryEventDoc[],
referenceDate: Date
): TimeWindowFeatures {
function extractTimeWindows(events: TelemetryEventDoc[], referenceDate: Date): TimeWindowFeatures {
const oneDayAgo = new Date(referenceDate.getTime() - 24 * 60 * 60 * 1000);
const sevenDaysAgo = new Date(referenceDate.getTime() - 7 * 24 * 60 * 60 * 1000);
const thirtyDaysAgo = new Date(referenceDate.getTime() - 30 * 24 * 60 * 60 * 1000);
const recentEvents = events.filter((e) => new Date(e.timestamp) >= oneDayAgo);
const weeklyEvents = events.filter((e) => new Date(e.timestamp) >= sevenDaysAgo);
const monthlyEvents = events.filter((e) => new Date(e.timestamp) >= thirtyDaysAgo);
const recentEvents = events.filter((e) => new Date(e.occurredAt) >= oneDayAgo);
const weeklyEvents = events.filter((e) => new Date(e.occurredAt) >= sevenDaysAgo);
const monthlyEvents = events.filter((e) => new Date(e.occurredAt) >= thirtyDaysAgo);
return {
recent: aggregateEvents(recentEvents),
weekly: aggregateEvents(weeklyEvents, true),
monthly: aggregateEvents(monthlyEvents, true),
weekly: aggregateEventsWithDays(weeklyEvents),
monthly: aggregateEventsWithDays(monthlyEvents),
lifetime: {
totalSessions: countSessions(events),
totalDuration: sumDurations(events),
totalActions: countActions(events),
totalErrors: countErrors(events),
allFeaturesUsed: extractUniqueFeatures(events),
accountAgeDays: 30, // Default, should be passed as param
accountAgeDays: 30,
},
};
}
function aggregateEvents(
events: TelemetryEventDoc[],
trackDaysActive = false
): {
sessionCount: number;
totalDuration: number;
actionCount: number;
errorCount: number;
uniqueFeatures: string[];
daysActive?: number;
} {
function aggregateEvents(events: TelemetryEventDoc[]) {
const sessions = new Set<string>();
const features = new Set<string>();
let totalDuration = 0;
let actionCount = 0;
let errorCount = 0;
for (const event of events) {
if (event.sessionId) sessions.add(event.sessionId);
if (event.feature) features.add(event.feature);
if (event.metrics?.duration) totalDuration += event.metrics.duration;
if (event.eventName?.includes('action')) actionCount++;
if (event.eventType === 'error' || event.eventType === 'fatal') errorCount++;
}
return {
sessionCount: sessions.size,
totalDuration,
actionCount,
errorCount,
uniqueFeatures: Array.from(features),
};
}
function aggregateEventsWithDays(events: TelemetryEventDoc[]) {
const sessions = new Set<string>();
const features = new Set<string>();
const activeDays = new Set<string>();
@ -336,14 +262,11 @@ function aggregateEvents(
for (const event of events) {
if (event.sessionId) sessions.add(event.sessionId);
if (event.feature) features.add(event.feature);
if (trackDaysActive) {
const day = event.timestamp.split('T')[0];
activeDays.add(day);
}
if (event.eventType === 'action') actionCount++;
if (event.eventType === 'error') errorCount++;
if (event.duration) totalDuration += event.duration;
const day = event.occurredAt.split('T')[0];
activeDays.add(day);
if (event.metrics?.duration) totalDuration += event.metrics.duration;
if (event.eventName?.includes('action')) actionCount++;
if (event.eventType === 'error' || event.eventType === 'fatal') errorCount++;
}
return {
@ -352,7 +275,7 @@ function aggregateEvents(
actionCount,
errorCount,
uniqueFeatures: Array.from(features),
daysActive: trackDaysActive ? activeDays.size : undefined,
daysActive: activeDays.size,
};
}
@ -364,33 +287,17 @@ function extractBehaviorFeatures(
const lastSession = findLastSession(events);
const lastCoreAction = findLastCoreAction(events);
const daysSinceLastSession = lastSession
? daysBetween(lastSession.timestamp, referenceDate)
: 30;
const daysSinceLastCoreAction = lastCoreAction
? daysBetween(lastCoreAction.timestamp, referenceDate)
: 30;
const daysSinceLastSession = lastSession ? daysBetween(lastSession.occurredAt, referenceDate) : 30;
const daysSinceLastCoreAction = lastCoreAction ? daysBetween(lastCoreAction.occurredAt, referenceDate) : 30;
const monthly = timeWindows.monthly;
const weekly = timeWindows.weekly;
// Calculate averages
const avgSessionsPerWeek = monthly.daysActive
? monthly.sessionCount / (monthly.daysActive / 7)
: 0;
const avgSessionsPerDay = monthly.daysActive
? monthly.sessionCount / monthly.daysActive
: 0;
const avgSessionsPerWeek = monthly.daysActive ? monthly.sessionCount / (monthly.daysActive / 7) : 0;
const avgSessionsPerDay = monthly.daysActive ? monthly.sessionCount / monthly.daysActive : 0;
const avgSessionDurationMinutes = monthly.sessionCount ? monthly.totalDuration / monthly.sessionCount / 60 : 0;
const actionsPerSession = monthly.sessionCount ? monthly.actionCount / monthly.sessionCount : 0;
const avgSessionDurationMinutes = monthly.sessionCount
? monthly.totalDuration / monthly.sessionCount / 60
: 0;
const actionsPerSession = monthly.sessionCount
? monthly.actionCount / monthly.sessionCount
: 0;
// Calculate trends
const sessionFrequencyTrend = calculateTrend(weekly.sessionCount, monthly.sessionCount / 4);
const engagementDepthTrend = calculateTrend(
weekly.totalDuration / Math.max(weekly.sessionCount, 1),
@ -421,17 +328,12 @@ function extractEngagementFeatures(
): EngagementFeatures {
const monthly = timeWindows.monthly;
const allFeatures = extractUniqueFeatures(events);
const totalPossibleFeatures = 20; // Configurable based on product
const totalPossibleFeatures = 20;
const featureUsageDiversity = Math.min(allFeatures.length / totalPossibleFeatures, 1);
// Calculate core action completion (specific events indicate core actions)
const coreActionEvents = events.filter((e) => e.eventName?.includes('core_action'));
const coreActionCompletionRate = monthly.actionCount
? coreActionEvents.length / monthly.actionCount
: 0;
const coreActionCompletionRate = monthly.actionCount ? coreActionEvents.length / monthly.actionCount : 0;
// Power user score based on advanced features
const advancedFeatures = allFeatures.filter((f) =>
['export', 'integration', 'automation', 'advanced'].some((a) => f.includes(a))
);
@ -440,7 +342,7 @@ function extractEngagementFeatures(
return {
featureUsageDiversity,
coreActionCompletionRate,
featureAdoptionVelocity: monthly.uniqueFeatures.length / 4, // per week
featureAdoptionVelocity: monthly.uniqueFeatures.length / 4,
powerUserScore,
onboardingCompletionRate: calculateOnboardingCompletion(events),
firstValueMomentAchieved: hasFirstValueMoment(events),
@ -455,29 +357,22 @@ function extractPerformanceFeatures(
const monthly = timeWindows.monthly;
const weekly = timeWindows.weekly;
const monthlyErrors = countErrors(
events.filter((e) => new Date(e.timestamp) >= new Date(Date.now() - 30 * 24 * 60 * 60 * 1000))
);
const monthlyErrors = countErrors(events.filter((e) => new Date(e.occurredAt) >= new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)));
const weeklyErrors = weekly.errorCount;
const errorRateLast30Days = monthly.actionCount
? monthlyErrors / monthly.actionCount
: 0;
const errorRateLast7Days = weekly.actionCount
? weeklyErrors / weekly.actionCount
: 0;
const errorRateLast30Days = monthly.actionCount ? monthlyErrors / monthly.actionCount : 0;
const errorRateLast7Days = weekly.actionCount ? weeklyErrors / weekly.actionCount : 0;
// Extract latency from events
const latencyEvents = events.filter((e) => e.duration && e.duration < 30000); // Filter outliers
const latencyEvents = events.filter((e) => e.metrics?.duration && e.metrics.duration < 30000);
const avgLatencyMs = latencyEvents.length
? latencyEvents.reduce((sum, e) => sum + (e.duration || 0), 0) / latencyEvents.length
? latencyEvents.reduce((sum, e) => sum + (e.metrics?.duration || 0), 0) / latencyEvents.length
: 0;
return {
errorRateLast7Days,
errorRateLast30Days,
crashCountLast7Days: countCrashes(weeklyEvents(events)),
crashCountLast30Days: countCrashes(monthlyEvents(events)),
crashCountLast7Days: countCrashes(getWeeklyEvents(events)),
crashCountLast30Days: countCrashes(getMonthlyEvents(events)),
avgLatencyMs,
slowRequestCount: countSlowRequests(events),
timeoutCount: countTimeouts(events),
@ -497,14 +392,12 @@ function extractSocialFeatures(events: TelemetryEventDoc[]): SocialFeatures {
collaborationScore: calculateCollaborationScore(events),
teamMemberCount: extractTeamMemberCount(events),
integrationsConnected: integrationEvents.length,
externalSharesLast30Days: shareEvents.filter((e) => e.properties?.external === true).length,
externalSharesLast30Days: shareEvents.filter((e) => e.context?.external === true).length,
};
}
function extractRevenueFeatures(events: TelemetryEventDoc[]): RevenueFeatures {
const planChangeEvents = events.filter(
(e) => e.eventName?.includes('plan') || e.eventName?.includes('subscription')
);
const planChangeEvents = events.filter((e) => e.eventName?.includes('plan') || e.eventName?.includes('subscription'));
const supportEvents = events.filter((e) => e.eventName?.includes('support'));
const upgrades = planChangeEvents.filter((e) => e.eventName?.includes('upgrade')).length;
@ -520,7 +413,7 @@ function extractRevenueFeatures(events: TelemetryEventDoc[]): RevenueFeatures {
daysSincePlanChange: extractDaysSincePlanChange(events),
supportTicketCount: supportEvents.length,
supportSatisfactionScore: calculateSupportSatisfaction(supportEvents),
escalatedTicketCount: supportEvents.filter((e) => e.properties?.escalated).length,
escalatedTicketCount: supportEvents.filter((e) => e.context?.escalated).length,
};
}
@ -528,52 +421,31 @@ function extractRollingWindowFeatures(timeWindows: TimeWindowFeatures): RollingW
const monthly = timeWindows.monthly;
const weekly = timeWindows.weekly;
// 7-day rolling averages
const rollingAvgSessions7d = weekly.sessionCount / 7;
const rollingAvgDuration7d = weekly.sessionCount
? weekly.totalDuration / weekly.sessionCount / 60
: 0;
const rollingAvgDuration7d = weekly.sessionCount ? weekly.totalDuration / weekly.sessionCount / 60 : 0;
const rollingAvgActions7d = weekly.sessionCount ? weekly.actionCount / weekly.sessionCount : 0;
// Week-over-week change (comparing current week to average week in month)
const avgWeekInMonth = monthly.sessionCount / 4;
const wowSessionChange = avgWeekInMonth ? (weekly.sessionCount - avgWeekInMonth) / avgWeekInMonth : 0;
const avgDurationWeekInMonth = monthly.sessionCount
? monthly.totalDuration / monthly.sessionCount / 60 / 4
: 0;
const avgDurationWeekInMonth = monthly.sessionCount ? monthly.totalDuration / monthly.sessionCount / 60 / 4 : 0;
const wowDurationChange = avgDurationWeekInMonth
? (rollingAvgDuration7d - avgDurationWeekInMonth) / avgDurationWeekInMonth
: 0;
// Cohort percentiles (would require cohort data - using estimates)
const cohortSessionPercentile = estimateCohortPercentile(rollingAvgSessions7d, 'sessions');
const cohortEngagementPercentile = estimateCohortPercentile(
timeWindows.monthly.uniqueFeatures.length,
'features'
);
const cohortRetentionPercentile = estimateCohortPercentile(
monthly.daysActive || 0,
'retention'
);
return {
rollingAvgSessions7d,
rollingAvgDuration7d,
rollingAvgActions7d,
wowSessionChange,
wowDurationChange,
wowActionsChange: wowSessionChange, // Correlated with session change
cohortSessionPercentile,
cohortEngagementPercentile,
cohortRetentionPercentile,
wowActionsChange: wowSessionChange,
cohortSessionPercentile: estimateCohortPercentile(rollingAvgSessions7d, 'sessions'),
cohortEngagementPercentile: estimateCohortPercentile(timeWindows.monthly.uniqueFeatures.length, 'features'),
cohortRetentionPercentile: estimateCohortPercentile(monthly.daysActive || 0, 'retention'),
};
}
// ============================================================================
// Product-Specific Feature Extraction
// ============================================================================
export function extractProductSpecificFeatures(
events: TelemetryEventDoc[],
productId: string
@ -600,12 +472,10 @@ function extractNomGapFeatures(events: TelemetryEventDoc[]): ProductSpecificFeat
const fastEvents = events.filter((e) => e.feature === 'fasting');
const completedFasts = fastEvents.filter((e) => e.eventName === 'fast_completed');
const totalFasts = fastEvents.filter((e) => e.eventName === 'fast_started').length;
const protocolEvents = events.filter((e) => e.feature === 'protocol');
const adheredProtocols = protocolEvents.filter((e) => e.properties?.adhered).length;
const adheredProtocols = protocolEvents.filter((e) => e.context?.adhered).length;
const streakEvents = events.filter((e) => e.eventName?.includes('streak'));
const currentStreak = Math.max(...streakEvents.map((e) => e.properties?.streakLength || 0), 0);
const currentStreak = Math.max(...streakEvents.map((e) => (e.context?.streakLength as number) || 0), 0);
return {
fastCompletionRate: totalFasts ? completedFasts.length / totalFasts : 0,
@ -617,19 +487,16 @@ function extractNomGapFeatures(events: TelemetryEventDoc[]): ProductSpecificFeat
function extractJarvisJrFeatures(events: TelemetryEventDoc[]): ProductSpecificFeatures {
const agentEvents = events.filter((e) => e.feature === 'agent');
const uniqueAgents = new Set(agentEvents.map((e) => e.properties?.agentId)).size;
const voiceEvents = events.filter((e) => e.properties?.mode === 'voice');
const textEvents = events.filter((e) => e.properties?.mode === 'text');
const uniqueAgents = new Set(agentEvents.map((e) => e.context?.agentId as string)).size;
const voiceEvents = events.filter((e) => e.context?.mode === 'voice');
const textEvents = events.filter((e) => e.context?.mode === 'text');
const totalSessions = voiceEvents.length + textEvents.length;
const skillEvents = events.filter((e) => e.eventName?.includes('skill'));
const skillProgression = calculateSkillProgression(skillEvents);
return {
agentDiversityScore: Math.min(uniqueAgents / 3, 1),
voiceSessionRatio: totalSessions ? voiceEvents.length / totalSessions : 0,
skillProgressionRate: skillProgression,
skillProgressionRate: calculateSkillProgression(skillEvents),
sessionCompletionRate: calculateSessionCompletionRate(events),
};
}
@ -638,12 +505,9 @@ function extractChronoMindFeatures(events: TelemetryEventDoc[]): ProductSpecific
const timerEvents = events.filter((e) => e.feature === 'timer');
const completedTimers = timerEvents.filter((e) => e.eventName === 'timer_completed').length;
const totalTimers = timerEvents.filter((e) => e.eventName === 'timer_started').length;
const cascadeEvents = events.filter((e) => e.feature === 'cascade');
const acknowledgedCascades = cascadeEvents.filter((e) => e.properties?.acknowledged).length;
const acknowledgedCascades = cascadeEvents.filter((e) => e.context?.acknowledged).length;
const routineEvents = events.filter((e) => e.feature === 'routine');
const completedRoutines = routineEvents.filter((e) => e.eventName === 'routine_completed').length;
return {
timerCompletionRate: totalTimers ? completedTimers / totalTimers : 0,
@ -655,37 +519,31 @@ function extractChronoMindFeatures(events: TelemetryEventDoc[]): ProductSpecific
function extractMindLystFeatures(events: TelemetryEventDoc[]): ProductSpecificFeatures {
const brainEvents = events.filter((e) => e.feature === 'brain');
const uniqueBrains = new Set(brainEvents.map((e) => e.properties?.brainId)).size;
const uniqueBrains = new Set(brainEvents.map((e) => e.context?.brainId as string)).size;
const triageEvents = events.filter((e) => e.eventName?.includes('triage'));
const accurateTriages = triageEvents.filter((e) => e.properties?.accurate).length;
const accurateTriages = triageEvents.filter((e) => e.context?.accurate).length;
const memoryEvents = events.filter((e) => e.eventName?.includes('memory_capture'));
const reflectionEvents = events.filter((e) => e.eventName?.includes('reflection'));
const completedReflections = reflectionEvents.filter((e) => e.properties?.completed).length;
const completedReflections = reflectionEvents.filter((e) => e.context?.completed).length;
return {
brainUsageDiversity: Math.min(uniqueBrains / 3, 1),
triageAccuracyScore: triageEvents.length ? accurateTriages / triageEvents.length : 0,
memoryCaptureFrequency: memoryEvents.length / 30, // per day
reflectionCompletionRate: reflectionEvents.length
? completedReflections / reflectionEvents.length
: 0,
memoryCaptureFrequency: memoryEvents.length / 30,
reflectionCompletionRate: reflectionEvents.length ? completedReflections / reflectionEvents.length : 0,
};
}
function extractPeakPulseFeatures(events: TelemetryEventDoc[]): ProductSpecificFeatures {
const sessionEvents = events.filter((e) => e.feature === 'activity_session');
const goalEvents = events.filter((e) => e.feature === 'goal');
const completedGoals = goalEvents.filter((e) => e.properties?.completed).length;
const completedGoals = goalEvents.filter((e) => e.context?.completed).length;
const streakEvents = events.filter((e) => e.eventName?.includes('streak'));
const currentStreak = Math.max(...streakEvents.map((e) => e.properties?.streakLength || 0), 0);
const currentStreak = Math.max(...streakEvents.map((e) => (e.context?.streakLength as number) || 0), 0);
const shareEvents = events.filter((e) => e.eventName?.includes('share'));
return {
activitySessionFrequency: sessionEvents.length / 30, // per day
activitySessionFrequency: sessionEvents.length / 30,
goalCompletionRate: goalEvents.length ? completedGoals / goalEvents.length : 0,
streakMaintenanceScore: Math.min(currentStreak / 7, 1),
socialSharingCount: shareEvents.length,
@ -694,41 +552,33 @@ function extractPeakPulseFeatures(events: TelemetryEventDoc[]): ProductSpecificF
function extractLysnrAIFeatures(events: TelemetryEventDoc[]): ProductSpecificFeatures {
const dictationEvents = events.filter((e) => e.feature === 'dictation');
const completedDictations = dictationEvents.filter(
(e) => e.eventName === 'dictation_completed'
).length;
const accuracyEvents = dictationEvents.filter((e) => e.properties?.accuracy !== undefined);
const completedDictations = dictationEvents.filter((e) => e.eventName === 'dictation_completed').length;
const accuracyEvents = dictationEvents.filter((e) => e.metrics?.accuracy !== undefined);
const avgAccuracy = accuracyEvents.length
? accuracyEvents.reduce((sum, e) => sum + (e.properties?.accuracy || 0), 0) /
accuracyEvents.length
? accuracyEvents.reduce((sum, e) => sum + ((e.metrics?.accuracy as number) || 0), 0) / accuracyEvents.length
: 0;
const hotkeyEvents = events.filter((e) => e.eventName?.includes('hotkey'));
const vocabularyEvents = events.filter((e) => e.eventName?.includes('vocabulary'));
return {
dictationFrequency: dictationEvents.length / 30, // per day
dictationFrequency: dictationEvents.length / 30,
accuracyRate: avgAccuracy,
hotkeyUsageRate: hotkeyEvents.length / Math.max(dictationEvents.length, 1),
vocabularyGrowthRate: calculateVocabularyGrowth(vocabularyEvents),
};
}
// ============================================================================
// Helper Functions
// ============================================================================
function findLastSession(events: TelemetryEventDoc[]): TelemetryEventDoc | undefined {
return events
.filter((e) => e.eventType === 'session_start' || e.sessionId)
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0];
.filter((e) => e.eventName?.includes('session_start') || e.sessionId)
.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime())[0];
}
function findLastCoreAction(events: TelemetryEventDoc[]): TelemetryEventDoc | undefined {
return events
.filter((e) => e.properties?.isCoreAction === true || e.eventName?.includes('core'))
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0];
.filter((e) => e.context?.isCoreAction === true || e.eventName?.includes('core'))
.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime())[0];
}
function countSessions(events: TelemetryEventDoc[]): number {
@ -736,24 +586,23 @@ function countSessions(events: TelemetryEventDoc[]): number {
}
function sumDurations(events: TelemetryEventDoc[]): number {
return events.reduce((sum, e) => sum + (e.duration || 0), 0);
return events.reduce((sum, e) => sum + (e.metrics?.duration || 0), 0);
}
function countActions(events: TelemetryEventDoc[]): number {
return events.filter((e) => e.eventType === 'action').length;
return events.filter((e) => e.eventName?.includes('action')).length;
}
function countErrors(events: TelemetryEventDoc[]): number {
return events.filter((e) => e.eventType === 'error' || e.eventName?.includes('error')).length;
return events.filter((e) => e.eventType === 'error' || e.eventType === 'fatal').length;
}
function extractUniqueFeatures(events: TelemetryEventDoc[]): string[] {
return Array.from(new Set(events.map((e) => e.feature).filter(Boolean) as string[]);
return Array.from(new Set(events.map((e) => e.feature).filter(Boolean) as string[]));
}
function daysBetween(timestamp: string, reference: Date): number {
const diff = reference.getTime() - new Date(timestamp).getTime();
return Math.floor(diff / (1000 * 60 * 60 * 24));
return Math.floor((reference.getTime() - new Date(timestamp).getTime()) / (1000 * 60 * 60 * 24));
}
function calculateTrend(current: number, baseline: number): number {
@ -768,33 +617,18 @@ function calculateDataQualityScore(
): number {
let score = 0;
let factors = 0;
if (behavior.sessionsLast30Days > 0) {
score += Math.min(behavior.sessionsLast30Days / 10, 1);
factors++;
}
if (engagement.uniqueFeaturesUsed > 0) {
score += Math.min(engagement.uniqueFeaturesUsed / 5, 1);
factors++;
}
if (performance.errorRateLast30Days >= 0) {
score += 1 - performance.errorRateLast30Days;
factors++;
}
if (behavior.sessionsLast30Days > 0) { score += Math.min(behavior.sessionsLast30Days / 10, 1); factors++; }
if (engagement.featureUsageDiversity > 0) { score += Math.min(engagement.featureUsageDiversity * 5, 1); factors++; }
if (performance.errorRateLast30Days >= 0) { score += 1 - performance.errorRateLast30Days; factors++; }
return factors > 0 ? score / factors : 0;
}
// Placeholder implementations for product-specific helpers
function calculateAutophagyEngagement(events: TelemetryEventDoc[]): number {
const autophagyEvents = events.filter((e) => e.properties?.stage === 'autophagy');
return Math.min(autophagyEvents.length / 10, 1);
return Math.min(events.filter((e) => e.context?.stage === 'autophagy').length / 10, 1);
}
function calculateSkillProgression(events: TelemetryEventDoc[]): number {
if (events.length === 0) return 0;
const progressed = events.filter((e) => e.properties?.progressed).length;
return progressed / events.length;
return events.length ? events.filter((e) => e.context?.progressed).length / events.length : 0;
}
function calculateSessionCompletionRate(events: TelemetryEventDoc[]): number {
@ -804,61 +638,50 @@ function calculateSessionCompletionRate(events: TelemetryEventDoc[]): number {
}
function calculateRoutineAdherence(events: TelemetryEventDoc[]): number {
if (events.length === 0) return 0;
const onTime = events.filter((e) => e.properties?.onTime).length;
return onTime / events.length;
return events.length ? events.filter((e) => e.context?.onTime).length / events.length : 0;
}
function calculateUrgencyResponse(events: TelemetryEventDoc[]): number {
const urgent = events.filter((e) => e.properties?.urgent === true);
if (urgent.length === 0) return 0;
const responded = urgent.filter((e) => e.properties?.responded).length;
return responded / urgent.length;
const urgent = events.filter((e) => e.context?.urgent === true);
return urgent.length ? urgent.filter((e) => e.context?.responded).length / urgent.length : 0;
}
function calculateVocabularyGrowth(events: TelemetryEventDoc[]): number {
const wordsAdded = events.reduce((sum, e) => sum + (e.properties?.wordsAdded || 0), 0);
return wordsAdded / 30; // per day
return events.reduce((sum, e) => sum + ((e.metrics?.wordsAdded as number) || 0), 0) / 30;
}
function calculateOnboardingCompletion(events: TelemetryEventDoc[]): number {
const onboardingSteps = events.filter((e) => e.eventName?.includes('onboarding'));
const completed = onboardingSteps.filter((e) => e.properties?.completed).length;
const totalSteps = 5; // Configurable
return Math.min(completed / totalSteps, 1);
const steps = events.filter((e) => e.eventName?.includes('onboarding'));
return Math.min(steps.filter((e) => e.context?.completed).length / 5, 1);
}
function hasFirstValueMoment(events: TelemetryEventDoc[]): boolean {
return events.some((e) => e.eventName?.includes('first_value') || e.properties?.ahaMoment);
return events.some((e) => e.eventName?.includes('first_value') || e.context?.ahaMoment);
}
function calculateTimeToFirstValue(events: TelemetryEventDoc[]): number {
const firstSession = events.find((e) => e.eventType === 'session_start');
const firstSession = events.find((e) => e.eventName?.includes('session_start'));
const firstValue = events.find((e) => e.eventName?.includes('first_value'));
if (!firstSession || !firstValue) return 0;
return (
(new Date(firstValue.timestamp).getTime() - new Date(firstSession.timestamp).getTime()) /
(1000 * 60 * 60)
);
return firstSession && firstValue
? (new Date(firstValue.occurredAt).getTime() - new Date(firstSession.occurredAt).getTime()) / (1000 * 60 * 60)
: 0;
}
function countCrashes(events: TelemetryEventDoc[]): number {
return events.filter((e) => e.eventName?.includes('crash') || e.properties?.crash).length;
return events.filter((e) => e.eventName?.includes('crash') || e.context?.crash).length;
}
function countSlowRequests(events: TelemetryEventDoc[]): number {
return events.filter((e) => e.duration && e.duration > 5000).length;
return events.filter((e) => e.metrics?.duration && e.metrics.duration > 5000).length;
}
function countTimeouts(events: TelemetryEventDoc[]): number {
return events.filter((e) => e.properties?.timeout || e.eventName?.includes('timeout')).length;
return events.filter((e) => e.context?.timeout || e.eventName?.includes('timeout')).length;
}
function calculateErrorRecoveryRate(events: TelemetryEventDoc[]): number {
const errors = events.filter((e) => e.eventType === 'error');
if (errors.length === 0) return 1;
const recovered = errors.filter((e) => e.properties?.recovered).length;
return recovered / errors.length;
const errors = events.filter((e) => e.eventType === 'error' || e.eventType === 'fatal');
return errors.length ? errors.filter((e) => e.context?.recovered).length / errors.length : 1;
}
function countSupportTickets(events: TelemetryEventDoc[]): number {
@ -866,67 +689,58 @@ function countSupportTickets(events: TelemetryEventDoc[]): number {
}
function calculateCollaborationScore(events: TelemetryEventDoc[]): number {
const collabEvents = events.filter((e) => e.properties?.collaborative === true);
return Math.min(collabEvents.length / 10, 1);
return Math.min(events.filter((e) => e.context?.collaborative === true).length / 10, 1);
}
function extractTeamMemberCount(events: TelemetryEventDoc[]): number {
const teamEvents = events.filter((e) => e.properties?.teamSize !== undefined);
return teamEvents.length > 0 ? Math.max(...teamEvents.map((e) => e.properties?.teamSize || 0)) : 0;
const teamEvents = events.filter((e) => e.context?.teamSize !== undefined);
return teamEvents.length ? Math.max(...teamEvents.map((e) => (e.context?.teamSize as number) || 0)) : 0;
}
function extractPlanTier(events: TelemetryEventDoc[]): number {
const planEvent = events.find((e) => e.properties?.planTier !== undefined);
return planEvent?.properties?.planTier || 0;
const planEvent = events.find((e) => e.context?.planTier !== undefined);
return (planEvent?.context?.planTier as number) || 0;
}
function extractLifetimeValue(events: TelemetryEventDoc[]): number {
return events.reduce((sum, e) => sum + (e.properties?.revenue || 0), 0);
return events.reduce((sum, e) => sum + ((e.metrics?.revenue as number) || 0), 0);
}
function extractMrrContribution(events: TelemetryEventDoc[]): number {
const mrrEvent = events.find((e) => e.properties?.mrr !== undefined);
return mrrEvent?.properties?.mrr || 0;
const mrrEvent = events.find((e) => e.metrics?.mrr !== undefined);
return (mrrEvent?.metrics?.mrr as number) || 0;
}
function extractDaysSincePayment(events: TelemetryEventDoc[]): number {
const paymentEvent = events
.filter((e) => e.eventName?.includes('payment'))
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0];
return paymentEvent ? daysBetween(paymentEvent.timestamp, new Date()) : 30;
.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime())[0];
return paymentEvent ? daysBetween(paymentEvent.occurredAt, new Date()) : 30;
}
function extractDaysSincePlanChange(events: TelemetryEventDoc[]): number {
const planChange = events
.filter((e) => e.eventName?.includes('plan_change'))
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0];
return planChange ? daysBetween(planChange.timestamp, new Date()) : 90;
.sort((a, b) => new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime())[0];
return planChange ? daysBetween(planChange.occurredAt, new Date()) : 90;
}
function calculateSupportSatisfaction(events: TelemetryEventDoc[]): number {
const ratedEvents = events.filter((e) => e.properties?.satisfaction !== undefined);
if (ratedEvents.length === 0) return 0;
const sum = ratedEvents.reduce((acc, e) => acc + (e.properties?.satisfaction || 0), 0);
return sum / ratedEvents.length;
const rated = events.filter((e) => e.context?.satisfaction !== undefined);
return rated.length ? rated.reduce((acc, e) => acc + ((e.context?.satisfaction as number) || 0), 0) / rated.length : 0;
}
function estimateCohortPercentile(value: number, metric: string): number {
// Simplified estimation - in production, this would query cohort data
const baselines: Record<string, number> = {
sessions: 2,
features: 5,
retention: 15,
};
const baseline = baselines[metric] || 1;
return Math.min(Math.round((value / baseline) * 50), 100);
const baselines: Record<string, number> = { sessions: 2, features: 5, retention: 15 };
return Math.min(Math.round((value / (baselines[metric] || 1)) * 50), 100);
}
function weeklyEvents(events: TelemetryEventDoc[]): TelemetryEventDoc[] {
function getWeeklyEvents(events: TelemetryEventDoc[]): TelemetryEventDoc[] {
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
return events.filter((e) => new Date(e.timestamp) >= weekAgo);
return events.filter((e) => new Date(e.occurredAt) >= weekAgo);
}
function monthlyEvents(events: TelemetryEventDoc[]): TelemetryEventDoc[] {
function getMonthlyEvents(events: TelemetryEventDoc[]): TelemetryEventDoc[] {
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
return events.filter((e) => new Date(e.timestamp) >= monthAgo);
return events.filter((e) => new Date(e.occurredAt) >= monthAgo);
}