tledger 0.3.1 → 0.4.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.
@@ -1,13 +1,25 @@
1
1
  import {
2
- creditsForUsage,
3
- FAST_MODE_MULTIPLIER,
4
- RATE_CARD_AS_OF,
5
- } from "./token-ledger-rates.mjs";
2
+ calculateCodexPurchasedCredits,
3
+ CODEX_CREDIT_RATE_CARD_AS_OF,
4
+ } from "../lib/token-ledger-rates.mjs";
6
5
  import {
7
- splitUsageBucketsAtBoundaries,
8
- usageBuckets,
9
- usageBucketsInRange,
6
+ MAX_SAFE_TOKEN_COUNT,
7
+ checkedFiniteAdd,
8
+ checkedTokenAdd,
9
+ tokenValue,
10
10
  } from "../lib/token-ledger-usage.mjs";
11
+ import {
12
+ createTimeZoneFormatter,
13
+ localDateBoundary,
14
+ localDateString,
15
+ shiftCalendarDate,
16
+ todayInTimeZone,
17
+ } from "../lib/token-ledger-calendar.mjs";
18
+ import { buildRangeAnalysis as buildIndexedRangeAnalysis } from "../lib/token-ledger-range-analysis.mjs";
19
+ import {
20
+ quotaIdentityMatchesContract,
21
+ snapshotHasCurrentQuotaIdentityContract,
22
+ } from "../lib/token-ledger-quota-contract.mjs";
11
23
 
12
24
  const WEEK_MINUTES = 10_080;
13
25
  const RESET_JITTER_SECONDS = 5 * 60;
@@ -17,96 +29,41 @@ const MAX_TREND_DAYS = 3_650;
17
29
  const LONG_GAP_MS = 36 * 60 * 60 * 1_000;
18
30
 
19
31
  const MODEL_SORT_ORDER = new Map([
20
- ["Luna", 0],
21
- ["Sol", 1],
22
- ["Terra", 2],
23
- ["GPT-5.5", 3],
24
- ["GPT-5.4", 4],
25
- ["Daybreak", 5],
26
- ["Auto review", 6],
27
- ["Other", 7],
28
- ["Unknown", 8],
29
- ["Unattributed", 9],
32
+ ["Astra", 0],
33
+ ["Luna", 1],
34
+ ["Sol", 2],
35
+ ["Terra", 3],
36
+ ["GPT-5.5", 4],
37
+ ["GPT-5.4", 5],
38
+ ["Daybreak", 6],
39
+ ["Auto review", 7],
40
+ ["Other", 8],
41
+ ["Unknown", 9],
42
+ ["Unattributed", 10],
30
43
  ]);
31
44
 
32
45
  function finiteTimestamp(value) {
33
- const timestamp = new Date(value).getTime();
46
+ if (typeof value !== "string" && !Number.isFinite(value)) return null;
47
+ const timestamp = typeof value === "string"
48
+ ? Date.parse(value)
49
+ : new Date(value).getTime();
34
50
  return Number.isFinite(timestamp) ? timestamp : null;
35
51
  }
36
52
 
