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.
@@ -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
+ }