@trazum/core 1.35.0 → 1.37.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,126 @@
1
+ /**
2
+ * The usage profile `optimize` multiplies by, taken from a log instead of
3
+ * from somebody's typing.
4
+ *
5
+ * `optimize` prices a prompt change as `token delta × callsPerMonth`, with
6
+ * `avgOutputTokens` and `cacheHitRate` shaping the rest. Those three numbers
7
+ * are typed into a config file by a human who is guessing, and the two values
8
+ * most often typed are `1000` and whatever the README's example used. A usage
9
+ * log sitting in the same repository knows all three exactly.
10
+ *
11
+ * **What this module does not do is turn an estimate into a fact.** The token
12
+ * delta stays an estimate with its ±10% band; what stops being a guess is
13
+ * everything it is multiplied by. That distinction has to survive into the
14
+ * rendering, which is why `MeasuredUsage` carries the provenance of each
15
+ * figure rather than only the figures.
16
+ *
17
+ * The hardest decision here is the call count. A month's saving from a log
18
+ * covering three days is a forecast wearing arithmetic's clothes, and this
19
+ * repository refuses those. So the scaling has a floor, it is stated whenever
20
+ * it happens, and under the floor the tool reports the period it measured and
21
+ * declines the multiplication rather than performing it quietly.
22
+ */
23
+ import type { UsageProfileReport } from './usage.js';
24
+ import type { UsageProfile } from './types.js';
25
+ /**
26
+ * The shortest span that may be scaled to a month, in days.
27
+ *
28
+ * A full week, because the week is the cycle traffic actually has: weekdays
29
+ * against weekends is the one periodicity nearly every workload shows, and a
30
+ * span shorter than one cycle scaled up multiplies whichever part of the
31
+ * cycle it happened to catch. Three weekdays scaled to a month is not a
32
+ * monthly figure, it is a Tuesday with a multiplier.
33
+ *
34
+ * Above the floor this is still a *rate*, not a prediction: "at the rate this
35
+ * log measured" is arithmetic about the past, and every rendering says so.
36
+ */
37
+ export declare const MIN_SCALE_DAYS = 7;
38
+ /** Days in the month this scales to. Stated rather than hidden in a constant. */
39
+ export declare const SCALE_TO_DAYS = 30;
40
+ export interface MeasuredUsage {
41
+ /** Ready to hand to `optimize`. Every field below is measured, not typed. */
42
+ profile: UsageProfile;
43
+ /** Calls the log actually recorded for this slice. Never scaled. */
44
+ calls: number;
45
+ /** What those calls actually cost. The measured half of any comparison. */
46
+ spentUsd: number;
47
+ /**
48
+ * The period the calls fall in, in days, or null when no call carried a
49
+ * clock. Null is why `scaled` can be null with calls above the floor.
50
+ */
51
+ spanDays: number | null;
52
+ /**
53
+ * How `callsPerMonth` was reached, or **null when it was not scaled at
54
+ * all** — in which case `profile.callsPerMonth` is the raw measured count
55
+ * over whatever period the log covers, and the rendering must say so
56
+ * rather than printing it under a "per month" heading.
57
+ */
58
+ scaled: {
59
+ fromDays: number;
60
+ factor: number;
61
+ } | null;
62
+ /**
63
+ * The share of input tokens served from cache, 0–1.
64
+ *
65
+ * `UsageProfile.cacheHitRate` is documented as the fraction of *calls* that
66
+ * reuse the prefix; what a log can measure is the fraction of input
67
+ * *tokens* that were cache reads. Those are the same number only when every
68
+ * call is the same size. It is handed over as the better of two
69
+ * approximations — a measured token share beats a typed call share — and
70
+ * named honestly here so no rendering can call it something it is not.
71
+ */
72
+ cacheReadShare: number;
73
+ /**
74
+ * The model carrying the most spend in this slice, and how many models the
75
+ * slice used at all. Above one, the single model handed to `optimize` is a
76
+ * simplification and the rendering says which share it covers.
77
+ */
78
+ models: {
79
+ chosen: string;
80
+ count: number;
81
+ chosenShareOfSpend: number;
82
+ };
83
+ /** True when no call in the slice recorded any output tokens. */
84
+ outputUnmeasured: boolean;
85
+ }
86
+ /**
87
+ * Derives the usage profile for one label from a measured report.
88
+ *
89
+ * Returns `null` when the label carries no priced calls — a slice with no
90
+ * measured traffic has nothing to hand over, and inventing a zero-call
91
+ * profile would produce a $0 saving that reads as "this change is worthless"
92
+ * rather than "nothing here was measured".
93
+ */
94
+ export declare function measuredUsage(report: UsageProfileReport, label: string, options?: {
95
+ batchEligible?: boolean;
96
+ }): MeasuredUsage | null;
97
+ /**
98
+ * Every label the report priced, with the prompt file the config maps to it —
99
+ * and, deliberately, both kinds of mismatch.
100
+ *
101
+ * The two failures this surfaces are the ones a person cannot see from either
102
+ * side alone: a prompt file mapped to a label that no longer appears in the
103
+ * log (renamed, retired, or a typo that has been silently doing nothing), and
104
+ * a label carrying real money with no prompt file mapped at all (the workload
105
+ * nobody can optimise because nobody said where it lives).
106
+ */
107
+ export interface LabelCoverage {
108
+ /** Labels with both traffic and a mapped prompt file. */
109
+ joined: {
110
+ label: string;
111
+ promptPath: string;
112
+ spentUsd: number;
113
+ }[];
114
+ /** Mapped prompt files whose label has no priced traffic in this log. */
115
+ mappedWithoutTraffic: {
116
+ label: string;
117
+ promptPath: string;
118
+ }[];
119
+ /** Labels with priced traffic and no prompt file mapped, dearest first. */
120
+ trafficWithoutPrompt: {
121
+ label: string;
122
+ spentUsd: number;
123
+ }[];
124
+ }
125
+ export declare function labelCoverage(report: UsageProfileReport, labels: Record<string, string>): LabelCoverage;
126
+ //# sourceMappingURL=measured-profile.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"measured-profile.d.ts","sourceRoot":"","sources":["../src/measured-profile.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAGH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,cAAc,IAAI,CAAC;AAEhC,iFAAiF;AACjF,eAAO,MAAM,aAAa,KAAK,CAAC;AAEhC,MAAM,WAAW,aAAa;IAC5B,6EAA6E;IAC7E,OAAO,EAAE,YAAY,CAAC;IACtB,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAC;IACd,2EAA2E;IAC3E,QAAQ,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB;;;;;OAKG;IACH,MAAM,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACpD;;;;;;;;;OASG;IACH,cAAc,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,MAAM,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,kBAAkB,EAAE,MAAM,CAAA;KAAE,CAAC;IACtE,iEAAiE;IACjE,gBAAgB,EAAE,OAAO,CAAC;CAC3B;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,kBAAkB,EAC1B,KAAK,EAAE,MAAM,EACb,OAAO,GAAE;IAAE,aAAa,CAAC,EAAE,OAAO,CAAA;CAAO,GACxC,aAAa,GAAG,IAAI,CAuDtB;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,aAAa;IAC5B,yDAAyD;IACzD,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAClE,yEAAyE;IACzE,oBAAoB,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC9D,2EAA2E;IAC3E,oBAAoB,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC7D;AAED,wBAAgB,aAAa,CAC3B,MAAM,EAAE,kBAAkB,EAC1B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC7B,aAAa,CAsBf"}
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The usage profile `optimize` multiplies by, taken from a log instead of
3
+ * from somebody's typing.
4
+ *
5
+ * `optimize` prices a prompt change as `token delta × callsPerMonth`, with
6
+ * `avgOutputTokens` and `cacheHitRate` shaping the rest. Those three numbers
7
+ * are typed into a config file by a human who is guessing, and the two values
8
+ * most often typed are `1000` and whatever the README's example used. A usage
9
+ * log sitting in the same repository knows all three exactly.
10
+ *
11
+ * **What this module does not do is turn an estimate into a fact.** The token
12
+ * delta stays an estimate with its ±10% band; what stops being a guess is
13
+ * everything it is multiplied by. That distinction has to survive into the
14
+ * rendering, which is why `MeasuredUsage` carries the provenance of each
15
+ * figure rather than only the figures.
16
+ *
17
+ * The hardest decision here is the call count. A month's saving from a log
18
+ * covering three days is a forecast wearing arithmetic's clothes, and this
19
+ * repository refuses those. So the scaling has a floor, it is stated whenever
20
+ * it happens, and under the floor the tool reports the period it measured and
21
+ * declines the multiplication rather than performing it quietly.
22
+ */
23
+ import { UNLABELLED } from './usage.js';
24
+ /**
25
+ * The shortest span that may be scaled to a month, in days.
26
+ *
27
+ * A full week, because the week is the cycle traffic actually has: weekdays
28
+ * against weekends is the one periodicity nearly every workload shows, and a
29
+ * span shorter than one cycle scaled up multiplies whichever part of the
30
+ * cycle it happened to catch. Three weekdays scaled to a month is not a
31
+ * monthly figure, it is a Tuesday with a multiplier.
32
+ *
33
+ * Above the floor this is still a *rate*, not a prediction: "at the rate this
34
+ * log measured" is arithmetic about the past, and every rendering says so.
35
+ */
36
+ export const MIN_SCALE_DAYS = 7;
37
+ /** Days in the month this scales to. Stated rather than hidden in a constant. */
38
+ export const SCALE_TO_DAYS = 30;
39
+ /**
40
+ * Derives the usage profile for one label from a measured report.
41
+ *
42
+ * Returns `null` when the label carries no priced calls — a slice with no
43
+ * measured traffic has nothing to hand over, and inventing a zero-call
44
+ * profile would produce a $0 saving that reads as "this change is worthless"
45
+ * rather than "nothing here was measured".
46
+ */
47
+ export function measuredUsage(report, label, options = {}) {
48
+ const slices = report.byLabelAndModel.filter((row) => row.label === label);
49
+ if (slices.length === 0)
50
+ return null;
51
+ const calls = slices.reduce((sum, row) => sum + row.breakdown.calls, 0);
52
+ if (calls === 0)
53
+ return null;
54
+ const spentUsd = slices.reduce((sum, row) => sum + row.breakdown.totalUsd, 0);
55
+ const outputTokens = slices.reduce((sum, row) => sum + row.breakdown.outputTokens, 0);
56
+ const inputTokens = slices.reduce((sum, row) => sum + row.breakdown.inputTokens, 0);
57
+ const cacheReadTokens = slices.reduce((sum, row) => sum + row.breakdown.cacheReadTokens, 0);
58
+ // The model carrying the most spend. Ties break on the larger call count, so
59
+ // the answer is stable rather than dependent on map ordering.
60
+ const ranked = [...slices].sort((a, b) => b.breakdown.totalUsd - a.breakdown.totalUsd || b.breakdown.calls - a.breakdown.calls);
61
+ const chosen = ranked[0];
62
+ const chosenShareOfSpend = spentUsd > 0 ? chosen.breakdown.totalUsd / spentUsd : 0;
63
+ /**
64
+ * The span of *this slice*, not of the whole log. A label active for three
65
+ * days of a thirty-day log has a three-day rate, and using the log's span
66
+ * would divide its calls across weeks it never ran in.
67
+ */
68
+ const spanDays = report.span === null ? null : (report.span.toMs - report.span.fromMs) / 86_400_000;
69
+ const scaled = spanDays !== null && spanDays >= MIN_SCALE_DAYS
70
+ ? { fromDays: spanDays, factor: SCALE_TO_DAYS / spanDays }
71
+ : null;
72
+ const readShare = inputTokens + cacheReadTokens > 0 ? cacheReadTokens / (inputTokens + cacheReadTokens) : 0;
73
+ return {
74
+ profile: {
75
+ model: chosen.model,
76
+ // Scaled when the span earns it, the raw measured count otherwise. The
77
+ // caller must read `scaled` before printing this under any heading that
78
+ // says "month".
79
+ callsPerMonth: scaled === null ? calls : Math.round(calls * scaled.factor),
80
+ avgOutputTokens: Math.round(outputTokens / calls),
81
+ cacheHitRate: readShare,
82
+ batchEligible: options.batchEligible ?? false,
83
+ },
84
+ calls,
85
+ spentUsd,
86
+ spanDays,
87
+ scaled,
88
+ cacheReadShare: readShare,
89
+ models: { chosen: chosen.model, count: slices.length, chosenShareOfSpend },
90
+ outputUnmeasured: outputTokens === 0,
91
+ };
92
+ }
93
+ export function labelCoverage(report, labels) {
94
+ const spendByLabel = new Map(report.byLabel.map((row) => [row.label, row.breakdown.totalUsd]));
95
+ const joined = [];
96
+ const mappedWithoutTraffic = [];
97
+ for (const [label, promptPath] of Object.entries(labels)) {
98
+ const spentUsd = spendByLabel.get(label);
99
+ if (spentUsd === undefined)
100
+ mappedWithoutTraffic.push({ label, promptPath });
101
+ else
102
+ joined.push({ label, promptPath, spentUsd });
103
+ }
104
+ const trafficWithoutPrompt = report.byLabel
105
+ // The unlabelled bucket is not a workload somebody forgot to map; it is
106
+ // calls that carry no label at all, which `fieldCoverage` already reports.
107
+ .filter((row) => row.label !== UNLABELLED && labels[row.label] === undefined)
108
+ .map((row) => ({ label: row.label, spentUsd: row.breakdown.totalUsd }))
109
+ .sort((a, b) => b.spentUsd - a.spentUsd);
110
+ joined.sort((a, b) => b.spentUsd - a.spentUsd);
111
+ mappedWithoutTraffic.sort((a, b) => a.label.localeCompare(b.label));
112
+ return { joined, mappedWithoutTraffic, trafficWithoutPrompt };
113
+ }
114
+ //# sourceMappingURL=measured-profile.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"measured-profile.js","sourceRoot":"","sources":["../src/measured-profile.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trazum/core",
3
- "version": "1.35.0",
3
+ "version": "1.37.0",
4
4
  "description": "Trazum core: priced advisories for LLM prompts (caching, model tier, batching, schemas), plus deterministic trimming, token counting and pricing.",
