@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/dist/rollup.js
ADDED
|
@@ -0,0 +1,844 @@
|
|
|
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
|
+
import { conform } from './conform.js';
|
|
43
|
+
/**
|
|
44
|
+
* Every numeric field of a breakdown, and how two of them combine.
|
|
45
|
+
*
|
|
46
|
+
* Listed rather than inferred, because both mistakes here are silent. A field
|
|
47
|
+
* left out of both lists would vanish from every merged breakdown — a finding
|
|
48
|
+
* present in each contribution and absent from the roll-up. And
|
|
49
|
+
* `maxCallInputTokens` summed would report a fleet whose largest call is the
|
|
50
|
+
* sum of four machines' largest calls, which is a number no call ever had, in
|
|
51
|
+
* the direction that makes a context window look tight.
|
|
52
|
+
*
|
|
53
|
+
* `rollup.test.js` derives the field names from `usage.ts` and fails the build
|
|
54
|
+
* when one is in neither list, so the next field added upstream cannot be
|
|
55
|
+
* quietly dropped here.
|
|
56
|
+
*/
|
|
57
|
+
const BREAKDOWN_SUM = [
|
|
58
|
+
'calls',
|
|
59
|
+
'inputTokens',
|
|
60
|
+
'cacheReadTokens',
|
|
61
|
+
'cacheWriteTokens',
|
|
62
|
+
'cacheWrite5mTokens',
|
|
63
|
+
'cacheWrite1hTokens',
|
|
64
|
+
'outputTokens',
|
|
65
|
+
'assumedWriteTtlCalls',
|
|
66
|
+
'inputUsd',
|
|
67
|
+
'cacheReadUsd',
|
|
68
|
+
'cacheWriteUsd',
|
|
69
|
+
'outputUsd',
|
|
70
|
+
'totalUsd',
|
|
71
|
+
'cachedTokensAtInputRateUsd',
|
|
72
|
+
'cacheWriteUsdIfAssumed1h',
|
|
73
|
+
'truncatedCalls',
|
|
74
|
+
'truncatedOutputUsd',
|
|
75
|
+
'stopReasonCalls',
|
|
76
|
+
];
|
|
77
|
+
/** Fields whose combination is a maximum, because a sum would invent a call. */
|
|
78
|
+
const BREAKDOWN_MAX = ['maxCallInputTokens'];
|
|
79
|
+
/** Every counter of a coverage tally. All of them sum. */
|
|
80
|
+
const COVERAGE_FIELDS = [
|
|
81
|
+
'label',
|
|
82
|
+
'session',
|
|
83
|
+
'outcome',
|
|
84
|
+
'ts',
|
|
85
|
+
'stopReason',
|
|
86
|
+
'cacheTtl',
|
|
87
|
+
'cacheWrites',
|
|
88
|
+
'parsed',
|
|
89
|
+
];
|
|
90
|
+
const emptyBreakdown = () => {
|
|
91
|
+
const out = {};
|
|
92
|
+
for (const field of BREAKDOWN_SUM)
|
|
93
|
+
out[field] = 0;
|
|
94
|
+
for (const field of BREAKDOWN_MAX)
|
|
95
|
+
out[field] = 0;
|
|
96
|
+
return out;
|
|
97
|
+
};
|
|
98
|
+
const emptyCoverage = () => {
|
|
99
|
+
const out = {};
|
|
100
|
+
for (const field of COVERAGE_FIELDS)
|
|
101
|
+
out[field] = 0;
|
|
102
|
+
return out;
|
|
103
|
+
};
|
|
104
|
+
const numberAt = (source, field) => {
|
|
105
|
+
const value = source[field];
|
|
106
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
107
|
+
};
|
|
108
|
+
const asString = (value) => typeof value === 'string' && value !== '' ? value : null;
|
|
109
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
110
|
+
/**
|
|
111
|
+
* Adds one contribution's breakdown into an accumulator.
|
|
112
|
+
*
|
|
113
|
+
* Returns the numeric fields it did not recognise, so a document from a newer
|
|
114
|
+
* Trazum is reported rather than half-merged. Guessing that an unknown number
|
|
115
|
+
* is additive is how a ratio becomes four times itself.
|
|
116
|
+
*/
|
|
117
|
+
function addBreakdown(into, from) {
|
|
118
|
+
if (!isRecord(from))
|
|
119
|
+
return [];
|
|
120
|
+
const target = into;
|
|
121
|
+
for (const field of BREAKDOWN_SUM)
|
|
122
|
+
target[field] = (target[field] ?? 0) + numberAt(from, field);
|
|
123
|
+
for (const field of BREAKDOWN_MAX) {
|
|
124
|
+
target[field] = Math.max(target[field] ?? 0, numberAt(from, field));
|
|
125
|
+
}
|
|
126
|
+
const known = new Set([...BREAKDOWN_SUM, ...BREAKDOWN_MAX]);
|
|
127
|
+
return Object.keys(from).filter((key) => !known.has(key) && typeof from[key] === 'number');
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Merges keyed breakdowns — labels, models, or the pair — into one map.
|
|
131
|
+
*
|
|
132
|
+
* The identity travels in the value rather than being parsed back out of the
|
|
133
|
+
* key: a label may contain any character, including whatever separator a key
|
|
134
|
+
* would join on, and a pair key split on a separator the label also uses
|
|
135
|
+
* silently merges two different workloads.
|
|
136
|
+
*/
|
|
137
|
+
function mergeKeyed(into, rows, identityOf) {
|
|
138
|
+
if (!Array.isArray(rows))
|
|
139
|
+
return;
|
|
140
|
+
for (const row of rows) {
|
|
141
|
+
if (!isRecord(row))
|
|
142
|
+
continue;
|
|
143
|
+
const identity = identityOf(row);
|
|
144
|
+
if (identity === null)
|
|
145
|
+
continue;
|
|
146
|
+
const key = JSON.stringify([identity.label, identity.model]);
|
|
147
|
+
const slice = into.get(key) ?? { ...identity, breakdown: emptyBreakdown() };
|
|
148
|
+
addBreakdown(slice.breakdown, row.breakdown);
|
|
149
|
+
into.set(key, slice);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** Largest bill first, the order somebody would act in. */
|
|
153
|
+
const byMoney = (rows, usd) => [...rows].sort((a, b) => usd(b) - usd(a));
|
|
154
|
+
const DAY_MS = 86_400_000;
|
|
155
|
+
/** UTC `YYYY-MM-DD` for an instant — the bucketing every day figure here uses. */
|
|
156
|
+
const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
157
|
+
/** Midnight UTC of the day an instant falls in. */
|
|
158
|
+
const midnightOf = (ms) => Date.parse(`${dayOf(ms)}T00:00:00.000Z`);
|
|
159
|
+
/**
|
|
160
|
+
* The longest claim this module will walk day by day: ten years.
|
|
161
|
+
*
|
|
162
|
+
* A bound rather than trust, because these documents come from elsewhere. A
|
|
163
|
+
* contribution claiming `untilMs: 1e15` is a malformed document, not a team
|
|
164
|
+
* with a long memory, and enumerating it would be thirty million iterations
|
|
165
|
+
* inside a merge somebody ran on four files. The claim is kept and reported;
|
|
166
|
+
* only the enumeration is refused, with a caveat saying so — a refusal never
|
|
167
|
+
* arrives bare.
|
|
168
|
+
*/
|
|
169
|
+
const MAX_CLAIM_DAYS = 3660;
|
|
170
|
+
/**
|
|
171
|
+
* The stretches of a claimed window on which nothing was recorded.
|
|
172
|
+
*
|
|
173
|
+
* Returns `null` when the claim cannot be walked: unbounded on either end, out
|
|
174
|
+
* of order, or longer than the bound above. The window is half-open, so the
|
|
175
|
+
* last claimed day is the one containing `untilMs - 1` — an `until` of
|
|
176
|
+
* midnight claims up to the previous day and not that day, which is how the
|
|
177
|
+
* profile's own filter reads it.
|
|
178
|
+
*/
|
|
179
|
+
function silenceIn(claim, daysWithTraffic) {
|
|
180
|
+
const { sinceMs, untilMs } = claim;
|
|
181
|
+
if (sinceMs === null || untilMs === null)
|
|
182
|
+
return null;
|
|
183
|
+
if (!Number.isFinite(sinceMs) || !Number.isFinite(untilMs))
|
|
184
|
+
return null;
|
|
185
|
+
if (untilMs <= sinceMs)
|
|
186
|
+
return null;
|
|
187
|
+
const first = midnightOf(sinceMs);
|
|
188
|
+
const last = midnightOf(untilMs - 1);
|
|
189
|
+
const span = Math.round((last - first) / DAY_MS) + 1;
|
|
190
|
+
if (span > MAX_CLAIM_DAYS)
|
|
191
|
+
return null;
|
|
192
|
+
const runs = [];
|
|
193
|
+
let open = null;
|
|
194
|
+
let days = 0;
|
|
195
|
+
for (let at = first; at <= last; at += DAY_MS) {
|
|
196
|
+
const day = dayOf(at);
|
|
197
|
+
if (daysWithTraffic.has(day)) {
|
|
198
|
+
if (open !== null)
|
|
199
|
+
runs.push(open);
|
|
200
|
+
open = null;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
days += 1;
|
|
204
|
+
if (open === null)
|
|
205
|
+
open = { from: day, to: day, days: 1 };
|
|
206
|
+
else {
|
|
207
|
+
open.to = day;
|
|
208
|
+
open.days += 1;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (open !== null)
|
|
212
|
+
runs.push(open);
|
|
213
|
+
return { runs, days };
|
|
214
|
+
}
|
|
215
|
+
/** The findings a document carries per call, which no summary can reconstruct. */
|
|
216
|
+
const PER_RECORD_FINDINGS = [
|
|
217
|
+
{
|
|
218
|
+
field: 'conversations',
|
|
219
|
+
finding: 'conversation growth',
|
|
220
|
+
because: 'it is measured over the turns of one session, and a document carries the growth rather than the turns',
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
field: 'inputShapes',
|
|
224
|
+
finding: 'input shape — the median and p95 call',
|
|
225
|
+
because: 'a percentile of two contributors is not a percentile of their percentiles, and the calls behind them are in neither document',
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
field: 'outputShapes',
|
|
229
|
+
finding: 'output concentration',
|
|
230
|
+
because: 'it asks what share of the calls holds what share of the output spend, and both shares need the individual calls',
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
field: 'repeatedTurns',
|
|
234
|
+
finding: 'repeated turns — the shape of a retry loop',
|
|
235
|
+
because: 'it compares consecutive calls in one session seconds apart, and consecutive calls are what a summary has already collapsed',
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
field: 'truncationRetries',
|
|
239
|
+
finding: 'truncation followed by a retry',
|
|
240
|
+
because: 'it pairs a truncated answer with the call that followed it, and a document carries neither call',
|
|
241
|
+
},
|
|
242
|
+
];
|
|
243
|
+
/**
|
|
244
|
+
* Merges profile documents into one roll-up.
|
|
245
|
+
*
|
|
246
|
+
* Every input is checked against the `profile` contract before a figure of it
|
|
247
|
+
* is used. A document that does not conform is **rejected with its reason and
|
|
248
|
+
* not merged** — a roll-up that quietly skipped a malformed contribution would
|
|
249
|
+
* report a total missing one machine's entire bill, and the reader would have
|
|
250
|
+
* no way to tell that from that machine having spent nothing.
|
|
251
|
+
*/
|
|
252
|
+
export function rollUp(inputs) {
|
|
253
|
+
const contributors = [];
|
|
254
|
+
const rejected = [];
|
|
255
|
+
const cannotSay = new Set();
|
|
256
|
+
const total = emptyBreakdown();
|
|
257
|
+
const unpriced = emptyBreakdown();
|
|
258
|
+
const unpricedModels = new Set();
|
|
259
|
+
const labels = new Map();
|
|
260
|
+
const models = new Map();
|
|
261
|
+
const pairs = new Map();
|
|
262
|
+
const coverage = emptyCoverage();
|
|
263
|
+
const outcomeValues = new Map();
|
|
264
|
+
const outcome = { recorded: 0, parsed: 0, unrecordedUsd: 0 };
|
|
265
|
+
const duplicateLines = { count: 0, usd: 0 };
|
|
266
|
+
const unknownFields = new Set();
|
|
267
|
+
const days = new Map();
|
|
268
|
+
const findingsPresentIn = new Map();
|
|
269
|
+
/** Findings a nested roll-up already refused to merge, carried through. */
|
|
270
|
+
const nestedFindings = new Map();
|
|
271
|
+
/** Identical-document groups a nested roll-up already found. */
|
|
272
|
+
const nestedIdentical = [];
|
|
273
|
+
/** The text of each accepted contribution, for the identical-document check. */
|
|
274
|
+
const seenText = new Map();
|
|
275
|
+
let identicalUsd = 0;
|
|
276
|
+
let claimFrom = null;
|
|
277
|
+
let claimTo = null;
|
|
278
|
+
let claimants = 0;
|
|
279
|
+
let spanFrom = null;
|
|
280
|
+
let spanTo = null;
|
|
281
|
+
let spanCalls = 0;
|
|
282
|
+
for (const input of inputs) {
|
|
283
|
+
/**
|
|
284
|
+
* A profile, or a roll-up somebody else already made.
|
|
285
|
+
*
|
|
286
|
+
* Detected rather than forced, because a roll-up is a contribution too:
|
|
287
|
+
* three teams roll up their own machines, and the organisation rolls up
|
|
288
|
+
* the three. Every summable part of a roll-up carries the same field names
|
|
289
|
+
* as a profile — that is what makes the nesting arithmetic free — and the
|
|
290
|
+
* parts that are *not* summable are exactly the ones this loop has to
|
|
291
|
+
* carry through by hand rather than lose a layer at a time.
|
|
292
|
+
*/
|
|
293
|
+
const checked = conform(input.text);
|
|
294
|
+
const nested = checked.contract === 'roll-up';
|
|
295
|
+
if (!checked.conforms || (checked.contract !== 'profile' && !nested)) {
|
|
296
|
+
const first = checked.problems[0];
|
|
297
|
+
rejected.push({
|
|
298
|
+
name: input.name,
|
|
299
|
+
via: null,
|
|
300
|
+
because: first === undefined
|
|
301
|
+
? (checked.because ?? 'it is neither a profile nor a roll-up')
|
|
302
|
+
: `${first.at}: ${first.detail}`,
|
|
303
|
+
});
|
|
304
|
+
cannotSay.add('contribution-rejected');
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
// `conform` parsed it once already and reported nothing, so this cannot
|
|
308
|
+
// throw — but a parse whose failure mode is a crash is a parse worth
|
|
309
|
+
// guarding, and the reject path above is already the right answer.
|
|
310
|
+
let parsed;
|
|
311
|
+
try {
|
|
312
|
+
parsed = JSON.parse(input.text.trim());
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
rejected.push({ name: input.name, via: null, because: 'it is not valid JSON' });
|
|
316
|
+
cannotSay.add('contribution-rejected');
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (!isRecord(parsed)) {
|
|
320
|
+
rejected.push({ name: input.name, via: null, because: 'a document must be a JSON object' });
|
|
321
|
+
cannotSay.add('contribution-rejected');
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const doc = parsed;
|
|
325
|
+
const text = input.text.trim();
|
|
326
|
+
const sameAs = seenText.get(text);
|
|
327
|
+
if (sameAs === undefined) {
|
|
328
|
+
seenText.set(text, [input.name]);
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
sameAs.push(input.name);
|
|
332
|
+
cannotSay.add('identical-contributions');
|
|
333
|
+
identicalUsd += isRecord(doc.total) ? numberAt(doc.total, 'totalUsd') : 0;
|
|
334
|
+
}
|
|
335
|
+
for (const field of addBreakdown(total, doc.total))
|
|
336
|
+
unknownFields.add(field);
|
|
337
|
+
addBreakdown(unpriced, doc.unpriced);
|
|
338
|
+
if (Array.isArray(doc.unpricedModels)) {
|
|
339
|
+
for (const model of doc.unpricedModels) {
|
|
340
|
+
const name = asString(model);
|
|
341
|
+
if (name !== null)
|
|
342
|
+
unpricedModels.add(name);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
mergeKeyed(labels, doc.byLabel, (row) => {
|
|
346
|
+
const label = asString(row.label);
|
|
347
|
+
return label === null ? null : { label, model: null };
|
|
348
|
+
});
|
|
349
|
+
mergeKeyed(models, doc.byModel, (row) => {
|
|
350
|
+
const model = asString(row.model);
|
|
351
|
+
return model === null ? null : { label: null, model };
|
|
352
|
+
});
|
|
353
|
+
mergeKeyed(pairs, doc.byLabelAndModel, (row) => {
|
|
354
|
+
const label = asString(row.label);
|
|
355
|
+
const model = asString(row.model);
|
|
356
|
+
return label === null || model === null ? null : { label, model };
|
|
357
|
+
});
|
|
358
|
+
if (isRecord(doc.fieldCoverage)) {
|
|
359
|
+
const target = coverage;
|
|
360
|
+
for (const field of COVERAGE_FIELDS) {
|
|
361
|
+
target[field] = (target[field] ?? 0) + numberAt(doc.fieldCoverage, field);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (isRecord(doc.outcomeTally)) {
|
|
365
|
+
const tally = doc.outcomeTally;
|
|
366
|
+
outcome.recorded += numberAt(tally, 'recorded');
|
|
367
|
+
outcome.parsed += numberAt(tally, 'parsed');
|
|
368
|
+
outcome.unrecordedUsd += numberAt(tally, 'unrecordedUsd');
|
|
369
|
+
if (Array.isArray(tally.byValue)) {
|
|
370
|
+
for (const row of tally.byValue) {
|
|
371
|
+
if (!isRecord(row))
|
|
372
|
+
continue;
|
|
373
|
+
const value = asString(row.value);
|
|
374
|
+
if (value === null)
|
|
375
|
+
continue;
|
|
376
|
+
const current = outcomeValues.get(value) ?? { calls: 0, usd: 0 };
|
|
377
|
+
current.calls += numberAt(row, 'calls');
|
|
378
|
+
current.usd += numberAt(row, 'usd');
|
|
379
|
+
outcomeValues.set(value, current);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (isRecord(doc.duplicateLines)) {
|
|
384
|
+
duplicateLines.count += numberAt(doc.duplicateLines, 'count');
|
|
385
|
+
duplicateLines.usd += numberAt(doc.duplicateLines, 'usd');
|
|
386
|
+
}
|
|
387
|
+
if (Array.isArray(doc.spendByDay)) {
|
|
388
|
+
for (const row of doc.spendByDay) {
|
|
389
|
+
if (!isRecord(row))
|
|
390
|
+
continue;
|
|
391
|
+
const day = asString(row.day);
|
|
392
|
+
if (day === null)
|
|
393
|
+
continue;
|
|
394
|
+
const bucket = days.get(day) ?? {
|
|
395
|
+
usd: 0,
|
|
396
|
+
calls: 0,
|
|
397
|
+
contributors: 0,
|
|
398
|
+
models: new Map(),
|
|
399
|
+
from: new Set(),
|
|
400
|
+
topLabel: null,
|
|
401
|
+
topLabelUsd: null,
|
|
402
|
+
};
|
|
403
|
+
bucket.usd += numberAt(row, 'usd');
|
|
404
|
+
bucket.calls += numberAt(row, 'calls');
|
|
405
|
+
bucket.from.add(input.name);
|
|
406
|
+
// How many *machines* saw this day, not how many documents mentioned
|
|
407
|
+
// it: a nested roll-up already knows its own count, and counting it as
|
|
408
|
+
// one would report twelve machines as three.
|
|
409
|
+
bucket.contributors += Math.max(1, numberAt(row, 'contributors') || 1);
|
|
410
|
+
if (bucket.from.size === 1) {
|
|
411
|
+
bucket.topLabel = asString(row.topLabel);
|
|
412
|
+
bucket.topLabelUsd = bucket.topLabel === null ? null : numberAt(row, 'topLabelUsd');
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
// A second contributor on this day puts the merged answer out of
|
|
416
|
+
// reach, and the first contributor's answer stops being the
|
|
417
|
+
// roll-up's answer.
|
|
418
|
+
bucket.topLabel = null;
|
|
419
|
+
bucket.topLabelUsd = null;
|
|
420
|
+
cannotSay.add('day-top-label-unknown');
|
|
421
|
+
}
|
|
422
|
+
if (Array.isArray(row.byModel)) {
|
|
423
|
+
for (const entry of row.byModel) {
|
|
424
|
+
if (!isRecord(entry))
|
|
425
|
+
continue;
|
|
426
|
+
const name = asString(entry.model);
|
|
427
|
+
if (name === null)
|
|
428
|
+
continue;
|
|
429
|
+
const current = bucket.models.get(name) ?? { usd: 0, calls: 0 };
|
|
430
|
+
current.usd += numberAt(entry, 'usd');
|
|
431
|
+
current.calls += numberAt(entry, 'calls');
|
|
432
|
+
bucket.models.set(name, current);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
days.set(day, bucket);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
for (const entry of PER_RECORD_FINDINGS) {
|
|
439
|
+
const value = doc[entry.field];
|
|
440
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
441
|
+
const list = findingsPresentIn.get(entry.field) ?? [];
|
|
442
|
+
list.push(input.name);
|
|
443
|
+
findingsPresentIn.set(entry.field, list);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* A roll-up somebody else already made: carry through what does not sum.
|
|
448
|
+
*
|
|
449
|
+
* Everything above this point merged already, because a roll-up names its
|
|
450
|
+
* summable parts exactly the way a profile does. What is left is the half
|
|
451
|
+
* that would quietly disappear a layer at a time — and each of these is a
|
|
452
|
+
* refusal that must survive nesting or the format is worse than no format:
|
|
453
|
+
*
|
|
454
|
+
* - **Contributors are flattened, not collapsed.** Twelve machines stay
|
|
455
|
+
* twelve machines with twelve sets of gaps.
|
|
456
|
+
* - **Rejections travel, with the roll-up they came through.** Otherwise a
|
|
457
|
+
* machine whose document did not conform could be made to disappear by
|
|
458
|
+
* adding a layer.
|
|
459
|
+
* - **Caveats travel.** An inner roll-up that could not see overlap does
|
|
460
|
+
* not become an outer roll-up that could.
|
|
461
|
+
* - **Findings that did not roll up inside do not roll up outside.**
|
|
462
|
+
*/
|
|
463
|
+
if (nested) {
|
|
464
|
+
if (Array.isArray(doc.contributors)) {
|
|
465
|
+
for (const row of doc.contributors) {
|
|
466
|
+
if (!isRecord(row))
|
|
467
|
+
continue;
|
|
468
|
+
const name = asString(row.name);
|
|
469
|
+
if (name === null)
|
|
470
|
+
continue;
|
|
471
|
+
const innerSpan = isRecord(row.span)
|
|
472
|
+
? {
|
|
473
|
+
fromMs: numberAt(row.span, 'fromMs'),
|
|
474
|
+
toMs: numberAt(row.span, 'toMs'),
|
|
475
|
+
calls: numberAt(row.span, 'calls'),
|
|
476
|
+
}
|
|
477
|
+
: null;
|
|
478
|
+
contributors.push({
|
|
479
|
+
name,
|
|
480
|
+
// The roll-up it arrived through — never overwritten when it is
|
|
481
|
+
// already set, so a third layer says which roll-up handed it over
|
|
482
|
+
// rather than claiming the machine came straight from there.
|
|
483
|
+
via: asString(row.via) ?? input.name,
|
|
484
|
+
totalUsd: numberAt(row, 'totalUsd'),
|
|
485
|
+
calls: numberAt(row, 'calls'),
|
|
486
|
+
span: innerSpan,
|
|
487
|
+
spanDays: typeof row.spanDays === 'number' ? row.spanDays : null,
|
|
488
|
+
claimed: isRecord(row.claimed)
|
|
489
|
+
? {
|
|
490
|
+
sinceMs: typeof row.claimed.sinceMs === 'number' ? row.claimed.sinceMs : null,
|
|
491
|
+
untilMs: typeof row.claimed.untilMs === 'number' ? row.claimed.untilMs : null,
|
|
492
|
+
}
|
|
493
|
+
: null,
|
|
494
|
+
silence: isRecord(row.silence)
|
|
495
|
+
? {
|
|
496
|
+
runs: Array.isArray(row.silence.runs) ? row.silence.runs : [],
|
|
497
|
+
days: numberAt(row.silence, 'days'),
|
|
498
|
+
}
|
|
499
|
+
: null,
|
|
500
|
+
undatedExcluded: typeof row.undatedExcluded === 'number' ? row.undatedExcluded : null,
|
|
501
|
+
gaps: Array.isArray(row.gaps) ? row.gaps : [],
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (Array.isArray(doc.rejected)) {
|
|
506
|
+
for (const row of doc.rejected) {
|
|
507
|
+
if (!isRecord(row))
|
|
508
|
+
continue;
|
|
509
|
+
const name = asString(row.name);
|
|
510
|
+
if (name === null)
|
|
511
|
+
continue;
|
|
512
|
+
rejected.push({
|
|
513
|
+
name,
|
|
514
|
+
via: asString(row.via) ?? input.name,
|
|
515
|
+
because: asString(row.because) ?? 'no reason travelled with it',
|
|
516
|
+
});
|
|
517
|
+
cannotSay.add('contribution-rejected');
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (Array.isArray(doc.cannotSay)) {
|
|
521
|
+
for (const code of doc.cannotSay) {
|
|
522
|
+
const caveat = asString(code);
|
|
523
|
+
if (caveat !== null)
|
|
524
|
+
cannotSay.add(caveat);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (Array.isArray(doc.notMerged)) {
|
|
528
|
+
for (const row of doc.notMerged) {
|
|
529
|
+
if (!isRecord(row))
|
|
530
|
+
continue;
|
|
531
|
+
const finding = asString(row.finding);
|
|
532
|
+
if (finding === null)
|
|
533
|
+
continue;
|
|
534
|
+
const list = nestedFindings.get(finding) ?? {
|
|
535
|
+
because: asString(row.because) ?? '',
|
|
536
|
+
presentIn: [],
|
|
537
|
+
};
|
|
538
|
+
if (Array.isArray(row.presentIn)) {
|
|
539
|
+
for (const who of row.presentIn) {
|
|
540
|
+
const named = asString(who);
|
|
541
|
+
if (named !== null && !list.presentIn.includes(named))
|
|
542
|
+
list.presentIn.push(named);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
nestedFindings.set(finding, list);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (isRecord(doc.identicalContributions)) {
|
|
549
|
+
identicalUsd += numberAt(doc.identicalContributions, 'usd');
|
|
550
|
+
if (Array.isArray(doc.identicalContributions.groups)) {
|
|
551
|
+
for (const group of doc.identicalContributions.groups) {
|
|
552
|
+
if (Array.isArray(group)) {
|
|
553
|
+
nestedIdentical.push(group.map((name) => String(name)));
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (isRecord(doc.claimedSpan)) {
|
|
559
|
+
const from = numberAt(doc.claimedSpan, 'fromMs');
|
|
560
|
+
const to = numberAt(doc.claimedSpan, 'toMs');
|
|
561
|
+
if (from > 0 && to > from) {
|
|
562
|
+
claimFrom = claimFrom === null ? from : Math.min(claimFrom, from);
|
|
563
|
+
claimTo = claimTo === null ? to : Math.max(claimTo, to);
|
|
564
|
+
claimants += Math.max(1, numberAt(doc.claimedSpan, 'contributors') || 1);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (isRecord(doc.span)) {
|
|
568
|
+
const from = numberAt(doc.span, 'fromMs');
|
|
569
|
+
const to = numberAt(doc.span, 'toMs');
|
|
570
|
+
spanFrom = spanFrom === null ? from : Math.min(spanFrom, from);
|
|
571
|
+
spanTo = spanTo === null ? to : Math.max(spanTo, to);
|
|
572
|
+
spanCalls += numberAt(doc.span, 'calls');
|
|
573
|
+
}
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
// --- this contributor, as it will be listed ---------------------------
|
|
577
|
+
const totals = isRecord(doc.total) ? doc.total : {};
|
|
578
|
+
const ownUnpriced = isRecord(doc.unpriced) ? doc.unpriced : {};
|
|
579
|
+
const ownCoverage = isRecord(doc.fieldCoverage) ? doc.fieldCoverage : {};
|
|
580
|
+
const span = isRecord(doc.span)
|
|
581
|
+
? {
|
|
582
|
+
fromMs: numberAt(doc.span, 'fromMs'),
|
|
583
|
+
toMs: numberAt(doc.span, 'toMs'),
|
|
584
|
+
calls: numberAt(doc.span, 'calls'),
|
|
585
|
+
}
|
|
586
|
+
: null;
|
|
587
|
+
if (span !== null) {
|
|
588
|
+
spanFrom = spanFrom === null ? span.fromMs : Math.min(spanFrom, span.fromMs);
|
|
589
|
+
spanTo = spanTo === null ? span.toMs : Math.max(spanTo, span.toMs);
|
|
590
|
+
spanCalls += span.calls;
|
|
591
|
+
}
|
|
592
|
+
const gaps = [];
|
|
593
|
+
const skipped = Array.isArray(doc.skippedLines) ? doc.skippedLines.length : 0;
|
|
594
|
+
if (skipped > 0) {
|
|
595
|
+
gaps.push({
|
|
596
|
+
kind: 'unreadable-lines',
|
|
597
|
+
// The positions are deliberately absent: a line number is an offset
|
|
598
|
+
// into a file only this contributor has, and a merged list of them
|
|
599
|
+
// points at nothing.
|
|
600
|
+
detail: `${skipped} line${skipped === 1 ? '' : 's'} of this contributor's log could not be read`,
|
|
601
|
+
usd: null,
|
|
602
|
+
calls: null,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
const unpricedCalls = numberAt(ownUnpriced, 'calls');
|
|
606
|
+
if (unpricedCalls > 0) {
|
|
607
|
+
gaps.push({
|
|
608
|
+
kind: 'unpriced-calls',
|
|
609
|
+
detail: `${unpricedCalls} call${unpricedCalls === 1 ? '' : 's'} ran on a model the catalogue does not price`,
|
|
610
|
+
usd: null,
|
|
611
|
+
calls: unpricedCalls,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
const parsedRecords = numberAt(ownCoverage, 'parsed');
|
|
615
|
+
const dated = numberAt(ownCoverage, 'ts');
|
|
616
|
+
if (span === null) {
|
|
617
|
+
gaps.push({
|
|
618
|
+
kind: 'no-clock',
|
|
619
|
+
detail: 'no record carried a timestamp, so none of this contributor is in any day',
|
|
620
|
+
usd: null,
|
|
621
|
+
calls: null,
|
|
622
|
+
});
|
|
623
|
+
cannotSay.add('contributor-without-clock');
|
|
624
|
+
}
|
|
625
|
+
else if (parsedRecords > 0 && dated < parsedRecords) {
|
|
626
|
+
gaps.push({
|
|
627
|
+
kind: 'partial-clock',
|
|
628
|
+
detail: `${parsedRecords - dated} of ${parsedRecords} records carried no timestamp`,
|
|
629
|
+
usd: null,
|
|
630
|
+
calls: parsedRecords - dated,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
if (parsedRecords > 0 && numberAt(ownCoverage, 'session') === 0) {
|
|
634
|
+
gaps.push({
|
|
635
|
+
kind: 'no-sessions',
|
|
636
|
+
detail: 'no record carried a session, so this contributor brings no conversation findings',
|
|
637
|
+
usd: null,
|
|
638
|
+
calls: null,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
if (parsedRecords > 0 && numberAt(ownCoverage, 'label') === 0) {
|
|
642
|
+
gaps.push({
|
|
643
|
+
kind: 'no-labels',
|
|
644
|
+
detail: 'no record carried a label, so this contributor is unlabelled in every per-workload figure',
|
|
645
|
+
usd: null,
|
|
646
|
+
calls: null,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
if (isRecord(doc.duplicateLines)) {
|
|
650
|
+
const count = numberAt(doc.duplicateLines, 'count');
|
|
651
|
+
if (count > 0) {
|
|
652
|
+
gaps.push({
|
|
653
|
+
kind: 'duplicate-lines',
|
|
654
|
+
detail: `${count} line${count === 1 ? '' : 's'} of this contributor's log repeated an earlier line exactly`,
|
|
655
|
+
usd: numberAt(doc.duplicateLines, 'usd'),
|
|
656
|
+
calls: count,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* The window this contributor asked for, if it asked for one.
|
|
662
|
+
*
|
|
663
|
+
* `timeWindow` is present in a profile document only when the profile was
|
|
664
|
+
* run with `--since` or `--until`, so its absence is the ordinary case and
|
|
665
|
+
* is an answer rather than a defect: nobody claimed a period, and the span
|
|
666
|
+
* is all that is known.
|
|
667
|
+
*/
|
|
668
|
+
const window = isRecord(doc.timeWindow) ? doc.timeWindow : null;
|
|
669
|
+
const claimed = window === null
|
|
670
|
+
? null
|
|
671
|
+
: {
|
|
672
|
+
sinceMs: typeof window.sinceMs === 'number' ? window.sinceMs : null,
|
|
673
|
+
untilMs: typeof window.untilMs === 'number' ? window.untilMs : null,
|
|
674
|
+
};
|
|
675
|
+
if (claimed === null) {
|
|
676
|
+
cannotSay.add('no-claimed-period');
|
|
677
|
+
}
|
|
678
|
+
else if (claimed.sinceMs === null || claimed.untilMs === null) {
|
|
679
|
+
cannotSay.add('claim-not-bounded');
|
|
680
|
+
}
|
|
681
|
+
/** The days this contributor itself put spend on — its own bucketing. */
|
|
682
|
+
const ownDays = new Set();
|
|
683
|
+
if (Array.isArray(doc.spendByDay)) {
|
|
684
|
+
for (const row of doc.spendByDay) {
|
|
685
|
+
if (!isRecord(row))
|
|
686
|
+
continue;
|
|
687
|
+
const day = asString(row.day);
|
|
688
|
+
if (day !== null)
|
|
689
|
+
ownDays.add(day);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
const silence = claimed === null ? null : silenceIn(claimed, ownDays);
|
|
693
|
+
if (claimed !== null &&
|
|
694
|
+
claimed.sinceMs !== null &&
|
|
695
|
+
claimed.untilMs !== null &&
|
|
696
|
+
silence === null) {
|
|
697
|
+
// Bounded, in order, and still not walked: it was longer than the bound.
|
|
698
|
+
cannotSay.add('claim-too-long-to-enumerate');
|
|
699
|
+
}
|
|
700
|
+
if (silence !== null && silence.days > 0) {
|
|
701
|
+
cannotSay.add('silence-inside-a-claim');
|
|
702
|
+
gaps.push({
|
|
703
|
+
kind: 'silent-days',
|
|
704
|
+
detail: `${silence.days} day${silence.days === 1 ? '' : 's'} inside the window this contributor asked for recorded nothing`,
|
|
705
|
+
usd: null,
|
|
706
|
+
calls: null,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
if (claimed?.sinceMs != null && claimed.untilMs != null) {
|
|
710
|
+
claimFrom = claimFrom === null ? claimed.sinceMs : Math.min(claimFrom, claimed.sinceMs);
|
|
711
|
+
claimTo = claimTo === null ? claimed.untilMs : Math.max(claimTo, claimed.untilMs);
|
|
712
|
+
claimants += 1;
|
|
713
|
+
}
|
|
714
|
+
contributors.push({
|
|
715
|
+
name: input.name,
|
|
716
|
+
via: null,
|
|
717
|
+
totalUsd: numberAt(totals, 'totalUsd'),
|
|
718
|
+
calls: numberAt(totals, 'calls'),
|
|
719
|
+
span,
|
|
720
|
+
spanDays: span === null ? null : (span.toMs - span.fromMs) / 86_400_000,
|
|
721
|
+
claimed,
|
|
722
|
+
silence,
|
|
723
|
+
undatedExcluded: window === null ? null : numberAt(window, 'undatedExcluded'),
|
|
724
|
+
gaps,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
if (contributors.length > 1)
|
|
728
|
+
cannotSay.add('overlap-invisible');
|
|
729
|
+
if (unknownFields.size > 0)
|
|
730
|
+
cannotSay.add('unknown-fields-dropped');
|
|
731
|
+
/**
|
|
732
|
+
* Mismatched spans, on the rule `fleetRollup` already uses: more than a day
|
|
733
|
+
* apart in length, or one contributor with a clock beside one without. A
|
|
734
|
+
* share of a sum stays valid either way; reading it as a comparison of
|
|
735
|
+
* *rates* is the mistake this names.
|
|
736
|
+
*/
|
|
737
|
+
const spanLengths = contributors.map((contributor) => contributor.spanDays);
|
|
738
|
+
const known = spanLengths.filter((days_) => days_ !== null);
|
|
739
|
+
if ((spanLengths.some((length) => length === null) && known.length > 0) ||
|
|
740
|
+
(known.length > 1 && Math.max(...known) - Math.min(...known) > 1)) {
|
|
741
|
+
cannotSay.add('mismatched-spans');
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Contributor names that turn up more than once.
|
|
745
|
+
*
|
|
746
|
+
* Handing over both a roll-up and one of the machines inside it counts that
|
|
747
|
+
* machine's money twice — and unlike the identical-document check, this one
|
|
748
|
+
* can see it: the two documents differ, but the name does not. Named and
|
|
749
|
+
* never subtracted, because two teams genuinely running `api.json` is
|
|
750
|
+
* possible and removing money to decide between them is the repair this tool
|
|
751
|
+
* does not make.
|
|
752
|
+
*/
|
|
753
|
+
const timesSeen = new Map();
|
|
754
|
+
for (const contributor of contributors) {
|
|
755
|
+
timesSeen.set(contributor.name, (timesSeen.get(contributor.name) ?? 0) + 1);
|
|
756
|
+
}
|
|
757
|
+
const repeatedContributors = [...timesSeen.entries()]
|
|
758
|
+
.filter(([, times]) => times > 1)
|
|
759
|
+
.map(([name]) => name)
|
|
760
|
+
.sort();
|
|
761
|
+
if (repeatedContributors.length > 0)
|
|
762
|
+
cannotSay.add('contributor-named-twice');
|
|
763
|
+
const notMerged = PER_RECORD_FINDINGS.filter((entry) => findingsPresentIn.has(entry.field)).map((entry) => ({
|
|
764
|
+
finding: entry.finding,
|
|
765
|
+
because: entry.because,
|
|
766
|
+
presentIn: findingsPresentIn.get(entry.field) ?? [],
|
|
767
|
+
}));
|
|
768
|
+
if (cannotSay.has('day-top-label-unknown')) {
|
|
769
|
+
const shared = [...days.values()].filter((bucket) => bucket.from.size > 1);
|
|
770
|
+
notMerged.push({
|
|
771
|
+
finding: "a day's dearest label",
|
|
772
|
+
because: 'each contributor knows its own, and the merged answer needs the per-label-per-day spend that no document carries',
|
|
773
|
+
presentIn: [...new Set(shared.flatMap((bucket) => [...bucket.from]))].sort(),
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
// Findings an inner roll-up already refused to merge do not become mergeable
|
|
777
|
+
// by being handed on. Merged by their own text so the same finding from two
|
|
778
|
+
// roll-ups is one entry naming both sets of contributors.
|
|
779
|
+
for (const [finding, entry] of nestedFindings) {
|
|
780
|
+
const existing = notMerged.find((row) => row.finding === finding);
|
|
781
|
+
if (existing === undefined) {
|
|
782
|
+
notMerged.push({ finding, because: entry.because, presentIn: [...entry.presentIn].sort() });
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
for (const who of entry.presentIn) {
|
|
786
|
+
if (!existing.presentIn.includes(who))
|
|
787
|
+
existing.presentIn.push(who);
|
|
788
|
+
}
|
|
789
|
+
existing.presentIn.sort();
|
|
790
|
+
}
|
|
791
|
+
if (unknownFields.size > 0) {
|
|
792
|
+
notMerged.push({
|
|
793
|
+
finding: `numeric fields this version cannot combine: ${[...unknownFields].sort().join(', ')}`,
|
|
794
|
+
because: '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',
|
|
795
|
+
presentIn: [],
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
return {
|
|
799
|
+
schemaVersion: 1,
|
|
800
|
+
contributors,
|
|
801
|
+
rejected,
|
|
802
|
+
identicalContributions: {
|
|
803
|
+
groups: [...[...seenText.values()].filter((names) => names.length > 1), ...nestedIdentical],
|
|
804
|
+
usd: identicalUsd,
|
|
805
|
+
},
|
|
806
|
+
total,
|
|
807
|
+
unpriced,
|
|
808
|
+
unpricedModels: [...unpricedModels].sort(),
|
|
809
|
+
byLabel: byMoney([...labels.values()].map((slice) => ({ label: slice.label ?? '', breakdown: slice.breakdown })), (row) => row.breakdown.totalUsd),
|
|
810
|
+
byModel: byMoney([...models.values()].map((slice) => ({ model: slice.model ?? '', breakdown: slice.breakdown })), (row) => row.breakdown.totalUsd),
|
|
811
|
+
byLabelAndModel: byMoney([...pairs.values()].map((slice) => ({
|
|
812
|
+
label: slice.label ?? '',
|
|
813
|
+
model: slice.model ?? '',
|
|
814
|
+
breakdown: slice.breakdown,
|
|
815
|
+
})), (row) => row.breakdown.totalUsd),
|
|
816
|
+
spendByDay: [...days.entries()]
|
|
817
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
818
|
+
.map(([day, bucket]) => ({
|
|
819
|
+
day,
|
|
820
|
+
usd: bucket.usd,
|
|
821
|
+
calls: bucket.calls,
|
|
822
|
+
contributors: bucket.contributors,
|
|
823
|
+
byModel: byMoney([...bucket.models.entries()].map(([model, figures]) => ({ model, ...figures })), (row) => row.usd),
|
|
824
|
+
topLabel: bucket.topLabel,
|
|
825
|
+
topLabelUsd: bucket.topLabelUsd,
|
|
826
|
+
})),
|
|
827
|
+
span: spanFrom === null || spanTo === null ? null : { fromMs: spanFrom, toMs: spanTo, calls: spanCalls },
|
|
828
|
+
claimedSpan: claimFrom === null || claimTo === null
|
|
829
|
+
? null
|
|
830
|
+
: { fromMs: claimFrom, toMs: claimTo, contributors: claimants },
|
|
831
|
+
fieldCoverage: coverage,
|
|
832
|
+
outcomeTally: {
|
|
833
|
+
byValue: byMoney([...outcomeValues.entries()].map(([value, figures]) => ({ value, ...figures })), (row) => row.usd),
|
|
834
|
+
recorded: outcome.recorded,
|
|
835
|
+
parsed: outcome.parsed,
|
|
836
|
+
unrecordedUsd: outcome.unrecordedUsd,
|
|
837
|
+
},
|
|
838
|
+
duplicateLines,
|
|
839
|
+
repeatedContributors,
|
|
840
|
+
notMerged,
|
|
841
|
+
cannotSay: [...cannotSay].sort(),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
//# sourceMappingURL=rollup.js.map
|