ai-experiments 2.0.2 → 2.1.1

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,565 @@
1
+ /**
2
+ * ClickHouse storage backend for AI experiments using chdb (embedded ClickHouse)
3
+ *
4
+ * Design: Single flat table where experiment events are the atomic unit.
5
+ * Cartesian product results, variant comparisons, and winner analysis
6
+ * are all derived via aggregation queries.
7
+ *
8
+ * This maximizes compression and enables fast analytical queries across
9
+ * thousands of experiment runs.
10
+ */
11
+
12
+ import { Session } from 'chdb'
13
+ import type { TrackingBackend, TrackingEvent, ExperimentResult } from './types.js'
14
+
15
+ const CREATE_TABLE_SQL = `
16
+ CREATE TABLE IF NOT EXISTS experiments (
17
+ -- Event identity
18
+ eventId String,
19
+ eventType LowCardinality(String),
20
+ timestamp DateTime64(3),
21
+
22
+ -- Experiment context
23
+ experimentId String,
24
+ experimentName String DEFAULT '',
25
+
26
+ -- Variant context
27
+ variantId String DEFAULT '',
28
+ variantName String DEFAULT '',
29
+
30
+ -- Cartesian product dimensions (stored as JSON for flexibility)
31
+ dimensions String DEFAULT '{}',
32
+
33
+ -- Execution data
34
+ runId String DEFAULT '',
35
+ success UInt8 DEFAULT 1,
36
+ durationMs UInt32 DEFAULT 0,
37
+
38
+ -- Result data
39
+ result String DEFAULT '{}',
40
+ metricName LowCardinality(String) DEFAULT '',
41
+ metricValue Float64 DEFAULT 0,
42
+
43
+ -- Error tracking
44
+ errorMessage String DEFAULT '',
45
+ errorStack String DEFAULT '',
46
+
47
+ -- Metadata
48
+ metadata String DEFAULT '{}'
49
+ )
50
+ ENGINE = MergeTree()
51
+ ORDER BY (experimentId, variantId, timestamp, eventId)
52
+ SETTINGS index_granularity = 8192
53
+ `
54
+
55
+ export interface ChdbStorageOptions {
56
+ /** Path to store the chdb database (default: ./.experiments-chdb) */
57
+ dataPath?: string
58
+ /** Whether to create tables on init (default: true) */
59
+ autoInit?: boolean
60
+ }
61
+
62
+ export class ChdbStorage implements TrackingBackend {
63
+ private session: Session
64
+ private initialized = false
65
+ private eventCounter = 0
66
+
67
+ constructor(private options: ChdbStorageOptions = {}) {
68
+ this.session = new Session(options.dataPath ?? './.experiments-chdb')
69
+ }
70
+
71
+ private async init(): Promise<void> {
72
+ if (this.initialized) return
73
+ if (this.options.autoInit !== false) {
74
+ this.session.query(CREATE_TABLE_SQL)
75
+ }
76
+ this.initialized = true
77
+ }
78
+
79
+ private escapeString(s: string): string {
80
+ return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
81
+ }
82
+
83
+ private toJson(value: unknown): string {
84
+ return JSON.stringify(value ?? {})
85
+ }
86
+
87
+ private parseJson<T>(value: string): T {
88
+ try {
89
+ return JSON.parse(value || '{}')
90
+ } catch {
91
+ return {} as T
92
+ }
93
+ }
94
+
95
+ private nextEventId(): string {
96
+ return `evt-${Date.now()}-${++this.eventCounter}`
97
+ }
98
+
99
+ // ─────────────────────────────────────────────────────────────────────────────
100
+ // TrackingBackend Implementation
101
+ // ─────────────────────────────────────────────────────────────────────────────
102
+
103
+ async track(event: TrackingEvent): Promise<void> {
104
+ await this.init()
105
+
106
+ const eventId = this.nextEventId()
107
+ const timestamp = event.timestamp.toISOString()
108
+ const data = event.data
109
+
110
+ // Extract standard fields from event data
111
+ const experimentId = String(data.experimentId ?? '')
112
+ const experimentName = String(data.experimentName ?? '')
113
+ const variantId = String(data.variantId ?? '')
114
+ const variantName = String(data.variantName ?? '')
115
+ const runId = String(data.runId ?? '')
116
+ const success = data.success !== false ? 1 : 0
117
+ const durationMs = Number(data.duration ?? data.durationMs ?? 0)
118
+ const metricName = String(data.metricName ?? '')
119
+ const metricValue = Number(data.metricValue ?? 0)
120
+ const errorMessage = data.error instanceof Error ? data.error.message : String(data.errorMessage ?? '')
121
+ const errorStack = data.error instanceof Error ? (data.error.stack ?? '') : ''
122
+
123
+ // Extract dimensions (for cartesian product tracking)
124
+ const dimensions = data.dimensions ?? data.config ?? {}
125
+ const result = data.result ?? {}
126
+ const metadata = data.metadata ?? {}
127
+
128
+ this.session.query(`
129
+ INSERT INTO experiments (
130
+ eventId, eventType, timestamp,
131
+ experimentId, experimentName,
132
+ variantId, variantName,
133
+ dimensions, runId, success, durationMs,
134
+ result, metricName, metricValue,
135
+ errorMessage, errorStack, metadata
136
+ ) VALUES (
137
+ '${eventId}', '${event.type}', '${timestamp}',
138
+ '${this.escapeString(experimentId)}', '${this.escapeString(experimentName)}',
139
+ '${this.escapeString(variantId)}', '${this.escapeString(variantName)}',
140
+ '${this.escapeString(this.toJson(dimensions))}',
141
+ '${this.escapeString(runId)}', ${success}, ${durationMs},
142
+ '${this.escapeString(this.toJson(result))}',
143
+ '${this.escapeString(metricName)}', ${metricValue},
144
+ '${this.escapeString(errorMessage)}', '${this.escapeString(errorStack)}',
145
+ '${this.escapeString(this.toJson(metadata))}'
146
+ )
147
+ `)
148
+ }
149
+
150
+ async flush(): Promise<void> {
151
+ // chdb writes are synchronous, nothing to flush
152
+ }
153
+
154
+ // ─────────────────────────────────────────────────────────────────────────────
155
+ // Experiment Result Storage (direct API)
156
+ // ─────────────────────────────────────────────────────────────────────────────
157
+
158
+ async storeResult(result: ExperimentResult): Promise<void> {
159
+ await this.track({
160
+ type: 'variant.complete',
161
+ timestamp: result.completedAt,
162
+ data: {
163
+ experimentId: result.experimentId,
164
+ variantId: result.variantId,
165
+ variantName: result.variantName,
166
+ runId: result.runId,
167
+ success: result.success,
168
+ duration: result.duration,
169
+ result: result.result,
170
+ metricValue: result.metricValue,
171
+ error: result.error,
172
+ metadata: result.metadata,
173
+ },
174
+ })
175
+ }
176
+
177
+ async storeResults(results: ExperimentResult[]): Promise<void> {
178
+ for (const result of results) {
179
+ await this.storeResult(result)
180
+ }
181
+ }
182
+
183
+ // ─────────────────────────────────────────────────────────────────────────────
184
+ // Analytics Queries
185
+ // ─────────────────────────────────────────────────────────────────────────────
186
+
187
+ /**
188
+ * Get all experiments
189
+ */
190
+ async getExperiments(): Promise<Array<{
191
+ experimentId: string
192
+ experimentName: string
193
+ variantCount: number
194
+ runCount: number
195
+ firstRun: string
196
+ lastRun: string
197
+ }>> {
198
+ await this.init()
199
+
200
+ const result = this.session.query(`
201
+ SELECT
202
+ experimentId,
203
+ any(experimentName) AS experimentName,
204
+ uniq(variantId) AS variantCount,
205
+ count() AS runCount,
206
+ min(timestamp) AS firstRun,
207
+ max(timestamp) AS lastRun
208
+ FROM experiments
209
+ WHERE experimentId != '' AND eventType = 'variant.complete'
210
+ GROUP BY experimentId
211
+ ORDER BY lastRun DESC
212
+ `, 'JSONEachRow')
213
+
214
+ if (!result.trim()) return []
215
+
216
+ return result.trim().split('\n')
217
+ .filter(Boolean)
218
+ .map(line => JSON.parse(line))
219
+ }
220
+
221
+ /**
222
+ * Get variant performance for an experiment
223
+ */
224
+ async getVariantStats(experimentId: string): Promise<Array<{
225
+ variantId: string
226
+ variantName: string
227
+ runCount: number
228
+ successCount: number
229
+ successRate: number
230
+ avgDuration: number
231
+ avgMetric: number
232
+ minMetric: number
233
+ maxMetric: number
234
+ }>> {
235
+ await this.init()
236
+
237
+ const result = this.session.query(`
238
+ SELECT
239
+ variantId,
240
+ any(variantName) AS variantName,
241
+ count() AS runCount,
242
+ countIf(success = 1) AS successCount,
243
+ countIf(success = 1) / count() AS successRate,
244
+ avg(durationMs) AS avgDuration,
245
+ avg(metricValue) AS avgMetric,
246
+ min(metricValue) AS minMetric,
247
+ max(metricValue) AS maxMetric
248
+ FROM experiments
249
+ WHERE experimentId = '${this.escapeString(experimentId)}'
250
+ AND eventType = 'variant.complete'
251
+ GROUP BY variantId
252
+ ORDER BY avgMetric DESC
253
+ `, 'JSONEachRow')
254
+
255
+ if (!result.trim()) return []
256
+
257
+ return result.trim().split('\n')
258
+ .filter(Boolean)
259
+ .map(line => {
260
+ const row = JSON.parse(line)
261
+ return {
262
+ ...row,
263
+ runCount: Number(row.runCount),
264
+ successCount: Number(row.successCount),
265
+ successRate: Number(row.successRate),
266
+ avgDuration: Number(row.avgDuration),
267
+ avgMetric: Number(row.avgMetric),
268
+ minMetric: Number(row.minMetric),
269
+ maxMetric: Number(row.maxMetric),
270
+ }
271
+ })
272
+ }
273
+
274
+ /**
275
+ * Get the best performing variant for an experiment
276
+ */
277
+ async getBestVariant(experimentId: string, options: {
278
+ metric?: 'avgMetric' | 'successRate' | 'avgDuration'
279
+ minimumRuns?: number
280
+ } = {}): Promise<{
281
+ variantId: string
282
+ variantName: string
283
+ metricValue: number
284
+ runCount: number
285
+ } | null> {
286
+ const { metric = 'avgMetric', minimumRuns = 1 } = options
287
+ await this.init()
288
+
289
+ const orderBy = metric === 'avgDuration' ? 'ASC' : 'DESC'
290
+ const metricExpr = metric === 'successRate'
291
+ ? 'countIf(success = 1) / count()'
292
+ : metric === 'avgDuration'
293
+ ? 'avg(durationMs)'
294
+ : 'avg(metricValue)'
295
+
296
+ const result = this.session.query(`
297
+ SELECT
298
+ variantId,
299
+ any(variantName) AS variantName,
300
+ ${metricExpr} AS metricValue,
301
+ count() AS runCount
302
+ FROM experiments
303
+ WHERE experimentId = '${this.escapeString(experimentId)}'
304
+ AND eventType = 'variant.complete'
305
+ GROUP BY variantId
306
+ HAVING runCount >= ${minimumRuns}
307
+ ORDER BY metricValue ${orderBy}
308
+ LIMIT 1
309
+ `, 'JSONEachRow')
310
+
311
+ if (!result.trim()) return null
312
+
313
+ const row = JSON.parse(result.trim().split('\n')[0])
314
+ return {
315
+ variantId: row.variantId,
316
+ variantName: row.variantName,
317
+ metricValue: Number(row.metricValue),
318
+ runCount: Number(row.runCount),
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Get cartesian product analysis - performance by dimension values
324
+ */
325
+ async getCartesianAnalysis(experimentId: string, dimension: string): Promise<Array<{
326
+ dimensionValue: string
327
+ runCount: number
328
+ avgMetric: number
329
+ successRate: number
330
+ }>> {
331
+ await this.init()
332
+
333
+ const result = this.session.query(`
334
+ SELECT
335
+ JSONExtractString(dimensions, '${this.escapeString(dimension)}') AS dimensionValue,
336
+ count() AS runCount,
337
+ avg(metricValue) AS avgMetric,
338
+ countIf(success = 1) / count() AS successRate
339
+ FROM experiments
340
+ WHERE experimentId = '${this.escapeString(experimentId)}'
341
+ AND eventType = 'variant.complete'
342
+ AND dimensionValue != ''
343
+ GROUP BY dimensionValue
344
+ ORDER BY avgMetric DESC
345
+ `, 'JSONEachRow')
346
+
347
+ if (!result.trim()) return []
348
+
349
+ return result.trim().split('\n')
350
+ .filter(Boolean)
351
+ .map(line => {
352
+ const row = JSON.parse(line)
353
+ return {
354
+ dimensionValue: row.dimensionValue,
355
+ runCount: Number(row.runCount),
356
+ avgMetric: Number(row.avgMetric),
357
+ successRate: Number(row.successRate),
358
+ }
359
+ })
360
+ }
361
+
362
+ /**
363
+ * Get multi-dimensional cartesian analysis
364
+ */
365
+ async getCartesianGrid(experimentId: string, dimensions: string[]): Promise<Array<{
366
+ dimensions: Record<string, string>
367
+ runCount: number
368
+ avgMetric: number
369
+ successRate: number
370
+ }>> {
371
+ await this.init()
372
+
373
+ const dimExtracts = dimensions.map(d =>
374
+ `JSONExtractString(dimensions, '${this.escapeString(d)}') AS dim_${d}`
375
+ ).join(', ')
376
+
377
+ const dimGroupBy = dimensions.map(d => `dim_${d}`).join(', ')
378
+
379
+ const result = this.session.query(`
380
+ SELECT
381
+ ${dimExtracts},
382
+ count() AS runCount,
383
+ avg(metricValue) AS avgMetric,
384
+ countIf(success = 1) / count() AS successRate
385
+ FROM experiments
386
+ WHERE experimentId = '${this.escapeString(experimentId)}'
387
+ AND eventType = 'variant.complete'
388
+ GROUP BY ${dimGroupBy}
389
+ ORDER BY avgMetric DESC
390
+ `, 'JSONEachRow')
391
+
392
+ if (!result.trim()) return []
393
+
394
+ return result.trim().split('\n')
395
+ .filter(Boolean)
396
+ .map(line => {
397
+ const row = JSON.parse(line)
398
+ const dims: Record<string, string> = {}
399
+ for (const d of dimensions) {
400
+ dims[d] = row[`dim_${d}`]
401
+ }
402
+ return {
403
+ dimensions: dims,
404
+ runCount: Number(row.runCount),
405
+ avgMetric: Number(row.avgMetric),
406
+ successRate: Number(row.successRate),
407
+ }
408
+ })
409
+ }
410
+
411
+ /**
412
+ * Get time series of experiment metrics
413
+ */
414
+ async getTimeSeries(experimentId: string, options: {
415
+ interval?: 'hour' | 'day' | 'week'
416
+ variantId?: string
417
+ } = {}): Promise<Array<{
418
+ period: string
419
+ runCount: number
420
+ avgMetric: number
421
+ successRate: number
422
+ }>> {
423
+ const { interval = 'day', variantId } = options
424
+ await this.init()
425
+
426
+ const dateFunc = interval === 'hour'
427
+ ? 'toStartOfHour(timestamp)'
428
+ : interval === 'week'
429
+ ? 'toStartOfWeek(timestamp)'
430
+ : 'toStartOfDay(timestamp)'
431
+
432
+ const variantFilter = variantId
433
+ ? `AND variantId = '${this.escapeString(variantId)}'`
434
+ : ''
435
+
436
+ const result = this.session.query(`
437
+ SELECT
438
+ ${dateFunc} AS period,
439
+ count() AS runCount,
440
+ avg(metricValue) AS avgMetric,
441
+ countIf(success = 1) / count() AS successRate
442
+ FROM experiments
443
+ WHERE experimentId = '${this.escapeString(experimentId)}'
444
+ AND eventType = 'variant.complete'
445
+ ${variantFilter}
446
+ GROUP BY period
447
+ ORDER BY period ASC
448
+ `, 'JSONEachRow')
449
+
450
+ if (!result.trim()) return []
451
+
452
+ return result.trim().split('\n')
453
+ .filter(Boolean)
454
+ .map(line => {
455
+ const row = JSON.parse(line)
456
+ return {
457
+ period: row.period,
458
+ runCount: Number(row.runCount),
459
+ avgMetric: Number(row.avgMetric),
460
+ successRate: Number(row.successRate),
461
+ }
462
+ })
463
+ }
464
+
465
+ /**
466
+ * Get raw events for an experiment
467
+ */
468
+ async getEvents(experimentId: string, options: {
469
+ eventType?: string
470
+ variantId?: string
471
+ limit?: number
472
+ } = {}): Promise<TrackingEvent[]> {
473
+ const { eventType, variantId, limit = 100 } = options
474
+ await this.init()
475
+
476
+ const filters = [`experimentId = '${this.escapeString(experimentId)}'`]
477
+ if (eventType) filters.push(`eventType = '${this.escapeString(eventType)}'`)
478
+ if (variantId) filters.push(`variantId = '${this.escapeString(variantId)}'`)
479
+
480
+ const result = this.session.query(`
481
+ SELECT
482
+ eventType,
483
+ timestamp,
484
+ experimentId,
485
+ experimentName,
486
+ variantId,
487
+ variantName,
488
+ dimensions,
489
+ runId,
490
+ success,
491
+ durationMs,
492
+ result,
493
+ metricName,
494
+ metricValue,
495
+ errorMessage,
496
+ metadata
497
+ FROM experiments
498
+ WHERE ${filters.join(' AND ')}
499
+ ORDER BY timestamp DESC
500
+ LIMIT ${limit}
501
+ `, 'JSONEachRow')
502
+
503
+ if (!result.trim()) return []
504
+
505
+ return result.trim().split('\n')
506
+ .filter(Boolean)
507
+ .map(line => {
508
+ const row = JSON.parse(line)
509
+ return {
510
+ type: row.eventType,
511
+ timestamp: new Date(row.timestamp),
512
+ data: {
513
+ experimentId: row.experimentId,
514
+ experimentName: row.experimentName,
515
+ variantId: row.variantId,
516
+ variantName: row.variantName,
517
+ dimensions: this.parseJson(row.dimensions),
518
+ runId: row.runId,
519
+ success: row.success === 1,
520
+ duration: row.durationMs,
521
+ result: this.parseJson(row.result),
522
+ metricName: row.metricName,
523
+ metricValue: row.metricValue,
524
+ errorMessage: row.errorMessage,
525
+ metadata: this.parseJson(row.metadata),
526
+ },
527
+ }
528
+ })
529
+ }
530
+
531
+ /**
532
+ * Raw SQL query access for custom analytics
533
+ */
534
+ query(sql: string, format: string = 'JSONEachRow'): string {
535
+ return this.session.query(sql, format)
536
+ }
537
+
538
+ /**
539
+ * Close the storage (cleanup)
540
+ */
541
+ close(): void {
542
+ // Session cleanup handled by chdb
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Create a chdb storage backend for experiments
548
+ *
549
+ * @example
550
+ * ```ts
551
+ * import { configureTracking } from 'ai-experiments'
552
+ * import { createChdbBackend } from 'ai-experiments/storage'
553
+ *
554
+ * const storage = createChdbBackend({ dataPath: './.my-experiments' })
555
+ *
556
+ * configureTracking({ backend: storage })
557
+ *
558
+ * // Later: analyze results
559
+ * const best = await storage.getBestVariant('my-experiment')
560
+ * console.log(`Best variant: ${best.variantName} (${best.metricValue})`)
561
+ * ```
562
+ */
563
+ export function createChdbBackend(options?: ChdbStorageOptions): ChdbStorage {
564
+ return new ChdbStorage(options)
565
+ }