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 CHANGED
@@ -94,6 +94,105 @@ llm-cost calibrate ~/backfill.out --seed 1 --holdout 0.2
94
94
 
95
95
  Read-only and local — the input is never written back or committed (point it at a gitignored file). Committed tests use only synthetic fixtures (`test/forecast-recovers-known-dist.test.mjs`).
96
96
 
97
+ ## What drives your cost? (`cost-drivers`)
98
+
99
+ `cost-drivers` runs an end-to-end correlation analysis: it reads your LLM cost records, reads diff statistics from a local git repo, joins them by issue key, and prints Spearman rank correlation, linear Pearson, log-log Pearson, and a decile table. The goal is to understand which attributes of an issue predict how much it costs — using your own data, not anyone else's benchmarks.
100
+
101
+ **Minimal inputs:** a local git repo whose commit subjects include issue keys, and transcripts (or a `usage.jsonl`) for the same issues.
102
+
103
+ ```bash
104
+ llm-cost cost-drivers --repo ~/code/my-project
105
+ llm-cost cost-drivers --repo ~/code/my-project --metric turns
106
+ llm-cost cost-drivers --repo ~/code/my-project --from-usage ~/llm-cost-history.jsonl
107
+ ```
108
+
109
+ Example readout (synthetic numbers — for illustration only):
110
+
111
+ ```
112
+ ════════════════════════════════════════════════════════════════════════
113
+ COST DRIVERS — diff churn vs tokens
114
+ ════════════════════════════════════════════════════════════════════════
115
+ Join strategy: issue
116
+ Source: ~/code/my-project
117
+ n = 42 pairs unjoined: 3 usage, 5 diffs unmatched commits: 11
118
+
119
+ Correlations:
120
+ Spearman 0.34
121
+ Pearson(linear) 0.21
122
+ Pearson(log-log) 0.40
123
+
124
+ Decile table:
125
+ Decile Feature range n Median cost
126
+ ────────────────────────────────────────────────────────────────────────
127
+ 1 14 – 87 4 58.3K
128
+ 2 91 – 210 4 72.1K
129
+ 3 215 – 380 4 91.4K
130
+ 4 384 – 510 4 103.2K
131
+ 5 512 – 740 5 128.7K
132
+ 6 744 – 1.1K 4 145.3K
133
+ 7 1.1K – 1.6K 4 189.6K
134
+ 8 1.6K – 2.4K 4 224.1K
135
+ 9 2.5K – 4.1K 5 301.8K
136
+ 10 4.2K – 9.3K 4 512.4K
137
+ ```
138
+
139
+ Reading that block: **Feature range** is diff churn (additions + deletions) in lines; **Median cost** is the median token count for issues in that churn decile. The three correlation coefficients tell the same story from different angles — see "Reading the output" below.
140
+
141
+ ### Join model
142
+
143
+ `cost-drivers` needs to know which cost record belongs to which diff. The `--join-by` flag selects the strategy:
144
+
145
+ | Strategy | How it joins | When to use |
146
+ |---|---|---|
147
+ | `issue` (default) | Extracts issue keys (e.g. `ABC-123`) from commit subjects and from each cost record's `issueIdentifier` / workspace path | Works out of the box with Symphony's per-issue worktree convention and squash-merge commit messages |
148
+ | `worktree` | Joins on the cost record's workspace path vs. the diff record's key | Useful when your diff records carry workspace paths instead of issue keys |
149
+ | `time` | Attributes each cost record to the next commit within `--window` (e.g. `30m`, `2h`, `1d`) | Label-free fallback when commit subjects don't contain keys; inherently approximate |
150
+
151
+ ```bash
152
+ # explicit strategies
153
+ llm-cost cost-drivers --repo ~/code/my-project --join-by issue # default
154
+ llm-cost cost-drivers --repo ~/code/my-project --join-by worktree
155
+ llm-cost cost-drivers --repo ~/code/my-project --join-by time --window 2h
156
+
157
+ # override the key-extraction regex if your project uses a different format
158
+ llm-cost cost-drivers --repo ~/code/my-project --key-pattern 'TICKET-\d+'
159
+ ```
160
+
161
+ The `keyOfUsage`, `keyOfDiff`, and `join` overrides are available via the library API (`joinCostWithFeature`) for cases the CLI flags don't cover — for example joining on a custom field, or implementing a fully custom reconciliation.
162
+
163
+ #### Escape hatch: join externally with `dump-* → correlate`
164
+
165
+ If none of the built-in strategies fit, emit the two streams and join them yourself:
166
+
167
+ ```bash
168
+ # 1. dump the cost stream
169
+ llm-cost dump-usage > usage.jsonl
170
+
171
+ # 2. dump the diff stream
172
+ llm-cost dump-diffs --repo ~/code/my-project > diffs.jsonl
173
+
174
+ # 3. join them however you like, then feed back a { feature, cost } CSV
175
+ llm-cost correlate --pairs my-pairs.csv # CSV: feature,cost[,key]
176
+ ```
177
+
178
+ `correlate --pairs` accepts `.csv` (header `feature,cost`) or `.json` (array of `{feature, cost}` objects) and produces the same readout as `cost-drivers`.
179
+
180
+ ### Reading the output
181
+
182
+ **Three correlation views, not one.** LLM cost is heavy-tailed — a handful of expensive issues can dominate a linear average. `cost-drivers` therefore reports:
183
+
184
+ - **Spearman** (rank correlation): captures monotonic relationships without being skewed by outliers. If big issues generally cost more than small ones, Spearman will pick that up even when the raw values vary wildly.
185
+ - **Pearson (linear)**: the standard linear correlation on raw values. On heavy-tailed data it can read near zero even when Spearman is meaningful; it is sensitive to a few extreme issues.
186
+ - **Pearson (log-log)**: Pearson on log₁₀-transformed values, the right view when both axes span orders of magnitude. If cost and diff size both grow geometrically, this is the coefficient that captures it.
187
+
188
+ A large gap between Spearman and linear Pearson is a signal that the relationship is real but nonlinear or that a few outliers are suppressing the linear view — not that the relationship is absent.
189
+
190
+ **Always check `n`.** With a small sample (say n < 20) the coefficients are unreliable and the decile table will have very few rows per bucket. Treat the output as directional until you have more history.
191
+
192
+ **Diff size is output, not effort.** A feature that happens to touch many files will show high churn whether or not it was the most complex work. Churn is the most readily available proxy; other features (issue estimate, turn count) may or may not track cost better on your workload.
193
+
194
+ **Local-git limits.** `readGitDiffs` only sees commits already in your local checkout — run `git fetch` or `git pull` first if you want remote-only commits. For the default `issue` strategy, commits must also carry issue keys in their subjects (the default pattern matches `ABC-123`-style keys; override with `--key-pattern`).
195
+
97
196
  ## Library
