ai-experiments 2.1.3 → 2.3.0

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +13 -1
  3. package/package.json +16 -16
  4. package/src/clickhouse-storage.ts +698 -0
  5. package/src/decide.ts +8 -10
  6. package/src/experiment.ts +12 -12
  7. package/src/index.ts +13 -3
  8. package/src/tracking.ts +3 -3
  9. package/test/cartesian.test.ts +287 -0
  10. package/test/clickhouse-storage.test.ts +470 -0
  11. package/test/decide.test.ts +414 -0
  12. package/test/experiment.test.ts +473 -0
  13. package/test/tracking.test.ts +347 -0
  14. package/vitest.config.ts +34 -0
  15. package/.turbo/turbo-build.log +0 -4
  16. package/LICENSE +0 -21
  17. package/dist/cartesian.d.ts +0 -140
  18. package/dist/cartesian.d.ts.map +0 -1
  19. package/dist/cartesian.js +0 -216
  20. package/dist/cartesian.js.map +0 -1
  21. package/dist/chdb-storage.d.ts +0 -135
  22. package/dist/chdb-storage.d.ts.map +0 -1
  23. package/dist/chdb-storage.js +0 -468
  24. package/dist/chdb-storage.js.map +0 -1
  25. package/dist/decide.d.ts +0 -152
  26. package/dist/decide.d.ts.map +0 -1
  27. package/dist/decide.js +0 -329
  28. package/dist/decide.js.map +0 -1
  29. package/dist/experiment.d.ts +0 -53
  30. package/dist/experiment.d.ts.map +0 -1
  31. package/dist/experiment.js +0 -292
  32. package/dist/experiment.js.map +0 -1
  33. package/dist/index.d.ts +0 -16
  34. package/dist/index.d.ts.map +0 -1
  35. package/dist/index.js +0 -21
  36. package/dist/index.js.map +0 -1
  37. package/dist/tracking.d.ts +0 -159
  38. package/dist/tracking.d.ts.map +0 -1
  39. package/dist/tracking.js +0 -310
  40. package/dist/tracking.js.map +0 -1
  41. package/dist/types.d.ts +0 -198
  42. package/dist/types.d.ts.map +0 -1
  43. package/dist/types.js +0 -5
  44. package/dist/types.js.map +0 -1
  45. package/src/cartesian.js +0 -215
  46. package/src/chdb-storage.js +0 -464
  47. package/src/chdb-storage.ts +0 -567
  48. package/src/decide.js +0 -328
  49. package/src/experiment.js +0 -291
  50. package/src/index.js +0 -20
  51. package/src/tracking.js +0 -309
  52. package/src/types.js +0 -4