37
- function dateStringFromParts(parts) {
38
- return [parts.year, parts.month, parts.day]
39
- .map((value, index) =>
40
- index === 0 ? String(value) : String(value).padStart(2, "0"),
41
- )
42
- .join("-");
43
- }
44
-
45
- function shiftCalendarDate(dateString, amount) {
46
- const [year, month, day] = dateString.split("-").map(Number);
47
- const date = new Date(Date.UTC(year, month - 1, day + amount));
48
- return dateStringFromParts({
49
- year: date.getUTCFullYear(),
50
- month: date.getUTCMonth() + 1,
51
- day: date.getUTCDate(),
52
- });
53
- }
54
-
55
- function offsetAt(instant, timeZone) {
56
- const parts = new Intl.DateTimeFormat("en-US", {
57
- timeZone,
58
- timeZoneName: "longOffset",
59
- }).formatToParts(instant);
60
- const value = parts.find((part) => part.type === "timeZoneName")?.value ?? "GMT";
61
- if (value === "GMT") return 0;
62
- const match = value.match(/^GMT([+-])(\d{2}):?(\d{2})?$/);
63
- if (!match) return 0;
64
- const minutes = Number(match[2]) * 60 + Number(match[3] || 0);
65
- return (match[1] === "+" ? 1 : -1) * minutes * 60 * 1_000;
66
- }
67
-
68
- function zonedMidnight(dateString, timeZone) {
69
- const [year, month, day] = dateString.split("-").map(Number);
70
- const utcGuess = Date.UTC(year, month - 1, day);
71
- let instant = new Date(utcGuess - offsetAt(new Date(utcGuess), timeZone));
72
- instant = new Date(utcGuess - offsetAt(instant, timeZone));
73
- return instant;
74
- }
75
-
76
- function localDateString(timestampMs, timeZone) {
77
- return new Intl.DateTimeFormat("en-CA", {
78
- timeZone,
79
- year: "numeric",
80
- month: "2-digit",
81
- day: "2-digit",
82
- }).format(new Date(timestampMs));
83
- }
84
-
85
- function todayInTimeZone(timeZone) {
86
- const parts = new Intl.DateTimeFormat("en-US", {
87
- timeZone,
88
- year: "numeric",
89
- month: "2-digit",
90
- day: "2-digit",
91
- }).formatToParts(new Date());
92
- const values = Object.fromEntries(
93
- parts
94
- .filter((part) => part.type !== "literal")
95
- .map((part) => [part.type, Number(part.value)]),
96
- );
97
- return dateStringFromParts(values);
98
- }
99
-
100
53
  export function multiDayBounds(value, timeZone, rangeDays) {
101
54
  const days = Number(rangeDays);
102
55
  if (!Number.isSafeInteger(days) || days < 1 || days > MAX_TREND_DAYS) {
103
56
  throw new Error(`Trend range must be between 1 and ${MAX_TREND_DAYS} days.`);
104
57
  }
58
+ const formatter = createTimeZoneFormatter(timeZone);
105
59
  let endDateString = value;
106
60
  if (!endDateString || endDateString === "today") {
107
- endDateString = todayInTimeZone(timeZone);
61
+ endDateString = todayInTimeZone(timeZone, formatter);
108
62
  } else if (endDateString === "yesterday") {
109
- endDateString = shiftCalendarDate(todayInTimeZone(timeZone), -1);
63
+ endDateString = shiftCalendarDate(
64
+ todayInTimeZone(timeZone, formatter),
65
+ -1,
66
+ );
110
67
  }
111
68
  if (!/^\d{4}-\d{2}-\d{2}$/.test(endDateString)) {
112
69
  throw new Error("Trend end date must be YYYY-MM-DD, today, or yesterday.");
@@ -125,19 +82,28 @@ export function multiDayBounds(value, timeZone, rangeDays) {
125
82
  dateString: endDateString,
126
83
  startDateString,
127
84
  endDateString,
128
- start: zonedMidnight(startDateString, timeZone),
129
- end: zonedMidnight(shiftCalendarDate(endDateString, 1), timeZone),
85
+ start: localDateBoundary(startDateString, timeZone, formatter),
86
+ end: localDateBoundary(
87
+ shiftCalendarDate(endDateString, 1),
88
+ timeZone,
89
+ formatter,
90
+ ),
130
91
  timeZone,
131
92
  rangeDays: days,
132
93
  };
133
94
  }
134
95
 
135
- function clampPercent(value) {
136
- return Math.min(100, Math.max(0, Number(value) || 0));
96
+ export function priorPeriodBounds(bounds, days = bounds.rangeDays) {
97
+ return multiDayBounds(
98
+ shiftCalendarDate(bounds.startDateString, -1),
99
+ bounds.timeZone,
100
+ days,
101
+ );
137
102
  }
138
103
 
139
104
  export function trendModelLabel(value) {
140
105
  const model = String(value || "unknown").trim().toLowerCase();
106
+ if (model.includes("astra")) return "Astra";
141
107
  if (model.includes("luna")) return "Luna";
142
108
  if (model.includes("sol")) return "Sol";
143
109
  if (model.includes("terra")) return "Terra";
@@ -150,7 +116,19 @@ export function trendModelLabel(value) {
150
116
  }
151
117
 
152
118
  export function weeklyQuotaObservations(snapshot = {}) {
153
- let observations = (snapshot.quotaObservations ?? [])
119
+ if (!snapshotHasCurrentQuotaIdentityContract(snapshot)) return [];
120
+ if (!Array.isArray(snapshot.quotaObservations)) return [];
121
+ let observations = snapshot.quotaObservations
122
+ .filter((observation) =>
123
+ observation !== null &&
124
+ typeof observation === "object" &&
125
+ observation.windowMinutes === WEEK_MINUTES &&
126
+ Number.isFinite(observation.resetsAt) &&
127
+ observation.resetsAt > 0 &&
128
+ Number.isFinite(observation.usedPercent) &&
129
+ observation.usedPercent >= 0 &&
130
+ observation.usedPercent <= 100,
131
+ )
154
132
  .map((observation) => {
155
133
  const timestampMs = finiteTimestamp(observation.timestamp);
156
134
  const lastSeenAtMs = finiteTimestamp(observation.lastSeenAt);
@@ -161,52 +139,17 @@ export function weeklyQuotaObservations(snapshot = {}) {
161
139
  timestampMs === null
162
140
  ? lastSeenAtMs
163
141
  : Math.max(timestampMs, lastSeenAtMs ?? timestampMs),
164
- resetsAt: Number(observation.resetsAt),
165
- usedPercent: Number(observation.usedPercent),
166
142
  };
167
143
  })
168
- .filter(
169
- (observation) =>
170
- Number(observation.windowMinutes) === WEEK_MINUTES &&
171
- observation.timestampMs !== null &&
172
- Number.isFinite(observation.resetsAt) &&
173
- observation.resetsAt > 0 &&
174
- Number.isFinite(observation.usedPercent),
175
- )
176
- .map((observation) => ({
177
- ...observation,
178
- usedPercent: clampPercent(observation.usedPercent),
179
- }));
180
-
181
- // Keep exactly one meter: the account-wide weekly limit. Legacy snapshots
182
- // tag it with scope: "account"; current snapshots carry a limitKey per
183
- // limit bucket, where the account-wide bucket has no limitName. Named
184
- // buckets (per-model limit pools) are separate meters and must not be
185
- // stitched into this line.
186
- const accountScoped = observations.filter(
187
- (observation) => observation.scope === "account",
144
+ .filter((observation) => observation.timestampMs !== null);
145
+
146
+ // Keep exactly one meter: only explicit provider-derived account scope is
147
+ // authoritative. A missing display label does not prove account scope.
148
+ observations = observations.filter(
149
+ (observation) =>
150
+ observation.scope === "account" &&
151
+ quotaIdentityMatchesContract(observation),
188
152
  );
189
- if (accountScoped.length) {
190
- observations = accountScoped;
191
- } else if (observations.some((observation) => observation.limitKey)) {
192
- const groups = new Map();
193
- for (const observation of observations) {
194
- const key = observation.limitKey ?? "anonymous";
195
- const group = groups.get(key) ?? [];
196
- group.push(observation);
197
- groups.set(key, group);
198
- }
199
- const accountWide = [...groups.values()].filter((group) =>
200
- group.every((observation) => !observation.limitName),
201
- );
202
- const pool = accountWide.length ? accountWide : [...groups.values()];
203
- observations = pool.sort((left, right) => right.length - left.length)[0];
204
- } else {
205
- const accountWide = observations.filter(
206
- (observation) => !observation.limitName,
207
- );
208
- if (accountWide.length) observations = accountWide;
209
- }
210
153
 
211
154
  observations.sort(
212
155
  (left, right) =>
@@ -222,6 +165,13 @@ export function weeklyQuotaObservations(snapshot = {}) {
222
165
  // stale readings from sessions still reporting a superseded window would
223
166
  // otherwise be fused into one line, producing meter drain that never happened.
224
167
  export function normalizeQuotaTimeline(observations) {
168
+ // Some source logs stamp new quota payloads with an old session timestamp.
169
+ // A weekly reading cannot precede its own window. Such an anchor would
170
+ // reorder entire epochs and suppress otherwise valid subsequent readings.
171
+ observations = observations.filter((observation) =>
172
+ observation.timestampMs >=
173
+ (observation.resetsAt - WEEK_MINUTES * 60 - RESET_JITTER_SECONDS) * 1_000,
174
+ );
225
175
  if (!observations.length) return [];
226
176
 
227
177
  const epochs = [];
@@ -305,46 +255,53 @@ export function normalizeQuotaTimeline(observations) {
305
255
  }
306
256
 
307
257
  export function eventCredits(event) {
308
- // Recompute from token components first so the current rate card applies;
309
- // snapshots can carry credits stored under an outdated card. Fast-mode
310
- // turns (service tier "priority") debit the limit at a higher rate.
311
- const multiplier =
312
- event.serviceTier === "priority" ? FAST_MODE_MULTIPLIER : 1;
313
- const computed = creditsForUsage(event.model, event);
314
- if (Number.isFinite(computed) && computed >= 0) return computed * multiplier;
315
- const stored = Number(event.rateCardCredits);
316
- if (event.rateCardCredits !== null && event.rateCardCredits !== undefined) {
317
- // Stored credits from current snapshots already include the fast-mode
318
- // multiplier.
319
- if (Number.isFinite(stored) && stored >= 0) return stored;
320
- }
321
- return null;
258
+ // Always recompute with the current purchased-credit card. Snapshot values
259
+ // may have been stored under an older card and must not fill a current gap.
260
+ return calculateCodexPurchasedCredits({
261
+ model: event.rateCardModel ?? event.model,
262
+ serviceTier: event.serviceTier,
263
+ usage: event,
264
+ });
322
265
  }
323
266
 
324
267
  function eventWeight(event, fallbackCreditsPerToken) {
325
268
  const credits = eventCredits(event);
326
269
  if (Number.isFinite(credits) && credits > 0) return credits;
327
- const tokens = Math.max(0, Number(event.totalTokens) || 0);
270
+ const tokens = tokenValue(event.totalTokens, {
271
+ allowFractional: event.rangeAllocationEstimated === true,
272
+ });
328
273
  return fallbackCreditsPerToken > 0 ? tokens * fallbackCreditsPerToken : tokens;
329
274
  }
330
275
 
276
+ function addRatedTotals(totals, credits, tokens) {
277
+ const scaledTokens = tokens / totals.scale;
278
+ const scaledCredits = credits / totals.scale;
279
+ const nextTokens = totals.tokens + scaledTokens;
280
+ const nextCredits = checkedFiniteAdd(totals.credits, scaledCredits);
281
+ const scaleFactor = Math.max(1, nextTokens / MAX_SAFE_TOKEN_COUNT);
282
+ totals.tokens = nextTokens / scaleFactor;
283
+ totals.credits = nextCredits / scaleFactor;
284
+ totals.scale *= scaleFactor;
285
+ }
286
+
331
287
  function allocateBurn(delta, events, timeZone) {
332
288
  if (!(delta > 0)) return { contributions: new Map(), method: "none" };
333
289
 
334
- let ratedCredits = 0;
335
- let ratedTokens = 0;
290
+ const ratedTotals = { credits: 0, tokens: 0, scale: 1 };
336
291
  let hasUnrated = false;
337
292
  for (const event of events) {
293
+ if (event?.invalidTokenRecord === true) continue;
338
294
  const credits = eventCredits(event);
339
- const tokens = Math.max(0, Number(event.totalTokens) || 0);
295
+ const allowFractional = event.rangeAllocationEstimated === true;
296
+ const tokens = tokenValue(event.totalTokens, { allowFractional });
340
297
  if (Number.isFinite(credits) && credits > 0) {
341
- ratedCredits += credits;
342
- ratedTokens += tokens;
298
+ addRatedTotals(ratedTotals, credits, tokens);
343
299
  } else if (tokens > 0) {
344
300
  hasUnrated = true;
345
301
  }
346
302
  }
347
303
 
304
+ const { credits: ratedCredits, tokens: ratedTokens } = ratedTotals;
348
305
  const fallbackCreditsPerToken =
349
306
  ratedCredits > 0 && ratedTokens > 0 ? ratedCredits / ratedTokens : 0;
350
307
  const weights = new Map();
@@ -354,12 +311,12 @@ function allocateBurn(delta, events, timeZone) {
354
311
  const weight = eventWeight(event, fallbackCreditsPerToken);
355
312
  if (!(weight > 0)) continue;
356
313
  const model = trendModelLabel(event.model);
357
- weights.set(model, (weights.get(model) ?? 0) + weight);
314
+ weights.set(model, checkedFiniteAdd(weights.get(model) ?? 0, weight));
358
315
  if (timeZone) {
359
316
  const day = localDateString(event.timestampMs, timeZone);
360
- dayWeights.set(day, (dayWeights.get(day) ?? 0) + weight);
317
+ dayWeights.set(day, checkedFiniteAdd(dayWeights.get(day) ?? 0, weight));
361
318
  }
362
- totalWeight += weight;
319
+ totalWeight = checkedFiniteAdd(totalWeight, weight);
363
320
  }
364
321
 
365
322
  if (!(totalWeight > 0)) {
@@ -391,19 +348,24 @@ function allocateBurn(delta, events, timeZone) {
391
348
  }
392
349
 
393
350
  // Fractions of the span [startMs, endMs) falling on each local calendar day.
394
- function durationDayShares(startMs, endMs, timeZone) {
351
+ export function durationDayShares(startMs, endMs, timeZone) {
352
+ const formatter = createTimeZoneFormatter(timeZone);
395
353
  if (!(endMs > startMs)) {
396
- return new Map([[localDateString(endMs, timeZone), 1]]);
354
+ return new Map([[localDateString(endMs, timeZone, formatter), 1]]);
397
355
  }
398
356
  const shares = new Map();
399
357
  let cursor = startMs;
400
358
  while (cursor < endMs) {
401
- const day = localDateString(cursor, timeZone);
402
- const nextMidnightMs = zonedMidnight(
359
+ const day = localDateString(cursor, timeZone, formatter);
360
+ const nextBoundaryMs = localDateBoundary(
403
361
  shiftCalendarDate(day, 1),
404
362
  timeZone,
363
+ formatter,
405
364
  ).getTime();
406
- const sliceEnd = Math.min(endMs, Math.max(nextMidnightMs, cursor + 1));
365
+ if (!(nextBoundaryMs > cursor)) {
366
+ throw new Error(`Local calendar boundary did not advance after ${day}.`);
367
+ }
368
+ const sliceEnd = Math.min(endMs, nextBoundaryMs);
407
369
  shares.set(day, (shares.get(day) ?? 0) + (sliceEnd - cursor));
408
370
  cursor = sliceEnd;
409
371
  }
@@ -413,11 +375,27 @@ function durationDayShares(startMs, endMs, timeZone) {
413
375
 
414
376
  function tokenTotalsByModel(events) {
415
377
  const totals = new Map();
378
+ let scale = 1;
379
+ let totalTokens = 0;
416
380
  for (const event of events) {
417
- const tokens = Math.max(0, Number(event.totalTokens) || 0);
381
+ if (event?.invalidTokenRecord === true) continue;
382
+ const allowFractional = event.rangeAllocationEstimated === true;
383
+ const tokens = tokenValue(event.totalTokens, { allowFractional });
418
384
  if (!(tokens > 0)) continue;
419
385
  const model = trendModelLabel(event.model);
420
- totals.set(model, (totals.get(model) ?? 0) + tokens);
386
+ const contribution = tokens / scale;
387
+ const nextTotal = totalTokens + contribution;
388
+ const scaleFactor = Math.max(1, nextTotal / MAX_SAFE_TOKEN_COUNT);
389
+ if (scaleFactor > 1) {
390
+ for (const [modelName, value] of totals) {
391
+ totals.set(modelName, value / scaleFactor);
392
+ }
393
+ totalTokens /= scaleFactor;
394
+ scale *= scaleFactor;
395
+ }
396
+ const scaledContribution = contribution / scaleFactor;
397
+ totals.set(model, (totals.get(model) ?? 0) + scaledContribution);
398
+ totalTokens += scaledContribution;
421
399
  }
422
400
  return totals;
423
401
  }
@@ -434,12 +412,6 @@ function cloneAllocations(allocations) {
434
412
  );
435
413
  }
436
414
 
437
- function eventsInBounds(events, bounds) {
438
- const startMs = bounds.start.getTime();
439
- const endMs = bounds.end.getTime();
440
- return usageBucketsInRange({ events }, startMs, endMs);
441
- }
442
-
443
415
  function buildModelStats(displayedEvents, intervals, bounds) {
444
416
  const rows = new Map();
445
417
  const rowFor = (model) => {
@@ -457,17 +429,26 @@ function buildModelStats(displayedEvents, intervals, bounds) {
457
429
  };
458
430
 
459
431
  for (const event of displayedEvents) {
432
+ if (event?.invalidTokenRecord === true) continue;
460
433
  const model = trendModelLabel(event.model);
461
434
  const row = rowFor(model);
462
- const tokens = Math.max(0, Number(event.totalTokens) || 0);
435
+ const allowFractional = event.rangeAllocationEstimated === true;
436
+ const tokens = tokenValue(event.totalTokens, { allowFractional });
463
437
  const credits = eventCredits(event);
464
- row.tokens += tokens;
438
+ row.tokens = checkedTokenAdd(row.tokens, tokens, { allowFractional });
465
439
  if (Number.isFinite(credits) && credits >= 0) {
466
- row.credits += credits;
467
- row.ratedTokens += tokens;
440
+ row.credits = checkedFiniteAdd(row.credits, credits);
441
+ row.ratedTokens = checkedTokenAdd(row.ratedTokens, tokens, {
442
+ allowFractional,
443
+ });
468
444
  }
469
445
  const effort = String(event.effort || "unknown").toLowerCase();
470
- row.efforts.set(effort, (row.efforts.get(effort) ?? 0) + tokens);
446
+ row.efforts.set(
447
+ effort,
448
+ checkedTokenAdd(row.efforts.get(effort) ?? 0, tokens, {
449
+ allowFractional,
450
+ }),
451
+ );
471
452
  }
472
453
 
473
454
  const startMs = bounds.start.getTime();
@@ -476,8 +457,12 @@ function buildModelStats(displayedEvents, intervals, bounds) {
476
457
  if (interval.endMs < startMs || interval.endMs >= endMs) continue;
477
458
  for (const [model, burnPoints] of interval.contributions) {
478
459
  const row = rowFor(model);
479
- row.burnPoints += burnPoints;
480
- row.attributedTokens += interval.modelTokens.get(model) ?? 0;
460
+ row.burnPoints = checkedFiniteAdd(row.burnPoints, burnPoints);
461
+ row.attributedTokens = checkedTokenAdd(
462
+ row.attributedTokens,
463
+ interval.modelTokens.get(model) ?? 0,
464
+ { allowFractional: true },
465
+ );
481
466
  }
482
467
  }
483
468
 
@@ -510,13 +495,33 @@ function buildModelStats(displayedEvents, intervals, bounds) {
510
495
  );
511
496
  }
512
497
 
513
- export function buildUsageTrend(snapshot = {}, bounds) {
498
+ export function buildRangeAnalysis(
499
+ snapshot = {},
500
+ bounds,
501
+ { priorBounds = null, includeTrend = true } = {},
502
+ ) {
503
+ const quotaObservations = normalizeQuotaTimeline(
504
+ weeklyQuotaObservations(snapshot),
505
+ );
506
+ const indexed = buildIndexedRangeAnalysis(snapshot, bounds, {
507
+ priorBounds,
508
+ quotaObservations,
509
+ });
510
+ return Object.freeze({
511
+ ...indexed,
512
+ trend: includeTrend
513
+ ? buildUsageTrendFromAnalysis(snapshot, bounds, indexed)
514
+ : null,
515
+ });
516
+ }
517
+
518
+ function buildUsageTrendFromAnalysis(snapshot, bounds, rangeAnalysis) {
514
519
  const startMs = bounds.start.getTime();
515
520
  const endMs = bounds.end.getTime();
516
- const displayedEvents = eventsInBounds(usageBuckets(snapshot), bounds);
517
- const observations = normalizeQuotaTimeline(
518
- weeklyQuotaObservations(snapshot),
519
- ).filter((observation) => observation.timestampMs < endMs);
521
+ const displayedEvents = rangeAnalysis.currentEvents;
522
+ const observations = rangeAnalysis.quotaObservations.filter(
523
+ (observation) => observation.timestampMs < endMs,
524
+ );
520
525
 
521
526
  if (!observations.length) {
522
527
  return {
@@ -528,23 +533,12 @@ export function buildUsageTrend(snapshot = {}, bounds) {
528
533
  sampleCount: 0,
529
534
  allocationMethod: "unavailable",
530
535
  observedThroughMs: null,
531
- rateCardAsOf: snapshot.provenance?.rateCardAsOf ?? RATE_CARD_AS_OF,
536
+ rateCardAsOf: CODEX_CREDIT_RATE_CARD_AS_OF,
532
537
  };
533
538
  }
534
539
 
535
- const sortedEvents = splitUsageBucketsAtBoundaries(
536
- usageBuckets(snapshot),
537
- [
538
- startMs,
539
- endMs,
540
- ...observations.flatMap((observation) => [
541
- observation.cycleStartMs,
542
- observation.timestampMs,
543
- ]),
544
- ],
545
- )
540
+ const sortedEvents = rangeAnalysis.trendEvents
546
541
  .map((event) => ({ ...event, timestampMs: finiteTimestamp(event.timestamp) }))
547
- .filter((event) => event.timestampMs !== null && event.timestampMs < endMs)
548
542
  .sort((left, right) => left.timestampMs - right.timestampMs);
549
543
  const points = [];
550
544
  const resets = [];
@@ -606,7 +600,10 @@ export function buildUsageTrend(snapshot = {}, bounds) {
606
600
  methods.add(allocation.method);
607
601
  }
608
602
  for (const [model, burnPoints] of allocation.contributions) {
609
- allocations.set(model, (allocations.get(model) ?? 0) + burnPoints);
603
+ allocations.set(
604
+ model,
605
+ checkedFiniteAdd(allocations.get(model) ?? 0, burnPoints),
606
+ );
610
607
  }
611
608
  if (delta > 0) {
612
609
  intervals.push({
@@ -747,10 +744,19 @@ export function buildUsageTrend(snapshot = {}, bounds) {
747
744
  observedThroughMs:
748
745
  [...displayPoints].reverse().find((point) => point.observed)
749
746
  ?.timestampMs ?? null,
750
- rateCardAsOf: snapshot.provenance?.rateCardAsOf ?? RATE_CARD_AS_OF,
747
+ rateCardAsOf: CODEX_CREDIT_RATE_CARD_AS_OF,
751
748
  };
752
749
  }
753
750
 
751
+ export function buildUsageTrend(snapshot = {}, bounds, { analysis = null } = {}) {
752
+ const rangeAnalysis = analysis ?? buildRangeAnalysis(snapshot, bounds);
753
+ return rangeAnalysis.trend ?? buildUsageTrendFromAnalysis(
754
+ snapshot,
755
+ bounds,
756
+ rangeAnalysis,
757
+ );
758
+ }
759
+
754
760
  // Bin observed meter drain into calendar-day (or multi-day) columns in the
755
761
  // same percent unit as the meter line. Daily totals are the meter's own
756
762
  // observed drops; only the per-model split within a drop and the day
@@ -787,8 +793,8 @@ export function buildBurnDayBins(trend, bounds, { days, binSize = 1 } = {}) {
787
793
  )) {
788
794
  const share = burnPoints * fraction;
789
795
  if (!(share > 0)) continue;
790
- bin.values.set(model, (bin.values.get(model) ?? 0) + share);
791
- bin.totalPercent += share;
796
+ bin.values.set(model, checkedFiniteAdd(bin.values.get(model) ?? 0, share));
797
+ bin.totalPercent = checkedFiniteAdd(bin.totalPercent, share);
792
798
  }
793
799
  }
794
800
  }
@@ -797,8 +803,8 @@ export function buildBurnDayBins(trend, bounds, { days, binSize = 1 } = {}) {
797
803
  let totalPercent = 0;
798
804
  for (const bin of bins) {
799
805
  for (const [model, value] of bin.values) {
800
- totals.set(model, (totals.get(model) ?? 0) + value);
801
- totalPercent += value;
806
+ totals.set(model, checkedFiniteAdd(totals.get(model) ?? 0, value));
807
+ totalPercent = checkedFiniteAdd(totalPercent, value);
802
808
  }
803
809
  }
804
810
  return { bins, totals, totalPercent, binSize, binCount };