98
197
 
99
198
  ```js
@@ -111,6 +210,21 @@ const result = await backfillUsageFromTranscripts({ outFile: '/tmp/usage.jsonl'
111
210
 
112
211
  Pass `{ cwdPattern, claudeProjectsDir, codexSessionsDir }` to override defaults.
113
212
 
213
+ ### Diff-size feature records
214
+
215
+ `readGitDiffs(repoPath, { revRange, keyPattern })` reads local `git log --numstat`
216
+ output and yields one aggregated record per issue key found in commit subjects:
217
+
218
+ ```js
219
+ for await (const diff of readGitDiffs('/path/to/repo')) {
220
+ console.log(diff.key, diff.additions + diff.deletions, diff.changedFiles);
221
+ }
222
+ ```
223
+
224
+ It is local-first: no GitHub token, network, or API calls. The tradeoff is that it
225
+ sees only history already present in the checkout, and commits must carry issue
226
+ keys in their subjects, as with squash-merge subjects like `[ABC-12]: add widget`.
227
+
114
228
  ## What it doesn't (and can't) do
115
229
 
116
230
  - **Story-point estimates** — live in your tracker, not the transcripts (see the sibling `llm-cost-estimation`).
package/bin/llm-cost.mjs CHANGED
@@ -17,7 +17,8 @@
17
17
  * For any other layout, pass `--cwd-pattern` with a JavaScript regex
18
18
  * containing one capture group for the issue identifier.
19
19
  */
20
- import { resolve } from 'node:path';
20
+ import { readFile, writeFile } from 'node:fs/promises';
21
+ import { extname, resolve } from 'node:path';
21
22
  import { parseArgs } from 'node:util';
22
23
  import {
23
24
  backfillUsageFromTranscripts,
@@ -25,7 +26,11 @@ import {
25
26
  computeIssueCost,
26
27
  computeIssueCostFromUsage,
27
28
  computeWorktreeCost,
29
+ correlateCostWithFeature,
30
+ iterateUsageFromTranscripts,
31
+ joinCostWithFeature,
28
32
  listKnownIssues,
33
+ readGitDiffs,
29
34
  readUsageRecords,
30
35
  validateUsageRecord,
31
36
  } from '../src/index.mjs';
@@ -54,6 +59,15 @@ async function main() {
54
59
  holdout: { type: 'string' },
55
60
  quantile: { type: 'string' },
56
61
  threshold: { type: 'string' },
62
+ // cost-drivers / dump-* / correlate verbs
63
+ repo: { type: 'string' },
64
+ metric: { type: 'string' },
65
+ 'join-by': { type: 'string' },
66
+ 'key-pattern': { type: 'string' },
67
+ 'rev-range': { type: 'string' },
68
+ window: { type: 'string' },
69
+ pairs: { type: 'string' },
70
+ csv: { type: 'string' },
57
71
  json: { type: 'boolean' },
58
72
  help: { type: 'boolean', short: 'h' },
59
73
  },
@@ -150,6 +164,35 @@ async function main() {
150
164
  return;
151
165
  }
152
166
 
167
+ // `llm-cost cost-drivers --repo <path>` — end-to-end cost↔diff correlation.
168
+ // Reads cost from either transcripts (default) or --from-usage, reads diffs
169
+ // from a local git repo, joins by the chosen strategy, and prints the
170
+ // Spearman / Pearson / decile readout.
171
+ if (command === 'cost-drivers') {
172
+ await runCostDrivers(values, options);
173
+ return;
174
+ }
175
+
176
+ // `llm-cost dump-usage` — emit the cost stream as JSONL for the user to
177
+ // join externally. Source: --from-usage if set; otherwise transcripts.
178
+ if (command === 'dump-usage') {
179
+ await runDumpUsage(values, options);
180
+ return;
181
+ }
182
+
183
+ // `llm-cost dump-diffs --repo <path>` — emit the diff stream as JSONL.
184
+ if (command === 'dump-diffs') {
185
+ await runDumpDiffs(values);
186
+ return;
187
+ }
188
+
189
+ // `llm-cost correlate --pairs <file.csv|json>` — read externally-joined
190
+ // {feature, cost} pairs and print the readout. No git/transcripts needed.
191
+ if (command === 'correlate') {
192
+ await runCorrelate(values);
193
+ return;
194
+ }
195
+
153
196
  if (command === 'list') {
154
197
  const ids = await listKnownIssues(options);
155
198
  if (values.json === true) {
@@ -342,6 +385,11 @@ function printUsage() {
342
385
  llm-cost list
343
386
  llm-cost backfill --out <usage.jsonl-path>
344
387
  llm-cost calibrate <usage.jsonl-or-dir> [--seed N] [--holdout F]
388
+ llm-cost cost-drivers --repo <path> [--metric tokens|turns]
389
+ [--join-by issue|worktree|time] [--window <dur>]
390
+ llm-cost dump-usage [--from-usage <path>] [--json]
391
+ llm-cost dump-diffs --repo <path> [--key-pattern <regex>] [--json]
392
+ llm-cost correlate --pairs <file.csv|json> [--csv <out>] [--json]
345
393
  llm-cost --help
346
394
 
347
395
  Per-issue token, turn, and quota analytics for Claude Code and Codex CLI sessions.
@@ -374,7 +422,30 @@ Options:
374
422
  --quantile <0..1> (calibrate only) Quantile band to test. Default 0.8 (P80).
375
423
  --threshold <0..1> (calibrate only) Flag a cell when coverage drifts from
376
424
  the target by more than this. Default 0.1 (10 points).
377
- --json Emit machine-readable JSON instead of the table.
425
+ --repo <path> (cost-drivers / dump-diffs only) Local git repo to read
426
+ diffs from. Diff size = additions + deletions.
427
+ --metric tokens|turns (cost-drivers only) Cost dimension to correlate. Default tokens.
428
+ --join-by issue|worktree|time
429
+ (cost-drivers only) How to attribute cost to a diff.
430
+ Default 'issue' (extract a key from commit subjects
431
+ and from each cost record's issueIdentifier/workspacePath).
432
+ 'worktree' joins on the cost record's workspacePath.
433
+ 'time' attributes each cost record to the next commit
434
+ inside --window, an approximate label-free fallback.
435
+ --window <duration> (cost-drivers --join-by time only) Sweep window before
436
+ a commit, e.g. '30m', '2h', '1d', or a millisecond integer.
437
+ --key-pattern <regex> (cost-drivers / dump-diffs only) Regex for issue keys in
438
+ commit subjects. Default \`[A-Z][A-Z0-9]+-\\d+\`.
439
+ --rev-range <range> (cost-drivers / dump-diffs only) Optional git rev range
440
+ (e.g. \`origin/main..HEAD\`).
441
+ --pairs <file> (correlate only) Externally-joined pairs to read.
442
+ .csv: header \`feature,cost[,key]\`; .json: array of
443
+ \`{feature, cost, key?}\` objects.
444
+ --csv <path> (cost-drivers / correlate only) Write per-pair rows to
445
+ this caller-named path. Never written to the cwd by default.
446
+ --json Emit machine-readable JSON instead of the table. For
447
+ dump-* the default is JSONL (one record per line);
448
+ --json packs the records into a single JSON array.
378
449
  -h, --help Print this message.
379
450
 
380
451
  Examples:
@@ -395,6 +466,15 @@ Examples:
395
466
  # Check whether the forecaster's P80 band is actually calibrated against a
396
467
  # local, estimate-tagged dataset. The input stays local — never committed.
397
468
  llm-cost calibrate ~/backfill.out --seed 1 --holdout 0.2
469
+
470
+ # End-to-end: what predicts how many tokens an issue eats?
471
+ llm-cost cost-drivers --repo ~/code/my-repo --metric tokens
472
+ llm-cost cost-drivers --repo ~/code/my-repo --join-by worktree --json
473
+
474
+ # Escape hatches: emit the two streams and join them yourself.
475
+ llm-cost dump-usage > usage.jsonl
476
+ llm-cost dump-diffs --repo ~/code/my-repo > diffs.jsonl
477
+ llm-cost correlate --pairs my-pairs.csv # CSV: feature,cost
398
478
  `);
