ai-experiments 0.1.0 → 2.0.2

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.
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Cartesian product utilities for parameter exploration
3
+ */
4
+
5
+ import type { CartesianParams, CartesianResult } from './types.js'
6
+
7
+ /**
8
+ * Generate cartesian product of parameter sets
9
+ *
10
+ * Takes an object where each key maps to an array of possible values,
11
+ * and returns all possible combinations as an array of objects.
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * import { cartesian } from 'ai-experiments'
16
+ *
17
+ * const combinations = cartesian({
18
+ * model: ['sonnet', 'opus', 'gpt-4o'],
19
+ * temperature: [0.3, 0.7, 1.0],
20
+ * maxTokens: [100, 500, 1000],
21
+ * })
22
+ *
23
+ * // Returns 27 combinations (3 * 3 * 3):
24
+ * // [
25
+ * // { model: 'sonnet', temperature: 0.3, maxTokens: 100 },
26
+ * // { model: 'sonnet', temperature: 0.3, maxTokens: 500 },
27
+ * // { model: 'sonnet', temperature: 0.3, maxTokens: 1000 },
28
+ * // { model: 'sonnet', temperature: 0.7, maxTokens: 100 },
29
+ * // ...
30
+ * // ]
31
+ *
32
+ * // Use with experiments:
33
+ * const variants = combinations.map((config, i) => ({
34
+ * id: `variant-${i}`,
35
+ * name: `${config.model} T=${config.temperature} max=${config.maxTokens}`,
36
+ * config,
37
+ * }))
38
+ * ```
39
+ */
40
+ export function cartesian<T extends CartesianParams>(params: T): CartesianResult<T> {
41
+ const keys = Object.keys(params) as (keyof T)[]
42
+ const values = keys.map((k) => params[k])
43
+
44
+ // Handle empty input
45
+ if (keys.length === 0) {
46
+ return [] as CartesianResult<T>
47
+ }
48
+
49
+ // Handle single parameter
50
+ if (keys.length === 1) {
51
+ const key = keys[0]!
52
+ return values[0]!.map((val) => ({ [key]: val } as { [K in keyof T]: T[K][number] }))
53
+ }
54
+
55
+ // Generate all combinations using recursive helper
56
+ const combinations = cartesianProduct(values)
57
+
58
+ // Map back to objects
59
+ return combinations.map((combo) => {
60
+ const obj = {} as { [K in keyof T]: T[K][number] }
61
+ keys.forEach((key, i) => {
62
+ obj[key] = combo[i] as T[typeof key][number]
63
+ })
64
+ return obj
65
+ })
66
+ }
67
+
68
+ /**
69
+ * Recursive cartesian product implementation
70
+ */
71
+ function cartesianProduct<T>(arrays: T[][]): T[][] {
72
+ if (arrays.length === 0) return [[]]
73
+
74
+ const [first, ...rest] = arrays
75
+
76
+ // Base case: single array
77
+ if (rest.length === 0) {
78
+ return first!.map((x) => [x])
79
+ }
80
+
81
+ // Recursive case
82
+ const restProduct = cartesianProduct(rest)
83
+ const result: T[][] = []
84
+
85
+ for (const x of first!) {
86
+ for (const restCombo of restProduct) {
87
+ result.push([x, ...restCombo])
88
+ }
89
+ }
90
+
91
+ return result
92
+ }
93
+
94
+ /**
95
+ * Generate a grid of parameter combinations with filtering
96
+ *
97
+ * Similar to cartesian(), but allows filtering out invalid combinations.
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * import { cartesianFilter } from 'ai-experiments'
102
+ *
103
+ * const combinations = cartesianFilter(
104
+ * {
105
+ * model: ['sonnet', 'opus'],
106
+ * temperature: [0.3, 0.7, 1.0],
107
+ * maxTokens: [100, 500],
108
+ * },
109
+ * // Filter out combinations where opus uses high temperature
110
+ * (combo) => !(combo.model === 'opus' && combo.temperature > 0.7)
111
+ * )
112
+ * ```
113
+ */
114
+ export function cartesianFilter<T extends CartesianParams>(
115
+ params: T,
116
+ filter: (combo: { [K in keyof T]: T[K][number] }) => boolean
117
+ ): CartesianResult<T> {
118
+ const allCombinations = cartesian(params)
119
+ return allCombinations.filter(filter)
120
+ }
121
+
122
+ /**
123
+ * Generate a random sample from the cartesian product
124
+ *
125
+ * Useful when the full cartesian product is too large to test all combinations.
126
+ *
127
+ * @example
128
+ * ```ts
129
+ * import { cartesianSample } from 'ai-experiments'
130
+ *
131
+ * // Full product would be 1000 combinations (10 * 10 * 10)
132
+ * // Sample just 20 random combinations
133
+ * const sample = cartesianSample(
134
+ * {
135
+ * param1: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
136
+ * param2: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
137
+ * param3: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
138
+ * },
139
+ * 20
140
+ * )
141
+ * ```
142
+ */
143
+ export function cartesianSample<T extends CartesianParams>(
144
+ params: T,
145
+ sampleSize: number,
146
+ options: {
147
+ /** Random seed for reproducibility */
148
+ seed?: number
149
+ /** Whether to sample without replacement (default: true) */
150
+ unique?: boolean
151
+ } = {}
152
+ ): CartesianResult<T> {
153
+ const { unique = true } = options
154
+
155
+ // Generate all combinations first
156
+ const allCombinations = cartesian(params)
157
+
158
+ // If sample size is larger than available combinations, return all
159
+ if (sampleSize >= allCombinations.length) {
160
+ return allCombinations
161
+ }
162
+
163
+ // Shuffle and take first n items
164
+ const shuffled = [...allCombinations]
165
+
166
+ // Simple Fisher-Yates shuffle
167
+ for (let i = shuffled.length - 1; i > 0; i--) {
168
+ const j = Math.floor(Math.random() * (i + 1))
169
+ ;[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]
170
+ }
171
+
172
+ return shuffled.slice(0, sampleSize)
173
+ }
174
+
175
+ /**
176
+ * Count the total number of combinations without generating them
177
+ *
178
+ * Useful for checking if cartesian product is feasible before generating.
179
+ *
180
+ * @example
181
+ * ```ts
182
+ * import { cartesianCount } from 'ai-experiments'
183
+ *
184
+ * const count = cartesianCount({
185
+ * model: ['sonnet', 'opus', 'gpt-4o'],
186
+ * temperature: [0.3, 0.5, 0.7, 0.9],
187
+ * maxTokens: [100, 500, 1000, 2000],
188
+ * })
189
+ * // Returns 48 (3 * 4 * 4)
190
+ *
191
+ * if (count > 100) {
192
+ * console.log('Too many combinations, use cartesianSample instead')
193
+ * }
194
+ * ```
195
+ */
196
+ export function cartesianCount<T extends CartesianParams>(params: T): number {
197
+ const keys = Object.keys(params) as (keyof T)[]
198
+
199
+ if (keys.length === 0) return 0
200
+
201
+ return keys.reduce((total, key) => {
202
+ const arr = params[key]
203
+ return total * (arr?.length ?? 0)
204
+ }, 1)
205
+ }
206
+
207
+ /**
208
+ * Generate cartesian product with labels for each dimension
209
+ *
210
+ * Returns combinations with additional metadata about which dimension each value came from.
211
+ *
212
+ * @example
213
+ * ```ts
214
+ * import { cartesianWithLabels } from 'ai-experiments'
215
+ *
216
+ * const labeled = cartesianWithLabels({
217
+ * model: ['sonnet', 'opus'],
218
+ * temperature: [0.3, 0.7],
219
+ * })
220
+ * // [
221
+ * // { values: { model: 'sonnet', temperature: 0.3 }, labels: { model: 0, temperature: 0 } },
222
+ * // { values: { model: 'sonnet', temperature: 0.7 }, labels: { model: 0, temperature: 1 } },
223
+ * // { values: { model: 'opus', temperature: 0.3 }, labels: { model: 1, temperature: 0 } },
224
+ * // { values: { model: 'opus', temperature: 0.7 }, labels: { model: 1, temperature: 1 } },
225
+ * // ]
226
+ * ```
227
+ */
228
+ export function cartesianWithLabels<T extends CartesianParams>(
229
+ params: T
230
+ ): Array<{
231
+ values: { [K in keyof T]: T[K][number] }
232
+ labels: { [K in keyof T]: number }
233
+ }> {
234
+ const keys = Object.keys(params) as (keyof T)[]
235
+ const values = keys.map((k) => params[k])
236
+
237
+ if (keys.length === 0) {
238
+ return []
239
+ }
240
+
241
+ const combinations = cartesianProduct(values)
242
+
243
+ return combinations.map((combo) => {
244
+ const valuesObj = {} as { [K in keyof T]: T[K][number] }
245
+ const labelsObj = {} as { [K in keyof T]: number }
246
+
247
+ keys.forEach((key, i) => {
248
+ const value = combo[i] as T[typeof key][number]
249
+ valuesObj[key] = value
250
+ const arr = params[key]
251
+ labelsObj[key] = arr ? arr.indexOf(value) : -1
252
+ })
253
+
254
+ return {
255
+ values: valuesObj,
256
+ labels: labelsObj,
257
+ }
258
+ })
259
+ }
package/src/decide.ts ADDED
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Decision making utilities
3
+ */
4
+
5
+ import type { DecideOptions, DecisionResult } from './types.js'
6
+ import { track } from './tracking.js'
7
+
8
+ /**
9
+ * Make a decision by evaluating and scoring multiple options
10
+ *
11
+ * Scores each option and returns the best one (highest score).
12
+ *
13
+ * @example
14
+ * ```ts
15
+ * import { decide } from 'ai-experiments'
16
+ *
17
+ * // Simple decision with sync scoring
18
+ * const result = await decide({
19
+ * options: ['apple', 'banana', 'orange'],
20
+ * score: (fruit) => {
21
+ * const prices = { apple: 1.5, banana: 0.5, orange: 2.0 }
22
+ * return 1 / prices[fruit] // Lower price = higher score
23
+ * },
24
+ * })
25
+ * console.log(result.selected) // 'banana'
26
+ *
27
+ * // Decision with async scoring (AI-based)
28
+ * const result = await decide({
29
+ * options: [
30
+ * { prompt: 'Summarize in one sentence' },
31
+ * { prompt: 'Provide a detailed summary' },
32
+ * { prompt: 'Extract key points only' },
33
+ * ],
34
+ * score: async (option) => {
35
+ * const result = await ai.generate(option)
36
+ * return evaluateQuality(result)
37
+ * },
38
+ * context: 'Choosing best summarization approach',
39
+ * })
40
+ *
41
+ * // Get all options sorted by score
42
+ * const result = await decide({
43
+ * options: ['fast', 'accurate', 'balanced'],
44
+ * score: (approach) => evaluateApproach(approach),
45
+ * returnAll: true,
46
+ * })
47
+ * console.log(result.allOptions)
48
+ * // [
49
+ * // { option: 'balanced', score: 0.9 },
50
+ * // { option: 'accurate', score: 0.85 },
51
+ * // { option: 'fast', score: 0.7 },
52
+ * // ]
53
+ * ```
54
+ */
55
+ export async function decide<T>(
56
+ options: DecideOptions<T>
57
+ ): Promise<DecisionResult<T>> {
58
+ const { options: choices, score, context, returnAll = false } = options
59
+
60
+ if (choices.length === 0) {
61
+ throw new Error('Cannot decide with empty options')
62
+ }
63
+
64
+ // Score all options
65
+ const scoredOptions = await Promise.all(
66
+ choices.map(async (option) => {
67
+ const optionScore = await score(option)
68
+ return { option, score: optionScore }
69
+ })
70
+ )
71
+
72
+ // Sort by score (highest first)
73
+ const sorted = scoredOptions.sort((a, b) => b.score - a.score)
74
+
75
+ // Select best option
76
+ const best = sorted[0]!
77
+
78
+ // Track decision
79
+ track({
80
+ type: 'decision.made',
81
+ timestamp: new Date(),
82
+ data: {
83
+ context,
84
+ optionCount: choices.length,
85
+ selectedScore: best.score,
86
+ allScores: sorted.map((s) => s.score),
87
+ },
88
+ })
89
+
90
+ const result: DecisionResult<T> = {
91
+ selected: best.option,
92
+ score: best.score,
93
+ }
94
+
95
+ if (returnAll) {
96
+ result.allOptions = sorted
97
+ }
98
+
99
+ return result
100
+ }
101
+
102
+ /**
103
+ * Weighted random selection from options
104
+ *
105
+ * Each option has a weight, and selection probability is proportional to weight.
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * import { decideWeighted } from 'ai-experiments'
110
+ *
111
+ * const result = decideWeighted([
112
+ * { value: 'A', weight: 0.7 }, // 70% chance
113
+ * { value: 'B', weight: 0.2 }, // 20% chance
114
+ * { value: 'C', weight: 0.1 }, // 10% chance
115
+ * ])
116
+ *
117
+ * console.log(result) // Most likely 'A', but could be B or C
118
+ * ```
119
+ */
120
+ export function decideWeighted<T>(
121
+ options: Array<{ value: T; weight: number }>
122
+ ): T {
123
+ if (options.length === 0) {
124
+ throw new Error('Cannot decide with empty options')
125
+ }
126
+
127
+ // Calculate total weight
128
+ const totalWeight = options.reduce((sum, opt) => sum + opt.weight, 0)
129
+
130
+ if (totalWeight <= 0) {
131
+ throw new Error('Total weight must be positive')
132
+ }
133
+
134
+ // Generate random value between 0 and totalWeight
135
+ const random = Math.random() * totalWeight
136
+
137
+ // Find the option that corresponds to this random value
138
+ let cumulative = 0
139
+ for (const option of options) {
140
+ cumulative += option.weight
141
+ if (random <= cumulative) {
142
+ return option.value
143
+ }
144
+ }
145
+
146
+ // Fallback (should not reach here due to floating point precision)
147
+ return options[options.length - 1]!.value
148
+ }
149
+
150
+ /**
151
+ * Epsilon-greedy decision strategy
152
+ *
153
+ * With probability epsilon, select a random option (exploration).
154
+ * Otherwise, select the best option by score (exploitation).
155
+ *
156
+ * @example
157
+ * ```ts
158
+ * import { decideEpsilonGreedy } from 'ai-experiments'
159
+ *
160
+ * const result = await decideEpsilonGreedy({
161
+ * options: ['model-a', 'model-b', 'model-c'],
162
+ * score: async (model) => await evaluateModel(model),
163
+ * epsilon: 0.1, // 10% exploration, 90% exploitation
164
+ * })
165
+ * ```
166
+ */
167
+ export async function decideEpsilonGreedy<T>(
168
+ options: DecideOptions<T> & { epsilon: number }
169
+ ): Promise<DecisionResult<T>> {
170
+ const { epsilon, options: choices, score, context } = options
171
+
172
+ if (epsilon < 0 || epsilon > 1) {
173
+ throw new Error('Epsilon must be between 0 and 1')
174
+ }
175
+
176
+ // Exploration: random selection
177
+ if (Math.random() < epsilon) {
178
+ const randomIndex = Math.floor(Math.random() * choices.length)
179
+ const selected = choices[randomIndex]!
180
+ const selectedScore = await score(selected)
181
+
182
+ track({
183
+ type: 'decision.made',
184
+ timestamp: new Date(),
185
+ data: {
186
+ context,
187
+ strategy: 'epsilon-greedy-explore',
188
+ epsilon,
189
+ selectedScore,
190
+ },
191
+ })
192
+
193
+ return {
194
+ selected,
195
+ score: selectedScore,
196
+ }
197
+ }
198
+
199
+ // Exploitation: best option
200
+ const result = await decide({ options: choices, score, context })
201
+
202
+ track({
203
+ type: 'decision.made',
204
+ timestamp: new Date(),
205
+ data: {
206
+ context,
207
+ strategy: 'epsilon-greedy-exploit',
208
+ epsilon,
209
+ selectedScore: result.score,
210
+ },
211
+ })
212
+
213
+ return result
214
+ }
215
+
216
+ /**
217
+ * Thompson sampling decision strategy
218
+ *
219
+ * Bayesian approach to balancing exploration and exploitation.
220
+ * Each option has a Beta distribution representing our belief about its true score.
221
+ *
222
+ * @example
223
+ * ```ts
224
+ * import { decideThompsonSampling } from 'ai-experiments'
225
+ *
226
+ * // Track successes and failures for each option
227
+ * const priors = {
228
+ * 'variant-a': { alpha: 10, beta: 5 }, // 10 successes, 5 failures
229
+ * 'variant-b': { alpha: 8, beta: 3 }, // 8 successes, 3 failures
230
+ * 'variant-c': { alpha: 2, beta: 2 }, // 2 successes, 2 failures (uncertain)
231
+ * }
232
+ *
233
+ * const result = decideThompsonSampling(['variant-a', 'variant-b', 'variant-c'], priors)
234
+ * // More likely to select 'variant-b' (higher rate) but will sometimes explore 'variant-c'
235
+ * ```
236
+ */
237
+ export function decideThompsonSampling<T extends string>(
238
+ options: T[],
239
+ priors: Record<T, { alpha: number; beta: number }>
240
+ ): T {
241
+ if (options.length === 0) {
242
+ throw new Error('Cannot decide with empty options')
243
+ }
244
+
245
+ // Sample from Beta distribution for each option
246
+ const samples = options.map((option) => {
247
+ const { alpha, beta } = priors[option]
248
+ return {
249
+ option,
250
+ sample: sampleBeta(alpha, beta),
251
+ }
252
+ })
253
+
254
+ // Select option with highest sample
255
+ const best = samples.reduce((prev, current) =>
256
+ current.sample > prev.sample ? current : prev
257
+ )
258
+
259
+ track({
260
+ type: 'decision.made',
261
+ timestamp: new Date(),
262
+ data: {
263
+ strategy: 'thompson-sampling',
264
+ selected: best.option,
265
+ sample: best.sample,
266
+ priors: priors[best.option],
267
+ },
268
+ })
269
+
270
+ return best.option
271
+ }
272
+
273
+ /**
274
+ * Sample from Beta distribution using the ratio of two Gamma distributions
275
+ */
276
+ function sampleBeta(alpha: number, beta: number): number {
277
+ const x = sampleGamma(alpha, 1)
278
+ const y = sampleGamma(beta, 1)
279
+ return x / (x + y)
280
+ }
281
+
282
+ /**
283
+ * Sample from Gamma distribution using Marsaglia and Tsang method
284
+ */
285
+ function sampleGamma(shape: number, scale: number): number {
286
+ // Simple implementation for shape >= 1
287
+ if (shape < 1) {
288
+ // Use the property: Gamma(a) = Gamma(a+1) * U^(1/a)
289
+ return sampleGamma(shape + 1, scale) * Math.pow(Math.random(), 1 / shape)
290
+ }
291
+
292
+ const d = shape - 1 / 3
293
+ const c = 1 / Math.sqrt(9 * d)
294
+
295
+ while (true) {
296
+ let x: number
297
+ let v: number
298
+
299
+ do {
300
+ x = randomNormal()
301
+ v = 1 + c * x
302
+ } while (v <= 0)
303
+
304
+ v = v * v * v
305
+ const u = Math.random()
306
+
307
+ if (u < 1 - 0.0331 * x * x * x * x) {
308
+ return d * v * scale
309
+ }
310
+
311
+ if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) {
312
+ return d * v * scale
313
+ }
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Sample from standard normal distribution using Box-Muller transform
319
+ */
320
+ function randomNormal(): number {
321
+ const u1 = Math.random()
322
+ const u2 = Math.random()
323
+ return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2)
324
+ }
325
+
326
+ /**
327
+ * Upper Confidence Bound (UCB) decision strategy
328
+ *
329
+ * Select the option with the highest upper confidence bound.
330
+ * Balances exploration and exploitation using confidence intervals.
331
+ *
332
+ * @example
333
+ * ```ts
334
+ * import { decideUCB } from 'ai-experiments'
335
+ *
336
+ * const stats = {
337
+ * 'variant-a': { mean: 0.85, count: 100 },
338
+ * 'variant-b': { mean: 0.82, count: 50 }, // Less data, higher uncertainty
339
+ * 'variant-c': { mean: 0.78, count: 10 }, // Very uncertain
340
+ * }
341
+ *
342
+ * const result = decideUCB(['variant-a', 'variant-b', 'variant-c'], stats, {
343
+ * explorationFactor: 2.0,
344
+ * totalCount: 160,
345
+ * })
346
+ * // Might select variant-c to explore more, despite lower mean
347
+ * ```
348
+ */
349
+ export function decideUCB<T extends string>(
350
+ options: T[],
351
+ stats: Record<T, { mean: number; count: number }>,
352
+ config: {
353
+ /** Exploration factor (typically 1-2) */
354
+ explorationFactor: number
355
+ /** Total number of trials across all options */
356
+ totalCount: number
357
+ }
358
+ ): T {
359
+ const { explorationFactor, totalCount } = config
360
+
361
+ if (options.length === 0) {
362
+ throw new Error('Cannot decide with empty options')
363
+ }
364
+
365
+ // Calculate UCB for each option
366
+ const ucbScores = options.map((option) => {
367
+ const { mean, count } = stats[option]
368
+
369
+ // UCB = mean + c * sqrt(ln(N) / n)
370
+ const explorationBonus =
371
+ explorationFactor * Math.sqrt(Math.log(totalCount) / Math.max(count, 1))
372
+
373
+ return {
374
+ option,
375
+ ucb: mean + explorationBonus,
376
+ mean,
377
+ count,
378
+ }
379
+ })
380
+
381
+ // Select option with highest UCB
382
+ const best = ucbScores.reduce((prev, current) => (current.ucb > prev.ucb ? current : prev))
383
+
384
+ track({
385
+ type: 'decision.made',
386
+ timestamp: new Date(),
387
+ data: {
388
+ strategy: 'ucb',
389
+ selected: best.option,
390
+ ucb: best.ucb,
391
+ mean: best.mean,
392
+ count: best.count,
393
+ explorationFactor,
394
+ },
395
+ })
396
+
397
+ return best.option
398
+ }