@@ -0,0 +1,473 @@
1
+ /**
2
+ * Tests for Experiment class - A/B testing primitives
3
+ *
4
+ * These tests verify the core experiment functionality including:
5
+ * - Experiment creation and execution
6
+ * - Variant comparison
7
+ * - Metric calculation
8
+ * - Best variant selection
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
12
+ import {
13
+ Experiment,
14
+ createVariantsFromGrid,
15
+ configureTracking,
16
+ createMemoryBackend,
17
+ } from '../src/index.js'
18
+
19
+ // Use memory backend for testing to avoid console output
20
+ const testBackend = createMemoryBackend()
21
+
22
+ describe('Experiment', () => {
23
+ beforeEach(() => {
24
+ testBackend.clear()
25
+ configureTracking({ backend: testBackend, enabled: true })
26
+ })
27
+
28
+ describe('basic experiment creation', () => {
29
+ it('executes variants and returns results', async () => {
30
+ const results = await Experiment({
31
+ id: 'test-experiment',
32
+ name: 'Test Experiment',
33
+ variants: [
34
+ {
35
+ id: 'variant-a',
36
+ name: 'Variant A',
37
+ config: { value: 1 },
38
+ },
39
+ {
40
+ id: 'variant-b',
41
+ name: 'Variant B',
42
+ config: { value: 2 },
43
+ },
44
+ ],
45
+ execute: async (config) => {
46
+ return { computed: config.value * 10 }
47
+ },
48
+ })
49
+
50
+ expect(results.experimentId).toBe('test-experiment')
51
+ expect(results.experimentName).toBe('Test Experiment')
52
+ expect(results.results).toHaveLength(2)
53
+ expect(results.successCount).toBe(2)
54
+ expect(results.failureCount).toBe(0)
55
+ })
56
+
57
+ it('returns ExperimentSummary with all required fields', async () => {
58
+ const results = await Experiment({
59
+ id: 'summary-test',
60
+ name: 'Summary Test',
61
+ variants: [{ id: 'v1', name: 'V1', config: {} }],
62
+ execute: async () => 'done',
63
+ })
64
+
65
+ expect(results).toHaveProperty('experimentId')
66
+ expect(results).toHaveProperty('experimentName')
67
+ expect(results).toHaveProperty('results')
68
+ expect(results).toHaveProperty('totalDuration')
69
+ expect(results).toHaveProperty('successCount')
70
+ expect(results).toHaveProperty('failureCount')
71
+ expect(results).toHaveProperty('startedAt')
72
+ expect(results).toHaveProperty('completedAt')
73
+ expect(results.startedAt).toBeInstanceOf(Date)
74
+ expect(results.completedAt).toBeInstanceOf(Date)
75
+ })
76
+
77
+ it('handles single variant', async () => {
78
+ const results = await Experiment({
79
+ id: 'single-variant',
80
+ name: 'Single Variant Test',
81
+ variants: [{ id: 'only', name: 'Only Variant', config: { x: 42 } }],
82
+ execute: async (config) => config.x,
83
+ })
84
+
85
+ expect(results.results).toHaveLength(1)
86
+ expect(results.results[0].result).toBe(42)
87
+ })
88
+
89
+ it('provides context to execute function', async () => {
90
+ let capturedContext: unknown = null
91
+
92
+ await Experiment({
93
+ id: 'context-test',
94
+ name: 'Context Test',
95
+ variants: [{ id: 'v1', name: 'V1', config: {} }],
96
+ execute: async (config, context) => {
97
+ capturedContext = context
98
+ return 'done'
99
+ },
100
+ })
101
+
102
+ expect(capturedContext).not.toBeNull()
103
+ expect(capturedContext).toHaveProperty('experimentId', 'context-test')
104
+ expect(capturedContext).toHaveProperty('variantId', 'v1')
105
+ expect(capturedContext).toHaveProperty('runId')
106
+ expect(capturedContext).toHaveProperty('startedAt')
107
+ })
108
+ })
109
+
110
+ describe('metric calculation', () => {
111
+ it('computes metric for each variant', async () => {
112
+ const results = await Experiment({
113
+ id: 'metric-test',
114
+ name: 'Metric Test',
115
+ variants: [
116
+ { id: 'low', name: 'Low Score', config: { score: 30 } },
117
+ { id: 'high', name: 'High Score', config: { score: 90 } },
118
+ ],
119
+ execute: async (config) => config.score,
120
+ metric: (result) => result,
121
+ })
122
+
123
+ expect(results.results[0].metricValue).toBe(30)
124
+ expect(results.results[1].metricValue).toBe(90)
125
+ })
126
+
127
+ it('identifies best variant by metric', async () => {
128
+ const results = await Experiment({
129
+ id: 'best-variant-test',
130
+ name: 'Best Variant Test',
131
+ variants: [
132
+ { id: 'poor', name: 'Poor', config: { quality: 0.3 } },
133
+ { id: 'good', name: 'Good', config: { quality: 0.7 } },
134
+ { id: 'excellent', name: 'Excellent', config: { quality: 0.95 } },
135
+ ],
136
+ execute: async (config) => ({ quality: config.quality }),
137
+ metric: (result) => result.quality,
138
+ })
139
+
140
+ expect(results.bestVariant).toBeDefined()
141
+ expect(results.bestVariant?.variantId).toBe('excellent')
142
+ expect(results.bestVariant?.variantName).toBe('Excellent')
143
+ expect(results.bestVariant?.metricValue).toBe(0.95)
144
+ })
145
+
146
+ it('supports async metric function', async () => {
147
+ const results = await Experiment({
148
+ id: 'async-metric',
149
+ name: 'Async Metric Test',
150
+ variants: [{ id: 'v1', name: 'V1', config: { value: 42 } }],
151
+ execute: async (config) => config.value,
152
+ metric: async (result) => {
153
+ await new Promise((r) => setTimeout(r, 10))
154
+ return result * 2
155
+ },
156
+ })
157
+
158
+ expect(results.results[0].metricValue).toBe(84)
159
+ })
160
+ })
161
+
162
+ describe('execution options', () => {
163
+ it('runs variants in parallel by default', async () => {
164
+ const executionOrder: string[] = []
165
+
166
+ await Experiment({
167
+ id: 'parallel-test',
168
+ name: 'Parallel Test',
169
+ variants: [
170
+ { id: 'v1', name: 'V1', config: { delay: 50, id: 'v1' } },
171
+ { id: 'v2', name: 'V2', config: { delay: 10, id: 'v2' } },
172
+ ],
173
+ execute: async (config) => {
174
+ await new Promise((r) => setTimeout(r, config.delay))
175
+ executionOrder.push(config.id)
176
+ return config.id
177
+ },
178
+ })
179
+
180
+ // In parallel mode, v2 should finish before v1 due to shorter delay
181
+ expect(executionOrder).toEqual(['v2', 'v1'])
182
+ })
183
+
184
+ it('runs variants sequentially when parallel=false', async () => {
185
+ const executionOrder: string[] = []
186
+
187
+ await Experiment(
188
+ {
189
+ id: 'sequential-test',
190
+ name: 'Sequential Test',
191
+ variants: [
192
+ { id: 'v1', name: 'V1', config: { delay: 20, id: 'v1' } },
193
+ { id: 'v2', name: 'V2', config: { delay: 10, id: 'v2' } },
194
+ ],
195
+ execute: async (config) => {
196
+ await new Promise((r) => setTimeout(r, config.delay))
197
+ executionOrder.push(config.id)
198
+ return config.id
199
+ },
200
+ },
201
+ { parallel: false }
202
+ )
203
+
204
+ // In sequential mode, v1 should finish before v2 starts
205
+ expect(executionOrder).toEqual(['v1', 'v2'])
206
+ })
207
+
208
+ it('respects maxConcurrency option', async () => {
209
+ let concurrent = 0
210
+ let maxConcurrent = 0
211
+
212
+ await Experiment(
213
+ {
214
+ id: 'concurrency-test',
215
+ name: 'Concurrency Test',
216
+ variants: Array.from({ length: 4 }, (_, i) => ({
217
+ id: `v${i}`,
218
+ name: `Variant ${i}`,
219
+ config: { index: i },
220
+ })),
221
+ execute: async () => {
222
+ concurrent++
223
+ maxConcurrent = Math.max(maxConcurrent, concurrent)
224
+ await new Promise((r) => setTimeout(r, 50))
225
+ concurrent--
226
+ return 'done'
227
+ },
228
+ },
229
+ { maxConcurrency: 2 }
230
+ )
231
+
232
+ expect(maxConcurrent).toBeLessThanOrEqual(2)
233
+ })
234
+
235
+ it('calls onVariantStart callback', async () => {
236
+ const startedVariants: string[] = []
237
+
238
+ await Experiment(
239
+ {
240
+ id: 'callback-start-test',
241
+ name: 'Callback Start Test',
242
+ variants: [
243
+ { id: 'v1', name: 'V1', config: {} },
244
+ { id: 'v2', name: 'V2', config: {} },
245
+ ],
246
+ execute: async () => 'done',
247
+ },
248
+ {
249
+ parallel: false,
250
+ onVariantStart: (variantId) => {
251
+ startedVariants.push(variantId)
252
+ },
253
+ }
254
+ )
255
+
256
+ expect(startedVariants).toEqual(['v1', 'v2'])
257
+ })
258
+
259
+ it('calls onVariantComplete callback', async () => {
260
+ const completedResults: number[] = []
261
+
262
+ await Experiment(
263
+ {
264
+ id: 'callback-complete-test',
265
+ name: 'Callback Complete Test',
266
+ variants: [
267
+ { id: 'v1', name: 'V1', config: { val: 10 } },
268
+ { id: 'v2', name: 'V2', config: { val: 20 } },
269
+ ],
270
+ execute: async (config) => config.val,
271
+ metric: (result) => result,
272
+ },
273
+ {
274
+ parallel: false,
275
+ onVariantComplete: (result) => {
276
+ completedResults.push(result.metricValue!)
277
+ },
278
+ }
279
+ )
280
+
281
+ expect(completedResults).toEqual([10, 20])
282
+ })
283
+ })
284
+
285
+ describe('error handling', () => {
286
+ it('captures variant errors without stopping experiment', async () => {
287
+ const results = await Experiment({
288
+ id: 'error-test',
289
+ name: 'Error Test',
290
+ variants: [
291
+ { id: 'success', name: 'Success', config: { fail: false } },
292
+ { id: 'failure', name: 'Failure', config: { fail: true } },
293
+ { id: 'success2', name: 'Success 2', config: { fail: false } },
294
+ ],
295
+ execute: async (config) => {
296
+ if (config.fail) {
297
+ throw new Error('Intentional failure')
298
+ }
299
+ return 'ok'
300
+ },
301
+ })
302
+
303
+ expect(results.successCount).toBe(2)
304
+ expect(results.failureCount).toBe(1)
305
+
306
+ const failedResult = results.results.find((r) => r.variantId === 'failure')
307
+ expect(failedResult?.success).toBe(false)
308
+ expect(failedResult?.error).toBeInstanceOf(Error)
309
+ expect(failedResult?.error?.message).toBe('Intentional failure')
310
+ })
311
+
312
+ it('stops on first error when stopOnError=true', async () => {
313
+ const executedVariants: string[] = []
314
+
315
+ const results = await Experiment(
316
+ {
317
+ id: 'stop-on-error-test',
318
+ name: 'Stop on Error Test',
319
+ variants: [
320
+ { id: 'v1', name: 'V1', config: { fail: false } },
321
+ { id: 'v2', name: 'V2', config: { fail: true } },
322
+ { id: 'v3', name: 'V3', config: { fail: false } },
323
+ ],
324
+ execute: async (config) => {
325
+ executedVariants.push(config.fail ? 'fail' : 'success')
326
+ if (config.fail) {
327
+ throw new Error('Stop here')
328
+ }
329
+ return 'ok'
330
+ },
331
+ },
332
+ { parallel: false, stopOnError: true }
333
+ )
334
+
335
+ // Should have stopped after v2's error
336
+ expect(results.results.length).toBeLessThanOrEqual(2)
337
+ expect(executedVariants).toContain('fail')
338
+ })
339
+
340
+ it('calls onVariantError callback', async () => {
341
+ const errors: { variantId: string; message: string }[] = []
342
+
343
+ await Experiment(
344
+ {
345
+ id: 'error-callback-test',
346
+ name: 'Error Callback Test',
347
+ variants: [
348
+ { id: 'ok', name: 'OK', config: { fail: false } },
349
+ { id: 'bad', name: 'Bad', config: { fail: true } },
350
+ ],
351
+ execute: async (config) => {
352
+ if (config.fail) {
353
+ throw new Error('Expected error')
354
+ }
355
+ return 'ok'
356
+ },
357
+ },
358
+ {
359
+ onVariantError: (variantId, error) => {
360
+ errors.push({ variantId, message: error.message })
361
+ },
362
+ }
363
+ )
364
+
365
+ expect(errors).toHaveLength(1)
366
+ expect(errors[0].variantId).toBe('bad')
367
+ expect(errors[0].message).toBe('Expected error')
368
+ })
369
+ })
370
+
371
+ describe('tracking integration', () => {
372
+ it('tracks experiment start and complete events', async () => {
373
+ await Experiment({
374
+ id: 'tracking-test',
375
+ name: 'Tracking Test',
376
+ variants: [{ id: 'v1', name: 'V1', config: {} }],
377
+ execute: async () => 'done',
378
+ })
379
+
380
+ const events = testBackend.getEvents()
381
+ const eventTypes = events.map((e) => e.type)
382
+
383
+ expect(eventTypes).toContain('experiment.start')
384
+ expect(eventTypes).toContain('experiment.complete')
385
+ expect(eventTypes).toContain('variant.start')
386
+ expect(eventTypes).toContain('variant.complete')
387
+ })
388
+
389
+ it('tracks metric computation', async () => {
390
+ await Experiment({
391
+ id: 'metric-tracking-test',
392
+ name: 'Metric Tracking Test',
393
+ variants: [{ id: 'v1', name: 'V1', config: {} }],
394
+ execute: async () => 42,
395
+ metric: (result) => result,
396
+ })
397
+
398
+ const events = testBackend.getEvents()
399
+ const metricEvent = events.find((e) => e.type === 'metric.computed')
400
+
401
+ expect(metricEvent).toBeDefined()
402
+ expect(metricEvent?.data.metricValue).toBe(42)
403
+ })
404
+ })
405
+ })
406
+
407
+ describe('createVariantsFromGrid', () => {
408
+ it('creates variants from single parameter', () => {
409
+ const variants = createVariantsFromGrid({
410
+ temperature: [0.3, 0.7, 1.0],
411
+ })
412
+
413
+ expect(variants).toHaveLength(3)
414
+ expect(variants[0].config).toEqual({ temperature: 0.3 })
415
+ expect(variants[1].config).toEqual({ temperature: 0.7 })
416
+ expect(variants[2].config).toEqual({ temperature: 1.0 })
417
+ })
418
+
419
+ it('creates cartesian product of multiple parameters', () => {
420
+ const variants = createVariantsFromGrid({
421
+ model: ['a', 'b'],
422
+ temperature: [0.3, 0.7],
423
+ })
424
+
425
+ // 2 * 2 = 4 combinations
426
+ expect(variants).toHaveLength(4)
427
+
428
+ const configs = variants.map((v) => v.config)
429
+ expect(configs).toContainEqual({ model: 'a', temperature: 0.3 })
430
+ expect(configs).toContainEqual({ model: 'a', temperature: 0.7 })
431
+ expect(configs).toContainEqual({ model: 'b', temperature: 0.3 })
432
+ expect(configs).toContainEqual({ model: 'b', temperature: 0.7 })
433
+ })
434
+
435
+ it('assigns unique IDs to each variant', () => {
436
+ const variants = createVariantsFromGrid({
437
+ x: [1, 2, 3],
438
+ })
439
+
440
+ const ids = variants.map((v) => v.id)
441
+ const uniqueIds = new Set(ids)
442
+
443
+ expect(uniqueIds.size).toBe(ids.length)
444
+ })
445
+
446
+ it('generates descriptive names from config values', () => {
447
+ const variants = createVariantsFromGrid({
448
+ model: ['sonnet'],
449
+ temp: [0.5],
450
+ })
451
+
452
+ expect(variants[0].name).toContain('model=sonnet')
453
+ expect(variants[0].name).toContain('temp=0.5')
454
+ })
455
+
456
+ it('handles three or more parameters', () => {
457
+ const variants = createVariantsFromGrid({
458
+ a: [1, 2],
459
+ b: ['x', 'y'],
460
+ c: [true, false],
461
+ })
462
+
463
+ // 2 * 2 * 2 = 8 combinations
464
+ expect(variants).toHaveLength(8)
465
+ })
466
+
467
+ it('handles empty grid by returning single variant with empty config', () => {
468
+ const variants = createVariantsFromGrid({})
469
+ // The implementation returns a single variant with empty config for empty grid
470
+ expect(variants).toHaveLength(1)
471
+ expect(variants[0].config).toEqual({})
472
+ })
473
+ })