llm-cost-attribution 0.2.0 → 0.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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Correlate a numeric feature series with a numeric cost series over already
3
+ * joined `{ feature, cost }` pairs.
4
+ *
5
+ * Cost data is monotonic-but-heavy-tailed: linear Pearson on raw values is
6
+ * easily dominated by a few outlier issues, while Spearman captures rank
7
+ * monotonicity that the linear view misses. This module therefore reports
8
+ * **both** rank and linear correlation (and a log-log Pearson view, which is
9
+ * the right linear view when both axes span orders of magnitude), so the
10
+ * caller can judge the relationship without being fooled by axis choice.
11
+ *
12
+ * Key-free and pure: it never reads git, Linear, the filesystem, or the
13
+ * network. Joining cost to features (e.g. diff size → tokens) belongs in
14
+ * `joinCostWithFeature`; this module only consumes the joined pairs.
15
+ *
16
+ * @typedef {{ feature: number, cost: number }} FeatureCostPair
17
+ *
18
+ * @typedef {{
19
+ * n: number,
20
+ * featureRange: { min: number, max: number },
21
+ * medianCost: number,
22
+ * }} DecileBucket
23
+ *
24
+ * @typedef {{
25
+ * n: number,
26
+ * spearman: number | null,
27
+ * pearsonLinear: number | null,
28
+ * pearsonLogLog: number | null,
29
+ * pearsonLogLogDropped: number,
30
+ * deciles: DecileBucket[],
31
+ * }} CorrelationResult
32
+ */
33
+
34
+ /**
35
+ * Compute Spearman, linear Pearson, log-log Pearson, and per-decile cost
36
+ * summaries over `{ feature, cost }` pairs.
37
+ *
38
+ * @param {Iterable<FeatureCostPair>} pairs
39
+ * @returns {CorrelationResult}
40
+ */
41
+ export function correlateCostWithFeature(pairs) {
42
+ const cleaned = [];
43
+ for (const pair of pairs ?? []) {
44
+ if (pair === null || typeof pair !== 'object') continue;
45
+ const feature = pair.feature;
46
+ const cost = pair.cost;
47
+ if (typeof feature !== 'number' || !Number.isFinite(feature)) continue;
48
+ if (typeof cost !== 'number' || !Number.isFinite(cost)) continue;
49
+ cleaned.push({ feature, cost });
50
+ }
51
+
52
+ const n = cleaned.length;
53
+
54
+ if (n < 2) {
55
+ return {
56
+ n,
57
+ spearman: null,
58
+ pearsonLinear: null,
59
+ pearsonLogLog: null,
60
+ pearsonLogLogDropped: 0,
61
+ deciles: [],
62
+ };
63
+ }
64
+
65
+ const featureRanks = averageRanks(cleaned.map((p) => p.feature));
66
+ const costRanks = averageRanks(cleaned.map((p) => p.cost));
67
+ const spearman = pearson(featureRanks, costRanks);
68
+
69
+ const features = cleaned.map((p) => p.feature);
70
+ const costs = cleaned.map((p) => p.cost);
71
+ const pearsonLinear = pearson(features, costs);
72
+
73
+ let pearsonLogLog = null;
74
+ let pearsonLogLogDropped = 0;
75
+ const logFeatures = [];
76
+ const logCosts = [];
77
+ for (const { feature, cost } of cleaned) {
78
+ if (feature > 0 && cost > 0) {
79
+ logFeatures.push(Math.log10(feature));
80
+ logCosts.push(Math.log10(cost));
81
+ } else {
82
+ pearsonLogLogDropped += 1;
83
+ }
84
+ }
85
+ if (logFeatures.length >= 2) {
86
+ pearsonLogLog = pearson(logFeatures, logCosts);
87
+ }
88
+
89
+ const deciles = buildDeciles(cleaned);
90
+
91
+ return {
92
+ n,
93
+ spearman,
94
+ pearsonLinear,
95
+ pearsonLogLog,
96
+ pearsonLogLogDropped,
97
+ deciles,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Average-rank tie handling: tied values receive the mean of the ranks they
103
+ * would occupy if broken arbitrarily. Ranks start at 1.
104
+ *
105
+ * @param {number[]} values
106
+ * @returns {number[]} Ranks in the original input order.
107
+ */
108
+ function averageRanks(values) {
109
+ const indexed = values.map((value, index) => ({ value, index }));
110
+ indexed.sort((a, b) => a.value - b.value);
111
+
112
+ const ranks = new Array(values.length);
113
+ let i = 0;
114
+ while (i < indexed.length) {
115
+ let j = i;
116
+ while (j + 1 < indexed.length && indexed[j + 1].value === indexed[i].value) j += 1;
117
+ const averageRank = (i + j + 2) / 2;
118
+ for (let k = i; k <= j; k += 1) ranks[indexed[k].index] = averageRank;
119
+ i = j + 1;
120
+ }
121
+ return ranks;
122
+ }
123
+
124
+ /**
125
+ * Pearson correlation coefficient. Returns `null` when either series has zero
126
+ * variance (constant series), since the coefficient is undefined.
127
+ *
128
+ * @param {number[]} xs
129
+ * @param {number[]} ys
130
+ * @returns {number | null}
131
+ */
132
+ function pearson(xs, ys) {
133
+ const n = xs.length;
134
+ if (n < 2) return null;
135
+
136
+ let meanX = 0;
137
+ let meanY = 0;
138
+ for (let i = 0; i < n; i += 1) {
139
+ meanX += xs[i];
140
+ meanY += ys[i];
141
+ }
142
+ meanX /= n;
143
+ meanY /= n;
144
+
145
+ let cov = 0;
146
+ let varX = 0;
147
+ let varY = 0;
148
+ for (let i = 0; i < n; i += 1) {
149
+ const dx = xs[i] - meanX;
150
+ const dy = ys[i] - meanY;
151
+ cov += dx * dy;
152
+ varX += dx * dx;
153
+ varY += dy * dy;
154
+ }
155
+
156
+ if (varX === 0 || varY === 0) return null;
157
+ return cov / Math.sqrt(varX * varY);
158
+ }
159
+
160
+ /**
161
+ * Split the pairs into 10 buckets ordered by feature, each with `n`, the
162
+ * feature range it covers, and the median cost inside it. Pairs are
163
+ * distributed by sorted position so bucket sizes differ by at most one.
164
+ *
165
+ * @param {FeatureCostPair[]} cleaned
166
+ * @returns {DecileBucket[]}
167
+ */
168
+ function buildDeciles(cleaned) {
169
+ if (cleaned.length === 0) return [];
170
+
171
+ const sorted = [...cleaned].sort((a, b) => a.feature - b.feature);
172
+ const total = sorted.length;
173
+ const buckets = [];
174
+ for (let bucketIndex = 0; bucketIndex < 10; bucketIndex += 1) {
175
+ const start = Math.floor((bucketIndex * total) / 10);
176
+ const end = Math.floor(((bucketIndex + 1) * total) / 10);
177
+ if (end <= start) continue;
178
+ const slice = sorted.slice(start, end);
179
+ buckets.push({
180
+ n: slice.length,
181
+ featureRange: {
182
+ min: slice[0].feature,
183
+ max: slice[slice.length - 1].feature,
184
+ },
185
+ medianCost: median(slice.map((p) => p.cost)),
186
+ });
187
+ }
188
+ return buckets;
189
+ }
190
+
191
+ /**
192
+ * Sorted-sample median. Uses the average of the two middle values for an
193
+ * even-sized sample.
194
+ *
195
+ * @param {number[]} values
196
+ * @returns {number}
197
+ */
198
+ function median(values) {
199
+ const sorted = [...values].sort((a, b) => a - b);
200
+ const mid = Math.floor(sorted.length / 2);
201
+ if (sorted.length % 2 === 0) return (sorted[mid - 1] + sorted[mid]) / 2;
202
+ return sorted[mid];
203
+ }
@@ -0,0 +1,393 @@
1
+ /**
2
+ * JoinCostWithFeature — the pluggable cost↔feature join port.
3
+ *
4
+ * The only workflow-specific decision in cost-drivers is *what ties a chunk of
5
+ * cost to a chunk of code change*. This module inverts that decision behind a
6
+ * named-strategy registry plus caller-supplied escape hatches, so the tool is
7
+ * not hard-wired to any one org's issue-key/worktree convention.
8
+ *
9
+ * Cost records (from `readUsageRecords`) and diff/feature records (from a
10
+ * `DiffSource` such as `readGitDiffs`) go in; `{ feature, cost }` pairs come
11
+ * out, shaped so they compose with `correlateCostWithFeature` without glue:
12
+ *
13
+ * { feature: <number>, cost: { tokens: <number>, turns: <number> } }
14
+ *
15
+ * Boundary rule: this is pure join logic. It imports neither git/child_process
16
+ * nor any transcript/Linear reader — both streams arrive as in-memory data
17
+ * (enforced by `npm run test:boundary`). The only sibling it leans on is the
18
+ * pure `issue-pattern` helper for the default issue-key extractor.
19
+ */
20
+ import { DEFAULT_CWD_PATTERN, issueFromCwd } from './issue-pattern.mjs';
21
+
22
+ /**
23
+ * Names of the built-in, label-free join strategies. The set is intentionally
24
+ * open: new strategies are added to {@link buildKeyStrategies} / the `time`
25
+ * branch without any caller having to change. Callers who need something
26
+ * outside this set reach for `keyOfUsage`/`keyOfDiff` or a full `join`.
27
+ *
28
+ * @type {readonly string[]}
29
+ */
30
+ export const BUILTIN_JOIN_STRATEGIES = Object.freeze(['issue-key', 'worktree', 'time']);
31
+
32
+ /**
33
+ * Join a stream of cost records with a stream of diff/feature records into
34
+ * `correlate`-ready `{ feature, cost }` pairs, using a selectable strategy.
35
+ *
36
+ * @param {object} args
37
+ * @param {Iterable<object> | AsyncIterable<object>} args.usage
38
+ * Cost records (e.g. from `readUsageRecords`). Each may carry
39
+ * `issueIdentifier?`, `workspacePath?`, `startedAt`/`endedAt`, `totalTokens`.
40
+ * One record == one turn (matching `rollupUsageRecords`), unless the record
41
+ * carries an explicit numeric `turns`/`turnCount`. `usageSource:
42
+ * 'unavailable'` records are skipped.
43
+ * @param {Iterable<object> | AsyncIterable<object>} args.diffs
44
+ * Diff records (e.g. from `readGitDiffs`): `{ key, additions, deletions,
45
+ * changedFiles }`. For the `worktree` strategy the join key is the diff's
46
+ * `key`/`workspacePath`; for `time` it is the diff's commit timestamp.
47
+ * @param {'issue-key'|'worktree'|'time'|string} [args.strategy]
48
+ * Named strategy. Defaults to `'issue-key'`. Ignored when `join` is given.
49
+ * @param {(usage: object) => (string|null|undefined)} [args.keyOfUsage]
50
+ * Override the strategy's usage-key extractor (custom key join).
51
+ * @param {(diff: object) => (string|null|undefined)} [args.keyOfDiff]
52
+ * Override the strategy's diff-key extractor (custom key join).
53
+ * @param {(usage: object[], diffs: object[]) => object[]} [args.join]
54
+ * Full escape hatch: bypass strategies and return pairs directly. The
55
+ * returned pair shape is validated.
56
+ * @param {(diff: object) => number} [args.featureOf]
57
+ * Reduce a (per-key merged) diff record to the numeric `feature`. Defaults to
58
+ * churn = `additions + deletions`.
59
+ * @param {RegExp} [args.cwdPattern]
60
+ * Pattern for the default issue-key extractor's `workspacePath` fallback.
61
+ * Defaults to `DEFAULT_CWD_PATTERN`. Overridable so the issue-key default
62
+ * never leaks one org's path convention.
63
+ * @param {number | {ms?: number, seconds?: number, minutes?: number, hours?: number}} [args.window]
64
+ * Required by `strategy: 'time'`: how far *before* a commit to sweep cost.
65
+ * @param {(usage: object) => (number|string|Date)} [args.timestampOfUsage]
66
+ * `time` strategy: usage timestamp. Defaults to `endedAt ?? startedAt`.
67
+ * @param {(diff: object) => (number|string|Date)} [args.timestampOfDiff]
68
+ * `time` strategy: commit timestamp. Defaults to
69
+ * `committedAt ?? timestamp ?? endedAt`.
70
+ * @returns {Promise<{pairs: object[], unjoined: {usage: string[], diffs: string[]}}>}
71
+ * `pairs` ready for `correlateCostWithFeature`, and `unjoined` listing the
72
+ * keys (or, for `time`, the timestamps) present on only one side.
73
+ */
74
+ export async function joinCostWithFeature(args = {}) {
75
+ const {
76
+ usage,
77
+ diffs,
78
+ strategy = 'issue-key',
79
+ keyOfUsage,
80
+ keyOfDiff,
81
+ join,
82
+ featureOf = defaultFeatureOf,
83
+ cwdPattern = DEFAULT_CWD_PATTERN,
84
+ window,
85
+ timestampOfUsage,
86
+ timestampOfDiff,
87
+ } = args;
88
+
89
+ const usageRecords = await collect(usage);
90
+ const diffRecords = await collect(diffs);
91
+
92
+ // Full custom join: bypass every strategy; only validate what comes back.
93
+ if (join !== undefined) {
94
+ if (typeof join !== 'function') throw new TypeError('`join` must be a function');
95
+ const pairs = validatePairs(join(usageRecords, diffRecords), 'join');
96
+ return { pairs, unjoined: { usage: [], diffs: [] } };
97
+ }
98
+
99
+ if (strategy === 'time' && keyOfUsage === undefined && keyOfDiff === undefined) {
100
+ const pairs = timeJoin(usageRecords, diffRecords, { window, timestampOfUsage, timestampOfDiff, featureOf });
101
+ return { pairs: pairs.joined, unjoined: pairs.unjoined };
102
+ }
103
+
104
+ // Key-based join: issue-key (default), worktree, or caller-supplied extractors.
105
+ const strategies = buildKeyStrategies(cwdPattern);
106
+ const base = strategies[strategy];
107
+ if (base === undefined && !(keyOfUsage !== undefined || keyOfDiff !== undefined)) {
108
+ throw new Error(
109
+ `unknown join strategy: ${JSON.stringify(strategy)}. ` +
110
+ `Use one of ${BUILTIN_JOIN_STRATEGIES.join(', ')}, supply keyOfUsage/keyOfDiff, or pass a full join().`,
111
+ );
112
+ }
113
+ const resolvedKeyOfUsage = keyOfUsage ?? base?.keyOfUsage ?? strategies['issue-key'].keyOfUsage;
114
+ const resolvedKeyOfDiff = keyOfDiff ?? base?.keyOfDiff ?? strategies['issue-key'].keyOfDiff;
115
+
116
+ return keyJoin(usageRecords, diffRecords, { keyOfUsage: resolvedKeyOfUsage, keyOfDiff: resolvedKeyOfDiff, featureOf });
117
+ }
118
+
119
+ /* ------------------------------------------------------------------------- *
120
+ * Key-based join (issue-key / worktree / custom extractors)
121
+ * ------------------------------------------------------------------------- */
122
+
123
+ function keyJoin(usageRecords, diffRecords, { keyOfUsage, keyOfDiff, featureOf }) {
124
+ const costByKey = new Map();
125
+ for (const rec of usageRecords) {
126
+ const contribution = usageContribution(rec);
127
+ if (contribution === null) continue;
128
+ const key = normalizeKey(keyOfUsage(rec));
129
+ if (key === null) continue;
130
+ addCost(costByKey, key, contribution);
131
+ }
132
+
133
+ const diffByKey = new Map();
134
+ for (const rec of diffRecords) {
135
+ const key = normalizeKey(keyOfDiff(rec));
136
+ if (key === null) continue;
137
+ addDiff(diffByKey, key, rec);
138
+ }
139
+
140
+ const pairs = [];
141
+ const unjoinedDiffs = [];
142
+ for (const [key, mergedDiff] of diffByKey) {
143
+ const cost = costByKey.get(key);
144
+ if (cost === undefined) {
145
+ unjoinedDiffs.push(key);
146
+ continue;
147
+ }
148
+ pairs.push(makePair(key, featureOf(mergedDiff), cost));
149
+ }
150
+
151
+ const unjoinedUsage = [];
152
+ for (const key of costByKey.keys()) {
153
+ if (!diffByKey.has(key)) unjoinedUsage.push(key);
154
+ }
155
+
156
+ return {
157
+ pairs: validatePairs(pairs, 'key-strategy'),
158
+ unjoined: { usage: unjoinedUsage.sort(), diffs: unjoinedDiffs.sort() },
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Build the registry of key-based strategies, binding the runtime `cwdPattern`
164
+ * into the issue-key extractor. Adding a new named key strategy is a one-line
165
+ * addition here — no caller changes.
166
+ */
167
+ function buildKeyStrategies(cwdPattern) {
168
+ return {
169
+ 'issue-key': {
170
+ keyOfUsage: (u) => {
171
+ if (typeof u.issueIdentifier === 'string' && u.issueIdentifier !== '') return u.issueIdentifier;
172
+ return issueFromCwd(String(u.workspacePath ?? ''), cwdPattern);
173
+ },
174
+ keyOfDiff: (d) => d.key ?? d.issueIdentifier ?? null,
175
+ },
176
+ worktree: {
177
+ keyOfUsage: (u) => normalizePath(u.workspacePath ?? u.cwd),
178
+ keyOfDiff: (d) => normalizePath(d.workspacePath ?? d.worktreePath ?? d.key),
179
+ },
180
+ };
181
+ }
182
+
183
+ /* ------------------------------------------------------------------------- *
184
+ * Time-window join (noisy, label-free fallback)
185
+ * ------------------------------------------------------------------------- */
186
+
187
+ /**
188
+ * Attribute each cost record to the nearest commit at or after it, within
189
+ * `window` milliseconds. Deliberately approximate — there is no shared key, so
190
+ * a burst of cost is credited to the next commit that "lands" it. Documented as
191
+ * a low-confidence fallback.
192
+ */
193
+ function timeJoin(usageRecords, diffRecords, { window, timestampOfUsage, timestampOfDiff, featureOf }) {
194
+ const windowMs = resolveWindowMs(window);
195
+ const tsUsage = timestampOfUsage ?? ((u) => u.endedAt ?? u.startedAt);
196
+ const tsDiff = timestampOfDiff ?? ((d) => d.committedAt ?? d.timestamp ?? d.endedAt);
197
+
198
+ const commits = diffRecords
199
+ .map((rec) => ({ rec, at: toMillis(tsDiff(rec)) }))
200
+ .filter((c) => c.at !== null)
201
+ .sort((a, b) => a.at - b.at);
202
+
203
+ const buckets = commits.map((c) => ({ commit: c, cost: { tokens: 0, turns: 0 }, matched: 0 }));
204
+
205
+ const unjoinedUsage = [];
206
+ for (const rec of usageRecords) {
207
+ const contribution = usageContribution(rec);
208
+ if (contribution === null) continue;
209
+ const at = toMillis(tsUsage(rec));
210
+ if (at === null) {
211
+ unjoinedUsage.push('(no timestamp)');
212
+ continue;
213
+ }
214
+ const idx = nearestCommitAtOrAfter(buckets, at, windowMs);
215
+ if (idx === -1) {
216
+ unjoinedUsage.push(new Date(at).toISOString());
217
+ continue;
218
+ }
219
+ buckets[idx].cost.tokens += contribution.tokens;
220
+ buckets[idx].cost.turns += contribution.turns;
221
+ buckets[idx].matched += 1;
222
+ }
223
+
224
+ const joined = [];
225
+ const unjoinedDiffs = [];
226
+ for (const bucket of buckets) {
227
+ const label = isoOrString(bucket.commit.at);
228
+ if (bucket.matched === 0) {
229
+ unjoinedDiffs.push(label);
230
+ continue;
231
+ }
232
+ joined.push(makePair(label, featureOf(bucket.commit.rec), bucket.cost, { approximate: true }));
233
+ }
234
+
235
+ return {
236
+ joined: validatePairs(joined, 'time-strategy'),
237
+ unjoined: { usage: unjoinedUsage, diffs: unjoinedDiffs },
238
+ };
239
+ }
240
+
241
+ /** First commit whose time is >= `at` and within `windowMs` of it; else -1. */
242
+ function nearestCommitAtOrAfter(buckets, at, windowMs) {
243
+ let best = -1;
244
+ for (let i = 0; i < buckets.length; i++) {
245
+ const commitAt = buckets[i].commit.at;
246
+ if (commitAt < at) continue;
247
+ if (commitAt - at > windowMs) break; // sorted ascending — no later one is closer
248
+ best = i;
249
+ break;
250
+ }
251
+ return best;
252
+ }
253
+
254
+ function resolveWindowMs(window) {
255
+ if (typeof window === 'number' && Number.isFinite(window) && window > 0) return window;
256
+ if (window !== null && typeof window === 'object') {
257
+ const ms =
258
+ numOr0(window.ms) +
259
+ numOr0(window.seconds) * 1000 +
260
+ numOr0(window.minutes) * 60_000 +
261
+ numOr0(window.hours) * 3_600_000;
262
+ if (ms > 0) return ms;
263
+ }
264
+ throw new Error("strategy: 'time' requires a positive `window` (ms number or { ms|seconds|minutes|hours }).");
265
+ }
266
+
267
+ /* ------------------------------------------------------------------------- *
268
+ * Shared helpers
269
+ * ------------------------------------------------------------------------- */
270
+
271
+ function defaultFeatureOf(diff) {
272
+ return numOr0(diff.additions) + numOr0(diff.deletions);
273
+ }
274
+
275
+ /**
276
+ * Cost contributed by one usage record, or `null` to skip it. One record is
277
+ * one turn (matching `rollupUsageRecords`) unless it carries an explicit
278
+ * numeric `turns`/`turnCount`. `unavailable` records carry no usage and are
279
+ * dropped.
280
+ */
281
+ function usageContribution(rec) {
282
+ if (rec.usageSource === 'unavailable') return null;
283
+ const tokens = Number.isFinite(rec.totalTokens)
284
+ ? rec.totalTokens
285
+ : numOr0(rec.inputTokens) + numOr0(rec.outputTokens);
286
+ let turns = 1;
287
+ if (Number.isFinite(rec.turns)) turns = rec.turns;
288
+ else if (Number.isFinite(rec.turnCount)) turns = rec.turnCount;
289
+ return { tokens, turns };
290
+ }
291
+
292
+ function addCost(map, key, contribution) {
293
+ const existing = map.get(key);
294
+ if (existing === undefined) {
295
+ map.set(key, { tokens: contribution.tokens, turns: contribution.turns });
296
+ return;
297
+ }
298
+ existing.tokens += contribution.tokens;
299
+ existing.turns += contribution.turns;
300
+ }
301
+
302
+ function addDiff(map, key, rec) {
303
+ const existing = map.get(key);
304
+ if (existing === undefined) {
305
+ map.set(key, {
306
+ key,
307
+ additions: numOr0(rec.additions),
308
+ deletions: numOr0(rec.deletions),
309
+ changedFiles: numOr0(rec.changedFiles),
310
+ });
311
+ return;
312
+ }
313
+ existing.additions += numOr0(rec.additions);
314
+ existing.deletions += numOr0(rec.deletions);
315
+ existing.changedFiles += numOr0(rec.changedFiles);
316
+ }
317
+
318
+ function makePair(key, feature, cost, extra) {
319
+ const pair = { key, feature, cost: { tokens: cost.tokens, turns: cost.turns } };
320
+ if (extra !== undefined) Object.assign(pair, extra);
321
+ return pair;
322
+ }
323
+
324
+ /**
325
+ * Assert the `{ feature, cost: { tokens, turns } }` contract every consumer
326
+ * (notably `correlateCostWithFeature`) relies on. Runs on built-in output and,
327
+ * critically, on the opaque output of a caller-supplied `join`.
328
+ */
329
+ function validatePairs(pairs, source) {
330
+ if (!Array.isArray(pairs)) {
331
+ throw new TypeError(`${source} must return an array of pairs, got ${typeof pairs}`);
332
+ }
333
+ pairs.forEach((pair, i) => {
334
+ if (pair === null || typeof pair !== 'object') {
335
+ throw new TypeError(`${source} pair[${i}] is not an object`);
336
+ }
337
+ if (!Number.isFinite(pair.feature)) {
338
+ throw new TypeError(`${source} pair[${i}].feature must be a finite number, got ${stringifyValue(pair.feature)}`);
339
+ }
340
+ const cost = pair.cost;
341
+ if (cost === null || typeof cost !== 'object') {
342
+ throw new TypeError(`${source} pair[${i}].cost must be an object with { tokens, turns }`);
343
+ }
344
+ if (!Number.isFinite(cost.tokens)) {
345
+ throw new TypeError(`${source} pair[${i}].cost.tokens must be a finite number, got ${stringifyValue(cost.tokens)}`);
346
+ }
347
+ if (!Number.isFinite(cost.turns)) {
348
+ throw new TypeError(`${source} pair[${i}].cost.turns must be a finite number, got ${stringifyValue(cost.turns)}`);
349
+ }
350
+ });
351
+ return pairs;
352
+ }
353
+
354
+ async function collect(source) {
355
+ const out = [];
356
+ if (source === null || source === undefined) return out;
357
+ for await (const item of source) out.push(item);
358
+ return out;
359
+ }
360
+
361
+ function normalizeKey(value) {
362
+ if (value === null || value === undefined) return null;
363
+ const s = String(value).trim();
364
+ return s === '' ? null : s;
365
+ }
366
+
367
+ function normalizePath(value) {
368
+ if (value === null || value === undefined) return null;
369
+ const s = String(value).trim().replace(/[/\\]+$/, '');
370
+ return s === '' ? null : s;
371
+ }
372
+
373
+ function toMillis(value) {
374
+ if (value === null || value === undefined) return null;
375
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.getTime();
376
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
377
+ const t = Date.parse(String(value));
378
+ return Number.isNaN(t) ? null : t;
379
+ }
380
+
381
+ function isoOrString(ms) {
382
+ return new Date(ms).toISOString();
383
+ }
384
+
385
+ function numOr0(v) {
386
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0;
387
+ }
388
+
389
+ function stringifyValue(v) {
390
+ if (typeof v === 'number') return String(v);
391
+ if (typeof v === 'string') return JSON.stringify(v);
392
+ return Object.prototype.toString.call(v);
393
+ }