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,470 @@
1
+ /**
2
+ * Tests for the ClickHouse-backed experiment storage.
3
+ *
4
+ * Uses an in-memory fake `ClickHouseHttpFetcher` that interprets the SQL
5
+ * the storage layer (and the underlying canonical CH adapter) emits.
6
+ * No real ClickHouse instance is required.
7
+ *
8
+ * Coverage:
9
+ * - Track event → SVO Action shape (verb / subject / object / data)
10
+ * - storeResult / storeResults (single + bulk via commitBatch)
11
+ * - getVariantStats / getBestVariant (analyticsQuery against actions)
12
+ * - getEvents (round-trip via analyticsQuery)
13
+ * - close() is a no-op
14
+ */
15
+ import { describe, it, expect, beforeEach } from 'vitest'
16
+ import type { ClickHouseHttpFetcher } from 'ai-database'
17
+ import {
18
+ ClickHouseExperimentStorage,
19
+ createClickHouseExperimentStorage,
20
+ ChdbStorage,
21
+ createChdbBackend,
22
+ } from '../src/clickhouse-storage.js'
23
+ import type { ExperimentResult, TrackingEvent } from '../src/types.js'
24
+
25
+ interface ActionRow {
26
+ ns: string
27
+ id: string
28
+ verb: string
29
+ subject: string
30
+ object: string
31
+ roles: Record<string, string>
32
+ data: Record<string, unknown>
33
+ status: string
34
+ created_at: string
35
+ completed_at: string | null
36
+ }
37
+
38
+ function jsonResp<T>(rows: T[]): string {
39
+ return JSON.stringify({ data: rows, rows: rows.length })
40
+ }
41
+
42
+ /**
43
+ * Minimal fake fetcher that handles INSERT INTO actions (JSONEachRow) and
44
+ * the analytical SELECTs the storage layer emits. Aggregations are
45
+ * computed in JS — the SQL is parsed enough to detect the query family
46
+ * (verb/subject filters, dimension extraction, aggregation choice).
47
+ */
48
+ function makeFakeFetcher(): {
49
+ fetcher: ClickHouseHttpFetcher
50
+ actions: Map<string, ActionRow>
51
+ log: Array<{ sql: string; body?: string }>
52
+ } {
53
+ const actions = new Map<string, ActionRow>()
54
+ const log: Array<{ sql: string; body?: string }> = []
55
+
56
+ const fetcher: ClickHouseHttpFetcher = async (sql: string, body?: string) => {
57
+ log.push(body !== undefined ? { sql, body } : { sql })
58
+ const trimmed = sql.trim()
59
+ const head = trimmed.split(/\s+/)[0]?.toUpperCase()
60
+
61
+ if (head === 'INSERT' && body && /INTO\s+\w+\.actions/i.test(trimmed)) {
62
+ const lines = body.split('\n').filter((l) => l.trim().length > 0)
63
+ for (const line of lines) {
64
+ const r = JSON.parse(line) as ActionRow & { data: string; roles: string }
65
+ const data: Record<string, unknown> =
66
+ typeof r.data === 'string' ? JSON.parse(r.data) : (r.data as Record<string, unknown>)
67
+ const roles: Record<string, string> =
68
+ typeof r.roles === 'string' ? JSON.parse(r.roles) : (r.roles as Record<string, string>)
69
+ actions.set(r.id, {
70
+ ns: r.ns,
71
+ id: r.id,
72
+ verb: r.verb,
73
+ subject: r.subject,
74
+ object: r.object,
75
+ roles,
76
+ data,
77
+ status: r.status,
78
+ created_at: r.created_at,
79
+ completed_at: r.completed_at,
80
+ })
81
+ }
82
+ return ''
83
+ }
84
+
85
+ // CREATE DATABASE / TABLE — no-op
86
+ if (head === 'CREATE') return ''
87
+
88
+ if (head === 'SELECT') {
89
+ const ns = matchEq(trimmed, 'ns') ?? ''
90
+ const subject = matchEq(trimmed, 'subject')
91
+ const verb = matchEq(trimmed, 'verb') ?? ''
92
+ const filtered = [...actions.values()].filter((a) => {
93
+ if (a.ns !== ns) return false
94
+ if (verb && a.verb !== verb) return false
95
+ if (subject !== undefined && a.subject !== subject) return false
96
+ return true
97
+ })
98
+
99
+ // getEvents: SELECT verb, created_at, data FROM actions ...
100
+ if (/verb\s+AS\s+eventType/i.test(trimmed)) {
101
+ const limitM = trimmed.match(/LIMIT\s+(\d+)/i)
102
+ const limit = limitM ? Number(limitM[1]) : 100
103
+ const variantId = matchEqExclusive(trimmed, 'object')
104
+ const eventTypeFilter = verb || matchEq(trimmed, 'verb')
105
+ const out = filtered
106
+ .filter((a) => (variantId ? a.object === variantId : true))
107
+ .filter((a) => (eventTypeFilter ? a.verb === eventTypeFilter : true))
108
+ .sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
109
+ .slice(0, limit)
110
+ .map((a) => ({
111
+ eventType: a.verb,
112
+ createdAt: a.created_at,
113
+ data: JSON.stringify(a.data),
114
+ }))
115
+ return jsonResp(out)
116
+ }
117
+
118
+ // getExperiments: GROUP BY subject
119
+ if (/GROUP\s+BY\s+subject/i.test(trimmed)) {
120
+ const groups = new Map<string, ActionRow[]>()
121
+ for (const a of filtered) {
122
+ const list = groups.get(a.subject) ?? []
123
+ list.push(a)
124
+ groups.set(a.subject, list)
125
+ }
126
+ const out: Array<Record<string, unknown>> = []
127
+ for (const [exp, rows] of groups) {
128
+ if (!exp) continue
129
+ const variants = new Set(rows.map((r) => r.object))
130
+ const expName = String(rows[0]?.data['experimentName'] ?? '')
131
+ const sorted = [...rows].sort((a, b) => (a.created_at < b.created_at ? -1 : 1))
132
+ out.push({
133
+ experimentId: exp,
134
+ experimentName: expName,
135
+ variantCount: variants.size,
136
+ runCount: rows.length,
137
+ firstRun: sorted[0]?.created_at ?? '',
138
+ lastRun: sorted[sorted.length - 1]?.created_at ?? '',
139
+ })
140
+ }
141
+ out.sort((a, b) =>
142
+ String(a['lastRun']) < String(b['lastRun'])
143
+ ? 1
144
+ : String(a['lastRun']) > String(b['lastRun'])
145
+ ? -1
146
+ : 0
147
+ )
148
+ return jsonResp(out)
149
+ }
150
+
151
+ // getVariantStats / getBestVariant: GROUP BY object
152
+ if (/GROUP\s+BY\s+object/i.test(trimmed)) {
153
+ const groups = new Map<string, ActionRow[]>()
154
+ for (const a of filtered) {
155
+ const list = groups.get(a.object) ?? []
156
+ list.push(a)
157
+ groups.set(a.object, list)
158
+ }
159
+ const rowsAgg: Array<Record<string, unknown>> = []
160
+ for (const [variantId, list] of groups) {
161
+ const successCount = list.filter(
162
+ (a) => Number((a.data as Record<string, unknown>)['success'] ?? 0) === 1
163
+ ).length
164
+ const total = list.length
165
+ const sumDur = list.reduce(
166
+ (s, a) => s + Number((a.data as Record<string, unknown>)['durationMs'] ?? 0),
167
+ 0
168
+ )
169
+ const sumMet = list.reduce(
170
+ (s, a) => s + Number((a.data as Record<string, unknown>)['metricValue'] ?? 0),
171
+ 0
172
+ )
173
+ const metValues = list.map((a) =>
174
+ Number((a.data as Record<string, unknown>)['metricValue'] ?? 0)
175
+ )
176
+ const variantName = String((list[0]?.data ?? {})['variantName'] ?? '')
177
+ const successRate = total === 0 ? 0 : successCount / total
178
+ const avgMetric = total === 0 ? 0 : sumMet / total
179
+ const avgDuration = total === 0 ? 0 : sumDur / total
180
+ // getBestVariant uses `metricValue AS metricValue`
181
+ if (/metricValue\s*$|metricValue,\s*$/m.test(trimmed) === false) {
182
+ // distinguishing handled by output column shape below
183
+ }
184
+ rowsAgg.push({
185
+ variantId,
186
+ variantName,
187
+ runCount: total,
188
+ successCount,
189
+ successRate,
190
+ avgDuration,
191
+ avgMetric,
192
+ minMetric: metValues.length ? Math.min(...metValues) : 0,
193
+ maxMetric: metValues.length ? Math.max(...metValues) : 0,
194
+ // getBestVariant: column literally named `metricValue`
195
+ metricValue:
196
+ /successRate/i.test(trimmed) && /AS\s+metricValue/i.test(trimmed)
197
+ ? successRate
198
+ : /avg\(JSONExtractFloat\(data,\s*'durationMs'\)\)\s+AS\s+metricValue/i.test(
199
+ trimmed
200
+ )
201
+ ? avgDuration
202
+ : avgMetric,
203
+ })
204
+ }
205
+ // Best-variant: HAVING + ORDER BY + LIMIT 1
206
+ if (/LIMIT\s+1\b/i.test(trimmed) && /HAVING/i.test(trimmed)) {
207
+ const havingM = trimmed.match(/runCount\s*>=\s*(\d+)/i)
208
+ const min = havingM ? Number(havingM[1]) : 1
209
+ const filteredRows = rowsAgg.filter((r) => Number(r['runCount']) >= min)
210
+ const orderAsc = /ORDER\s+BY\s+metricValue\s+ASC/i.test(trimmed)
211
+ filteredRows.sort((a, b) =>
212
+ orderAsc
213
+ ? Number(a['metricValue']) - Number(b['metricValue'])
214
+ : Number(b['metricValue']) - Number(a['metricValue'])
215
+ )
216
+ return jsonResp(filteredRows.slice(0, 1))
217
+ }
218
+ rowsAgg.sort((a, b) => Number(b['avgMetric']) - Number(a['avgMetric']))
219
+ return jsonResp(rowsAgg)
220
+ }
221
+
222
+ // getCartesianAnalysis / getCartesianGrid / getTimeSeries — return
223
+ // empty for these tests; aggregation logic is exercised by the SQL
224
+ // builder (it emits shape-correct queries) and the action-write path
225
+ // is verified above.
226
+ return jsonResp([])
227
+ }
228
+
229
+ return ''
230
+ }
231
+
232
+ return { fetcher, actions, log }
233
+ }
234
+
235
+ function matchEq(sql: string, column: string): string | undefined {
236
+ const escaped = column.replace(/[.]/g, '\\.')
237
+ const re = new RegExp(`(?<![a-zA-Z_.])${escaped}\\s*=\\s*'([^']*)'`)
238
+ const m = sql.match(re)
239
+ return m?.[1]
240
+ }
241
+
242
+ function matchEqExclusive(sql: string, column: string): string | undefined {
243
+ // Same as matchEq but explicitly typed for clarity
244
+ return matchEq(sql, column)
245
+ }
246
+
247
+ describe('ClickHouseExperimentStorage', () => {
248
+ let fake: ReturnType<typeof makeFakeFetcher>
249
+ let storage: ClickHouseExperimentStorage
250
+
251
+ beforeEach(() => {
252
+ fake = makeFakeFetcher()
253
+ storage = createClickHouseExperimentStorage({
254
+ fetcher: fake.fetcher,
255
+ database: 'aidb',
256
+ namespace: 'experiments',
257
+ })
258
+ })
259
+
260
+ it('records track() events as SVO Actions', async () => {
261
+ const event: TrackingEvent = {
262
+ type: 'variant.complete',
263
+ timestamp: new Date('2026-05-05T10:00:00.000Z'),
264
+ data: {
265
+ experimentId: 'exp-1',
266
+ experimentName: 'Greeting',
267
+ variantId: 'v1',
268
+ variantName: 'Formal',
269
+ runId: 'run-1',
270
+ success: true,
271
+ duration: 100,
272
+ metricValue: 0.5,
273
+ metricName: 'wordCount',
274
+ dimensions: { temperature: 0.7 },
275
+ },
276
+ }
277
+ await storage.track(event)
278
+
279
+ expect(fake.actions.size).toBe(1)
280
+ const [row] = [...fake.actions.values()]
281
+ expect(row!.verb).toBe('variant.complete')
282
+ expect(row!.subject).toBe('exp-1')
283
+ expect(row!.object).toBe('v1')
284
+ expect(row!.status).toBe('completed')
285
+ expect(row!.roles['runId']).toBe('run-1')
286
+ expect((row!.data as Record<string, unknown>)['experimentName']).toBe('Greeting')
287
+ expect((row!.data as Record<string, unknown>)['variantName']).toBe('Formal')
288
+ expect((row!.data as Record<string, unknown>)['success']).toBe(1)
289
+ expect((row!.data as Record<string, unknown>)['durationMs']).toBe(100)
290
+ expect((row!.data as Record<string, unknown>)['metricValue']).toBe(0.5)
291
+ expect((row!.data as Record<string, unknown>)['dimensions']).toEqual({ temperature: 0.7 })
292
+ })
293
+
294
+ it('marks variant.error events as failed status', async () => {
295
+ await storage.track({
296
+ type: 'variant.error',
297
+ timestamp: new Date('2026-05-05T10:00:00.000Z'),
298
+ data: {
299
+ experimentId: 'exp-1',
300
+ variantId: 'v1',
301
+ success: false,
302
+ error: new Error('boom'),
303
+ },
304
+ })
305
+
306
+ const [row] = [...fake.actions.values()]
307
+ expect(row!.status).toBe('failed')
308
+ expect((row!.data as Record<string, unknown>)['errorMessage']).toBe('boom')
309
+ })
310
+
311
+ it('storeResult writes a variant.complete row', async () => {
312
+ const result: ExperimentResult = {
313
+ experimentId: 'exp-2',
314
+ variantId: 'v1',
315
+ variantName: 'Variant 1',
316
+ runId: 'r-1',
317
+ result: { value: 42 },
318
+ metricValue: 0.9,
319
+ duration: 150,
320
+ startedAt: new Date('2026-05-05T10:00:00.000Z'),
321
+ completedAt: new Date('2026-05-05T10:00:00.150Z'),
322
+ success: true,
323
+ }
324
+ await storage.storeResult(result)
325
+
326
+ expect(fake.actions.size).toBe(1)
327
+ const [row] = [...fake.actions.values()]
328
+ expect(row!.verb).toBe('variant.complete')
329
+ expect(row!.subject).toBe('exp-2')
330
+ expect(row!.object).toBe('v1')
331
+ expect((row!.data as Record<string, unknown>)['metricValue']).toBe(0.9)
332
+ })
333
+
334
+ it('storeResults bulk-inserts via commitBatch (one HTTP body)', async () => {
335
+ const base = (i: number): ExperimentResult => ({
336
+ experimentId: 'exp-3',
337
+ variantId: `v${i}`,
338
+ variantName: `Variant ${i}`,
339
+ runId: `r-${i}`,
340
+ result: { value: i },
341
+ metricValue: i * 0.1,
342
+ duration: 100 + i,
343
+ startedAt: new Date('2026-05-05T10:00:00.000Z'),
344
+ completedAt: new Date('2026-05-05T10:00:00.000Z'),
345
+ success: true,
346
+ })
347
+ await storage.storeResults([base(1), base(2), base(3)])
348
+
349
+ expect(fake.actions.size).toBe(3)
350
+ // commitBatch sends one INSERT with multiple JSONEachRow lines
351
+ const inserts = fake.log.filter(
352
+ (e) => /^INSERT/i.test(e.sql) && /INTO\s+aidb\.actions/i.test(e.sql)
353
+ )
354
+ expect(inserts.length).toBeGreaterThanOrEqual(1)
355
+ const lastInsert = inserts[inserts.length - 1]!
356
+ expect(lastInsert.body!.split('\n').filter((l) => l.length > 0)).toHaveLength(3)
357
+ })
358
+
359
+ it('getVariantStats aggregates across runs', async () => {
360
+ const exp = 'exp-4'
361
+ for (let i = 0; i < 3; i++) {
362
+ await storage.storeResult({
363
+ experimentId: exp,
364
+ variantId: 'va',
365
+ variantName: 'A',
366
+ runId: `r-a-${i}`,
367
+ result: {},
368
+ metricValue: 1.0,
369
+ duration: 100,
370
+ startedAt: new Date(),
371
+ completedAt: new Date(`2026-05-05T10:00:0${i}.000Z`),
372
+ success: true,
373
+ })
374
+ }
375
+ for (let i = 0; i < 2; i++) {
376
+ await storage.storeResult({
377
+ experimentId: exp,
378
+ variantId: 'vb',
379
+ variantName: 'B',
380
+ runId: `r-b-${i}`,
381
+ result: {},
382
+ metricValue: 0.5,
383
+ duration: 200,
384
+ startedAt: new Date(),
385
+ completedAt: new Date(`2026-05-05T10:00:0${i}.000Z`),
386
+ success: true,
387
+ })
388
+ }
389
+
390
+ const stats = await storage.getVariantStats(exp)
391
+ expect(stats).toHaveLength(2)
392
+ const a = stats.find((s) => s.variantId === 'va')!
393
+ const b = stats.find((s) => s.variantId === 'vb')!
394
+ expect(a.runCount).toBe(3)
395
+ expect(a.successRate).toBe(1)
396
+ expect(a.avgMetric).toBeCloseTo(1.0, 5)
397
+ expect(b.runCount).toBe(2)
398
+ expect(b.avgMetric).toBeCloseTo(0.5, 5)
399
+ // Sorted by avgMetric DESC
400
+ expect(stats[0]!.variantId).toBe('va')
401
+ })
402
+
403
+ it('getBestVariant returns highest-metric variant by default', async () => {
404
+ const exp = 'exp-5'
405
+ await storage.storeResult({
406
+ experimentId: exp,
407
+ variantId: 'lo',
408
+ variantName: 'Low',
409
+ runId: 'r-1',
410
+ result: {},
411
+ metricValue: 0.1,
412
+ duration: 100,
413
+ startedAt: new Date(),
414
+ completedAt: new Date(),
415
+ success: true,
416
+ })
417
+ await storage.storeResult({
418
+ experimentId: exp,
419
+ variantId: 'hi',
420
+ variantName: 'High',
421
+ runId: 'r-2',
422
+ result: {},
423
+ metricValue: 0.9,
424
+ duration: 100,
425
+ startedAt: new Date(),
426
+ completedAt: new Date(),
427
+ success: true,
428
+ })
429
+
430
+ const best = await storage.getBestVariant(exp)
431
+ expect(best).not.toBeNull()
432
+ expect(best!.variantId).toBe('hi')
433
+ expect(best!.metricValue).toBeCloseTo(0.9, 5)
434
+ })
435
+
436
+ it('getEvents returns the recorded events', async () => {
437
+ await storage.track({
438
+ type: 'variant.complete',
439
+ timestamp: new Date('2026-05-05T10:00:00.000Z'),
440
+ data: { experimentId: 'exp-6', variantId: 'v1', metricValue: 0.5, success: true },
441
+ })
442
+ const events = await storage.getEvents('exp-6')
443
+ expect(events).toHaveLength(1)
444
+ expect(events[0]!.type).toBe('variant.complete')
445
+ expect(events[0]!.data['metricValue']).toBe(0.5)
446
+ })
447
+
448
+ it('close() is a no-op', () => {
449
+ expect(() => storage.close()).not.toThrow()
450
+ })
451
+
452
+ it('flush() resolves without error', async () => {
453
+ await expect(storage.flush()).resolves.toBeUndefined()
454
+ })
455
+ })
456
+
457
+ describe('Backwards-compat aliases', () => {
458
+ it('ChdbStorage is an alias for ClickHouseExperimentStorage', () => {
459
+ const fake = makeFakeFetcher()
460
+ // ChdbStorage is exported as a const alias for the class
461
+ const s = new ChdbStorage({ fetcher: fake.fetcher })
462
+ expect(s).toBeInstanceOf(ClickHouseExperimentStorage)
463
+ })
464
+
465
+ it('createChdbBackend creates a ClickHouseExperimentStorage', () => {
466
+ const fake = makeFakeFetcher()
467
+ const s = createChdbBackend({ fetcher: fake.fetcher })
468
+ expect(s).toBeInstanceOf(ClickHouseExperimentStorage)
469
+ })
470
+ })