@trazum/core 1.53.4 → 1.56.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/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
+ }