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.
- package/CHANGELOG.md +9 -0
- package/README.md +13 -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/.turbo/turbo-build.log +0 -4
- package/LICENSE +0 -21
- 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/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/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 -16
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -21
- 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 -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,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for decision-making utilities
|
|
3
|
+
*
|
|
4
|
+
* These tests verify the decision algorithms including:
|
|
5
|
+
* - Basic decide function with scoring
|
|
6
|
+
* - Weighted random selection
|
|
7
|
+
* - Epsilon-greedy exploration/exploitation
|
|
8
|
+
* - Thompson sampling
|
|
9
|
+
* - Upper Confidence Bound (UCB)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
13
|
+
import {
|
|
14
|
+
decide,
|
|
15
|
+
decideWeighted,
|
|
16
|
+
decideEpsilonGreedy,
|
|
17
|
+
decideThompsonSampling,
|
|
18
|
+
decideUCB,
|
|
19
|
+
configureTracking,
|
|
20
|
+
createMemoryBackend,
|
|
21
|
+
} from '../src/index.js'
|
|
22
|
+
|
|
23
|
+
// Use memory backend for testing
|
|
24
|
+
const testBackend = createMemoryBackend()
|
|
25
|
+
|
|
26
|
+
describe('decide', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
testBackend.clear()
|
|
29
|
+
configureTracking({ backend: testBackend, enabled: true })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('basic decision making', () => {
|
|
33
|
+
it('selects option with highest score', async () => {
|
|
34
|
+
const result = await decide({
|
|
35
|
+
options: ['apple', 'banana', 'cherry'],
|
|
36
|
+
score: (fruit) => {
|
|
37
|
+
const scores: Record<string, number> = { apple: 3, banana: 7, cherry: 5 }
|
|
38
|
+
return scores[fruit]!
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
expect(result.selected).toBe('banana')
|
|
43
|
+
expect(result.score).toBe(7)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns DecisionResult with required fields', async () => {
|
|
47
|
+
const result = await decide({
|
|
48
|
+
options: ['a', 'b'],
|
|
49
|
+
score: () => 1,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
expect(result).toHaveProperty('selected')
|
|
53
|
+
expect(result).toHaveProperty('score')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('throws error for empty options', async () => {
|
|
57
|
+
await expect(
|
|
58
|
+
decide({
|
|
59
|
+
options: [],
|
|
60
|
+
score: () => 0,
|
|
61
|
+
})
|
|
62
|
+
).rejects.toThrow('Cannot decide with empty options')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('supports async scoring function', async () => {
|
|
66
|
+
const result = await decide({
|
|
67
|
+
options: [1, 2, 3],
|
|
68
|
+
score: async (n) => {
|
|
69
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
70
|
+
return n * n
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
expect(result.selected).toBe(3)
|
|
75
|
+
expect(result.score).toBe(9)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('handles object options', async () => {
|
|
79
|
+
const options = [
|
|
80
|
+
{ name: 'cheap', price: 10, quality: 3 }, // 0.3
|
|
81
|
+
{ name: 'balanced', price: 25, quality: 7 }, // 0.28
|
|
82
|
+
{ name: 'premium', price: 50, quality: 9 }, // 0.18
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
const result = await decide({
|
|
86
|
+
options,
|
|
87
|
+
score: (opt) => opt.quality / opt.price, // value for money
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// 'cheap' has highest value for money (3/10 = 0.3)
|
|
91
|
+
expect(result.selected.name).toBe('cheap')
|
|
92
|
+
expect(result.score).toBeCloseTo(0.3)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('returns all options when returnAll=true', async () => {
|
|
96
|
+
const result = await decide({
|
|
97
|
+
options: ['a', 'b', 'c'],
|
|
98
|
+
score: (x) => x.charCodeAt(0),
|
|
99
|
+
returnAll: true,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
expect(result.allOptions).toBeDefined()
|
|
103
|
+
expect(result.allOptions).toHaveLength(3)
|
|
104
|
+
// Should be sorted by score descending
|
|
105
|
+
expect(result.allOptions![0].option).toBe('c')
|
|
106
|
+
expect(result.allOptions![2].option).toBe('a')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('tracking integration', () => {
|
|
111
|
+
it('tracks decision events', async () => {
|
|
112
|
+
await decide({
|
|
113
|
+
options: [1, 2, 3],
|
|
114
|
+
score: (n) => n,
|
|
115
|
+
context: 'test-decision',
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const events = testBackend.getEvents()
|
|
119
|
+
const decisionEvent = events.find((e) => e.type === 'decision.made')
|
|
120
|
+
|
|
121
|
+
expect(decisionEvent).toBeDefined()
|
|
122
|
+
expect(decisionEvent?.data.context).toBe('test-decision')
|
|
123
|
+
expect(decisionEvent?.data.optionCount).toBe(3)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('decideWeighted', () => {
|
|
129
|
+
it('returns one of the provided options', () => {
|
|
130
|
+
const result = decideWeighted([
|
|
131
|
+
{ value: 'a', weight: 1 },
|
|
132
|
+
{ value: 'b', weight: 1 },
|
|
133
|
+
{ value: 'c', weight: 1 },
|
|
134
|
+
])
|
|
135
|
+
|
|
136
|
+
expect(['a', 'b', 'c']).toContain(result)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('throws error for empty options', () => {
|
|
140
|
+
expect(() => decideWeighted([])).toThrow('Cannot decide with empty options')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('throws error for non-positive total weight', () => {
|
|
144
|
+
expect(() =>
|
|
145
|
+
decideWeighted([
|
|
146
|
+
{ value: 'a', weight: 0 },
|
|
147
|
+
{ value: 'b', weight: 0 },
|
|
148
|
+
])
|
|
149
|
+
).toThrow('Total weight must be positive')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('respects weights over many trials', () => {
|
|
153
|
+
// This is a statistical test - run many trials
|
|
154
|
+
const counts: Record<string, number> = { a: 0, b: 0 }
|
|
155
|
+
const trials = 1000
|
|
156
|
+
|
|
157
|
+
for (let i = 0; i < trials; i++) {
|
|
158
|
+
const result = decideWeighted([
|
|
159
|
+
{ value: 'a', weight: 0.8 },
|
|
160
|
+
{ value: 'b', weight: 0.2 },
|
|
161
|
+
])
|
|
162
|
+
counts[result]++
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// With 80/20 weights, 'a' should be selected significantly more
|
|
166
|
+
// Allow some variance but 'a' should be at least 60% of results
|
|
167
|
+
expect(counts.a).toBeGreaterThan(trials * 0.6)
|
|
168
|
+
expect(counts.b).toBeGreaterThan(0) // 'b' should still appear sometimes
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('handles single option', () => {
|
|
172
|
+
const result = decideWeighted([{ value: 'only', weight: 1 }])
|
|
173
|
+
expect(result).toBe('only')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('handles very unequal weights', () => {
|
|
177
|
+
const result = decideWeighted([
|
|
178
|
+
{ value: 'rare', weight: 0.001 },
|
|
179
|
+
{ value: 'common', weight: 0.999 },
|
|
180
|
+
])
|
|
181
|
+
|
|
182
|
+
// Just ensure it returns valid option
|
|
183
|
+
expect(['rare', 'common']).toContain(result)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe('decideEpsilonGreedy', () => {
|
|
188
|
+
beforeEach(() => {
|
|
189
|
+
testBackend.clear()
|
|
190
|
+
configureTracking({ backend: testBackend, enabled: true })
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('selects best option when epsilon=0 (pure exploitation)', async () => {
|
|
194
|
+
const results: number[] = []
|
|
195
|
+
|
|
196
|
+
// Run multiple times - should always pick best
|
|
197
|
+
for (let i = 0; i < 5; i++) {
|
|
198
|
+
const result = await decideEpsilonGreedy({
|
|
199
|
+
options: [1, 5, 10],
|
|
200
|
+
score: (n) => n,
|
|
201
|
+
epsilon: 0,
|
|
202
|
+
})
|
|
203
|
+
results.push(result.selected)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// All should be 10 (best option)
|
|
207
|
+
expect(results.every((r) => r === 10)).toBe(true)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('always explores when epsilon=1', async () => {
|
|
211
|
+
const results: number[] = []
|
|
212
|
+
|
|
213
|
+
// With epsilon=1, should randomly select each time
|
|
214
|
+
for (let i = 0; i < 30; i++) {
|
|
215
|
+
const result = await decideEpsilonGreedy({
|
|
216
|
+
options: [1, 2, 3],
|
|
217
|
+
score: (n) => n,
|
|
218
|
+
epsilon: 1,
|
|
219
|
+
})
|
|
220
|
+
results.push(result.selected)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Should have variety (not all the same)
|
|
224
|
+
const unique = new Set(results)
|
|
225
|
+
expect(unique.size).toBeGreaterThan(1)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('returns valid DecisionResult', async () => {
|
|
229
|
+
const result = await decideEpsilonGreedy({
|
|
230
|
+
options: ['a', 'b'],
|
|
231
|
+
score: (x) => x.charCodeAt(0),
|
|
232
|
+
epsilon: 0.5,
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
expect(result).toHaveProperty('selected')
|
|
236
|
+
expect(result).toHaveProperty('score')
|
|
237
|
+
expect(['a', 'b']).toContain(result.selected)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('throws error for invalid epsilon', async () => {
|
|
241
|
+
await expect(
|
|
242
|
+
decideEpsilonGreedy({
|
|
243
|
+
options: [1, 2],
|
|
244
|
+
score: (n) => n,
|
|
245
|
+
epsilon: -0.1,
|
|
246
|
+
})
|
|
247
|
+
).rejects.toThrow('Epsilon must be between 0 and 1')
|
|
248
|
+
|
|
249
|
+
await expect(
|
|
250
|
+
decideEpsilonGreedy({
|
|
251
|
+
options: [1, 2],
|
|
252
|
+
score: (n) => n,
|
|
253
|
+
epsilon: 1.5,
|
|
254
|
+
})
|
|
255
|
+
).rejects.toThrow('Epsilon must be between 0 and 1')
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
describe('decideThompsonSampling', () => {
|
|
260
|
+
beforeEach(() => {
|
|
261
|
+
testBackend.clear()
|
|
262
|
+
configureTracking({ backend: testBackend, enabled: true })
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('returns one of the provided options', () => {
|
|
266
|
+
const result = decideThompsonSampling(['a', 'b', 'c'], {
|
|
267
|
+
a: { alpha: 1, beta: 1 },
|
|
268
|
+
b: { alpha: 1, beta: 1 },
|
|
269
|
+
c: { alpha: 1, beta: 1 },
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
expect(['a', 'b', 'c']).toContain(result)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('throws error for empty options', () => {
|
|
276
|
+
expect(() => decideThompsonSampling([], {})).toThrow('Cannot decide with empty options')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('favors option with higher success rate over many trials', () => {
|
|
280
|
+
const counts: Record<string, number> = { high: 0, low: 0 }
|
|
281
|
+
const trials = 100
|
|
282
|
+
|
|
283
|
+
for (let i = 0; i < trials; i++) {
|
|
284
|
+
const result = decideThompsonSampling(['high', 'low'], {
|
|
285
|
+
high: { alpha: 90, beta: 10 }, // 90% success rate
|
|
286
|
+
low: { alpha: 10, beta: 90 }, // 10% success rate
|
|
287
|
+
})
|
|
288
|
+
counts[result]++
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 'high' should be selected significantly more often
|
|
292
|
+
expect(counts.high).toBeGreaterThan(counts.low)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('explores uncertain options', () => {
|
|
296
|
+
// With balanced priors, both options should be explored
|
|
297
|
+
const counts: Record<string, number> = { a: 0, b: 0 }
|
|
298
|
+
const trials = 50
|
|
299
|
+
|
|
300
|
+
for (let i = 0; i < trials; i++) {
|
|
301
|
+
const result = decideThompsonSampling(['a', 'b'], {
|
|
302
|
+
a: { alpha: 1, beta: 1 }, // Uncertain (uniform prior)
|
|
303
|
+
b: { alpha: 1, beta: 1 }, // Uncertain (uniform prior)
|
|
304
|
+
})
|
|
305
|
+
counts[result]++
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Both should be explored
|
|
309
|
+
expect(counts.a).toBeGreaterThan(0)
|
|
310
|
+
expect(counts.b).toBeGreaterThan(0)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('tracks decision events', () => {
|
|
314
|
+
decideThompsonSampling(['a', 'b'], {
|
|
315
|
+
a: { alpha: 5, beta: 5 },
|
|
316
|
+
b: { alpha: 5, beta: 5 },
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
const events = testBackend.getEvents()
|
|
320
|
+
const decisionEvent = events.find((e) => e.type === 'decision.made')
|
|
321
|
+
|
|
322
|
+
expect(decisionEvent).toBeDefined()
|
|
323
|
+
expect(decisionEvent?.data.strategy).toBe('thompson-sampling')
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
describe('decideUCB', () => {
|
|
328
|
+
beforeEach(() => {
|
|
329
|
+
testBackend.clear()
|
|
330
|
+
configureTracking({ backend: testBackend, enabled: true })
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('returns one of the provided options', () => {
|
|
334
|
+
const result = decideUCB(
|
|
335
|
+
['a', 'b', 'c'],
|
|
336
|
+
{
|
|
337
|
+
a: { mean: 0.5, count: 10 },
|
|
338
|
+
b: { mean: 0.5, count: 10 },
|
|
339
|
+
c: { mean: 0.5, count: 10 },
|
|
340
|
+
},
|
|
341
|
+
{ explorationFactor: 1, totalCount: 30 }
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
expect(['a', 'b', 'c']).toContain(result)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('throws error for empty options', () => {
|
|
348
|
+
expect(() => decideUCB([], {}, { explorationFactor: 1, totalCount: 0 })).toThrow(
|
|
349
|
+
'Cannot decide with empty options'
|
|
350
|
+
)
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it('selects option with highest mean when well-explored', () => {
|
|
354
|
+
// All options have many observations, so exploration bonus is small
|
|
355
|
+
const result = decideUCB(
|
|
356
|
+
['low', 'medium', 'high'],
|
|
357
|
+
{
|
|
358
|
+
low: { mean: 0.3, count: 1000 },
|
|
359
|
+
medium: { mean: 0.5, count: 1000 },
|
|
360
|
+
high: { mean: 0.8, count: 1000 },
|
|
361
|
+
},
|
|
362
|
+
{ explorationFactor: 1, totalCount: 3000 }
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
expect(result).toBe('high')
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('explores under-sampled options', () => {
|
|
369
|
+
// 'new' has very few observations, should get exploration bonus
|
|
370
|
+
const result = decideUCB(
|
|
371
|
+
['established', 'new'],
|
|
372
|
+
{
|
|
373
|
+
established: { mean: 0.7, count: 1000 },
|
|
374
|
+
new: { mean: 0.6, count: 5 }, // Few observations = high uncertainty
|
|
375
|
+
},
|
|
376
|
+
{ explorationFactor: 2, totalCount: 1005 }
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
// With high exploration factor and low count, 'new' should be explored
|
|
380
|
+
expect(result).toBe('new')
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('respects exploration factor', () => {
|
|
384
|
+
// With exploration factor of 0, should pick highest mean
|
|
385
|
+
const result = decideUCB(
|
|
386
|
+
['a', 'b'],
|
|
387
|
+
{
|
|
388
|
+
a: { mean: 0.8, count: 100 },
|
|
389
|
+
b: { mean: 0.5, count: 5 },
|
|
390
|
+
},
|
|
391
|
+
{ explorationFactor: 0, totalCount: 105 }
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
expect(result).toBe('a')
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('tracks decision events with UCB info', () => {
|
|
398
|
+
decideUCB(
|
|
399
|
+
['a', 'b'],
|
|
400
|
+
{
|
|
401
|
+
a: { mean: 0.5, count: 50 },
|
|
402
|
+
b: { mean: 0.5, count: 50 },
|
|
403
|
+
},
|
|
404
|
+
{ explorationFactor: 1.5, totalCount: 100 }
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
const events = testBackend.getEvents()
|
|
408
|
+
const decisionEvent = events.find((e) => e.type === 'decision.made')
|
|
409
|
+
|
|
410
|
+
expect(decisionEvent).toBeDefined()
|
|
411
|
+
expect(decisionEvent?.data.strategy).toBe('ucb')
|
|
412
|
+
expect(decisionEvent?.data.explorationFactor).toBe(1.5)
|
|
413
|
+
})
|
|
414
|
+
})
|