@trazum/core 1.36.0 → 1.38.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/dist/plan.d.ts ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Not a list of findings — a ranked, costed, non-additive plan of what to do.
3
+ *
4
+ * The report names findings; a person then decides what to do first by doing
5
+ * arithmetic in their head, and head-arithmetic on savings gets done by
6
+ * *adding* them — which the levers module has documented as wrong since it
7
+ * shipped ($12.60 plus $10.50 against a $21.00 slice). This module does the
8
+ * composition once, correctly, and attaches to every action the things the
9
+ * log cannot confirm, because a plan that hides its assumptions is advice
10
+ * pretending to be arithmetic.
11
+ *
12
+ * **Everything here is derived from figures the report already computed.**
13
+ * Route and batch come from `billLevers` (combined, never summed). The
14
+ * truncation action's stake is the retry bill `truncationRetries` measured.
15
+ * The cache action's stake is `cacheEconomics`' own delta. Nothing is
16
+ * invented, and each action carries how to check the part that is not
17
+ * arithmetic.
18
+ *
19
+ * **The total is stated honestly.** Actions on *different* slices add
20
+ * cleanly; the one composition that does not add — route and batch on the
21
+ * same slice — arrives already combined inside a single action, so the
22
+ * plan's total is a sum of non-overlapping figures by construction. Measured
23
+ * stakes (money already spent on retries, money already lost to caching) are
24
+ * totalled separately from projected savings: "what you would save" and
25
+ * "what you already paid" are different columns, and merging them makes a
26
+ * number that is neither.
27
+ */
28
+ import type { UsageProfileReport } from './usage.js';
29
+ import type { BillLevers } from './levers.js';
30
+ export type PlanActionKind = 'route' | 'batch' | 'route+batch' | 'fix-truncation' | 'fix-caching';
31
+ /**
32
+ * What the log cannot confirm, as data rather than prose.
33
+ *
34
+ * Rendering lives with whoever renders — the CLI localizes these, and 1.39's
35
+ * verification can match them structurally. An English sentence baked in here
36
+ * would be a browser-safe module deciding the reader's language.
37
+ */
38
+ export type PlanAssumption =
39
+ /** The cheaper model can actually do this work — quality, not arithmetic. */
40
+ {
41
+ kind: 'model-capability';
42
+ model: string;
43
+ }
44
+ /** These calls tolerate a batch window's latency. */
45
+ | {
46
+ kind: 'batch-window';
47
+ }
48
+ /** The truncation-retry pairing is real — the log sees shapes, not content. */
49
+ | {
50
+ kind: 'retry-pattern-real';
51
+ }
52
+ /** A max_tokens the answers fit inside removes the retry pair. */
53
+ | {
54
+ kind: 'max-tokens-fits';
55
+ }
56
+ /** The traffic pattern holds — a cache underwater on this log may pay on other traffic. */
57
+ | {
58
+ kind: 'traffic-pattern-holds';
59
+ };
60
+ export interface PlanAction {
61
+ kind: PlanActionKind;
62
+ /** The workload this acts on. `UNLABELLED` renders as the unlabelled bucket. */
63
+ label: string;
64
+ model: string;
65
+ /**
66
+ * Projected saving per the log's own period, for route/batch — or null for
67
+ * the measured-stake actions, whose money is in `stakeUsd` instead. Never
68
+ * both: a projection and a measurement in one field is a number that is
69
+ * neither.
70
+ */
71
+ savingUsd: number | null;
72
+ /**
73
+ * Money already measured against this problem — the retry bill, the cache
74
+ * loss. Null for the projected actions.
75
+ */
76
+ stakeUsd: number | null;
77
+ /** What the log cannot confirm. Every entry is a human's question to answer. */
78
+ assumes: PlanAssumption[];
79
+ /** How to check the assumption, when a Trazum command can. */
80
+ check: string | null;
81
+ /** What this action does, in one machine-stable keyword per detail. */
82
+ detail: {
83
+ /** Route target, when the action moves the calls. */
84
+ routeTo?: {
85
+ id: string;
86
+ displayName: string;
87
+ };
88
+ /** The measured pieces behind a stake. */
89
+ measured?: Record<string, number>;
90
+ };
91
+ }
92
+ export interface PlanDocument {
93
+ /** Same contract discipline as the profile JSON. */
94
+ schemaVersion: 1;
95
+ /** The period the plan's figures cover, or null when the log had no clock. */
96
+ span: {
97
+ fromMs: number;
98
+ toMs: number;
99
+ } | null;
100
+ /** The price table behind every dollar here. */
101
+ pricingLastReviewed: string;
102
+ /** Ranked: largest money first, projected or staked alike. */
103
+ actions: PlanAction[];
104
+ /**
105
+ * Projected savings summed — additive by construction, because same-slice
106
+ * compositions arrive pre-combined in one action.
107
+ */
108
+ projectedSavingUsd: number;
109
+ /** Measured stakes summed: money already paid to problems this plan names. */
110
+ measuredStakeUsd: number;
111
+ /** The bill the plan was made against. */
112
+ totalUsd: number;
113
+ }
114
+ /**
115
+ * Builds the plan from a report and its levers.
116
+ *
117
+ * `pricingLastReviewed` is passed in rather than imported so the plan records
118
+ * the catalogue that actually priced it — an overlay's date when one was in
119
+ * effect, which 1.39's verification needs to tell "the prediction was wrong"
120
+ * from "the prices changed".
121
+ */
122
+ export declare function buildPlan(report: UsageProfileReport, levers: BillLevers, pricingLastReviewed: string): PlanDocument;
123
+ /** Renders `UNLABELLED` for humans without leaking the sentinel. */
124
+ export declare function planLabelName(label: string, unlabelled: string): string;
125
+ //# sourceMappingURL=plan.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plan.d.ts","sourceRoot":"","sources":["../src/plan.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAGH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AACrD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,OAAO,GAAG,aAAa,GAAG,gBAAgB,GAAG,aAAa,CAAC;AAElG;;;;;;GAMG;AACH,MAAM,MAAM,cAAc;AACxB,6EAA6E;AAC3E;IAAE,IAAI,EAAE,kBAAkB,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE;AAC7C,qDAAqD;GACnD;IAAE,IAAI,EAAE,cAAc,CAAA;CAAE;AAC1B,+EAA+E;GAC7E;IAAE,IAAI,EAAE,oBAAoB,CAAA;CAAE;AAChC,kEAAkE;GAChE;IAAE,IAAI,EAAE,iBAAiB,CAAA;CAAE;AAC7B,2FAA2F;GACzF;IAAE,IAAI,EAAE,uBAAuB,CAAA;CAAE,CAAC;AAEtC,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,cAAc,CAAC;IACrB,gFAAgF;IAChF,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB;;;OAGG;IACH,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,gFAAgF;IAChF,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B,8DAA8D;IAC9D,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,uEAAuE;IACvE,MAAM,EAAE;QACN,qDAAqD;QACrD,OAAO,CAAC,EAAE;YAAE,EAAE,EAAE,MAAM,CAAC;YAAC,WAAW,EAAE,MAAM,CAAA;SAAE,CAAC;QAC9C,0CAA0C;QAC1C,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;KACnC,CAAC;CACH;AAED,MAAM,WAAW,YAAY;IAC3B,oDAAoD;IACpD,aAAa,EAAE,CAAC,CAAC;IACjB,8EAA8E;IAC9E,IAAI,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC9C,gDAAgD;IAChD,mBAAmB,EAAE,MAAM,CAAC;IAC5B,8DAA8D;IAC9D,OAAO,EAAE,UAAU,EAAE,CAAC;IACtB;;;OAGG;IACH,kBAAkB,EAAE,MAAM,CAAC;IAC3B,8EAA8E;IAC9E,gBAAgB,EAAE,MAAM,CAAC;IACzB,0CAA0C;IAC1C,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;GAOG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,kBAAkB,EAC1B,MAAM,EAAE,UAAU,EAClB,mBAAmB,EAAE,MAAM,GAC1B,YAAY,CAsFd;AAED,oEAAoE;AACpE,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAEvE"}
package/dist/plan.js ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Not a list of findings — a ranked, costed, non-additive plan of what to do.
3
+ *
4
+ * The report names findings; a person then decides what to do first by doing
5
+ * arithmetic in their head, and head-arithmetic on savings gets done by
6
+ * *adding* them — which the levers module has documented as wrong since it
7
+ * shipped ($12.60 plus $10.50 against a $21.00 slice). This module does the
8
+ * composition once, correctly, and attaches to every action the things the
9
+ * log cannot confirm, because a plan that hides its assumptions is advice
10
+ * pretending to be arithmetic.
11
+ *
12
+ * **Everything here is derived from figures the report already computed.**
13
+ * Route and batch come from `billLevers` (combined, never summed). The
14
+ * truncation action's stake is the retry bill `truncationRetries` measured.
15
+ * The cache action's stake is `cacheEconomics`' own delta. Nothing is
16
+ * invented, and each action carries how to check the part that is not
17
+ * arithmetic.
18
+ *
19
+ * **The total is stated honestly.** Actions on *different* slices add
20
+ * cleanly; the one composition that does not add — route and batch on the
21
+ * same slice — arrives already combined inside a single action, so the
22
+ * plan's total is a sum of non-overlapping figures by construction. Measured
23
+ * stakes (money already spent on retries, money already lost to caching) are
24
+ * totalled separately from projected savings: "what you would save" and
25
+ * "what you already paid" are different columns, and merging them makes a
26
+ * number that is neither.
27
+ */
28
+ import { UNLABELLED, cacheEconomics } from './usage.js';
29
+ /**
30
+ * Builds the plan from a report and its levers.
31
+ *
32
+ * `pricingLastReviewed` is passed in rather than imported so the plan records
33
+ * the catalogue that actually priced it — an overlay's date when one was in
34
+ * effect, which 1.39's verification needs to tell "the prediction was wrong"
35
+ * from "the prices changed".
36
+ */
37
+ export function buildPlan(report, levers, pricingLastReviewed) {
38
+ const actions = [];
39
+ for (const slice of levers.slices) {
40
+ const assumes = [];
41
+ let kind;
42
+ if (slice.route !== null && slice.batch !== null) {
43
+ kind = 'route+batch';
44
+ assumes.push({ kind: 'model-capability', model: slice.route.candidate.displayName });
45
+ assumes.push({ kind: 'batch-window' });
46
+ }
47
+ else if (slice.route !== null) {
48
+ kind = 'route';
49
+ assumes.push({ kind: 'model-capability', model: slice.route.candidate.displayName });
50
+ }
51
+ else if (slice.batch !== null) {
52
+ kind = 'batch';
53
+ assumes.push({ kind: 'batch-window' });
54
+ }
55
+ else {
56
+ continue;
57
+ }
58
+ actions.push({
59
+ kind,
60
+ label: slice.label,
61
+ model: slice.model,
62
+ savingUsd: slice.combinedUsd,
63
+ stakeUsd: null,
64
+ assumes,
65
+ check: slice.route !== null ? 'trazum route <log> --prompt-file <prompt> --cases <cases>' : null,
66
+ detail: slice.route !== null ? { routeTo: slice.route.candidate } : {},
67
+ });
68
+ }
69
+ for (const row of report.truncationRetries) {
70
+ actions.push({
71
+ kind: 'fix-truncation',
72
+ label: row.label,
73
+ model: row.model,
74
+ savingUsd: null,
75
+ stakeUsd: row.wastedUsd + row.retryUsd,
76
+ assumes: [{ kind: 'retry-pattern-real' }, { kind: 'max-tokens-fits' }],
77
+ check: null,
78
+ detail: {
79
+ measured: {
80
+ wastedUsd: row.wastedUsd,
81
+ retryUsd: row.retryUsd,
82
+ retried: row.retried,
83
+ truncatedCalls: row.truncatedCalls,
84
+ },
85
+ },
86
+ });
87
+ }
88
+ for (const slice of report.byLabelAndModel) {
89
+ const economics = cacheEconomics(slice.breakdown);
90
+ // Only a settled loss becomes an action: an unsettled verdict is a
91
+ // missing field, and "add the field" is the report's advice, not a plan's.
92
+ if (economics.verdict !== 'lost-money' || economics.worstCaseVerdict !== economics.verdict)
93
+ continue;
94
+ actions.push({
95
+ kind: 'fix-caching',
96
+ label: slice.label,
97
+ model: slice.model,
98
+ savingUsd: null,
99
+ stakeUsd: economics.deltaUsd,
100
+ assumes: [{ kind: 'traffic-pattern-holds' }],
101
+ check: null,
102
+ detail: {
103
+ measured: {
104
+ spentUsd: economics.spentUsd,
105
+ withoutCachingUsd: economics.withoutCachingUsd,
106
+ },
107
+ },
108
+ });
109
+ }
110
+ actions.sort((a, b) => (b.savingUsd ?? b.stakeUsd ?? 0) - (a.savingUsd ?? a.stakeUsd ?? 0));
111
+ return {
112
+ schemaVersion: 1,
113
+ span: report.span === null ? null : { fromMs: report.span.fromMs, toMs: report.span.toMs },
114
+ pricingLastReviewed,
115
+ actions,
116
+ projectedSavingUsd: actions.reduce((sum, a) => sum + (a.savingUsd ?? 0), 0),
117
+ measuredStakeUsd: actions.reduce((sum, a) => sum + (a.stakeUsd ?? 0), 0),
118
+ totalUsd: report.total.totalUsd,
119
+ };
120
+ }
121
+ /** Renders `UNLABELLED` for humans without leaking the sentinel. */
122
+ export function planLabelName(label, unlabelled) {
123
+ return label === UNLABELLED ? unlabelled : label;
124
+ }
125
+ //# sourceMappingURL=plan.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plan.js","sourceRoot":"","sources":["../src/plan.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.36.0",
3
+ "version": "1.38.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
@@ -43,6 +43,10 @@ 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
45
  export { measuredUsage, labelCoverage, MIN_SCALE_DAYS, SCALE_TO_DAYS } from './measured-profile.js';
46
+ export { assignSources, fleetRollup } from './fleet.js';
47
+ export { buildPlan, planLabelName } from './plan.js';
48
+ export type { PlanAction, PlanActionKind, PlanAssumption, PlanDocument } from './plan.js';
49
+ export type { FleetSource, FleetRollup } from './fleet.js';
46
50
  export type { MeasuredUsage, LabelCoverage } from './measured-profile.js';
47
51
  export type { GateExplanation } from './gate-explain.js';
48
52
  export type { CoverageDrift, CoverageField } from './coverage-drift.js';