waengine 1.7.3 โ 1.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +34 -3
- package/package.json +3 -2
- package/src/ab-testing.js +698 -0
- package/src/advanced-scheduler.js +577 -0
- package/src/ai-features.js +459 -0
- package/src/ai-integration.js +2 -1
- package/src/analytics-manager.js +458 -0
- package/src/business-manager.js +362 -0
- package/src/client.js +447 -39
- package/src/console-logger.js +256 -0
- package/src/core.js +28 -3
- package/src/cross-platform.js +538 -0
- package/src/database-manager.js +766 -0
- package/src/device-manager.js +1 -1
- package/src/easy-bot-fixed.js +341 -0
- package/src/easy-bot.js +503 -22
- package/src/error-handler.js +230 -0
- package/src/gaming-manager.js +842 -0
- package/src/http-client.js +1 -1
- package/src/index.js +15 -0
- package/src/message.js +197 -94
- package/src/multi-client.js +26 -12
- package/src/plugin-manager.js +59 -10
- package/src/prefix-manager.js +48 -1
- package/src/qr-terminal-fix.js +239 -0
- package/src/qr.js +170 -27
- package/src/quick-bot.js +63 -0
- package/src/reporting-manager.js +867 -0
- package/src/scheduler.js +14 -1
- package/src/security-manager.js +678 -0
- package/src/session-manager-old.js +314 -0
- package/src/session-manager.js +429 -24
- package/src/storage.js +254 -194
- package/src/ui-components.js +560 -0
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
import { getStorage } from "./storage.js";
|
|
2
|
+
import { ErrorHandler } from "./error-handler.js";
|
|
3
|
+
|
|
4
|
+
export class ABTestingManager {
|
|
5
|
+
constructor(client) {
|
|
6
|
+
this.client = client;
|
|
7
|
+
this.storage = getStorage();
|
|
8
|
+
this.errorHandler = new ErrorHandler();
|
|
9
|
+
this.experiments = new Map();
|
|
10
|
+
this.userAssignments = new Map();
|
|
11
|
+
this.results = new Map();
|
|
12
|
+
this.metrics = new Map();
|
|
13
|
+
|
|
14
|
+
this.initializeABTesting();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ===== INITIALIZATION =====
|
|
18
|
+
|
|
19
|
+
initializeABTesting() {
|
|
20
|
+
this.loadExperiments();
|
|
21
|
+
this.startMetricsCollection();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
loadExperiments() {
|
|
25
|
+
try {
|
|
26
|
+
const abData = this.storage.read.from("ab_testing").get("data") || {};
|
|
27
|
+
this.experiments = new Map(Object.entries(abData.experiments || {}));
|
|
28
|
+
this.userAssignments = new Map(Object.entries(abData.userAssignments || {}));
|
|
29
|
+
this.results = new Map(Object.entries(abData.results || {}));
|
|
30
|
+
this.metrics = new Map(Object.entries(abData.metrics || {}));
|
|
31
|
+
} catch (error) {
|
|
32
|
+
this.errorHandler.handle(error, 'ABTestingManager.loadExperiments');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
startMetricsCollection() {
|
|
37
|
+
setInterval(() => {
|
|
38
|
+
this.calculateExperimentMetrics();
|
|
39
|
+
this.checkExperimentCompletion();
|
|
40
|
+
}, 60000); // Every minute
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ===== EXPERIMENT MANAGEMENT =====
|
|
44
|
+
|
|
45
|
+
createExperiment(config) {
|
|
46
|
+
try {
|
|
47
|
+
const experimentId = this.generateExperimentId();
|
|
48
|
+
const experiment = {
|
|
49
|
+
id: experimentId,
|
|
50
|
+
name: config.name,
|
|
51
|
+
description: config.description || '',
|
|
52
|
+
type: config.type || 'message', // message, feature, ui
|
|
53
|
+
status: 'draft',
|
|
54
|
+
variants: config.variants || [],
|
|
55
|
+
trafficAllocation: config.trafficAllocation || 100,
|
|
56
|
+
targetAudience: config.targetAudience || {},
|
|
57
|
+
startDate: config.startDate ? new Date(config.startDate) : null,
|
|
58
|
+
endDate: config.endDate ? new Date(config.endDate) : null,
|
|
59
|
+
duration: config.duration || null, // in milliseconds
|
|
60
|
+
successMetrics: config.successMetrics || [],
|
|
61
|
+
hypothesis: config.hypothesis || '',
|
|
62
|
+
createdAt: new Date(),
|
|
63
|
+
createdBy: config.createdBy,
|
|
64
|
+
settings: {
|
|
65
|
+
minSampleSize: config.minSampleSize || 100,
|
|
66
|
+
confidenceLevel: config.confidenceLevel || 0.95,
|
|
67
|
+
statisticalPower: config.statisticalPower || 0.8,
|
|
68
|
+
...config.settings
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Validate experiment
|
|
73
|
+
this.validateExperiment(experiment);
|
|
74
|
+
|
|
75
|
+
this.experiments.set(experimentId, experiment);
|
|
76
|
+
this.saveABTestingData();
|
|
77
|
+
|
|
78
|
+
console.log(`๐งช A/B Test created: ${experiment.name} (${experimentId})`);
|
|
79
|
+
return experimentId;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
this.errorHandler.handle(error, 'ABTestingManager.createExperiment');
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
validateExperiment(experiment) {
|
|
87
|
+
if (!experiment.name) {
|
|
88
|
+
throw new Error('Experiment name is required');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!experiment.variants || experiment.variants.length < 2) {
|
|
92
|
+
throw new Error('At least 2 variants are required');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Validate traffic allocation
|
|
96
|
+
const totalAllocation = experiment.variants.reduce((sum, variant) => sum + (variant.allocation || 0), 0);
|
|
97
|
+
if (totalAllocation !== 100) {
|
|
98
|
+
throw new Error('Variant allocations must sum to 100%');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Validate success metrics
|
|
102
|
+
if (!experiment.successMetrics || experiment.successMetrics.length === 0) {
|
|
103
|
+
throw new Error('At least one success metric is required');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
startExperiment(experimentId) {
|
|
108
|
+
try {
|
|
109
|
+
const experiment = this.experiments.get(experimentId);
|
|
110
|
+
if (!experiment) {
|
|
111
|
+
throw new Error(`Experiment not found: ${experimentId}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (experiment.status !== 'draft') {
|
|
115
|
+
throw new Error(`Cannot start experiment in status: ${experiment.status}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
experiment.status = 'running';
|
|
119
|
+
experiment.actualStartDate = new Date();
|
|
120
|
+
|
|
121
|
+
// Set end date if duration is specified
|
|
122
|
+
if (experiment.duration && !experiment.endDate) {
|
|
123
|
+
experiment.actualEndDate = new Date(Date.now() + experiment.duration);
|
|
124
|
+
} else if (experiment.endDate) {
|
|
125
|
+
experiment.actualEndDate = experiment.endDate;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Initialize results tracking
|
|
129
|
+
this.initializeExperimentResults(experimentId);
|
|
130
|
+
|
|
131
|
+
this.saveABTestingData();
|
|
132
|
+
|
|
133
|
+
console.log(`๐ A/B Test started: ${experiment.name}`);
|
|
134
|
+
return true;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
this.errorHandler.handle(error, 'ABTestingManager.startExperiment');
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
stopExperiment(experimentId, reason = 'manual') {
|
|
142
|
+
try {
|
|
143
|
+
const experiment = this.experiments.get(experimentId);
|
|
144
|
+
if (!experiment) {
|
|
145
|
+
throw new Error(`Experiment not found: ${experimentId}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
experiment.status = 'stopped';
|
|
149
|
+
experiment.stoppedAt = new Date();
|
|
150
|
+
experiment.stopReason = reason;
|
|
151
|
+
|
|
152
|
+
// Calculate final results
|
|
153
|
+
this.calculateFinalResults(experimentId);
|
|
154
|
+
|
|
155
|
+
this.saveABTestingData();
|
|
156
|
+
|
|
157
|
+
console.log(`โน๏ธ A/B Test stopped: ${experiment.name} (${reason})`);
|
|
158
|
+
return true;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
this.errorHandler.handle(error, 'ABTestingManager.stopExperiment');
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ===== USER ASSIGNMENT =====
|
|
166
|
+
|
|
167
|
+
assignUserToVariant(experimentId, userId, userAttributes = {}) {
|
|
168
|
+
try {
|
|
169
|
+
const experiment = this.experiments.get(experimentId);
|
|
170
|
+
if (!experiment || experiment.status !== 'running') {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Check if user already assigned
|
|
175
|
+
const assignmentKey = `${experimentId}:${userId}`;
|
|
176
|
+
if (this.userAssignments.has(assignmentKey)) {
|
|
177
|
+
return this.userAssignments.get(assignmentKey);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check target audience
|
|
181
|
+
if (!this.matchesTargetAudience(userAttributes, experiment.targetAudience)) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Check traffic allocation
|
|
186
|
+
if (!this.shouldIncludeInExperiment(userId, experiment.trafficAllocation)) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Assign to variant
|
|
191
|
+
const variant = this.selectVariant(userId, experiment.variants);
|
|
192
|
+
|
|
193
|
+
const assignment = {
|
|
194
|
+
experimentId,
|
|
195
|
+
userId,
|
|
196
|
+
variantId: variant.id,
|
|
197
|
+
variantName: variant.name,
|
|
198
|
+
assignedAt: new Date(),
|
|
199
|
+
userAttributes
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
this.userAssignments.set(assignmentKey, assignment);
|
|
203
|
+
|
|
204
|
+
// Update experiment metrics
|
|
205
|
+
this.updateExperimentMetrics(experimentId, 'assignment', { variantId: variant.id });
|
|
206
|
+
|
|
207
|
+
this.saveABTestingData();
|
|
208
|
+
|
|
209
|
+
return assignment;
|
|
210
|
+
} catch (error) {
|
|
211
|
+
this.errorHandler.handle(error, 'ABTestingManager.assignUserToVariant');
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
getUserVariant(experimentId, userId) {
|
|
217
|
+
const assignmentKey = `${experimentId}:${userId}`;
|
|
218
|
+
return this.userAssignments.get(assignmentKey);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
matchesTargetAudience(userAttributes, targetAudience) {
|
|
222
|
+
if (!targetAudience || Object.keys(targetAudience).length === 0) {
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return Object.entries(targetAudience).every(([key, criteria]) => {
|
|
227
|
+
const userValue = userAttributes[key];
|
|
228
|
+
|
|
229
|
+
if (typeof criteria === 'object') {
|
|
230
|
+
if (criteria.in && Array.isArray(criteria.in)) {
|
|
231
|
+
return criteria.in.includes(userValue);
|
|
232
|
+
}
|
|
233
|
+
if (criteria.not && Array.isArray(criteria.not)) {
|
|
234
|
+
return !criteria.not.includes(userValue);
|
|
235
|
+
}
|
|
236
|
+
if (criteria.min !== undefined && userValue < criteria.min) {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
if (criteria.max !== undefined && userValue > criteria.max) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
return userValue === criteria;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return true;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
shouldIncludeInExperiment(userId, trafficAllocation) {
|
|
251
|
+
if (trafficAllocation >= 100) return true;
|
|
252
|
+
|
|
253
|
+
// Use consistent hashing based on user ID
|
|
254
|
+
const hash = this.hashUserId(userId);
|
|
255
|
+
return (hash % 100) < trafficAllocation;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
selectVariant(userId, variants) {
|
|
259
|
+
const hash = this.hashUserId(userId);
|
|
260
|
+
let cumulativeAllocation = 0;
|
|
261
|
+
|
|
262
|
+
for (const variant of variants) {
|
|
263
|
+
cumulativeAllocation += variant.allocation;
|
|
264
|
+
if ((hash % 100) < cumulativeAllocation) {
|
|
265
|
+
return variant;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Fallback to first variant
|
|
270
|
+
return variants[0];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
hashUserId(userId) {
|
|
274
|
+
let hash = 0;
|
|
275
|
+
for (let i = 0; i < userId.length; i++) {
|
|
276
|
+
const char = userId.charCodeAt(i);
|
|
277
|
+
hash = ((hash << 5) - hash) + char;
|
|
278
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
279
|
+
}
|
|
280
|
+
return Math.abs(hash);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ===== EVENT TRACKING =====
|
|
284
|
+
|
|
285
|
+
trackEvent(experimentId, userId, eventName, eventData = {}) {
|
|
286
|
+
try {
|
|
287
|
+
const assignment = this.getUserVariant(experimentId, userId);
|
|
288
|
+
if (!assignment) return false;
|
|
289
|
+
|
|
290
|
+
const experiment = this.experiments.get(experimentId);
|
|
291
|
+
if (!experiment || experiment.status !== 'running') return false;
|
|
292
|
+
|
|
293
|
+
const event = {
|
|
294
|
+
experimentId,
|
|
295
|
+
userId,
|
|
296
|
+
variantId: assignment.variantId,
|
|
297
|
+
eventName,
|
|
298
|
+
eventData,
|
|
299
|
+
timestamp: new Date()
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// Store event
|
|
303
|
+
const eventsKey = `${experimentId}:events`;
|
|
304
|
+
const events = this.results.get(eventsKey) || [];
|
|
305
|
+
events.push(event);
|
|
306
|
+
this.results.set(eventsKey, events);
|
|
307
|
+
|
|
308
|
+
// Update metrics
|
|
309
|
+
this.updateExperimentMetrics(experimentId, eventName, {
|
|
310
|
+
variantId: assignment.variantId,
|
|
311
|
+
eventData
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
this.saveABTestingData();
|
|
315
|
+
|
|
316
|
+
return true;
|
|
317
|
+
} catch (error) {
|
|
318
|
+
this.errorHandler.handle(error, 'ABTestingManager.trackEvent');
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
trackConversion(experimentId, userId, conversionValue = 1, conversionData = {}) {
|
|
324
|
+
return this.trackEvent(experimentId, userId, 'conversion', {
|
|
325
|
+
value: conversionValue,
|
|
326
|
+
...conversionData
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
trackClick(experimentId, userId, element, clickData = {}) {
|
|
331
|
+
return this.trackEvent(experimentId, userId, 'click', {
|
|
332
|
+
element,
|
|
333
|
+
...clickData
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
trackView(experimentId, userId, viewData = {}) {
|
|
338
|
+
return this.trackEvent(experimentId, userId, 'view', viewData);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ===== METRICS CALCULATION =====
|
|
342
|
+
|
|
343
|
+
initializeExperimentResults(experimentId) {
|
|
344
|
+
const experiment = this.experiments.get(experimentId);
|
|
345
|
+
if (!experiment) return;
|
|
346
|
+
|
|
347
|
+
const results = {
|
|
348
|
+
experimentId,
|
|
349
|
+
variants: {},
|
|
350
|
+
overall: {
|
|
351
|
+
totalUsers: 0,
|
|
352
|
+
totalEvents: 0,
|
|
353
|
+
startDate: new Date(),
|
|
354
|
+
lastUpdated: new Date()
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
// Initialize variant results
|
|
359
|
+
experiment.variants.forEach(variant => {
|
|
360
|
+
results.variants[variant.id] = {
|
|
361
|
+
id: variant.id,
|
|
362
|
+
name: variant.name,
|
|
363
|
+
users: 0,
|
|
364
|
+
events: {},
|
|
365
|
+
conversions: 0,
|
|
366
|
+
conversionRate: 0,
|
|
367
|
+
totalValue: 0,
|
|
368
|
+
averageValue: 0
|
|
369
|
+
};
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
this.results.set(experimentId, results);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
updateExperimentMetrics(experimentId, eventType, data) {
|
|
376
|
+
const results = this.results.get(experimentId);
|
|
377
|
+
if (!results) return;
|
|
378
|
+
|
|
379
|
+
const variantResults = results.variants[data.variantId];
|
|
380
|
+
if (!variantResults) return;
|
|
381
|
+
|
|
382
|
+
switch (eventType) {
|
|
383
|
+
case 'assignment':
|
|
384
|
+
variantResults.users++;
|
|
385
|
+
results.overall.totalUsers++;
|
|
386
|
+
break;
|
|
387
|
+
|
|
388
|
+
case 'conversion':
|
|
389
|
+
variantResults.conversions++;
|
|
390
|
+
variantResults.totalValue += data.eventData?.value || 1;
|
|
391
|
+
variantResults.conversionRate = variantResults.conversions / variantResults.users;
|
|
392
|
+
variantResults.averageValue = variantResults.totalValue / variantResults.conversions;
|
|
393
|
+
break;
|
|
394
|
+
|
|
395
|
+
default:
|
|
396
|
+
if (!variantResults.events[eventType]) {
|
|
397
|
+
variantResults.events[eventType] = 0;
|
|
398
|
+
}
|
|
399
|
+
variantResults.events[eventType]++;
|
|
400
|
+
results.overall.totalEvents++;
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
results.overall.lastUpdated = new Date();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
calculateExperimentMetrics() {
|
|
408
|
+
for (const [experimentId, experiment] of this.experiments) {
|
|
409
|
+
if (experiment.status !== 'running') continue;
|
|
410
|
+
|
|
411
|
+
const results = this.results.get(experimentId);
|
|
412
|
+
if (!results) continue;
|
|
413
|
+
|
|
414
|
+
// Calculate statistical significance
|
|
415
|
+
this.calculateStatisticalSignificance(experimentId);
|
|
416
|
+
|
|
417
|
+
// Update confidence intervals
|
|
418
|
+
this.calculateConfidenceIntervals(experimentId);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
calculateStatisticalSignificance(experimentId) {
|
|
423
|
+
const experiment = this.experiments.get(experimentId);
|
|
424
|
+
const results = this.results.get(experimentId);
|
|
425
|
+
|
|
426
|
+
if (!experiment || !results) return;
|
|
427
|
+
|
|
428
|
+
const variants = Object.values(results.variants);
|
|
429
|
+
if (variants.length < 2) return;
|
|
430
|
+
|
|
431
|
+
// Use control variant (first one) as baseline
|
|
432
|
+
const control = variants[0];
|
|
433
|
+
|
|
434
|
+
for (let i = 1; i < variants.length; i++) {
|
|
435
|
+
const variant = variants[i];
|
|
436
|
+
|
|
437
|
+
// Calculate z-score for conversion rate difference
|
|
438
|
+
const pControl = control.conversionRate;
|
|
439
|
+
const pVariant = variant.conversionRate;
|
|
440
|
+
const nControl = control.users;
|
|
441
|
+
const nVariant = variant.users;
|
|
442
|
+
|
|
443
|
+
if (nControl === 0 || nVariant === 0) continue;
|
|
444
|
+
|
|
445
|
+
const pPooled = (control.conversions + variant.conversions) / (nControl + nVariant);
|
|
446
|
+
const se = Math.sqrt(pPooled * (1 - pPooled) * (1/nControl + 1/nVariant));
|
|
447
|
+
|
|
448
|
+
if (se === 0) continue;
|
|
449
|
+
|
|
450
|
+
const zScore = (pVariant - pControl) / se;
|
|
451
|
+
const pValue = 2 * (1 - this.normalCDF(Math.abs(zScore)));
|
|
452
|
+
|
|
453
|
+
variant.statisticalSignificance = {
|
|
454
|
+
zScore,
|
|
455
|
+
pValue,
|
|
456
|
+
isSignificant: pValue < (1 - experiment.settings.confidenceLevel),
|
|
457
|
+
confidenceLevel: experiment.settings.confidenceLevel
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
calculateConfidenceIntervals(experimentId) {
|
|
463
|
+
const experiment = this.experiments.get(experimentId);
|
|
464
|
+
const results = this.results.get(experimentId);
|
|
465
|
+
|
|
466
|
+
if (!experiment || !results) return;
|
|
467
|
+
|
|
468
|
+
const zScore = this.getZScoreForConfidence(experiment.settings.confidenceLevel);
|
|
469
|
+
|
|
470
|
+
Object.values(results.variants).forEach(variant => {
|
|
471
|
+
if (variant.users === 0) return;
|
|
472
|
+
|
|
473
|
+
const p = variant.conversionRate;
|
|
474
|
+
const n = variant.users;
|
|
475
|
+
const se = Math.sqrt((p * (1 - p)) / n);
|
|
476
|
+
|
|
477
|
+
variant.confidenceInterval = {
|
|
478
|
+
lower: Math.max(0, p - zScore * se),
|
|
479
|
+
upper: Math.min(1, p + zScore * se),
|
|
480
|
+
confidenceLevel: experiment.settings.confidenceLevel
|
|
481
|
+
};
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
normalCDF(x) {
|
|
486
|
+
// Approximation of normal cumulative distribution function
|
|
487
|
+
const t = 1 / (1 + 0.2316419 * Math.abs(x));
|
|
488
|
+
const d = 0.3989423 * Math.exp(-x * x / 2);
|
|
489
|
+
const prob = d * t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.821256 + t * 1.330274))));
|
|
490
|
+
|
|
491
|
+
return x > 0 ? 1 - prob : prob;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
getZScoreForConfidence(confidenceLevel) {
|
|
495
|
+
// Common z-scores for confidence levels
|
|
496
|
+
const zScores = {
|
|
497
|
+
0.90: 1.645,
|
|
498
|
+
0.95: 1.96,
|
|
499
|
+
0.99: 2.576
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
return zScores[confidenceLevel] || 1.96;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ===== EXPERIMENT COMPLETION =====
|
|
506
|
+
|
|
507
|
+
checkExperimentCompletion() {
|
|
508
|
+
for (const [experimentId, experiment] of this.experiments) {
|
|
509
|
+
if (experiment.status !== 'running') continue;
|
|
510
|
+
|
|
511
|
+
// Check if experiment should end
|
|
512
|
+
const shouldEnd = this.shouldEndExperiment(experimentId);
|
|
513
|
+
|
|
514
|
+
if (shouldEnd.should) {
|
|
515
|
+
this.stopExperiment(experimentId, shouldEnd.reason);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
shouldEndExperiment(experimentId) {
|
|
521
|
+
const experiment = this.experiments.get(experimentId);
|
|
522
|
+
const results = this.results.get(experimentId);
|
|
523
|
+
|
|
524
|
+
if (!experiment || !results) {
|
|
525
|
+
return { should: false };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Check end date
|
|
529
|
+
if (experiment.actualEndDate && new Date() >= experiment.actualEndDate) {
|
|
530
|
+
return { should: true, reason: 'end_date_reached' };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Check minimum sample size
|
|
534
|
+
const totalUsers = results.overall.totalUsers;
|
|
535
|
+
if (totalUsers < experiment.settings.minSampleSize) {
|
|
536
|
+
return { should: false };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Check statistical significance
|
|
540
|
+
const variants = Object.values(results.variants);
|
|
541
|
+
const hasSignificantResult = variants.some(variant =>
|
|
542
|
+
variant.statisticalSignificance?.isSignificant
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
if (hasSignificantResult && totalUsers >= experiment.settings.minSampleSize * 2) {
|
|
546
|
+
return { should: true, reason: 'statistical_significance_reached' };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return { should: false };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
calculateFinalResults(experimentId) {
|
|
553
|
+
const experiment = this.experiments.get(experimentId);
|
|
554
|
+
const results = this.results.get(experimentId);
|
|
555
|
+
|
|
556
|
+
if (!experiment || !results) return;
|
|
557
|
+
|
|
558
|
+
// Calculate final metrics
|
|
559
|
+
this.calculateStatisticalSignificance(experimentId);
|
|
560
|
+
this.calculateConfidenceIntervals(experimentId);
|
|
561
|
+
|
|
562
|
+
// Determine winner
|
|
563
|
+
const variants = Object.values(results.variants);
|
|
564
|
+
const winner = variants.reduce((best, current) => {
|
|
565
|
+
if (!best) return current;
|
|
566
|
+
|
|
567
|
+
// Compare conversion rates
|
|
568
|
+
if (current.conversionRate > best.conversionRate) {
|
|
569
|
+
// Check if difference is statistically significant
|
|
570
|
+
if (current.statisticalSignificance?.isSignificant) {
|
|
571
|
+
return current;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return best;
|
|
576
|
+
}, null);
|
|
577
|
+
|
|
578
|
+
results.winner = winner;
|
|
579
|
+
results.finalizedAt = new Date();
|
|
580
|
+
|
|
581
|
+
console.log(`๐ A/B Test completed: ${experiment.name}, Winner: ${winner?.name || 'No clear winner'}`);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// ===== REPORTING =====
|
|
585
|
+
|
|
586
|
+
getExperimentReport(experimentId) {
|
|
587
|
+
const experiment = this.experiments.get(experimentId);
|
|
588
|
+
const results = this.results.get(experimentId);
|
|
589
|
+
|
|
590
|
+
if (!experiment || !results) return null;
|
|
591
|
+
|
|
592
|
+
return {
|
|
593
|
+
experiment: {
|
|
594
|
+
id: experiment.id,
|
|
595
|
+
name: experiment.name,
|
|
596
|
+
description: experiment.description,
|
|
597
|
+
status: experiment.status,
|
|
598
|
+
hypothesis: experiment.hypothesis,
|
|
599
|
+
startDate: experiment.actualStartDate,
|
|
600
|
+
endDate: experiment.actualEndDate,
|
|
601
|
+
duration: experiment.actualEndDate ?
|
|
602
|
+
experiment.actualEndDate - experiment.actualStartDate :
|
|
603
|
+
Date.now() - experiment.actualStartDate
|
|
604
|
+
},
|
|
605
|
+
results: {
|
|
606
|
+
...results,
|
|
607
|
+
variants: Object.values(results.variants).map(variant => ({
|
|
608
|
+
...variant,
|
|
609
|
+
improvement: this.calculateImprovement(variant, results.variants[experiment.variants[0].id])
|
|
610
|
+
}))
|
|
611
|
+
},
|
|
612
|
+
summary: this.generateExperimentSummary(experiment, results)
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
calculateImprovement(variant, control) {
|
|
617
|
+
if (!control || control.conversionRate === 0) return null;
|
|
618
|
+
|
|
619
|
+
const improvement = ((variant.conversionRate - control.conversionRate) / control.conversionRate) * 100;
|
|
620
|
+
|
|
621
|
+
return {
|
|
622
|
+
percentage: improvement,
|
|
623
|
+
absolute: variant.conversionRate - control.conversionRate,
|
|
624
|
+
isPositive: improvement > 0
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
generateExperimentSummary(experiment, results) {
|
|
629
|
+
const variants = Object.values(results.variants);
|
|
630
|
+
const totalUsers = results.overall.totalUsers;
|
|
631
|
+
const winner = results.winner;
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
totalParticipants: totalUsers,
|
|
635
|
+
duration: experiment.actualEndDate ?
|
|
636
|
+
Math.floor((experiment.actualEndDate - experiment.actualStartDate) / (1000 * 60 * 60 * 24)) :
|
|
637
|
+
Math.floor((Date.now() - experiment.actualStartDate) / (1000 * 60 * 60 * 24)),
|
|
638
|
+
hasWinner: !!winner,
|
|
639
|
+
winnerName: winner?.name,
|
|
640
|
+
winnerImprovement: winner ? this.calculateImprovement(winner, variants[0]) : null,
|
|
641
|
+
isStatisticallySignificant: winner?.statisticalSignificance?.isSignificant || false,
|
|
642
|
+
confidenceLevel: experiment.settings.confidenceLevel
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ===== UTILITY METHODS =====
|
|
647
|
+
|
|
648
|
+
generateExperimentId() {
|
|
649
|
+
return `exp_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
saveABTestingData() {
|
|
653
|
+
try {
|
|
654
|
+
const abData = {
|
|
655
|
+
experiments: Object.fromEntries(this.experiments),
|
|
656
|
+
userAssignments: Object.fromEntries(this.userAssignments),
|
|
657
|
+
results: Object.fromEntries(this.results),
|
|
658
|
+
metrics: Object.fromEntries(this.metrics)
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
this.storage.write.to("ab_testing").set("data", abData);
|
|
662
|
+
} catch (error) {
|
|
663
|
+
this.errorHandler.handle(error, 'ABTestingManager.saveABTestingData');
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ===== PUBLIC API =====
|
|
668
|
+
|
|
669
|
+
getAllExperiments() {
|
|
670
|
+
return Array.from(this.experiments.values());
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
getActiveExperiments() {
|
|
674
|
+
return Array.from(this.experiments.values()).filter(exp => exp.status === 'running');
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
getExperiment(experimentId) {
|
|
678
|
+
return this.experiments.get(experimentId);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
getUserExperiments(userId) {
|
|
682
|
+
const userExperiments = [];
|
|
683
|
+
|
|
684
|
+
for (const [key, assignment] of this.userAssignments) {
|
|
685
|
+
if (assignment.userId === userId) {
|
|
686
|
+
const experiment = this.experiments.get(assignment.experimentId);
|
|
687
|
+
if (experiment) {
|
|
688
|
+
userExperiments.push({
|
|
689
|
+
experiment,
|
|
690
|
+
assignment
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return userExperiments;
|
|
697
|
+
}
|
|
698
|
+
}
|