399
479
  }
400
480
 
@@ -638,6 +718,418 @@ function formatRate(perMillionUsd) {
638
718
  return `$${perMillionUsd.toFixed(4)}`;
639
719
  }
640
720
 
721
+ /* ------------------------------------------------------------------------- *
722
+ * cost-drivers / dump-* / correlate verbs
723
+ * ------------------------------------------------------------------------- */
724
+
725
+ const METRICS = ['tokens', 'turns'];
726
+ const JOIN_BY_TO_STRATEGY = { issue: 'issue-key', worktree: 'worktree', time: 'time' };
727
+ const JOIN_BY_CHOICES = Object.keys(JOIN_BY_TO_STRATEGY);
728
+
729
+ async function runCostDrivers(values, transcriptOptions) {
730
+ const repo = values.repo;
731
+ if (typeof repo !== 'string' || repo === '') {
732
+ console.error('error: cost-drivers requires --repo <path>');
733
+ process.exit(1);
734
+ }
735
+ const metric = pickMetric(values.metric);
736
+ const joinByLabel = pickJoinBy(values['join-by']);
737
+ const strategy = JOIN_BY_TO_STRATEGY[joinByLabel];
738
+ const keyPattern = parseRegexOption(values['key-pattern'], 'key-pattern');
739
+ const cwdPattern = parseRegexOption(values['cwd-pattern'], 'cwd-pattern') ?? DEFAULT_CWD_PATTERN;
740
+
741
+ const usage = values['from-usage'] !== undefined
742
+ ? readUsageRecords(values['from-usage'])
743
+ : iterateUsageFromTranscripts({
744
+ cwdPattern,
745
+ claudeProjectsDir: transcriptOptions.claudeProjectsDir,
746
+ codexSessionsDir: transcriptOptions.codexSessionsDir,
747
+ });
748
+
749
+ const diffsGen = readGitDiffs(resolve(repo), {
750
+ keyPattern: keyPattern ?? undefined,
751
+ revRange: values['rev-range'] ?? undefined,
752
+ });
753
+
754
+ // Drain the diffs into an array so we can grab the unmatched/error summary
755
+ // alongside the records the join consumes.
756
+ const diffRecords = [];
757
+ let diffSummary = null;
758
+ while (true) {
759
+ const step = await diffsGen.next();
760
+ if (step.done) { diffSummary = step.value; break; }
761
+ diffRecords.push(step.value);
762
+ }
763
+ if (diffSummary !== null && diffSummary.error !== null) {
764
+ console.error(`error: ${diffSummary.error.message}`);
765
+ process.exit(1);
766
+ }
767
+
768
+ const joinArgs = {
769
+ usage,
770
+ diffs: diffRecords,
771
+ strategy,
772
+ cwdPattern,
773
+ };
774
+ if (strategy === 'time') joinArgs.window = parseDurationMs(values.window);
775
+
776
+ let joinOut;
777
+ try {
778
+ joinOut = await joinCostWithFeature(joinArgs);
779
+ } catch (err) {
780
+ console.error(`error: ${err.message}`);
781
+ process.exit(1);
782
+ }
783
+
784
+ // Project to scalar { feature, cost } for the chosen metric.
785
+ const flatPairs = joinOut.pairs.map((p) => ({
786
+ key: p.key,
787
+ feature: p.feature,
788
+ cost: p.cost[metric],
789
+ }));
790
+ const result = correlateCostWithFeature(flatPairs);
791
+
792
+ if (values.csv !== undefined) await writeCsvPairs(values.csv, flatPairs, metric);
793
+
794
+ const unmatched = diffSummary?.unmatched ?? {};
795
+ const payload = {
796
+ metric,
797
+ joinBy: joinByLabel,
798
+ repo: resolve(repo),
799
+ n: result.n,
800
+ spearman: result.spearman,
801
+ pearsonLinear: result.pearsonLinear,
802
+ pearsonLogLog: result.pearsonLogLog,
803
+ pearsonLogLogDropped: result.pearsonLogLogDropped,
804
+ deciles: result.deciles,
805
+ unjoined: {
806
+ usage: joinOut.unjoined.usage,
807
+ diffs: joinOut.unjoined.diffs,
808
+ },
809
+ unmatchedCommits: unmatched.unmatchedCommits ?? 0,
810
+ skippedEmptyCommits: unmatched.skippedEmptyCommits ?? 0,
811
+ };
812
+
813
+ if (values.json === true) {
814
+ console.log(JSON.stringify(payload, null, 2));
815
+ return;
816
+ }
817
+ printCorrelationReport(payload, { repoLabel: resolve(repo) });
818
+ }
819
+
820
+ async function runDumpUsage(values, transcriptOptions) {
821
+ const cwdPattern = parseRegexOption(values['cwd-pattern'], 'cwd-pattern') ?? DEFAULT_CWD_PATTERN;
822
+ const source = values['from-usage'] !== undefined
823
+ ? readUsageRecords(values['from-usage'])
824
+ : iterateUsageFromTranscripts({
825
+ cwdPattern,
826
+ claudeProjectsDir: transcriptOptions.claudeProjectsDir,
827
+ codexSessionsDir: transcriptOptions.codexSessionsDir,
828
+ });
829
+
830
+ if (values.json === true) {
831
+ const all = [];
832
+ for await (const rec of source) all.push(rec);
833
+ process.stdout.write(JSON.stringify(all) + '\n');
834
+ return;
835
+ }
836
+ for await (const rec of source) {
837
+ process.stdout.write(JSON.stringify(rec) + '\n');
838
+ }
839
+ }
840
+
841
+ async function runDumpDiffs(values) {
842
+ const repo = values.repo;
843
+ if (typeof repo !== 'string' || repo === '') {
844
+ console.error('error: dump-diffs requires --repo <path>');
845
+ process.exit(1);
846
+ }
847
+ const keyPattern = parseRegexOption(values['key-pattern'], 'key-pattern');
848
+ const diffsGen = readGitDiffs(resolve(repo), {
849
+ keyPattern: keyPattern ?? undefined,
850
+ revRange: values['rev-range'] ?? undefined,
851
+ });
852
+
853
+ const records = [];
854
+ let summary = null;
855
+ while (true) {
856
+ const step = await diffsGen.next();
857
+ if (step.done) { summary = step.value; break; }
858
+ records.push(step.value);
859
+ }
860
+ if (summary !== null && summary.error !== null) {
861
+ console.error(`error: ${summary.error.message}`);
862
+ process.exit(1);
863
+ }
864
+
865
+ if (values.json === true) {
866
+ process.stdout.write(JSON.stringify(records) + '\n');
867
+ } else {
868
+ for (const rec of records) process.stdout.write(JSON.stringify(rec) + '\n');
869
+ }
870
+ const u = summary?.unmatched ?? {};
871
+ process.stderr.write(
872
+ `# ${records.length} records, ${u.unmatchedCommits ?? 0} unmatched commit${(u.unmatchedCommits ?? 0) === 1 ? '' : 's'}, ` +
873
+ `${u.skippedEmptyCommits ?? 0} skipped empty\n`,
874
+ );
875
+ }
876
+
877
+ async function runCorrelate(values) {
878
+ if (values.pairs === undefined || values.pairs === '') {
879
+ console.error('error: correlate requires --pairs <file.csv|json>');
880
+ process.exit(1);
881
+ }
882
+ let pairs;
883
+ try {
884
+ pairs = await readPairsFile(values.pairs);
885
+ } catch (err) {
886
+ console.error(`error: ${err.message}`);
887
+ process.exit(1);
888
+ }
889
+ const result = correlateCostWithFeature(pairs);
890
+ if (values.csv !== undefined) await writeCsvPairs(values.csv, pairs, 'cost');
891
+
892
+ const payload = {
893
+ source: resolve(values.pairs),
894
+ n: result.n,
895
+ spearman: result.spearman,
896
+ pearsonLinear: result.pearsonLinear,
897
+ pearsonLogLog: result.pearsonLogLog,
898
+ pearsonLogLogDropped: result.pearsonLogLogDropped,
899
+ deciles: result.deciles,
900
+ };
901
+ if (values.json === true) {
902
+ console.log(JSON.stringify(payload, null, 2));
903
+ return;
904
+ }
905
+ printCorrelationReport(payload, { repoLabel: resolve(values.pairs) });
906
+ }
907
+
908
+ /* ------------------------------------------------------------------------- *
909
+ * helpers for cost-drivers / correlate
910
+ * ------------------------------------------------------------------------- */
911
+
912
+ function pickMetric(raw) {
913
+ if (raw === undefined) return 'tokens';
914
+ if (!METRICS.includes(raw)) {
915
+ console.error(`error: --metric must be one of ${METRICS.join(', ')} (got "${raw}")`);
916
+ process.exit(1);
917
+ }
918
+ return raw;
919
+ }
920
+
921
+ function pickJoinBy(raw) {
922
+ if (raw === undefined) return 'issue';
923
+ if (!JOIN_BY_CHOICES.includes(raw)) {
924
+ console.error(`error: --join-by must be one of ${JOIN_BY_CHOICES.join(', ')} (got "${raw}")`);
925
+ process.exit(1);
926
+ }
927
+ return raw;
928
+ }
929
+
930
+ function parseRegexOption(raw, name) {
931
+ if (raw === undefined) return null;
932
+ try {
933
+ return new RegExp(raw);
934
+ } catch (err) {
935
+ console.error(`error: --${name} is not a valid regex: ${err.message}`);
936
+ process.exit(1);
937
+ }
938
+ }
939
+
940
+ /** Accept either a plain ms integer or a suffixed duration: 30s, 2m, 1h, 3d. */
941
+ function parseDurationMs(raw) {
942
+ if (raw === undefined) {
943
+ console.error("error: --join-by time requires --window (e.g. '30m', '2h', '1d', or a millisecond integer)");
944
+ process.exit(1);
945
+ }
946
+ const match = String(raw).trim().match(/^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/);
947
+ if (match === null) {
948
+ console.error(`error: --window must be a number with optional suffix ms/s/m/h/d (got "${raw}")`);
949
+ process.exit(1);
950
+ }
951
+ const value = Number(match[1]);
952
+ const unit = match[2] ?? 'ms';
953
+ const factor = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[unit];
954
+ return value * factor;
955
+ }
956
+
957
+ async function readPairsFile(path) {
958
+ const ext = extname(path).toLowerCase();
959
+ const text = await readFile(path, 'utf8');
960
+ if (ext === '.json') return parsePairsJson(text, path);
961
+ if (ext === '.csv') return parsePairsCsv(text, path);
962
+ // Fallback: sniff content.
963
+ const trimmed = text.trimStart();
964
+ if (trimmed.startsWith('[') || trimmed.startsWith('{')) return parsePairsJson(text, path);
965
+ return parsePairsCsv(text, path);
966
+ }
967
+
968
+ function parsePairsJson(text, path) {
969
+ let data;
970
+ try {
971
+ data = JSON.parse(text);
972
+ } catch (err) {
973
+ throw new Error(`${path}: not valid JSON (${err.message})`);
974
+ }
975
+ if (!Array.isArray(data)) throw new Error(`${path}: expected an array of {feature, cost} objects`);
976
+ const out = [];
977
+ for (let i = 0; i < data.length; i++) {
978
+ const row = data[i];
979
+ if (row === null || typeof row !== 'object') {
980
+ throw new Error(`${path}: row ${i} is not an object`);
981
+ }
982
+ const feature = Number(row.feature);
983
+ const cost = Number(row.cost);
984
+ if (!Number.isFinite(feature)) throw new Error(`${path}: row ${i} has non-numeric feature`);
985
+ if (!Number.isFinite(cost)) throw new Error(`${path}: row ${i} has non-numeric cost`);
986
+ const pair = { feature, cost };
987
+ if (typeof row.key === 'string') pair.key = row.key;
988
+ out.push(pair);
989
+ }
990
+ return out;
991
+ }
992
+
993
+ function parsePairsCsv(text, path) {
994
+ const lines = text.split(/\r?\n/).filter((l) => l !== '');
995
+ if (lines.length === 0) return [];
996
+ const header = splitCsvLine(lines[0]);
997
+ const featureIdx = header.findIndex((h) => h.toLowerCase() === 'feature');
998
+ const costIdx = header.findIndex((h) => h.toLowerCase() === 'cost');
999
+ const keyIdx = header.findIndex((h) => h.toLowerCase() === 'key');
1000
+ if (featureIdx === -1 || costIdx === -1) {
1001
+ throw new Error(`${path}: CSV must have header columns "feature" and "cost"`);
1002
+ }
1003
+ const out = [];
1004
+ for (let i = 1; i < lines.length; i++) {
1005
+ const fields = splitCsvLine(lines[i]);
1006
+ const feature = Number(fields[featureIdx]);
1007
+ const cost = Number(fields[costIdx]);
1008
+ if (!Number.isFinite(feature) || !Number.isFinite(cost)) continue;
1009
+ const pair = { feature, cost };
1010
+ if (keyIdx !== -1 && fields[keyIdx] !== undefined) pair.key = fields[keyIdx];
1011
+ out.push(pair);
1012
+ }
1013
+ return out;
1014
+ }
1015
+
1016
+ function splitCsvLine(line) {
1017
+ const out = [];
1018
+ let cur = '';
1019
+ let inQuotes = false;
1020
+ for (let i = 0; i < line.length; i++) {
1021
+ const ch = line[i];
1022
+ if (inQuotes) {
1023
+ if (ch === '"' && line[i + 1] === '"') { cur += '"'; i += 1; continue; }
1024
+ if (ch === '"') { inQuotes = false; continue; }
1025
+ cur += ch;
1026
+ continue;
1027
+ }
1028
+ if (ch === '"') { inQuotes = true; continue; }
1029
+ if (ch === ',') { out.push(cur); cur = ''; continue; }
1030
+ cur += ch;
1031
+ }
1032
+ out.push(cur);
1033
+ return out;
1034
+ }
1035
+
1036
+ async function writeCsvPairs(path, pairs, costLabel) {
1037
+ const hasKeyCol = pairs.some((p) => typeof p.key === 'string');
1038
+ const header = hasKeyCol ? `key,feature,${costLabel}` : `feature,${costLabel}`;
1039
+ const rows = pairs.map((p) => {
1040
+ const f = String(p.feature);
1041
+ const c = String(p.cost);
1042
+ if (!hasKeyCol) return `${f},${c}`;
1043
+ return `${csvField(p.key ?? '')},${f},${c}`;
1044
+ });
1045
+ await writeFile(path, header + '\n' + rows.join('\n') + (rows.length > 0 ? '\n' : ''), 'utf8');
1046
+ }
1047
+
1048
+ function csvField(value) {
1049
+ const s = String(value);
1050
+ if (/[",\r\n]/.test(s)) return `"${s.replaceAll('"', '""')}"`;
1051
+ return s;
1052
+ }
1053
+
1054
+ function printCorrelationReport(payload, { repoLabel }) {
1055
+ const fmtCoef = (v) => (v === null ? ' —' : (v >= 0 ? ` ${v.toFixed(3)}` : v.toFixed(3)));
1056
+ const fmtCost = (n) => {
1057
+ if (!Number.isFinite(n)) return ' —';
1058
+ if (Math.abs(n) >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
1059
+ if (Math.abs(n) >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
1060
+ return n.toFixed(n >= 100 || Number.isInteger(n) ? 0 : 2);
1061
+ };
1062
+ const fmtFeat = (n) => {
1063
+ if (!Number.isFinite(n)) return '—';
1064
+ if (Math.abs(n) >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
1065
+ if (Math.abs(n) >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
1066
+ return Number.isInteger(n) ? String(n) : n.toFixed(2);
1067
+ };
1068
+
1069
+ const heading = payload.metric !== undefined
1070
+ ? `COST DRIVERS — diff size vs ${payload.metric}`
1071
+ : `COST DRIVERS — external pairs`;
1072
+
1073
+ console.log(HEAD);
1074
+ console.log(heading);
1075
+ console.log(HEAD);
1076
+ if (payload.joinBy !== undefined) console.log(`Join strategy: ${payload.joinBy}`);
1077
+ console.log(`Source: ${repoLabel}`);
1078
+ const unjoinedUsage = payload.unjoined?.usage?.length ?? 0;
1079
+ const unjoinedDiffs = payload.unjoined?.diffs?.length ?? 0;
1080
+ if (payload.joinBy !== undefined) {
1081
+ console.log(
1082
+ `n = ${payload.n} pair${payload.n === 1 ? '' : 's'} ` +
1083
+ `unjoined: ${unjoinedUsage} usage, ${unjoinedDiffs} diffs ` +
1084
+ `unmatched commits: ${payload.unmatchedCommits}`,
1085
+ );
1086
+ } else {
1087
+ console.log(`n = ${payload.n} pair${payload.n === 1 ? '' : 's'}`);
1088
+ }
1089
+ console.log();
1090
+
1091
+ console.log('Correlations:');
1092
+ console.log(` Spearman ${fmtCoef(payload.spearman)}`);
1093
+ console.log(` Pearson(linear) ${fmtCoef(payload.pearsonLinear)}`);
1094
+ const ll = payload.pearsonLogLogDropped > 0
1095
+ ? `${fmtCoef(payload.pearsonLogLog)} (${payload.pearsonLogLogDropped} pair${payload.pearsonLogLogDropped === 1 ? '' : 's'} dropped: non-positive)`
1096
+ : fmtCoef(payload.pearsonLogLog);
1097
+ console.log(` Pearson(log-log) ${ll}`);
1098
+
1099
+ if (payload.deciles.length === 0) {
1100
+ console.log();
1101
+ console.log('Decile table: not enough pairs.');
1102
+ return;
1103
+ }
1104
+ console.log();
1105
+ const medianLabel = payload.metric === 'turns' ? 'Median turns' : 'Median tokens';
1106
+ console.log('Decile table:');
1107
+ console.log(
1108
+ padRight('Decile', 7) +
1109
+ ' ' + padRight('Lines changed', 24) +
1110
+ ' ' + padLeft('n', 4) +
1111
+ ' ' + padLeft(medianLabel, 13),
1112
+ );
1113
+ console.log(SEP);
1114
+ for (let i = 0; i < payload.deciles.length; i++) {
1115
+ const d = payload.deciles[i];
1116
+ const range = `${fmtFeat(d.featureRange.min)} – ${fmtFeat(d.featureRange.max)}`;
1117
+ console.log(
1118
+ padRight(String(i + 1), 7) +
1119
+ ' ' + padRight(range, 24) +
1120
+ ' ' + padLeft(String(d.n), 4) +
1121
+ ' ' + padLeft(fmtCost(d.medianCost), 13),
1122
+ );
1123
+ }
1124
+
1125
+ if (unjoinedUsage > 0 || unjoinedDiffs > 0) {
1126
+ console.log();
1127
+ console.log(
1128
+ `Note: ${unjoinedUsage} usage key${unjoinedUsage === 1 ? '' : 's'} and ${unjoinedDiffs} diff key${unjoinedDiffs === 1 ? '' : 's'} did not join.`,
1129
+ );
1130
+ }
1131
+ }
1132
+
641
1133
  main().catch((err) => {
642
1134
  console.error(err);
643
1135
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-cost-attribution",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Per-issue token, turn, and quota analytics for Claude Code and Codex CLI sessions. Reads the CLIs' own session JSONLs — no telemetry pipeline required.",
5
5
  "type": "module",
6
6
  "bin": {