ai-experiments 2.1.1 → 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.
- package/CHANGELOG.md +16 -0
- package/README.md +13 -1
- package/package.json +7 -7
- 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/.turbo/turbo-build.log +0 -5
- package/dist/cartesian.d.ts +0 -140
- package/dist/cartesian.d.ts.map +0 -1
- package/dist/cartesian.js +0 -216
- package/dist/cartesian.js.map +0 -1
- package/dist/decide.d.ts +0 -152
- package/dist/decide.d.ts.map +0 -1
- package/dist/decide.js +0 -329
- package/dist/decide.js.map +0 -1
- package/dist/experiment.d.ts +0 -53
- package/dist/experiment.d.ts.map +0 -1
- package/dist/experiment.js +0 -292
- package/dist/experiment.js.map +0 -1
- package/dist/index.d.ts +0 -14
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -19
- package/dist/index.js.map +0 -1
- package/dist/tracking.d.ts +0 -159
- package/dist/tracking.d.ts.map +0 -1
- package/dist/tracking.js +0 -310
- package/dist/tracking.js.map +0 -1
- package/dist/types.d.ts +0 -198
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +0 -1
- package/src/cartesian.js +0 -215
- package/src/chdb-storage.js +0 -464
- package/src/chdb-storage.ts +0 -565
- 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
package/src/decide.ts
CHANGED
|
@@ -52,9 +52,7 @@ import { track } from './tracking.js'
|
|
|
52
52
|
* // ]
|
|
53
53
|
* ```
|
|
54
54
|
*/
|
|
55
|
-
export async function decide<T>(
|
|
56
|
-
options: DecideOptions<T>
|
|
57
|
-
): Promise<DecisionResult<T>> {
|
|
55
|
+
export async function decide<T>(options: DecideOptions<T>): Promise<DecisionResult<T>> {
|
|
58
56
|
const { options: choices, score, context, returnAll = false } = options
|
|
59
57
|
|
|
60
58
|
if (choices.length === 0) {
|
|
@@ -117,9 +115,7 @@ export async function decide<T>(
|
|
|
117
115
|
* console.log(result) // Most likely 'A', but could be B or C
|
|
118
116
|
* ```
|
|
119
117
|
*/
|
|
120
|
-
export function decideWeighted<T>(
|
|
121
|
-
options: Array<{ value: T; weight: number }>
|
|
122
|
-
): T {
|
|
118
|
+
export function decideWeighted<T>(options: Array<{ value: T; weight: number }>): T {
|
|
123
119
|
if (options.length === 0) {
|
|
124
120
|
throw new Error('Cannot decide with empty options')
|
|
125
121
|
}
|
|
@@ -197,7 +193,11 @@ export async function decideEpsilonGreedy<T>(
|
|
|
197
193
|
}
|
|
198
194
|
|
|
199
195
|
// Exploitation: best option
|
|
200
|
-
const result = await decide({
|
|
196
|
+
const result = await decide({
|
|
197
|
+
options: choices,
|
|
198
|
+
score,
|
|
199
|
+
...(context !== undefined && { context }),
|
|
200
|
+
})
|
|
201
201
|
|
|
202
202
|
track({
|
|
203
203
|
type: 'decision.made',
|
|
@@ -252,9 +252,7 @@ export function decideThompsonSampling<T extends string>(
|
|
|
252
252
|
})
|
|
253
253
|
|
|
254
254
|
// Select option with highest sample
|
|
255
|
-
const best = samples.reduce((prev, current) =>
|
|
256
|
-
current.sample > prev.sample ? current : prev
|
|
257
|
-
)
|
|
255
|
+
const best = samples.reduce((prev, current) => (current.sample > prev.sample ? current : prev))
|
|
258
256
|
|
|
259
257
|
track({
|
|
260
258
|
type: 'decision.made',
|
package/src/experiment.ts
CHANGED
|
@@ -82,9 +82,9 @@ export async function Experiment<TConfig = unknown, TResult = unknown>(
|
|
|
82
82
|
variant: ExperimentVariant<TConfig>
|
|
83
83
|
): Promise<ExperimentResult<TResult>> => {
|
|
84
84
|
return runVariant(config, variant, contextData, {
|
|
85
|
-
onVariantStart,
|
|
86
|
-
onVariantComplete,
|
|
87
|
-
onVariantError,
|
|
85
|
+
...(onVariantStart !== undefined && { onVariantStart }),
|
|
86
|
+
...(onVariantComplete !== undefined && { onVariantComplete }),
|
|
87
|
+
...(onVariantError !== undefined && { onVariantError }),
|
|
88
88
|
})
|
|
89
89
|
}
|
|
90
90
|
|
|
@@ -109,9 +109,9 @@ export async function Experiment<TConfig = unknown, TResult = unknown>(
|
|
|
109
109
|
// Sequential execution
|
|
110
110
|
for (const variant of config.variants) {
|
|
111
111
|
const result = await runVariant(config, variant, contextData, {
|
|
112
|
-
onVariantStart,
|
|
113
|
-
onVariantComplete,
|
|
114
|
-
onVariantError,
|
|
112
|
+
...(onVariantStart !== undefined && { onVariantStart }),
|
|
113
|
+
...(onVariantComplete !== undefined && { onVariantComplete }),
|
|
114
|
+
...(onVariantError !== undefined && { onVariantError }),
|
|
115
115
|
})
|
|
116
116
|
results.push(result)
|
|
117
117
|
|
|
@@ -143,7 +143,7 @@ export async function Experiment<TConfig = unknown, TResult = unknown>(
|
|
|
143
143
|
experimentId: config.id,
|
|
144
144
|
experimentName: config.name,
|
|
145
145
|
results,
|
|
146
|
-
bestVariant,
|
|
146
|
+
...(bestVariant !== undefined && { bestVariant }),
|
|
147
147
|
totalDuration,
|
|
148
148
|
successCount: results.filter((r) => r.success).length,
|
|
149
149
|
failureCount: results.filter((r) => !r.success).length,
|
|
@@ -190,7 +190,7 @@ async function runVariant<TConfig, TResult>(
|
|
|
190
190
|
variantId: variant.id,
|
|
191
191
|
runId,
|
|
192
192
|
startedAt: startTime,
|
|
193
|
-
data: contextData,
|
|
193
|
+
...(contextData !== undefined && { data: contextData }),
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
// Track variant start
|
|
@@ -236,7 +236,7 @@ async function runVariant<TConfig, TResult>(
|
|
|
236
236
|
variantName: variant.name,
|
|
237
237
|
runId,
|
|
238
238
|
result,
|
|
239
|
-
metricValue,
|
|
239
|
+
...(metricValue !== undefined && { metricValue }),
|
|
240
240
|
duration,
|
|
241
241
|
startedAt: startTime,
|
|
242
242
|
completedAt: endTime,
|
|
@@ -332,9 +332,9 @@ export function createVariantsFromGrid<T extends Record<string, unknown[]>>(
|
|
|
332
332
|
const combinations = cartesianProduct(values)
|
|
333
333
|
|
|
334
334
|
return combinations.map((combo, index) => {
|
|
335
|
-
const config = Object.fromEntries(
|
|
336
|
-
|
|
337
|
-
|
|
335
|
+
const config = Object.fromEntries(keys.map((key, i) => [key, combo[i]])) as {
|
|
336
|
+
[K in keyof T]: T[K][number]
|
|
337
|
+
}
|
|
338
338
|
|
|
339
339
|
return {
|
|
340
340
|
id: `variant-${index}`,
|
package/src/index.ts
CHANGED
|
@@ -43,6 +43,16 @@ export {
|
|
|
43
43
|
createFileBackend,
|
|
44
44
|
} from './tracking.js'
|
|
45
45
|
|
|
46
|
-
// Export ClickHouse storage backend
|
|
47
|
-
export {
|
|
48
|
-
|
|
46
|
+
// Export ClickHouse storage backend (canonical adapter via ai-database)
|
|
47
|
+
export {
|
|
48
|
+
ClickHouseExperimentStorage,
|
|
49
|
+
createClickHouseExperimentStorage,
|
|
50
|
+
bootstrapExperimentsSchema,
|
|
51
|
+
// Backwards-compat aliases
|
|
52
|
+
ChdbStorage,
|
|
53
|
+
createChdbBackend,
|
|
54
|
+
} from './clickhouse-storage.js'
|
|
55
|
+
export type {
|
|
56
|
+
ClickHouseExperimentStorageOptions,
|
|
57
|
+
ChdbStorageOptions,
|
|
58
|
+
} from './clickhouse-storage.js'
|
package/src/tracking.ts
CHANGED
|
@@ -325,9 +325,9 @@ export function createFileBackend(options: {
|
|
|
325
325
|
*/
|
|
326
326
|
function extractKey(event: TrackingEvent): string {
|
|
327
327
|
const data = event.data
|
|
328
|
-
if ('experimentId' in data) return `exp=${data
|
|
329
|
-
if ('variantId' in data) return `variant=${data
|
|
330
|
-
if ('runId' in data) return `run=${data
|
|
328
|
+
if ('experimentId' in data) return `exp=${data['experimentId']}`
|
|
329
|
+
if ('variantId' in data) return `variant=${data['variantId']}`
|
|
330
|
+
if ('runId' in data) return `run=${data['runId']}`
|
|
331
331
|
return ''
|
|
332
332
|
}
|
|
333
333
|
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for cartesian product utilities
|
|
3
|
+
*
|
|
4
|
+
* These tests verify the parameter exploration functionality including:
|
|
5
|
+
* - Basic cartesian product generation
|
|
6
|
+
* - Filtering combinations
|
|
7
|
+
* - Sampling from large spaces
|
|
8
|
+
* - Counting combinations
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'vitest'
|
|
12
|
+
import {
|
|
13
|
+
cartesian,
|
|
14
|
+
cartesianFilter,
|
|
15
|
+
cartesianSample,
|
|
16
|
+
cartesianCount,
|
|
17
|
+
cartesianWithLabels,
|
|
18
|
+
} from '../src/index.js'
|
|
19
|
+
|
|
20
|
+
describe('cartesian', () => {
|
|
21
|
+
describe('basic functionality', () => {
|
|
22
|
+
it('generates all combinations for single parameter', () => {
|
|
23
|
+
const result = cartesian({
|
|
24
|
+
color: ['red', 'blue', 'green'],
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
expect(result).toHaveLength(3)
|
|
28
|
+
expect(result).toContainEqual({ color: 'red' })
|
|
29
|
+
expect(result).toContainEqual({ color: 'blue' })
|
|
30
|
+
expect(result).toContainEqual({ color: 'green' })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('generates cartesian product for two parameters', () => {
|
|
34
|
+
const result = cartesian({
|
|
35
|
+
size: ['small', 'large'],
|
|
36
|
+
color: ['red', 'blue'],
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
expect(result).toHaveLength(4)
|
|
40
|
+
expect(result).toContainEqual({ size: 'small', color: 'red' })
|
|
41
|
+
expect(result).toContainEqual({ size: 'small', color: 'blue' })
|
|
42
|
+
expect(result).toContainEqual({ size: 'large', color: 'red' })
|
|
43
|
+
expect(result).toContainEqual({ size: 'large', color: 'blue' })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('generates cartesian product for three parameters', () => {
|
|
47
|
+
const result = cartesian({
|
|
48
|
+
model: ['a', 'b'],
|
|
49
|
+
temperature: [0.3, 0.7],
|
|
50
|
+
maxTokens: [100, 500],
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// 2 * 2 * 2 = 8
|
|
54
|
+
expect(result).toHaveLength(8)
|
|
55
|
+
expect(result).toContainEqual({ model: 'a', temperature: 0.3, maxTokens: 100 })
|
|
56
|
+
expect(result).toContainEqual({ model: 'b', temperature: 0.7, maxTokens: 500 })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('returns empty array for empty input', () => {
|
|
60
|
+
const result = cartesian({})
|
|
61
|
+
expect(result).toEqual([])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('handles mixed types', () => {
|
|
65
|
+
const result = cartesian({
|
|
66
|
+
count: [1, 2],
|
|
67
|
+
enabled: [true, false],
|
|
68
|
+
name: ['test'],
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
expect(result).toHaveLength(4)
|
|
72
|
+
expect(result).toContainEqual({ count: 1, enabled: true, name: 'test' })
|
|
73
|
+
expect(result).toContainEqual({ count: 2, enabled: false, name: 'test' })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('preserves object values', () => {
|
|
77
|
+
const objA = { complex: true }
|
|
78
|
+
const objB = { complex: false }
|
|
79
|
+
|
|
80
|
+
const result = cartesian({
|
|
81
|
+
config: [objA, objB],
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
expect(result).toHaveLength(2)
|
|
85
|
+
expect(result[0].config).toBe(objA)
|
|
86
|
+
expect(result[1].config).toBe(objB)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('cartesianFilter', () => {
|
|
92
|
+
it('filters out unwanted combinations', () => {
|
|
93
|
+
const result = cartesianFilter(
|
|
94
|
+
{
|
|
95
|
+
model: ['sonnet', 'opus'],
|
|
96
|
+
temperature: [0.3, 0.7, 1.0],
|
|
97
|
+
},
|
|
98
|
+
// Filter out opus with high temperature
|
|
99
|
+
(combo) => !(combo.model === 'opus' && combo.temperature > 0.7)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
// 6 total - 1 (opus with 1.0) = 5
|
|
103
|
+
expect(result).toHaveLength(5)
|
|
104
|
+
expect(result).not.toContainEqual({ model: 'opus', temperature: 1.0 })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('returns empty array when all filtered', () => {
|
|
108
|
+
const result = cartesianFilter({ x: [1, 2, 3] }, () => false)
|
|
109
|
+
|
|
110
|
+
expect(result).toEqual([])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('returns all when nothing filtered', () => {
|
|
114
|
+
const result = cartesianFilter({ x: [1, 2] }, () => true)
|
|
115
|
+
|
|
116
|
+
expect(result).toHaveLength(2)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('works with complex filter conditions', () => {
|
|
120
|
+
const result = cartesianFilter(
|
|
121
|
+
{
|
|
122
|
+
a: [1, 2, 3],
|
|
123
|
+
b: [10, 20, 30],
|
|
124
|
+
},
|
|
125
|
+
// Only keep combinations where a * b < 50
|
|
126
|
+
(combo) => combo.a * combo.b < 50
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
// Valid: (1,10), (1,20), (1,30), (2,10), (2,20), (3,10)
|
|
130
|
+
expect(result).toHaveLength(6)
|
|
131
|
+
expect(result).not.toContainEqual({ a: 2, b: 30 })
|
|
132
|
+
expect(result).not.toContainEqual({ a: 3, b: 20 })
|
|
133
|
+
expect(result).not.toContainEqual({ a: 3, b: 30 })
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('cartesianSample', () => {
|
|
138
|
+
it('returns subset of specified size', () => {
|
|
139
|
+
const result = cartesianSample(
|
|
140
|
+
{
|
|
141
|
+
a: [1, 2, 3, 4, 5],
|
|
142
|
+
b: [1, 2, 3, 4, 5],
|
|
143
|
+
},
|
|
144
|
+
5
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
expect(result).toHaveLength(5)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('returns all combinations when sample size exceeds total', () => {
|
|
151
|
+
const result = cartesianSample({ x: [1, 2] }, 100)
|
|
152
|
+
|
|
153
|
+
expect(result).toHaveLength(2)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('returns unique combinations by default', () => {
|
|
157
|
+
const result = cartesianSample({ a: [1, 2, 3], b: [1, 2, 3] }, 5)
|
|
158
|
+
|
|
159
|
+
const serialized = result.map((r) => JSON.stringify(r))
|
|
160
|
+
const unique = new Set(serialized)
|
|
161
|
+
|
|
162
|
+
expect(unique.size).toBe(serialized.length)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('returns valid combinations', () => {
|
|
166
|
+
const result = cartesianSample({ color: ['red', 'blue'], size: ['small', 'large'] }, 3)
|
|
167
|
+
|
|
168
|
+
const validColors = ['red', 'blue']
|
|
169
|
+
const validSizes = ['small', 'large']
|
|
170
|
+
|
|
171
|
+
for (const combo of result) {
|
|
172
|
+
expect(validColors).toContain(combo.color)
|
|
173
|
+
expect(validSizes).toContain(combo.size)
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('handles sample size of 0', () => {
|
|
178
|
+
const result = cartesianSample({ x: [1, 2, 3] }, 0)
|
|
179
|
+
|
|
180
|
+
expect(result).toHaveLength(0)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('handles empty parameters', () => {
|
|
184
|
+
const result = cartesianSample({}, 10)
|
|
185
|
+
expect(result).toEqual([])
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('cartesianCount', () => {
|
|
190
|
+
it('counts single parameter correctly', () => {
|
|
191
|
+
const count = cartesianCount({
|
|
192
|
+
x: [1, 2, 3, 4, 5],
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
expect(count).toBe(5)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('counts product of multiple parameters', () => {
|
|
199
|
+
const count = cartesianCount({
|
|
200
|
+
a: [1, 2, 3],
|
|
201
|
+
b: ['x', 'y'],
|
|
202
|
+
c: [true, false],
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// 3 * 2 * 2 = 12
|
|
206
|
+
expect(count).toBe(12)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('returns 0 for empty parameters', () => {
|
|
210
|
+
const count = cartesianCount({})
|
|
211
|
+
expect(count).toBe(0)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('returns 0 if any parameter has empty array', () => {
|
|
215
|
+
const count = cartesianCount({
|
|
216
|
+
a: [1, 2, 3],
|
|
217
|
+
b: [],
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
expect(count).toBe(0)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('handles large parameter spaces efficiently', () => {
|
|
224
|
+
// This would be 10^6 = 1,000,000 combinations
|
|
225
|
+
// Count should be fast even though generating would be slow
|
|
226
|
+
const count = cartesianCount({
|
|
227
|
+
a: Array.from({ length: 100 }, (_, i) => i),
|
|
228
|
+
b: Array.from({ length: 100 }, (_, i) => i),
|
|
229
|
+
c: Array.from({ length: 100 }, (_, i) => i),
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
expect(count).toBe(1000000)
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
describe('cartesianWithLabels', () => {
|
|
237
|
+
it('returns values with index labels', () => {
|
|
238
|
+
const result = cartesianWithLabels({
|
|
239
|
+
model: ['sonnet', 'opus'],
|
|
240
|
+
temperature: [0.3, 0.7],
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
expect(result).toHaveLength(4)
|
|
244
|
+
|
|
245
|
+
// Check first combination
|
|
246
|
+
const first = result.find((r) => r.values.model === 'sonnet' && r.values.temperature === 0.3)
|
|
247
|
+
expect(first).toBeDefined()
|
|
248
|
+
expect(first?.labels.model).toBe(0)
|
|
249
|
+
expect(first?.labels.temperature).toBe(0)
|
|
250
|
+
|
|
251
|
+
// Check last combination
|
|
252
|
+
const last = result.find((r) => r.values.model === 'opus' && r.values.temperature === 0.7)
|
|
253
|
+
expect(last).toBeDefined()
|
|
254
|
+
expect(last?.labels.model).toBe(1)
|
|
255
|
+
expect(last?.labels.temperature).toBe(1)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('labels correspond to array positions', () => {
|
|
259
|
+
const options = ['a', 'b', 'c']
|
|
260
|
+
const result = cartesianWithLabels({
|
|
261
|
+
option: options,
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
for (let i = 0; i < options.length; i++) {
|
|
265
|
+
const item = result.find((r) => r.values.option === options[i])
|
|
266
|
+
expect(item?.labels.option).toBe(i)
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('returns empty array for empty input', () => {
|
|
271
|
+
const result = cartesianWithLabels({})
|
|
272
|
+
expect(result).toEqual([])
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('provides both values and labels in each result', () => {
|
|
276
|
+
const result = cartesianWithLabels({
|
|
277
|
+
x: [10, 20],
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
for (const item of result) {
|
|
281
|
+
expect(item).toHaveProperty('values')
|
|
282
|
+
expect(item).toHaveProperty('labels')
|
|
283
|
+
expect(typeof item.values.x).toBe('number')
|
|
284
|
+
expect(typeof item.labels.x).toBe('number')
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
})
|