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,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
+ })