ai-experiments 2.1.3 → 2.4.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.
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +17 -0
- package/README.md +13 -1
- package/dist/clickhouse-storage.d.ts +227 -0
- package/dist/clickhouse-storage.d.ts.map +1 -0
- package/dist/clickhouse-storage.js +522 -0
- package/dist/clickhouse-storage.js.map +1 -0
- package/dist/decide.d.ts.map +1 -1
- package/dist/decide.js +6 -2
- package/dist/decide.js.map +1 -1
- package/dist/experiment.js +9 -9
- package/dist/experiment.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/tracking.js +3 -3
- package/dist/tracking.js.map +1 -1
- package/package.json +16 -16
- package/src/clickhouse-storage.ts +698 -0
- package/src/decide.ts +8 -10
- package/src/experiment.ts +12 -12
- package/src/index.ts +13 -3
- package/src/tracking.ts +3 -3
- package/test/cartesian.test.ts +287 -0
- package/test/clickhouse-storage.test.ts +470 -0
- package/test/decide.test.ts +414 -0
- package/test/experiment.test.ts +473 -0
- package/test/tracking.test.ts +347 -0
- package/vitest.config.ts +34 -0
- package/LICENSE +0 -21
- package/dist/chdb-storage.d.ts +0 -135
- package/dist/chdb-storage.d.ts.map +0 -1
- package/dist/chdb-storage.js +0 -468
- package/dist/chdb-storage.js.map +0 -1
- package/src/cartesian.js +0 -215
- package/src/chdb-storage.js +0 -464
- package/src/chdb-storage.ts +0 -567
- package/src/decide.js +0 -328
- package/src/experiment.js +0 -291
- package/src/index.js +0 -20
- package/src/tracking.js +0 -309
- 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
|
+
})
|