@trazum/core 1.53.4 → 1.55.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/conform.d.ts +1 -1
- package/dist/conform.d.ts.map +1 -1
- package/dist/conform.js +58 -1
- package/dist/conform.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/rollup.d.ts +327 -0
- package/dist/rollup.d.ts.map +1 -0
- package/dist/rollup.js +844 -0
- package/dist/rollup.js.map +1 -0
- package/package.json +1 -1
- package/src/conform.ts +61 -1
- package/src/index.ts +11 -0
- package/src/rollup.ts +1159 -0
package/src/rollup.ts
ADDED
|
@@ -0,0 +1,1159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Several records, one roll-up, and every contributor's gaps still visible.
|
|
3
|
+
*
|
|
4
|
+
* Everything else in this repository assumes one operator with files on disk.
|
|
5
|
+
* `--by-source` and `owners` divide a bill somebody already collected;
|
|
6
|
+
* `fleetRollup` compares services whose logs one machine could open. None of
|
|
7
|
+
* them answers the question a team actually has: **four people measured four
|
|
8
|
+
* things, and nobody wants to email logs around.**
|
|
9
|
+
*
|
|
10
|
+
* So this module merges *documents*, not logs. Each contributor runs
|
|
11
|
+
* `trazum profile --json` wherever their traffic already is, and hands over a
|
|
12
|
+
* profile document — which carries no prompt text, no completion text, no
|
|
13
|
+
* session keys and no credentials, and never has. The roll-up is a format and
|
|
14
|
+
* a merge rather than a service: the transport is somebody else's problem,
|
|
15
|
+
* deliberately, because a tool whose argument is that it reads your bill
|
|
16
|
+
* without uploading it cannot also be the place everybody's bill is uploaded.
|
|
17
|
+
*
|
|
18
|
+
* **The merge is the easy half.** The half worth building carefully is what
|
|
19
|
+
* *cannot* be merged, and this module refuses in four different ways:
|
|
20
|
+
*
|
|
21
|
+
* - **Findings that need the records.** Percentile shapes, conversation
|
|
22
|
+
* growth, repeated turns, truncation retries — every one of them is computed
|
|
23
|
+
* from individual calls, and a summary of a summary cannot reproduce them.
|
|
24
|
+
* They are named, with the contributors that had them, rather than dropped.
|
|
25
|
+
* - **A day's dearest label.** Each contributor states its own; the merged
|
|
26
|
+
* answer needs per-label-per-day figures no document carries. Where two
|
|
27
|
+
* contributors share a day the answer is `null` with the reason attached,
|
|
28
|
+
* not the louder contributor's.
|
|
29
|
+
* - **Overlap between contributors.** Two people exporting the same traffic
|
|
30
|
+
* double the bill, and nothing here can see it: the roll-up never sees a raw
|
|
31
|
+
* line, so the duplicate detection a single profile does is structurally out
|
|
32
|
+
* of reach. Every roll-up of more than one contributor says so.
|
|
33
|
+
* - **Each contributor's own blind spots.** Unreadable lines, unpriced calls,
|
|
34
|
+
* a log with no clock. Summing them into one figure would say "3% of this
|
|
35
|
+
* roll-up is unpriced" when the truth is "one of your four machines is 90%
|
|
36
|
+
* unpriced and the other three are clean" — the same averaging-away this arc
|
|
37
|
+
* exists to refuse. They stay per contributor.
|
|
38
|
+
*
|
|
39
|
+
* No I/O: the caller hands over text it read, so this stays browser-safe and
|
|
40
|
+
* the CLI keeps its monopoly on the filesystem.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { conform } from './conform.js';
|
|
44
|
+
import type { OutcomeTally } from './outcome.js';
|
|
45
|
+
import type { FieldCoverage, UsageBreakdown } from './usage.js';
|
|
46
|
+
|
|
47
|
+
/** A document somebody handed over, under the name it should answer to. */
|
|
48
|
+
export interface RollupInput {
|
|
49
|
+
/** How this contributor is named in the roll-up. The caller's choice. */
|
|
50
|
+
name: string;
|
|
51
|
+
/** The profile document, as text — parsed and checked here, never trusted. */
|
|
52
|
+
text: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One contributor's own gap, kept whole.
|
|
57
|
+
*
|
|
58
|
+
* `usd` and `calls` are `null` where the kind does not have one, never `0`: a
|
|
59
|
+
* gap with no money attached and a gap that cost nothing are different
|
|
60
|
+
* statements, and the second one is a measurement.
|
|
61
|
+
*/
|
|
62
|
+
export interface ContributorGap {
|
|
63
|
+
kind:
|
|
64
|
+
/** Lines this contributor's parser could not read at all. */
|
|
65
|
+
| 'unreadable-lines'
|
|
66
|
+
/** Calls whose model the price catalogue does not know. */
|
|
67
|
+
| 'unpriced-calls'
|
|
68
|
+
/** No record carried a timestamp, so this contributor is in no day. */
|
|
69
|
+
| 'no-clock'
|
|
70
|
+
/** Some records carried a timestamp and some did not. */
|
|
71
|
+
| 'partial-clock'
|
|
72
|
+
/** No record carried a session. */
|
|
73
|
+
| 'no-sessions'
|
|
74
|
+
/** No record carried a label. */
|
|
75
|
+
| 'no-labels'
|
|
76
|
+
/** Duplicate lines this contributor found inside its own log. */
|
|
77
|
+
| 'duplicate-lines'
|
|
78
|
+
/** Days inside the window it asked for on which it recorded nothing. */
|
|
79
|
+
| 'silent-days';
|
|
80
|
+
detail: string;
|
|
81
|
+
usd: number | null;
|
|
82
|
+
calls: number | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A stretch inside a claimed window that recorded nothing at all.
|
|
87
|
+
*
|
|
88
|
+
* Contiguous runs rather than a list of dates: a contributor claiming a year
|
|
89
|
+
* and recording three days of traffic produces one entry per gap instead of
|
|
90
|
+
* three hundred and sixty-two strings, and the reader can still see exactly
|
|
91
|
+
* which days are missing.
|
|
92
|
+
*/
|
|
93
|
+
export interface SilentRun {
|
|
94
|
+
/** First silent day, `YYYY-MM-DD` UTC. */
|
|
95
|
+
from: string;
|
|
96
|
+
/** Last silent day, inclusive. */
|
|
97
|
+
to: string;
|
|
98
|
+
days: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface RollupContributor {
|
|
102
|
+
name: string;
|
|
103
|
+
/**
|
|
104
|
+
* The roll-up this contributor arrived through, or null when it was handed
|
|
105
|
+
* over directly.
|
|
106
|
+
*
|
|
107
|
+
* Contributors are **flattened, never collapsed**: a roll-up of three
|
|
108
|
+
* roll-ups lists twelve machines rather than three, because collapsing them
|
|
109
|
+
* would average twelve sets of gaps into three and that is the averaging
|
|
110
|
+
* this whole arc exists to refuse.
|
|
111
|
+
*/
|
|
112
|
+
via: string | null;
|
|
113
|
+
totalUsd: number;
|
|
114
|
+
calls: number;
|
|
115
|
+
/** The period this contributor's log covers, or null when it carried no clock. */
|
|
116
|
+
span: { fromMs: number; toMs: number; calls: number } | null;
|
|
117
|
+
/** The same span in days, or null. Stated, never extrapolated from. */
|
|
118
|
+
spanDays: number | null;
|
|
119
|
+
/**
|
|
120
|
+
* The window this contributor **asked for**, when it filtered by one.
|
|
121
|
+
*
|
|
122
|
+
* A claim, not a measurement, and the distinction is the point. `span` says
|
|
123
|
+
* what the records showed; this says what was gone looking for. A log whose
|
|
124
|
+
* latest record is the 5th may be a log of a quiet week or a log that
|
|
125
|
+
* stopped being written on the 5th, and only a claim can tell those apart —
|
|
126
|
+
* so a contributor that made none gets `null` here and the roll-up says that
|
|
127
|
+
* out loud rather than reading its span as a period.
|
|
128
|
+
*
|
|
129
|
+
* The window is half-open, `[sinceMs, untilMs)`, as `profileUsage` applies
|
|
130
|
+
* it.
|
|
131
|
+
*/
|
|
132
|
+
claimed: { sinceMs: number | null; untilMs: number | null } | null;
|
|
133
|
+
/**
|
|
134
|
+
* Days inside a fully bounded claim on which this contributor recorded
|
|
135
|
+
* nothing, and how many there are.
|
|
136
|
+
*
|
|
137
|
+
* **Named rather than interpolated**, the way a year report names its
|
|
138
|
+
* missing months. Whether a silent stretch is a quiet week or a broken
|
|
139
|
+
* export is the reader's to know; that it is silent is this tool's to say,
|
|
140
|
+
* and a roll-up that folded it into a smaller total would be wrong by an
|
|
141
|
+
* unknown amount in the flattering direction.
|
|
142
|
+
*
|
|
143
|
+
* Null when there is nothing to measure against: no claim, a claim with only
|
|
144
|
+
* one end, or a claim too long to enumerate.
|
|
145
|
+
*/
|
|
146
|
+
silence: { runs: SilentRun[]; days: number } | null;
|
|
147
|
+
/**
|
|
148
|
+
* Records the contributor's own window could not place, because they carried
|
|
149
|
+
* no clock — the honesty cost of filtering by one.
|
|
150
|
+
*
|
|
151
|
+
* Null when there was no window, never 0: zero would say a window excluded
|
|
152
|
+
* nothing, and no window is a different statement.
|
|
153
|
+
*/
|
|
154
|
+
undatedExcluded: number | null;
|
|
155
|
+
gaps: ContributorGap[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A finding that exists per contributor and does not roll up. */
|
|
159
|
+
export interface UnmergedFinding {
|
|
160
|
+
finding: string;
|
|
161
|
+
because: string;
|
|
162
|
+
/** The contributors that had one, so the reader knows where to go and look. */
|
|
163
|
+
presentIn: string[];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* What a roll-up cannot say about itself.
|
|
168
|
+
*
|
|
169
|
+
* String codes rather than prose, so a consumer can branch on them and the
|
|
170
|
+
* renderings can carry the sentences. `annual-record` established the shape.
|
|
171
|
+
*/
|
|
172
|
+
export type RollupCaveat =
|
|
173
|
+
/** More than one contributor, so overlap between them is unmeasurable. */
|
|
174
|
+
| 'overlap-invisible'
|
|
175
|
+
/** Contributors cover meaningfully different periods. */
|
|
176
|
+
| 'mismatched-spans'
|
|
177
|
+
/** Some contributor carried no clock at all. */
|
|
178
|
+
| 'contributor-without-clock'
|
|
179
|
+
/** A day drew from more than one contributor, so its dearest label is unknown. */
|
|
180
|
+
| 'day-top-label-unknown'
|
|
181
|
+
/** Two contributions were the same document. */
|
|
182
|
+
| 'identical-contributions'
|
|
183
|
+
/** A contribution was handed over and not merged. */
|
|
184
|
+
| 'contribution-rejected'
|
|
185
|
+
/** A contribution carried a numeric field this version cannot classify. */
|
|
186
|
+
| 'unknown-fields-dropped'
|
|
187
|
+
/** A contributor stated no window, so its span is all that is known of it. */
|
|
188
|
+
| 'no-claimed-period'
|
|
189
|
+
/** A contributor claimed days on which it recorded nothing. */
|
|
190
|
+
| 'silence-inside-a-claim'
|
|
191
|
+
/** A contributor claimed one end of a window and not the other. */
|
|
192
|
+
| 'claim-not-bounded'
|
|
193
|
+
/** A claim was too long to enumerate day by day, and was not. */
|
|
194
|
+
| 'claim-too-long-to-enumerate'
|
|
195
|
+
/** A contributor name appears more than once, so its money may be counted twice. */
|
|
196
|
+
| 'contributor-named-twice';
|
|
197
|
+
|
|
198
|
+
export interface RollupDay {
|
|
199
|
+
/** `YYYY-MM-DD`, UTC — the contributors' own bucketing, never re-derived. */
|
|
200
|
+
day: string;
|
|
201
|
+
usd: number;
|
|
202
|
+
calls: number;
|
|
203
|
+
/** How many contributors saw traffic on this day. */
|
|
204
|
+
contributors: number;
|
|
205
|
+
byModel: Array<{ model: string; usd: number; calls: number }>;
|
|
206
|
+
/**
|
|
207
|
+
* The dearest label of the day, or **null** when more than one contributor
|
|
208
|
+
* covered it.
|
|
209
|
+
*
|
|
210
|
+
* A profile knows its own day's dearest label; the merged answer needs each
|
|
211
|
+
* contributor's per-label-per-day spend, which no document carries. Picking
|
|
212
|
+
* the larger of two contributors' answers is what a helpful implementation
|
|
213
|
+
* would do, and it is wrong whenever a runner-up in both adds up to more
|
|
214
|
+
* than either winner.
|
|
215
|
+
*/
|
|
216
|
+
topLabel: string | null;
|
|
217
|
+
topLabelUsd: number | null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface RollupDocument {
|
|
221
|
+
schemaVersion: 1;
|
|
222
|
+
contributors: RollupContributor[];
|
|
223
|
+
/**
|
|
224
|
+
* Handed over and not merged, each with why. Never dropped in silence.
|
|
225
|
+
*
|
|
226
|
+
* `via` names the roll-up a rejection arrived through, when it came from
|
|
227
|
+
* one. A rejection that stopped travelling at a nesting boundary would mean
|
|
228
|
+
* a broken export could be made to disappear by adding a layer, which is the
|
|
229
|
+
* one thing a format built out of other people's measurements must not
|
|
230
|
+
* allow.
|
|
231
|
+
*/
|
|
232
|
+
rejected: Array<{ name: string; via: string | null; because: string }>;
|
|
233
|
+
/**
|
|
234
|
+
* Contributions that were the same document, grouped, and what the repeats
|
|
235
|
+
* added to the total.
|
|
236
|
+
*
|
|
237
|
+
* Merged rather than discarded, and stated rather than repaired — the rule a
|
|
238
|
+
* single profile already applies to duplicate lines. Whether it is one export
|
|
239
|
+
* handed over twice or two machines that genuinely produced identical
|
|
240
|
+
* documents is the reader's to know, and this tool does not decide it by
|
|
241
|
+
* throwing money away.
|
|
242
|
+
*
|
|
243
|
+
* The comparison is over the whole text, not a hash of it: a hash collision
|
|
244
|
+
* would report a duplicate that is not one, and this figure exists to make
|
|
245
|
+
* somebody distrust a total.
|
|
246
|
+
*/
|
|
247
|
+
identicalContributions: { groups: string[][]; usd: number };
|
|
248
|
+
total: UsageBreakdown;
|
|
249
|
+
unpriced: UsageBreakdown;
|
|
250
|
+
unpricedModels: string[];
|
|
251
|
+
byLabel: Array<{ label: string; breakdown: UsageBreakdown }>;
|
|
252
|
+
byModel: Array<{ model: string; breakdown: UsageBreakdown }>;
|
|
253
|
+
byLabelAndModel: Array<{ label: string; model: string; breakdown: UsageBreakdown }>;
|
|
254
|
+
spendByDay: RollupDay[];
|
|
255
|
+
/** Earliest start to latest end, over contributors that carried a clock. */
|
|
256
|
+
span: { fromMs: number; toMs: number; calls: number } | null;
|
|
257
|
+
/**
|
|
258
|
+
* Earliest claimed start to latest claimed end, over contributors that
|
|
259
|
+
* stated a fully bounded window.
|
|
260
|
+
*
|
|
261
|
+
* **Kept apart from `span`, deliberately.** One is what the records showed
|
|
262
|
+
* and the other is what somebody went looking for, and a roll-up that merged
|
|
263
|
+
* them would answer "what period does this cover" with a number that is half
|
|
264
|
+
* measurement and half intention. Null when no contributor claimed one.
|
|
265
|
+
*/
|
|
266
|
+
claimedSpan: { fromMs: number; toMs: number; contributors: number } | null;
|
|
267
|
+
fieldCoverage: FieldCoverage;
|
|
268
|
+
outcomeTally: OutcomeTally;
|
|
269
|
+
/** Summed **within-contributor** duplicates. Overlap between them is elsewhere. */
|
|
270
|
+
duplicateLines: { count: number; usd: number };
|
|
271
|
+
/**
|
|
272
|
+
* Contributor names that appear more than once, across nesting.
|
|
273
|
+
*
|
|
274
|
+
* Handing over both a roll-up and one of the machines inside it counts that
|
|
275
|
+
* machine's money twice, and unlike the identical-document check this one can
|
|
276
|
+
* see it — the documents differ, but the name is the same. **Named, never
|
|
277
|
+
* subtracted**: two machines genuinely called `api.json` in two teams is
|
|
278
|
+
* possible, and deciding which case this is by removing money would be the
|
|
279
|
+
* repair this tool never makes.
|
|
280
|
+
*/
|
|
281
|
+
repeatedContributors: string[];
|
|
282
|
+
notMerged: UnmergedFinding[];
|
|
283
|
+
cannotSay: RollupCaveat[];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Every numeric field of a breakdown, and how two of them combine.
|
|
288
|
+
*
|
|
289
|
+
* Listed rather than inferred, because both mistakes here are silent. A field
|
|
290
|
+
* left out of both lists would vanish from every merged breakdown — a finding
|
|
291
|
+
* present in each contribution and absent from the roll-up. And
|
|
292
|
+
* `maxCallInputTokens` summed would report a fleet whose largest call is the
|
|
293
|
+
* sum of four machines' largest calls, which is a number no call ever had, in
|
|
294
|
+
* the direction that makes a context window look tight.
|
|
295
|
+
*
|
|
296
|
+
* `rollup.test.js` derives the field names from `usage.ts` and fails the build
|
|
297
|
+
* when one is in neither list, so the next field added upstream cannot be
|
|
298
|
+
* quietly dropped here.
|
|
299
|
+
*/
|
|
300
|
+
const BREAKDOWN_SUM = [
|
|
301
|
+
'calls',
|
|
302
|
+
'inputTokens',
|
|
303
|
+
'cacheReadTokens',
|
|
304
|
+
'cacheWriteTokens',
|
|
305
|
+
'cacheWrite5mTokens',
|
|
306
|
+
'cacheWrite1hTokens',
|
|
307
|
+
'outputTokens',
|
|
308
|
+
'assumedWriteTtlCalls',
|
|
309
|
+
'inputUsd',
|
|
310
|
+
'cacheReadUsd',
|
|
311
|
+
'cacheWriteUsd',
|
|
312
|
+
'outputUsd',
|
|
313
|
+
'totalUsd',
|
|
314
|
+
'cachedTokensAtInputRateUsd',
|
|
315
|
+
'cacheWriteUsdIfAssumed1h',
|
|
316
|
+
'truncatedCalls',
|
|
317
|
+
'truncatedOutputUsd',
|
|
318
|
+
'stopReasonCalls',
|
|
319
|
+
] as const;
|
|
320
|
+
|
|
321
|
+
/** Fields whose combination is a maximum, because a sum would invent a call. */
|
|
322
|
+
const BREAKDOWN_MAX = ['maxCallInputTokens'] as const;
|
|
323
|
+
|
|
324
|
+
/** Every counter of a coverage tally. All of them sum. */
|
|
325
|
+
const COVERAGE_FIELDS = [
|
|
326
|
+
'label',
|
|
327
|
+
'session',
|
|
328
|
+
'outcome',
|
|
329
|
+
'ts',
|
|
330
|
+
'stopReason',
|
|
331
|
+
'cacheTtl',
|
|
332
|
+
'cacheWrites',
|
|
333
|
+
'parsed',
|
|
334
|
+
] as const;
|
|
335
|
+
|
|
336
|
+
const emptyBreakdown = (): UsageBreakdown => {
|
|
337
|
+
const out: Record<string, number> = {};
|
|
338
|
+
for (const field of BREAKDOWN_SUM) out[field] = 0;
|
|
339
|
+
for (const field of BREAKDOWN_MAX) out[field] = 0;
|
|
340
|
+
return out as unknown as UsageBreakdown;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const emptyCoverage = (): FieldCoverage => {
|
|
344
|
+
const out: Record<string, number> = {};
|
|
345
|
+
for (const field of COVERAGE_FIELDS) out[field] = 0;
|
|
346
|
+
return out as unknown as FieldCoverage;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const numberAt = (source: Record<string, unknown>, field: string): number => {
|
|
350
|
+
const value = source[field];
|
|
351
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const asString = (value: unknown): string | null =>
|
|
355
|
+
typeof value === 'string' && value !== '' ? value : null;
|
|
356
|
+
|
|
357
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
358
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Adds one contribution's breakdown into an accumulator.
|
|
362
|
+
*
|
|
363
|
+
* Returns the numeric fields it did not recognise, so a document from a newer
|
|
364
|
+
* Trazum is reported rather than half-merged. Guessing that an unknown number
|
|
365
|
+
* is additive is how a ratio becomes four times itself.
|
|
366
|
+
*/
|
|
367
|
+
function addBreakdown(into: UsageBreakdown, from: unknown): string[] {
|
|
368
|
+
if (!isRecord(from)) return [];
|
|
369
|
+
const target = into as unknown as Record<string, number>;
|
|
370
|
+
for (const field of BREAKDOWN_SUM) target[field] = (target[field] ?? 0) + numberAt(from, field);
|
|
371
|
+
for (const field of BREAKDOWN_MAX) {
|
|
372
|
+
target[field] = Math.max(target[field] ?? 0, numberAt(from, field));
|
|
373
|
+
}
|
|
374
|
+
const known = new Set<string>([...BREAKDOWN_SUM, ...BREAKDOWN_MAX]);
|
|
375
|
+
return Object.keys(from).filter((key) => !known.has(key) && typeof from[key] === 'number');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** One merged slice, keyed by whatever identifies it. */
|
|
379
|
+
interface KeyedSlice {
|
|
380
|
+
label: string | null;
|
|
381
|
+
model: string | null;
|
|
382
|
+
breakdown: UsageBreakdown;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Merges keyed breakdowns — labels, models, or the pair — into one map.
|
|
387
|
+
*
|
|
388
|
+
* The identity travels in the value rather than being parsed back out of the
|
|
389
|
+
* key: a label may contain any character, including whatever separator a key
|
|
390
|
+
* would join on, and a pair key split on a separator the label also uses
|
|
391
|
+
* silently merges two different workloads.
|
|
392
|
+
*/
|
|
393
|
+
function mergeKeyed(
|
|
394
|
+
into: Map<string, KeyedSlice>,
|
|
395
|
+
rows: unknown,
|
|
396
|
+
identityOf: (row: Record<string, unknown>) => { label: string | null; model: string | null } | null,
|
|
397
|
+
): void {
|
|
398
|
+
if (!Array.isArray(rows)) return;
|
|
399
|
+
for (const row of rows) {
|
|
400
|
+
if (!isRecord(row)) continue;
|
|
401
|
+
const identity = identityOf(row);
|
|
402
|
+
if (identity === null) continue;
|
|
403
|
+
const key = JSON.stringify([identity.label, identity.model]);
|
|
404
|
+
const slice = into.get(key) ?? { ...identity, breakdown: emptyBreakdown() };
|
|
405
|
+
addBreakdown(slice.breakdown, row.breakdown);
|
|
406
|
+
into.set(key, slice);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Largest bill first, the order somebody would act in. */
|
|
411
|
+
const byMoney = <T>(rows: T[], usd: (row: T) => number): T[] =>
|
|
412
|
+
[...rows].sort((a, b) => usd(b) - usd(a));
|
|
413
|
+
|
|
414
|
+
const DAY_MS = 86_400_000;
|
|
415
|
+
|
|
416
|
+
/** UTC `YYYY-MM-DD` for an instant — the bucketing every day figure here uses. */
|
|
417
|
+
const dayOf = (ms: number): string => new Date(ms).toISOString().slice(0, 10);
|
|
418
|
+
|
|
419
|
+
/** Midnight UTC of the day an instant falls in. */
|
|
420
|
+
const midnightOf = (ms: number): number => Date.parse(`${dayOf(ms)}T00:00:00.000Z`);
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The longest claim this module will walk day by day: ten years.
|
|
424
|
+
*
|
|
425
|
+
* A bound rather than trust, because these documents come from elsewhere. A
|
|
426
|
+
* contribution claiming `untilMs: 1e15` is a malformed document, not a team
|
|
427
|
+
* with a long memory, and enumerating it would be thirty million iterations
|
|
428
|
+
* inside a merge somebody ran on four files. The claim is kept and reported;
|
|
429
|
+
* only the enumeration is refused, with a caveat saying so — a refusal never
|
|
430
|
+
* arrives bare.
|
|
431
|
+
*/
|
|
432
|
+
const MAX_CLAIM_DAYS = 3660;
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The stretches of a claimed window on which nothing was recorded.
|
|
436
|
+
*
|
|
437
|
+
* Returns `null` when the claim cannot be walked: unbounded on either end, out
|
|
438
|
+
* of order, or longer than the bound above. The window is half-open, so the
|
|
439
|
+
* last claimed day is the one containing `untilMs - 1` — an `until` of
|
|
440
|
+
* midnight claims up to the previous day and not that day, which is how the
|
|
441
|
+
* profile's own filter reads it.
|
|
442
|
+
*/
|
|
443
|
+
function silenceIn(
|
|
444
|
+
claim: { sinceMs: number | null; untilMs: number | null },
|
|
445
|
+
daysWithTraffic: Set<string>,
|
|
446
|
+
): { runs: SilentRun[]; days: number } | null {
|
|
447
|
+
const { sinceMs, untilMs } = claim;
|
|
448
|
+
if (sinceMs === null || untilMs === null) return null;
|
|
449
|
+
if (!Number.isFinite(sinceMs) || !Number.isFinite(untilMs)) return null;
|
|
450
|
+
if (untilMs <= sinceMs) return null;
|
|
451
|
+
const first = midnightOf(sinceMs);
|
|
452
|
+
const last = midnightOf(untilMs - 1);
|
|
453
|
+
const span = Math.round((last - first) / DAY_MS) + 1;
|
|
454
|
+
if (span > MAX_CLAIM_DAYS) return null;
|
|
455
|
+
|
|
456
|
+
const runs: SilentRun[] = [];
|
|
457
|
+
let open: { from: string; to: string; days: number } | null = null;
|
|
458
|
+
let days = 0;
|
|
459
|
+
for (let at = first; at <= last; at += DAY_MS) {
|
|
460
|
+
const day = dayOf(at);
|
|
461
|
+
if (daysWithTraffic.has(day)) {
|
|
462
|
+
if (open !== null) runs.push(open);
|
|
463
|
+
open = null;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
days += 1;
|
|
467
|
+
if (open === null) open = { from: day, to: day, days: 1 };
|
|
468
|
+
else {
|
|
469
|
+
open.to = day;
|
|
470
|
+
open.days += 1;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (open !== null) runs.push(open);
|
|
474
|
+
return { runs, days };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** The findings a document carries per call, which no summary can reconstruct. */
|
|
478
|
+
const PER_RECORD_FINDINGS: Array<{ field: string; finding: string; because: string }> = [
|
|
479
|
+
{
|
|
480
|
+
field: 'conversations',
|
|
481
|
+
finding: 'conversation growth',
|
|
482
|
+
because:
|
|
483
|
+
'it is measured over the turns of one session, and a document carries the growth rather than the turns',
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
field: 'inputShapes',
|
|
487
|
+
finding: 'input shape — the median and p95 call',
|
|
488
|
+
because:
|
|
489
|
+
'a percentile of two contributors is not a percentile of their percentiles, and the calls behind them are in neither document',
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
field: 'outputShapes',
|
|
493
|
+
finding: 'output concentration',
|
|
494
|
+
because:
|
|
495
|
+
'it asks what share of the calls holds what share of the output spend, and both shares need the individual calls',
|
|
496
|
+
},
|
|
497
|
+
{
|
|
498
|
+
field: 'repeatedTurns',
|
|
499
|
+
finding: 'repeated turns — the shape of a retry loop',
|
|
500
|
+
because:
|
|
501
|
+
'it compares consecutive calls in one session seconds apart, and consecutive calls are what a summary has already collapsed',
|
|
502
|
+
},
|
|
503
|
+
{
|
|
504
|
+
field: 'truncationRetries',
|
|
505
|
+
finding: 'truncation followed by a retry',
|
|
506
|
+
because: 'it pairs a truncated answer with the call that followed it, and a document carries neither call',
|
|
507
|
+
},
|
|
508
|
+
];
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Merges profile documents into one roll-up.
|
|
512
|
+
*
|
|
513
|
+
* Every input is checked against the `profile` contract before a figure of it
|
|
514
|
+
* is used. A document that does not conform is **rejected with its reason and
|
|
515
|
+
* not merged** — a roll-up that quietly skipped a malformed contribution would
|
|
516
|
+
* report a total missing one machine's entire bill, and the reader would have
|
|
517
|
+
* no way to tell that from that machine having spent nothing.
|
|
518
|
+
*/
|
|
519
|
+
export function rollUp(inputs: RollupInput[]): RollupDocument {
|
|
520
|
+
const contributors: RollupContributor[] = [];
|
|
521
|
+
const rejected: Array<{ name: string; via: string | null; because: string }> = [];
|
|
522
|
+
const cannotSay = new Set<RollupCaveat>();
|
|
523
|
+
|
|
524
|
+
const total = emptyBreakdown();
|
|
525
|
+
const unpriced = emptyBreakdown();
|
|
526
|
+
const unpricedModels = new Set<string>();
|
|
527
|
+
const labels = new Map<string, KeyedSlice>();
|
|
528
|
+
const models = new Map<string, KeyedSlice>();
|
|
529
|
+
const pairs = new Map<string, KeyedSlice>();
|
|
530
|
+
const coverage = emptyCoverage();
|
|
531
|
+
const outcomeValues = new Map<string, { calls: number; usd: number }>();
|
|
532
|
+
const outcome = { recorded: 0, parsed: 0, unrecordedUsd: 0 };
|
|
533
|
+
const duplicateLines = { count: 0, usd: 0 };
|
|
534
|
+
const unknownFields = new Set<string>();
|
|
535
|
+
const days = new Map<
|
|
536
|
+
string,
|
|
537
|
+
{
|
|
538
|
+
usd: number;
|
|
539
|
+
calls: number;
|
|
540
|
+
contributors: number;
|
|
541
|
+
models: Map<string, { usd: number; calls: number }>;
|
|
542
|
+
from: Set<string>;
|
|
543
|
+
topLabel: string | null;
|
|
544
|
+
topLabelUsd: number | null;
|
|
545
|
+
}
|
|
546
|
+
>();
|
|
547
|
+
const findingsPresentIn = new Map<string, string[]>();
|
|
548
|
+
/** Findings a nested roll-up already refused to merge, carried through. */
|
|
549
|
+
const nestedFindings = new Map<string, { because: string; presentIn: string[] }>();
|
|
550
|
+
/** Identical-document groups a nested roll-up already found. */
|
|
551
|
+
const nestedIdentical: string[][] = [];
|
|
552
|
+
/** The text of each accepted contribution, for the identical-document check. */
|
|
553
|
+
const seenText = new Map<string, string[]>();
|
|
554
|
+
let identicalUsd = 0;
|
|
555
|
+
let claimFrom: number | null = null;
|
|
556
|
+
let claimTo: number | null = null;
|
|
557
|
+
let claimants = 0;
|
|
558
|
+
let spanFrom: number | null = null;
|
|
559
|
+
let spanTo: number | null = null;
|
|
560
|
+
let spanCalls = 0;
|
|
561
|
+
|
|
562
|
+
for (const input of inputs) {
|
|
563
|
+
/**
|
|
564
|
+
* A profile, or a roll-up somebody else already made.
|
|
565
|
+
*
|
|
566
|
+
* Detected rather than forced, because a roll-up is a contribution too:
|
|
567
|
+
* three teams roll up their own machines, and the organisation rolls up
|
|
568
|
+
* the three. Every summable part of a roll-up carries the same field names
|
|
569
|
+
* as a profile — that is what makes the nesting arithmetic free — and the
|
|
570
|
+
* parts that are *not* summable are exactly the ones this loop has to
|
|
571
|
+
* carry through by hand rather than lose a layer at a time.
|
|
572
|
+
*/
|
|
573
|
+
const checked = conform(input.text);
|
|
574
|
+
const nested = checked.contract === 'roll-up';
|
|
575
|
+
if (!checked.conforms || (checked.contract !== 'profile' && !nested)) {
|
|
576
|
+
const first = checked.problems[0];
|
|
577
|
+
rejected.push({
|
|
578
|
+
name: input.name,
|
|
579
|
+
via: null,
|
|
580
|
+
because:
|
|
581
|
+
first === undefined
|
|
582
|
+
? (checked.because ?? 'it is neither a profile nor a roll-up')
|
|
583
|
+
: `${first.at}: ${first.detail}`,
|
|
584
|
+
});
|
|
585
|
+
cannotSay.add('contribution-rejected');
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// `conform` parsed it once already and reported nothing, so this cannot
|
|
590
|
+
// throw — but a parse whose failure mode is a crash is a parse worth
|
|
591
|
+
// guarding, and the reject path above is already the right answer.
|
|
592
|
+
let parsed: unknown;
|
|
593
|
+
try {
|
|
594
|
+
parsed = JSON.parse(input.text.trim());
|
|
595
|
+
} catch {
|
|
596
|
+
rejected.push({ name: input.name, via: null, because: 'it is not valid JSON' });
|
|
597
|
+
cannotSay.add('contribution-rejected');
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (!isRecord(parsed)) {
|
|
601
|
+
rejected.push({ name: input.name, via: null, because: 'a document must be a JSON object' });
|
|
602
|
+
cannotSay.add('contribution-rejected');
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
const doc = parsed;
|
|
606
|
+
|
|
607
|
+
const text = input.text.trim();
|
|
608
|
+
const sameAs = seenText.get(text);
|
|
609
|
+
if (sameAs === undefined) {
|
|
610
|
+
seenText.set(text, [input.name]);
|
|
611
|
+
} else {
|
|
612
|
+
sameAs.push(input.name);
|
|
613
|
+
cannotSay.add('identical-contributions');
|
|
614
|
+
identicalUsd += isRecord(doc.total) ? numberAt(doc.total, 'totalUsd') : 0;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
for (const field of addBreakdown(total, doc.total)) unknownFields.add(field);
|
|
618
|
+
addBreakdown(unpriced, doc.unpriced);
|
|
619
|
+
if (Array.isArray(doc.unpricedModels)) {
|
|
620
|
+
for (const model of doc.unpricedModels) {
|
|
621
|
+
const name = asString(model);
|
|
622
|
+
if (name !== null) unpricedModels.add(name);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
mergeKeyed(labels, doc.byLabel, (row) => {
|
|
627
|
+
const label = asString(row.label);
|
|
628
|
+
return label === null ? null : { label, model: null };
|
|
629
|
+
});
|
|
630
|
+
mergeKeyed(models, doc.byModel, (row) => {
|
|
631
|
+
const model = asString(row.model);
|
|
632
|
+
return model === null ? null : { label: null, model };
|
|
633
|
+
});
|
|
634
|
+
mergeKeyed(pairs, doc.byLabelAndModel, (row) => {
|
|
635
|
+
const label = asString(row.label);
|
|
636
|
+
const model = asString(row.model);
|
|
637
|
+
return label === null || model === null ? null : { label, model };
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
if (isRecord(doc.fieldCoverage)) {
|
|
641
|
+
const target = coverage as unknown as Record<string, number>;
|
|
642
|
+
for (const field of COVERAGE_FIELDS) {
|
|
643
|
+
target[field] = (target[field] ?? 0) + numberAt(doc.fieldCoverage, field);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (isRecord(doc.outcomeTally)) {
|
|
648
|
+
const tally = doc.outcomeTally;
|
|
649
|
+
outcome.recorded += numberAt(tally, 'recorded');
|
|
650
|
+
outcome.parsed += numberAt(tally, 'parsed');
|
|
651
|
+
outcome.unrecordedUsd += numberAt(tally, 'unrecordedUsd');
|
|
652
|
+
if (Array.isArray(tally.byValue)) {
|
|
653
|
+
for (const row of tally.byValue) {
|
|
654
|
+
if (!isRecord(row)) continue;
|
|
655
|
+
const value = asString(row.value);
|
|
656
|
+
if (value === null) continue;
|
|
657
|
+
const current = outcomeValues.get(value) ?? { calls: 0, usd: 0 };
|
|
658
|
+
current.calls += numberAt(row, 'calls');
|
|
659
|
+
current.usd += numberAt(row, 'usd');
|
|
660
|
+
outcomeValues.set(value, current);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (isRecord(doc.duplicateLines)) {
|
|
666
|
+
duplicateLines.count += numberAt(doc.duplicateLines, 'count');
|
|
667
|
+
duplicateLines.usd += numberAt(doc.duplicateLines, 'usd');
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (Array.isArray(doc.spendByDay)) {
|
|
671
|
+
for (const row of doc.spendByDay) {
|
|
672
|
+
if (!isRecord(row)) continue;
|
|
673
|
+
const day = asString(row.day);
|
|
674
|
+
if (day === null) continue;
|
|
675
|
+
const bucket = days.get(day) ?? {
|
|
676
|
+
usd: 0,
|
|
677
|
+
calls: 0,
|
|
678
|
+
contributors: 0,
|
|
679
|
+
models: new Map<string, { usd: number; calls: number }>(),
|
|
680
|
+
from: new Set<string>(),
|
|
681
|
+
topLabel: null,
|
|
682
|
+
topLabelUsd: null,
|
|
683
|
+
};
|
|
684
|
+
bucket.usd += numberAt(row, 'usd');
|
|
685
|
+
bucket.calls += numberAt(row, 'calls');
|
|
686
|
+
bucket.from.add(input.name);
|
|
687
|
+
// How many *machines* saw this day, not how many documents mentioned
|
|
688
|
+
// it: a nested roll-up already knows its own count, and counting it as
|
|
689
|
+
// one would report twelve machines as three.
|
|
690
|
+
bucket.contributors += Math.max(1, numberAt(row, 'contributors') || 1);
|
|
691
|
+
if (bucket.from.size === 1) {
|
|
692
|
+
bucket.topLabel = asString(row.topLabel);
|
|
693
|
+
bucket.topLabelUsd = bucket.topLabel === null ? null : numberAt(row, 'topLabelUsd');
|
|
694
|
+
} else {
|
|
695
|
+
// A second contributor on this day puts the merged answer out of
|
|
696
|
+
// reach, and the first contributor's answer stops being the
|
|
697
|
+
// roll-up's answer.
|
|
698
|
+
bucket.topLabel = null;
|
|
699
|
+
bucket.topLabelUsd = null;
|
|
700
|
+
cannotSay.add('day-top-label-unknown');
|
|
701
|
+
}
|
|
702
|
+
if (Array.isArray(row.byModel)) {
|
|
703
|
+
for (const entry of row.byModel) {
|
|
704
|
+
if (!isRecord(entry)) continue;
|
|
705
|
+
const name = asString(entry.model);
|
|
706
|
+
if (name === null) continue;
|
|
707
|
+
const current = bucket.models.get(name) ?? { usd: 0, calls: 0 };
|
|
708
|
+
current.usd += numberAt(entry, 'usd');
|
|
709
|
+
current.calls += numberAt(entry, 'calls');
|
|
710
|
+
bucket.models.set(name, current);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
days.set(day, bucket);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
for (const entry of PER_RECORD_FINDINGS) {
|
|
718
|
+
const value = doc[entry.field];
|
|
719
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
720
|
+
const list = findingsPresentIn.get(entry.field) ?? [];
|
|
721
|
+
list.push(input.name);
|
|
722
|
+
findingsPresentIn.set(entry.field, list);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* A roll-up somebody else already made: carry through what does not sum.
|
|
728
|
+
*
|
|
729
|
+
* Everything above this point merged already, because a roll-up names its
|
|
730
|
+
* summable parts exactly the way a profile does. What is left is the half
|
|
731
|
+
* that would quietly disappear a layer at a time — and each of these is a
|
|
732
|
+
* refusal that must survive nesting or the format is worse than no format:
|
|
733
|
+
*
|
|
734
|
+
* - **Contributors are flattened, not collapsed.** Twelve machines stay
|
|
735
|
+
* twelve machines with twelve sets of gaps.
|
|
736
|
+
* - **Rejections travel, with the roll-up they came through.** Otherwise a
|
|
737
|
+
* machine whose document did not conform could be made to disappear by
|
|
738
|
+
* adding a layer.
|
|
739
|
+
* - **Caveats travel.** An inner roll-up that could not see overlap does
|
|
740
|
+
* not become an outer roll-up that could.
|
|
741
|
+
* - **Findings that did not roll up inside do not roll up outside.**
|
|
742
|
+
*/
|
|
743
|
+
if (nested) {
|
|
744
|
+
if (Array.isArray(doc.contributors)) {
|
|
745
|
+
for (const row of doc.contributors) {
|
|
746
|
+
if (!isRecord(row)) continue;
|
|
747
|
+
const name = asString(row.name);
|
|
748
|
+
if (name === null) continue;
|
|
749
|
+
const innerSpan = isRecord(row.span)
|
|
750
|
+
? {
|
|
751
|
+
fromMs: numberAt(row.span, 'fromMs'),
|
|
752
|
+
toMs: numberAt(row.span, 'toMs'),
|
|
753
|
+
calls: numberAt(row.span, 'calls'),
|
|
754
|
+
}
|
|
755
|
+
: null;
|
|
756
|
+
contributors.push({
|
|
757
|
+
name,
|
|
758
|
+
// The roll-up it arrived through — never overwritten when it is
|
|
759
|
+
// already set, so a third layer says which roll-up handed it over
|
|
760
|
+
// rather than claiming the machine came straight from there.
|
|
761
|
+
via: asString(row.via) ?? input.name,
|
|
762
|
+
totalUsd: numberAt(row, 'totalUsd'),
|
|
763
|
+
calls: numberAt(row, 'calls'),
|
|
764
|
+
span: innerSpan,
|
|
765
|
+
spanDays: typeof row.spanDays === 'number' ? row.spanDays : null,
|
|
766
|
+
claimed: isRecord(row.claimed)
|
|
767
|
+
? {
|
|
768
|
+
sinceMs: typeof row.claimed.sinceMs === 'number' ? row.claimed.sinceMs : null,
|
|
769
|
+
untilMs: typeof row.claimed.untilMs === 'number' ? row.claimed.untilMs : null,
|
|
770
|
+
}
|
|
771
|
+
: null,
|
|
772
|
+
silence: isRecord(row.silence)
|
|
773
|
+
? {
|
|
774
|
+
runs: Array.isArray(row.silence.runs) ? (row.silence.runs as SilentRun[]) : [],
|
|
775
|
+
days: numberAt(row.silence, 'days'),
|
|
776
|
+
}
|
|
777
|
+
: null,
|
|
778
|
+
undatedExcluded:
|
|
779
|
+
typeof row.undatedExcluded === 'number' ? row.undatedExcluded : null,
|
|
780
|
+
gaps: Array.isArray(row.gaps) ? (row.gaps as ContributorGap[]) : [],
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (Array.isArray(doc.rejected)) {
|
|
786
|
+
for (const row of doc.rejected) {
|
|
787
|
+
if (!isRecord(row)) continue;
|
|
788
|
+
const name = asString(row.name);
|
|
789
|
+
if (name === null) continue;
|
|
790
|
+
rejected.push({
|
|
791
|
+
name,
|
|
792
|
+
via: asString(row.via) ?? input.name,
|
|
793
|
+
because: asString(row.because) ?? 'no reason travelled with it',
|
|
794
|
+
});
|
|
795
|
+
cannotSay.add('contribution-rejected');
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (Array.isArray(doc.cannotSay)) {
|
|
800
|
+
for (const code of doc.cannotSay) {
|
|
801
|
+
const caveat = asString(code);
|
|
802
|
+
if (caveat !== null) cannotSay.add(caveat as RollupCaveat);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (Array.isArray(doc.notMerged)) {
|
|
807
|
+
for (const row of doc.notMerged) {
|
|
808
|
+
if (!isRecord(row)) continue;
|
|
809
|
+
const finding = asString(row.finding);
|
|
810
|
+
if (finding === null) continue;
|
|
811
|
+
const list = nestedFindings.get(finding) ?? {
|
|
812
|
+
because: asString(row.because) ?? '',
|
|
813
|
+
presentIn: [] as string[],
|
|
814
|
+
};
|
|
815
|
+
if (Array.isArray(row.presentIn)) {
|
|
816
|
+
for (const who of row.presentIn) {
|
|
817
|
+
const named = asString(who);
|
|
818
|
+
if (named !== null && !list.presentIn.includes(named)) list.presentIn.push(named);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
nestedFindings.set(finding, list);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (isRecord(doc.identicalContributions)) {
|
|
826
|
+
identicalUsd += numberAt(doc.identicalContributions, 'usd');
|
|
827
|
+
if (Array.isArray(doc.identicalContributions.groups)) {
|
|
828
|
+
for (const group of doc.identicalContributions.groups) {
|
|
829
|
+
if (Array.isArray(group)) {
|
|
830
|
+
nestedIdentical.push(group.map((name) => String(name)));
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (isRecord(doc.claimedSpan)) {
|
|
837
|
+
const from = numberAt(doc.claimedSpan, 'fromMs');
|
|
838
|
+
const to = numberAt(doc.claimedSpan, 'toMs');
|
|
839
|
+
if (from > 0 && to > from) {
|
|
840
|
+
claimFrom = claimFrom === null ? from : Math.min(claimFrom, from);
|
|
841
|
+
claimTo = claimTo === null ? to : Math.max(claimTo, to);
|
|
842
|
+
claimants += Math.max(1, numberAt(doc.claimedSpan, 'contributors') || 1);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (isRecord(doc.span)) {
|
|
847
|
+
const from = numberAt(doc.span, 'fromMs');
|
|
848
|
+
const to = numberAt(doc.span, 'toMs');
|
|
849
|
+
spanFrom = spanFrom === null ? from : Math.min(spanFrom, from);
|
|
850
|
+
spanTo = spanTo === null ? to : Math.max(spanTo, to);
|
|
851
|
+
spanCalls += numberAt(doc.span, 'calls');
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// --- this contributor, as it will be listed ---------------------------
|
|
858
|
+
const totals = isRecord(doc.total) ? doc.total : {};
|
|
859
|
+
const ownUnpriced = isRecord(doc.unpriced) ? doc.unpriced : {};
|
|
860
|
+
const ownCoverage = isRecord(doc.fieldCoverage) ? doc.fieldCoverage : {};
|
|
861
|
+
const span = isRecord(doc.span)
|
|
862
|
+
? {
|
|
863
|
+
fromMs: numberAt(doc.span, 'fromMs'),
|
|
864
|
+
toMs: numberAt(doc.span, 'toMs'),
|
|
865
|
+
calls: numberAt(doc.span, 'calls'),
|
|
866
|
+
}
|
|
867
|
+
: null;
|
|
868
|
+
|
|
869
|
+
if (span !== null) {
|
|
870
|
+
spanFrom = spanFrom === null ? span.fromMs : Math.min(spanFrom, span.fromMs);
|
|
871
|
+
spanTo = spanTo === null ? span.toMs : Math.max(spanTo, span.toMs);
|
|
872
|
+
spanCalls += span.calls;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const gaps: ContributorGap[] = [];
|
|
876
|
+
const skipped = Array.isArray(doc.skippedLines) ? doc.skippedLines.length : 0;
|
|
877
|
+
if (skipped > 0) {
|
|
878
|
+
gaps.push({
|
|
879
|
+
kind: 'unreadable-lines',
|
|
880
|
+
// The positions are deliberately absent: a line number is an offset
|
|
881
|
+
// into a file only this contributor has, and a merged list of them
|
|
882
|
+
// points at nothing.
|
|
883
|
+
detail: `${skipped} line${skipped === 1 ? '' : 's'} of this contributor's log could not be read`,
|
|
884
|
+
usd: null,
|
|
885
|
+
calls: null,
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
const unpricedCalls = numberAt(ownUnpriced, 'calls');
|
|
889
|
+
if (unpricedCalls > 0) {
|
|
890
|
+
gaps.push({
|
|
891
|
+
kind: 'unpriced-calls',
|
|
892
|
+
detail: `${unpricedCalls} call${unpricedCalls === 1 ? '' : 's'} ran on a model the catalogue does not price`,
|
|
893
|
+
usd: null,
|
|
894
|
+
calls: unpricedCalls,
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
const parsedRecords = numberAt(ownCoverage, 'parsed');
|
|
898
|
+
const dated = numberAt(ownCoverage, 'ts');
|
|
899
|
+
if (span === null) {
|
|
900
|
+
gaps.push({
|
|
901
|
+
kind: 'no-clock',
|
|
902
|
+
detail: 'no record carried a timestamp, so none of this contributor is in any day',
|
|
903
|
+
usd: null,
|
|
904
|
+
calls: null,
|
|
905
|
+
});
|
|
906
|
+
cannotSay.add('contributor-without-clock');
|
|
907
|
+
} else if (parsedRecords > 0 && dated < parsedRecords) {
|
|
908
|
+
gaps.push({
|
|
909
|
+
kind: 'partial-clock',
|
|
910
|
+
detail: `${parsedRecords - dated} of ${parsedRecords} records carried no timestamp`,
|
|
911
|
+
usd: null,
|
|
912
|
+
calls: parsedRecords - dated,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
if (parsedRecords > 0 && numberAt(ownCoverage, 'session') === 0) {
|
|
916
|
+
gaps.push({
|
|
917
|
+
kind: 'no-sessions',
|
|
918
|
+
detail: 'no record carried a session, so this contributor brings no conversation findings',
|
|
919
|
+
usd: null,
|
|
920
|
+
calls: null,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
if (parsedRecords > 0 && numberAt(ownCoverage, 'label') === 0) {
|
|
924
|
+
gaps.push({
|
|
925
|
+
kind: 'no-labels',
|
|
926
|
+
detail:
|
|
927
|
+
'no record carried a label, so this contributor is unlabelled in every per-workload figure',
|
|
928
|
+
usd: null,
|
|
929
|
+
calls: null,
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
if (isRecord(doc.duplicateLines)) {
|
|
933
|
+
const count = numberAt(doc.duplicateLines, 'count');
|
|
934
|
+
if (count > 0) {
|
|
935
|
+
gaps.push({
|
|
936
|
+
kind: 'duplicate-lines',
|
|
937
|
+
detail: `${count} line${count === 1 ? '' : 's'} of this contributor's log repeated an earlier line exactly`,
|
|
938
|
+
usd: numberAt(doc.duplicateLines, 'usd'),
|
|
939
|
+
calls: count,
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* The window this contributor asked for, if it asked for one.
|
|
946
|
+
*
|
|
947
|
+
* `timeWindow` is present in a profile document only when the profile was
|
|
948
|
+
* run with `--since` or `--until`, so its absence is the ordinary case and
|
|
949
|
+
* is an answer rather than a defect: nobody claimed a period, and the span
|
|
950
|
+
* is all that is known.
|
|
951
|
+
*/
|
|
952
|
+
const window = isRecord(doc.timeWindow) ? doc.timeWindow : null;
|
|
953
|
+
const claimed =
|
|
954
|
+
window === null
|
|
955
|
+
? null
|
|
956
|
+
: {
|
|
957
|
+
sinceMs: typeof window.sinceMs === 'number' ? window.sinceMs : null,
|
|
958
|
+
untilMs: typeof window.untilMs === 'number' ? window.untilMs : null,
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
if (claimed === null) {
|
|
962
|
+
cannotSay.add('no-claimed-period');
|
|
963
|
+
} else if (claimed.sinceMs === null || claimed.untilMs === null) {
|
|
964
|
+
cannotSay.add('claim-not-bounded');
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** The days this contributor itself put spend on — its own bucketing. */
|
|
968
|
+
const ownDays = new Set<string>();
|
|
969
|
+
if (Array.isArray(doc.spendByDay)) {
|
|
970
|
+
for (const row of doc.spendByDay) {
|
|
971
|
+
if (!isRecord(row)) continue;
|
|
972
|
+
const day = asString(row.day);
|
|
973
|
+
if (day !== null) ownDays.add(day);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const silence = claimed === null ? null : silenceIn(claimed, ownDays);
|
|
978
|
+
if (
|
|
979
|
+
claimed !== null &&
|
|
980
|
+
claimed.sinceMs !== null &&
|
|
981
|
+
claimed.untilMs !== null &&
|
|
982
|
+
silence === null
|
|
983
|
+
) {
|
|
984
|
+
// Bounded, in order, and still not walked: it was longer than the bound.
|
|
985
|
+
cannotSay.add('claim-too-long-to-enumerate');
|
|
986
|
+
}
|
|
987
|
+
if (silence !== null && silence.days > 0) {
|
|
988
|
+
cannotSay.add('silence-inside-a-claim');
|
|
989
|
+
gaps.push({
|
|
990
|
+
kind: 'silent-days',
|
|
991
|
+
detail: `${silence.days} day${silence.days === 1 ? '' : 's'} inside the window this contributor asked for recorded nothing`,
|
|
992
|
+
usd: null,
|
|
993
|
+
calls: null,
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
if (claimed?.sinceMs != null && claimed.untilMs != null) {
|
|
998
|
+
claimFrom = claimFrom === null ? claimed.sinceMs : Math.min(claimFrom, claimed.sinceMs);
|
|
999
|
+
claimTo = claimTo === null ? claimed.untilMs : Math.max(claimTo, claimed.untilMs);
|
|
1000
|
+
claimants += 1;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
contributors.push({
|
|
1004
|
+
name: input.name,
|
|
1005
|
+
via: null,
|
|
1006
|
+
totalUsd: numberAt(totals, 'totalUsd'),
|
|
1007
|
+
calls: numberAt(totals, 'calls'),
|
|
1008
|
+
span,
|
|
1009
|
+
spanDays: span === null ? null : (span.toMs - span.fromMs) / 86_400_000,
|
|
1010
|
+
claimed,
|
|
1011
|
+
silence,
|
|
1012
|
+
undatedExcluded: window === null ? null : numberAt(window, 'undatedExcluded'),
|
|
1013
|
+
gaps,
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
if (contributors.length > 1) cannotSay.add('overlap-invisible');
|
|
1018
|
+
if (unknownFields.size > 0) cannotSay.add('unknown-fields-dropped');
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Mismatched spans, on the rule `fleetRollup` already uses: more than a day
|
|
1022
|
+
* apart in length, or one contributor with a clock beside one without. A
|
|
1023
|
+
* share of a sum stays valid either way; reading it as a comparison of
|
|
1024
|
+
* *rates* is the mistake this names.
|
|
1025
|
+
*/
|
|
1026
|
+
const spanLengths = contributors.map((contributor) => contributor.spanDays);
|
|
1027
|
+
const known = spanLengths.filter((days_): days_ is number => days_ !== null);
|
|
1028
|
+
if (
|
|
1029
|
+
(spanLengths.some((length) => length === null) && known.length > 0) ||
|
|
1030
|
+
(known.length > 1 && Math.max(...known) - Math.min(...known) > 1)
|
|
1031
|
+
) {
|
|
1032
|
+
cannotSay.add('mismatched-spans');
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Contributor names that turn up more than once.
|
|
1037
|
+
*
|
|
1038
|
+
* Handing over both a roll-up and one of the machines inside it counts that
|
|
1039
|
+
* machine's money twice — and unlike the identical-document check, this one
|
|
1040
|
+
* can see it: the two documents differ, but the name does not. Named and
|
|
1041
|
+
* never subtracted, because two teams genuinely running `api.json` is
|
|
1042
|
+
* possible and removing money to decide between them is the repair this tool
|
|
1043
|
+
* does not make.
|
|
1044
|
+
*/
|
|
1045
|
+
const timesSeen = new Map<string, number>();
|
|
1046
|
+
for (const contributor of contributors) {
|
|
1047
|
+
timesSeen.set(contributor.name, (timesSeen.get(contributor.name) ?? 0) + 1);
|
|
1048
|
+
}
|
|
1049
|
+
const repeatedContributors = [...timesSeen.entries()]
|
|
1050
|
+
.filter(([, times]) => times > 1)
|
|
1051
|
+
.map(([name]) => name)
|
|
1052
|
+
.sort();
|
|
1053
|
+
if (repeatedContributors.length > 0) cannotSay.add('contributor-named-twice');
|
|
1054
|
+
|
|
1055
|
+
const notMerged: UnmergedFinding[] = PER_RECORD_FINDINGS.filter((entry) =>
|
|
1056
|
+
findingsPresentIn.has(entry.field),
|
|
1057
|
+
).map((entry) => ({
|
|
1058
|
+
finding: entry.finding,
|
|
1059
|
+
because: entry.because,
|
|
1060
|
+
presentIn: findingsPresentIn.get(entry.field) ?? [],
|
|
1061
|
+
}));
|
|
1062
|
+
|
|
1063
|
+
if (cannotSay.has('day-top-label-unknown')) {
|
|
1064
|
+
const shared = [...days.values()].filter((bucket) => bucket.from.size > 1);
|
|
1065
|
+
notMerged.push({
|
|
1066
|
+
finding: "a day's dearest label",
|
|
1067
|
+
because:
|
|
1068
|
+
'each contributor knows its own, and the merged answer needs the per-label-per-day spend that no document carries',
|
|
1069
|
+
presentIn: [...new Set(shared.flatMap((bucket) => [...bucket.from]))].sort(),
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Findings an inner roll-up already refused to merge do not become mergeable
|
|
1074
|
+
// by being handed on. Merged by their own text so the same finding from two
|
|
1075
|
+
// roll-ups is one entry naming both sets of contributors.
|
|
1076
|
+
for (const [finding, entry] of nestedFindings) {
|
|
1077
|
+
const existing = notMerged.find((row) => row.finding === finding);
|
|
1078
|
+
if (existing === undefined) {
|
|
1079
|
+
notMerged.push({ finding, because: entry.because, presentIn: [...entry.presentIn].sort() });
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
for (const who of entry.presentIn) {
|
|
1083
|
+
if (!existing.presentIn.includes(who)) existing.presentIn.push(who);
|
|
1084
|
+
}
|
|
1085
|
+
existing.presentIn.sort();
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
if (unknownFields.size > 0) {
|
|
1089
|
+
notMerged.push({
|
|
1090
|
+
finding: `numeric fields this version cannot combine: ${[...unknownFields].sort().join(', ')}`,
|
|
1091
|
+
because:
|
|
1092
|
+
'a field added after this roll-up was written may be a sum, a maximum or a ratio, and combining it the wrong way is worse than leaving it out',
|
|
1093
|
+
presentIn: [],
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
return {
|
|
1098
|
+
schemaVersion: 1,
|
|
1099
|
+
contributors,
|
|
1100
|
+
rejected,
|
|
1101
|
+
identicalContributions: {
|
|
1102
|
+
groups: [...[...seenText.values()].filter((names) => names.length > 1), ...nestedIdentical],
|
|
1103
|
+
usd: identicalUsd,
|
|
1104
|
+
},
|
|
1105
|
+
total,
|
|
1106
|
+
unpriced,
|
|
1107
|
+
unpricedModels: [...unpricedModels].sort(),
|
|
1108
|
+
byLabel: byMoney(
|
|
1109
|
+
[...labels.values()].map((slice) => ({ label: slice.label ?? '', breakdown: slice.breakdown })),
|
|
1110
|
+
(row) => row.breakdown.totalUsd,
|
|
1111
|
+
),
|
|
1112
|
+
byModel: byMoney(
|
|
1113
|
+
[...models.values()].map((slice) => ({ model: slice.model ?? '', breakdown: slice.breakdown })),
|
|
1114
|
+
(row) => row.breakdown.totalUsd,
|
|
1115
|
+
),
|
|
1116
|
+
byLabelAndModel: byMoney(
|
|
1117
|
+
[...pairs.values()].map((slice) => ({
|
|
1118
|
+
label: slice.label ?? '',
|
|
1119
|
+
model: slice.model ?? '',
|
|
1120
|
+
breakdown: slice.breakdown,
|
|
1121
|
+
})),
|
|
1122
|
+
(row) => row.breakdown.totalUsd,
|
|
1123
|
+
),
|
|
1124
|
+
spendByDay: [...days.entries()]
|
|
1125
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
1126
|
+
.map(([day, bucket]) => ({
|
|
1127
|
+
day,
|
|
1128
|
+
usd: bucket.usd,
|
|
1129
|
+
calls: bucket.calls,
|
|
1130
|
+
contributors: bucket.contributors,
|
|
1131
|
+
byModel: byMoney(
|
|
1132
|
+
[...bucket.models.entries()].map(([model, figures]) => ({ model, ...figures })),
|
|
1133
|
+
(row) => row.usd,
|
|
1134
|
+
),
|
|
1135
|
+
topLabel: bucket.topLabel,
|
|
1136
|
+
topLabelUsd: bucket.topLabelUsd,
|
|
1137
|
+
})),
|
|
1138
|
+
span:
|
|
1139
|
+
spanFrom === null || spanTo === null ? null : { fromMs: spanFrom, toMs: spanTo, calls: spanCalls },
|
|
1140
|
+
claimedSpan:
|
|
1141
|
+
claimFrom === null || claimTo === null
|
|
1142
|
+
? null
|
|
1143
|
+
: { fromMs: claimFrom, toMs: claimTo, contributors: claimants },
|
|
1144
|
+
fieldCoverage: coverage,
|
|
1145
|
+
outcomeTally: {
|
|
1146
|
+
byValue: byMoney(
|
|
1147
|
+
[...outcomeValues.entries()].map(([value, figures]) => ({ value, ...figures })),
|
|
1148
|
+
(row) => row.usd,
|
|
1149
|
+
),
|
|
1150
|
+
recorded: outcome.recorded,
|
|
1151
|
+
parsed: outcome.parsed,
|
|
1152
|
+
unrecordedUsd: outcome.unrecordedUsd,
|
|
1153
|
+
},
|
|
1154
|
+
duplicateLines,
|
|
1155
|
+
repeatedContributors,
|
|
1156
|
+
notMerged,
|
|
1157
|
+
cannotSay: [...cannotSay].sort(),
|
|
1158
|
+
};
|
|
1159
|
+
}
|