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.
- package/README.md +114 -0
- package/bin/llm-cost.mjs +494 -2
- package/package.json +1 -1
- package/src/correlate.mjs +203 -0
- package/src/cost-feature-join.mjs +393 -0
- package/src/git-diff-source.mjs +278 -0
- package/src/index.mjs +77 -0
|
@@ -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
|
+
}
|