5
5
  "license": "MIT",
6
6
  "author": "David Mu\u00f1oz Rey",
@@ -82,6 +82,14 @@ export interface SpendConfig {
82
82
  maxSessionUsd?: number;
83
83
  /** Per-label budgets, each gated against that label's own spend. */
84
84
  byLabel?: Record<string, number>;
85
+ /**
86
+ * Money budgets per source, in dollars — the fleet's version of `byLabel`.
87
+ * A source is a named service from the top-level `sources` block, and a
88
+ * budget written here fails `profile --by-source` when that service alone
89
+ * crosses it, with the failing service named rather than a total that
90
+ * hides which.
91
+ */
92
+ bySource?: Record<string, number>;
85
93
  }
86
94
 
87
95
  export interface TrazumConfig {
@@ -120,6 +128,14 @@ export interface TrazumConfig {
120
128
  * workload against its own limit in the same run. A flag still wins over
121
129
  * the config, as everywhere in this tool.
122
130
  */
131
+ /**
132
+ * The fleet: named services, each a list of glob patterns over usage-log
133
+ * paths. `profile --by-source` builds one report per source plus a rollup,
134
+ * assigning each file to the most specific matching pattern — the same
135
+ * precedence rule the budget patterns use. A file matching no source is
136
+ * named in the output rather than silently joining no report.
137
+ */
138
+ sources?: Record<string, string[]>;
123
139
  spend?: SpendConfig;
124
140
  /**
125
141
  * Findings as policy: a gate failure the team has looked at and decided to
@@ -169,6 +185,7 @@ export const CONFIG_KEYS = [
169
185
  'budgets',
170
186
  'labels',
171
187
  'spend',
188
+ 'sources',
172
189
  'waive',
173
190
  'maxGrowth',
174
191
  'baseline',
@@ -178,7 +195,7 @@ export const CONFIG_KEYS = [
178
195
 
179
196
  export const CONFIG_BASELINE_KEYS = ['path', 'maxGrowthTokens', 'maxGrowthPct'] as const;
180
197
 
181
- export const CONFIG_SPEND_KEYS = ['maxUsd', 'maxDayUsd', 'maxSessionUsd', 'byLabel'] as const;
198
+ export const CONFIG_SPEND_KEYS = ['maxUsd', 'maxDayUsd', 'maxSessionUsd', 'byLabel', 'bySource'] as const;
182
199
 
183
200
  export const CONFIG_WAIVE_KEYS = ['gate', 'reason', 'until'] as const;
184
201
 
@@ -385,6 +402,19 @@ function parseSpend(raw: unknown, source: string): SpendConfig {
385
402
  if (raw.maxSessionUsd !== undefined) {
386
403
  spend.maxSessionUsd = requireNonNegativeNumber(raw.maxSessionUsd, 'spend.maxSessionUsd', source);
387
404
  }
405
+ if (raw.bySource !== undefined) {
406
+ if (!isPlainObject(raw.bySource)) {
407
+ throw new ConfigError('"spend.bySource" must be an object', source);
408
+ }
409
+ const bySource: Record<string, number> = {};
410
+ for (const [name, value] of Object.entries(raw.bySource)) {
411
+ if (name.trim().length === 0) {
412
+ throw new ConfigError('"spend.bySource" has an empty source name', source);
413
+ }
414
+ bySource[name] = requireNonNegativeNumber(value, `spend.bySource["${name}"]`, source);
415
+ }
416
+ spend.bySource = bySource;
417
+ }
388
418
  if (raw.byLabel !== undefined) {
389
419
  if (!isPlainObject(raw.byLabel)) {
390
420
  throw new ConfigError('"spend.byLabel" must be an object', source);
@@ -401,6 +431,32 @@ function parseSpend(raw: unknown, source: string): SpendConfig {
401
431
  return spend;
402
432
  }
403
433
 
434
+ /**
435
+ * Validates the `sources` block: named services, each a non-empty list of
436
+ * glob patterns. Patterns are not checked against a filesystem here — a
437
+ * config file is validated wherever it is read, browser included, and which
438
+ * files exist is the CLI's question at run time.
439
+ */
440
+ function parseSources(raw: unknown, source: string): Record<string, string[]> {
441
+ if (!isPlainObject(raw)) throw new ConfigError('"sources" must be an object', source);
442
+ const sources: Record<string, string[]> = {};
443
+ for (const [name, value] of Object.entries(raw)) {
444
+ if (name.trim().length === 0) {
445
+ throw new ConfigError('"sources" has an empty source name', source);
446
+ }
447
+ if (!Array.isArray(value) || value.length === 0) {
448
+ throw new ConfigError(`"sources.${name}" must be a non-empty array of glob patterns`, source);
449
+ }
450
+ for (const pattern of value) {
451
+ if (typeof pattern !== 'string' || pattern.trim().length === 0) {
452
+ throw new ConfigError(`"sources.${name}" has an empty pattern`, source);
453
+ }
454
+ }
455
+ sources[name] = value as string[];
456
+ }
457
+ return sources;
458
+ }
459
+
404
460
  /**
405
461
  * Validates the `waive` list.
406
462
  *
@@ -597,6 +653,7 @@ export function parseConfig(raw: string, source = CONFIG_FILENAME): TrazumConfig
597
653
  if (document.budgets !== undefined) config.budgets = parseBudgets(document.budgets, source);
598
654
  if (document.labels !== undefined) config.labels = parseLabels(document.labels, source);
599
655
  if (document.spend !== undefined) config.spend = parseSpend(document.spend, source);
656
+ if (document.sources !== undefined) config.sources = parseSources(document.sources, source);
600
657
  if (document.waive !== undefined) config.waive = parseWaive(document.waive, source);
601
658
  if (document.baseline !== undefined) {
602
659
  config.baseline = parseBaselineConfig(document.baseline, source);
package/src/fleet.ts ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Twelve services, one rollup, and the one that is actually bleeding.
3
+ *
4
+ * `profile` merges a directory of logs into one bill, which is right for one
5
+ * service and wrong for a fleet: the merged report hides which service the
6
+ * money is coming from, per-service budgets cannot exist at all, and the
7
+ * findings a *comparison between services* could make are invisible — the
8
+ * same workload on Opus in one team and Haiku in another is a decision
9
+ * somebody should get to see, and no single merged total shows it.
10
+ *
11
+ * This module does the fleet arithmetic on reports the caller already built.
12
+ * It reads no files and runs no globs against a filesystem — the caller hands
13
+ * it file names and per-source reports, so the module stays browser-safe and
14
+ * the CLI keeps its monopoly on I/O.
15
+ *
16
+ * **The rollup refuses to average what it cannot compare.** Two sources whose
17
+ * logs cover different periods can be *summed* — a total is a total — but a
18
+ * share of that total is not a comparison of rates, and the module says which
19
+ * sources cover which days rather than letting a 3-day log look cheap beside
20
+ * a 30-day one.
21
+ */
22
+
23
+ import { mostSpecificMatch } from './glob.js';
24
+ import { UNLABELLED } from './usage.js';
25
+ import type { UsageProfileReport } from './usage.js';
26
+
27
+ /**
28
+ * Which source each file belongs to.
29
+ *
30
+ * Assignment is by the most specific matching glob, so `services/api/**`
31
+ * beats `services/**` on the same file — the same tie-break the budget
32
+ * patterns use, because two rules for pattern precedence in one tool is one
33
+ * rule too many. Files matching no source are returned rather than dropped:
34
+ * a log that silently joined no report would be spend missing from every
35
+ * bill, which is the flattering omission this repository refuses everywhere.
36
+ */
37
+ export function assignSources(
38
+ files: string[],
39
+ sources: Record<string, string[]>,
40
+ ): { bySource: Map<string, string[]>; unmatched: string[] } {
41
+ const bySource = new Map<string, string[]>();
42
+ const unmatched: string[] = [];
43
+
44
+ // Flatten to (pattern, source) pairs so specificity decides across sources.
45
+ const patterns: { pattern: string; source: string }[] = [];
46
+ for (const [source, globs] of Object.entries(sources)) {
47
+ for (const pattern of globs) patterns.push({ pattern, source });
48
+ }
49
+
50
+ for (const file of files) {
51
+ const best = mostSpecificMatch(
52
+ patterns.map((p) => p.pattern),
53
+ file,
54
+ );
55
+ if (best === null) {
56
+ unmatched.push(file);
57
+ continue;
58
+ }
59
+ const source = patterns.find((p) => p.pattern === best)!.source;
60
+ const list = bySource.get(source) ?? [];
61
+ list.push(file);
62
+ bySource.set(source, list);
63
+ }
64
+ return { bySource, unmatched };
65
+ }
66
+
67
+ export interface FleetSource {
68
+ name: string;
69
+ report: UsageProfileReport;
70
+ }
71
+
72
+ export interface FleetRollup {
73
+ /** Sum over every source. A total is a total, whatever the spans. */
74
+ totalUsd: number;
75
+ calls: number;
76
+ /** Every source, dearest first, with its share of the fleet's total. */
77
+ sources: { name: string; usd: number; calls: number; share: number; spanDays: number | null }[];
78
+ /**
79
+ * The one that is actually bleeding: the dearest source, with its share —
80
+ * or null when the fleet spent nothing, because "nothing is bleeding" and
81
+ * "the worst of nothing" are different statements.
82
+ */
83
+ worst: { name: string; usd: number; share: number } | null;
84
+ /**
85
+ * True when the sources' logs cover meaningfully different periods (their
86
+ * spans differ by more than one day, or some carry no clock at all). Shares
87
+ * of the total remain valid — they are shares of a sum — but reading them
88
+ * as *rate* comparisons is exactly the mistake this flag exists to stop,
89
+ * and every rendering states it when set.
90
+ */
91
+ mismatchedSpans: boolean;
92
+ /**
93
+ * The same workload label running on different models in different sources
94
+ * — one team on Opus, another on Haiku, same job. A merged bill renders
95
+ * this invisible: the label's slices coexist with no seam. Only splits
96
+ * where both sides carry real spend are reported, dearest gap first.
97
+ */
98
+ splitBrains: {
99
+ label: string;
100
+ sources: { name: string; model: string; usd: number }[];
101
+ }[];
102
+ /**
103
+ * Sources where caching lost money while the fleet's aggregate paid off.
104
+ * An aggregate verdict is the flattering rendering when three sources are
105
+ * quietly underwater; each is named with its own delta. Sources whose
106
+ * verdict matches the aggregate are not listed — this is the exception
107
+ * report, not the census.
108
+ */
109
+ cacheUnderwater: { name: string; deltaUsd: number }[];
110
+ }
111
+
112
+ /** Span length in days, or null when the report has no clock. */
113
+ function spanDaysOf(report: UsageProfileReport): number | null {
114
+ return report.span === null ? null : (report.span.toMs - report.span.fromMs) / 86_400_000;
115
+ }
116
+
117
+ export function fleetRollup(
118
+ sources: FleetSource[],
119
+ options: {
120
+ /**
121
+ * Per-source cache verdict delta, positive meaning caching added money to
122
+ * the bill — the caller computes it with `cacheEconomics` because that
123
+ * module owns the counterfactual, and this one must not restate it.
124
+ */
125
+ cacheDeltas?: Map<string, number>;
126
+ /** The fleet-wide delta under the same convention. */
127
+ aggregateCacheDelta?: number;
128
+ } = {},
129
+ ): FleetRollup {
130
+ const rows = sources
131
+ .map((s) => ({
132
+ name: s.name,
133
+ usd: s.report.total.totalUsd,
134
+ calls: s.report.total.calls,
135
+ spanDays: spanDaysOf(s.report),
136
+ }))
137
+ .sort((a, b) => b.usd - a.usd);
138
+
139
+ const totalUsd = rows.reduce((sum, r) => sum + r.usd, 0);
140
+ const calls = rows.reduce((sum, r) => sum + r.calls, 0);
141
+ const withShare = rows.map((r) => ({
142
+ ...r,
143
+ share: totalUsd > 0 ? r.usd / totalUsd : 0,
144
+ }));
145
+
146
+ const spans = rows.map((r) => r.spanDays);
147
+ const known = spans.filter((d): d is number => d !== null);
148
+ const mismatchedSpans =
149
+ spans.some((d) => d === null && known.length > 0) ||
150
+ (known.length > 1 && Math.max(...known) - Math.min(...known) > 1);
151
+
152
+ /**
153
+ * Split brains: one label, different models, different sources. Judged on
154
+ * each source's *dearest* model for the label, so a stray experiment call
155
+ * does not report a team as migrated.
156
+ */
157
+ const labelModel = new Map<string, Map<string, { model: string; usd: number }>>();
158
+ for (const source of sources) {
159
+ for (const slice of source.report.byLabelAndModel) {
160
+ if (slice.label === UNLABELLED) continue;
161
+ const perSource = labelModel.get(slice.label) ?? new Map();
162
+ const current = perSource.get(source.name);
163
+ if (current === undefined || slice.breakdown.totalUsd > current.usd) {
164
+ perSource.set(source.name, { model: slice.model, usd: slice.breakdown.totalUsd });
165
+ }
166
+ labelModel.set(slice.label, perSource);
167
+ }
168
+ }
169
+ const splitBrains: FleetRollup['splitBrains'] = [];
170
+ for (const [label, perSource] of labelModel) {
171
+ if (perSource.size < 2) continue;
172
+ const models = new Set([...perSource.values()].map((v) => v.model));
173
+ if (models.size < 2) continue;
174
+ const list = [...perSource.entries()]
175
+ .map(([name, v]) => ({ name, model: v.model, usd: v.usd }))
176
+ .filter((v) => v.usd > 0)
177
+ .sort((a, b) => b.usd - a.usd);
178
+ if (new Set(list.map((v) => v.model)).size < 2) continue;
179
+ splitBrains.push({ label, sources: list });
180
+ }
181
+ splitBrains.sort(
182
+ (a, b) =>
183
+ b.sources.reduce((s, v) => s + v.usd, 0) - a.sources.reduce((s, v) => s + v.usd, 0),
184
+ );
185
+
186
+ /**
187
+ * Cache underwater: only meaningful when the aggregate paid off — when the
188
+ * aggregate itself lost money, the whole-fleet report already shouts and
189
+ * naming each source would repeat it in pieces.
190
+ */
191
+ const cacheUnderwater: FleetRollup['cacheUnderwater'] = [];
192
+ if (
193
+ options.cacheDeltas !== undefined &&
194
+ options.aggregateCacheDelta !== undefined &&
195
+ options.aggregateCacheDelta <= 0
196
+ ) {
197
+ for (const [name, deltaUsd] of options.cacheDeltas) {
198
+ if (deltaUsd > 0) cacheUnderwater.push({ name, deltaUsd });
199
+ }
200
+ cacheUnderwater.sort((a, b) => b.deltaUsd - a.deltaUsd);
201
+ }
202
+
203
+ return {
204
+ totalUsd,
205
+ calls,
206
+ sources: withShare,
207
+ worst: totalUsd > 0 ? { name: rows[0]!.name, usd: rows[0]!.usd, share: withShare[0]!.share } : null,
208
+ mismatchedSpans,
209
+ splitBrains,
210
+ cacheUnderwater,
211
+ };
212
+ }
package/src/index.ts CHANGED
@@ -42,6 +42,10 @@ export { driversBetween } from './against.js';
42
42
  export type { AgainstDriver } from './against.js';
43
43
  export { coverageDrift, COVERAGE_FIELDS, COVERAGE_DRIFT_MIN } from './coverage-drift.js';
44
44
  export { explainGateFailure, gateMargin, GATE_MARGIN_TIGHT } from './gate-explain.js';
45
+ export { measuredUsage, labelCoverage, MIN_SCALE_DAYS, SCALE_TO_DAYS } from './measured-profile.js';
46
+ export { assignSources, fleetRollup } from './fleet.js';
47
+ export type { FleetSource, FleetRollup } from './fleet.js';
48
+ export type { MeasuredUsage, LabelCoverage } from './measured-profile.js';
45
49
  export type { GateExplanation } from './gate-explain.js';
46
50
  export type { CoverageDrift, CoverageField } from './coverage-drift.js';
47
51
  // The same tokens at another model's rates — arithmetic, not advice, and it