@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/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