tledger 0.3.0 → 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,55 +116,40 @@ export function trendModelLabel(value) {
150
116
  }
151
117
 
152
118
  export function weeklyQuotaObservations(snapshot = {}) {
153
- let observations = (snapshot.quotaObservations ?? [])
154
- .map((observation) => ({
155
- ...observation,
156
- timestampMs: finiteTimestamp(observation.timestamp),
157
- resetsAt: Number(observation.resetsAt),
158
- usedPercent: Number(observation.usedPercent),
159
- }))
160
- .filter(
161
- (observation) =>
162
- Number(observation.windowMinutes) === WEEK_MINUTES &&
163
- observation.timestampMs !== null &&
164
- Number.isFinite(observation.resetsAt) &&
165
- observation.resetsAt > 0 &&
166
- Number.isFinite(observation.usedPercent),
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,
167
131
  )
168
- .map((observation) => ({
169
- ...observation,
170
- usedPercent: clampPercent(observation.usedPercent),
171
- }));
172
-
173
- // Keep exactly one meter: the account-wide weekly limit. Legacy snapshots
174
- // tag it with scope: "account"; current snapshots carry a limitKey per
175
- // limit bucket, where the account-wide bucket has no limitName. Named
176
- // buckets (per-model limit pools) are separate meters and must not be
177
- // stitched into this line.
178
- const accountScoped = observations.filter(
179
- (observation) => observation.scope === "account",
132
+ .map((observation) => {
133
+ const timestampMs = finiteTimestamp(observation.timestamp);
134
+ const lastSeenAtMs = finiteTimestamp(observation.lastSeenAt);
135
+ return {
136
+ ...observation,
137
+ timestampMs,
138
+ observedThroughMs:
139
+ timestampMs === null
140
+ ? lastSeenAtMs
141
+ : Math.max(timestampMs, lastSeenAtMs ?? timestampMs),
142
+ };
143
+ })
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),
180
152
  );
181
- if (accountScoped.length) {
182
- observations = accountScoped;
183
- } else if (observations.some((observation) => observation.limitKey)) {
184
- const groups = new Map();
185
- for (const observation of observations) {
186
- const key = observation.limitKey ?? "anonymous";
187
- const group = groups.get(key) ?? [];
188
- group.push(observation);
189
- groups.set(key, group);
190
- }
191
- const accountWide = [...groups.values()].filter((group) =>
192
- group.every((observation) => !observation.limitName),
193
- );
194
- const pool = accountWide.length ? accountWide : [...groups.values()];
195
- observations = pool.sort((left, right) => right.length - left.length)[0];
196
- } else {
197
- const accountWide = observations.filter(
198
- (observation) => !observation.limitName,
199
- );
200
- if (accountWide.length) observations = accountWide;
201
- }
202
153
 
203
154
  observations.sort(
204
155
  (left, right) =>
@@ -214,6 +165,13 @@ export function weeklyQuotaObservations(snapshot = {}) {
214
165
  // stale readings from sessions still reporting a superseded window would
215
166
  // otherwise be fused into one line, producing meter drain that never happened.
216
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
+ );
217
175
  if (!observations.length) return [];
218
176
 
219
177
  const epochs = [];
@@ -277,6 +235,12 @@ export function normalizeQuotaTimeline(observations) {
277
235
  : Math.max(usedPercent, observation.usedPercent);
278
236
  normalized.push({
279
237
  ...observation,
238
+ observedThroughMs: Math.min(
239
+ Number.isFinite(observation.observedThroughMs)
240
+ ? observation.observedThroughMs
241
+ : observation.timestampMs,
242
+ nextFirstMs,
243
+ ),
280
244
  cycle,
281
245
  reset: !emitted && previousEpoch !== null,
282
246
  resetKind,
@@ -291,46 +255,53 @@ export function normalizeQuotaTimeline(observations) {
291
255
  }
292
256
 
293
257
  export function eventCredits(event) {
294
- // Recompute from token components first so the current rate card applies;
295
- // snapshots can carry credits stored under an outdated card. Fast-mode
296
- // turns (service tier "priority") debit the limit at a higher rate.
297
- const multiplier =
298
- event.serviceTier === "priority" ? FAST_MODE_MULTIPLIER : 1;
299
- const computed = creditsForUsage(event.model, event);
300
- if (Number.isFinite(computed) && computed >= 0) return computed * multiplier;
301
- const stored = Number(event.rateCardCredits);
302
- if (event.rateCardCredits !== null && event.rateCardCredits !== undefined) {
303
- // Stored credits from current snapshots already include the fast-mode
304
- // multiplier.
305
- if (Number.isFinite(stored) && stored >= 0) return stored;
306
- }
307
- 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
+ });
308
265
  }
309
266
 
310
267
  function eventWeight(event, fallbackCreditsPerToken) {
311
268
  const credits = eventCredits(event);
312
269
  if (Number.isFinite(credits) && credits > 0) return credits;
313
- const tokens = Math.max(0, Number(event.totalTokens) || 0);
270
+ const tokens = tokenValue(event.totalTokens, {
271
+ allowFractional: event.rangeAllocationEstimated === true,
272
+ });
314
273
  return fallbackCreditsPerToken > 0 ? tokens * fallbackCreditsPerToken : tokens;
315
274
  }
316
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
+
317
287
  function allocateBurn(delta, events, timeZone) {
318
288
  if (!(delta > 0)) return { contributions: new Map(), method: "none" };
319
289
 
320
- let ratedCredits = 0;
321
- let ratedTokens = 0;
290
+ const ratedTotals = { credits: 0, tokens: 0, scale: 1 };
322
291
  let hasUnrated = false;
323
292
  for (const event of events) {
293
+ if (event?.invalidTokenRecord === true) continue;
324
294
  const credits = eventCredits(event);
325
- const tokens = Math.max(0, Number(event.totalTokens) || 0);
295
+ const allowFractional = event.rangeAllocationEstimated === true;
296
+ const tokens = tokenValue(event.totalTokens, { allowFractional });
326
297
  if (Number.isFinite(credits) && credits > 0) {
327
- ratedCredits += credits;
328
- ratedTokens += tokens;
298
+ addRatedTotals(ratedTotals, credits, tokens);
329
299
  } else if (tokens > 0) {
330
300
  hasUnrated = true;
331
301
  }
332
302
  }
333
303
 
304
+ const { credits: ratedCredits, tokens: ratedTokens } = ratedTotals;
334
305
  const fallbackCreditsPerToken =
335
306
  ratedCredits > 0 && ratedTokens > 0 ? ratedCredits / ratedTokens : 0;
336
307
  const weights = new Map();
@@ -340,12 +311,12 @@ function allocateBurn(delta, events, timeZone) {
340
311
  const weight = eventWeight(event, fallbackCreditsPerToken);
341
312
  if (!(weight > 0)) continue;
342
313
  const model = trendModelLabel(event.model);
343
- weights.set(model, (weights.get(model) ?? 0) + weight);
314
+ weights.set(model, checkedFiniteAdd(weights.get(model) ?? 0, weight));
344
315
  if (timeZone) {
345
316
  const day = localDateString(event.timestampMs, timeZone);
346
- dayWeights.set(day, (dayWeights.get(day) ?? 0) + weight);
317
+ dayWeights.set(day, checkedFiniteAdd(dayWeights.get(day) ?? 0, weight));
347
318
  }
348
- totalWeight += weight;
319
+ totalWeight = checkedFiniteAdd(totalWeight, weight);
349
320
  }
350
321
 
351
322
  if (!(totalWeight > 0)) {
@@ -377,19 +348,24 @@ function allocateBurn(delta, events, timeZone) {
377
348
  }
378
349
 
379
350
  // Fractions of the span [startMs, endMs) falling on each local calendar day.
380
- function durationDayShares(startMs, endMs, timeZone) {
351
+ export function durationDayShares(startMs, endMs, timeZone) {
352
+ const formatter = createTimeZoneFormatter(timeZone);
381
353
  if (!(endMs > startMs)) {
382
- return new Map([[localDateString(endMs, timeZone), 1]]);
354
+ return new Map([[localDateString(endMs, timeZone, formatter), 1]]);
383
355
  }
384
356
  const shares = new Map();
385
357
  let cursor = startMs;
386
358
  while (cursor < endMs) {
387
- const day = localDateString(cursor, timeZone);
388
- const nextMidnightMs = zonedMidnight(
359
+ const day = localDateString(cursor, timeZone, formatter);
360
+ const nextBoundaryMs = localDateBoundary(
389
361
  shiftCalendarDate(day, 1),
390
362
  timeZone,
363
+ formatter,
391
364
  ).getTime();
392
- 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);
393
369
  shares.set(day, (shares.get(day) ?? 0) + (sliceEnd - cursor));
394
370
  cursor = sliceEnd;
395
371
  }
@@ -399,11 +375,27 @@ function durationDayShares(startMs, endMs, timeZone) {
399
375
 
400
376
  function tokenTotalsByModel(events) {
401
377
  const totals = new Map();
378
+ let scale = 1;
379
+ let totalTokens = 0;
402
380
  for (const event of events) {
403
- 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 });
404
384
  if (!(tokens > 0)) continue;
405
385
  const model = trendModelLabel(event.model);
406
- 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;
407
399
  }
408
400
  return totals;
409
401
  }
@@ -420,12 +412,6 @@ function cloneAllocations(allocations) {
420
412
  );
421
413
  }
422
414
 
423
- function eventsInBounds(events, bounds) {
424
- const startMs = bounds.start.getTime();
425
- const endMs = bounds.end.getTime();
426
- return usageBucketsInRange({ events }, startMs, endMs);
427
- }
428
-
429
415
  function buildModelStats(displayedEvents, intervals, bounds) {
430
416
  const rows = new Map();
431
417
  const rowFor = (model) => {
@@ -443,17 +429,26 @@ function buildModelStats(displayedEvents, intervals, bounds) {
443
429
  };
444
430
 
445
431
  for (const event of displayedEvents) {
432
+ if (event?.invalidTokenRecord === true) continue;
446
433
  const model = trendModelLabel(event.model);
447
434
  const row = rowFor(model);
448
- const tokens = Math.max(0, Number(event.totalTokens) || 0);
435
+ const allowFractional = event.rangeAllocationEstimated === true;
436
+ const tokens = tokenValue(event.totalTokens, { allowFractional });
449
437
  const credits = eventCredits(event);
450
- row.tokens += tokens;
438
+ row.tokens = checkedTokenAdd(row.tokens, tokens, { allowFractional });
451
439
  if (Number.isFinite(credits) && credits >= 0) {
452
- row.credits += credits;
453
- row.ratedTokens += tokens;
440
+ row.credits = checkedFiniteAdd(row.credits, credits);
441
+ row.ratedTokens = checkedTokenAdd(row.ratedTokens, tokens, {
442
+ allowFractional,
443
+ });
454
444
  }
455
445
  const effort = String(event.effort || "unknown").toLowerCase();
456
- 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
+ );
457
452
  }
458
453
 
459
454
  const startMs = bounds.start.getTime();
@@ -462,8 +457,12 @@ function buildModelStats(displayedEvents, intervals, bounds) {
462
457
  if (interval.endMs < startMs || interval.endMs >= endMs) continue;
463
458
  for (const [model, burnPoints] of interval.contributions) {
464
459
  const row = rowFor(model);
465
- row.burnPoints += burnPoints;
466
- 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
+ );
467
466
  }
468
467
  }
469
468
 
@@ -496,13 +495,33 @@ function buildModelStats(displayedEvents, intervals, bounds) {
496
495
  );
497
496
  }
498
497
 
499
- 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) {
500
519
  const startMs = bounds.start.getTime();
501
520
  const endMs = bounds.end.getTime();
502
- const displayedEvents = eventsInBounds(usageBuckets(snapshot), bounds);
503
- const observations = normalizeQuotaTimeline(
504
- weeklyQuotaObservations(snapshot),
505
- ).filter((observation) => observation.timestampMs < endMs);
521
+ const displayedEvents = rangeAnalysis.currentEvents;
522
+ const observations = rangeAnalysis.quotaObservations.filter(
523
+ (observation) => observation.timestampMs < endMs,
524
+ );
506
525
 
507
526
  if (!observations.length) {
508
527
  return {
@@ -514,23 +533,12 @@ export function buildUsageTrend(snapshot = {}, bounds) {
514
533
  sampleCount: 0,
515
534
  allocationMethod: "unavailable",
516
535
  observedThroughMs: null,
517
- rateCardAsOf: snapshot.provenance?.rateCardAsOf ?? RATE_CARD_AS_OF,
536
+ rateCardAsOf: CODEX_CREDIT_RATE_CARD_AS_OF,
518
537
  };
519
538
  }
520
539
 
521
- const sortedEvents = splitUsageBucketsAtBoundaries(
522
- usageBuckets(snapshot),
523
- [
524
- startMs,
525
- endMs,
526
- ...observations.flatMap((observation) => [
527
- observation.cycleStartMs,
528
- observation.timestampMs,
529
- ]),
530
- ],
531
- )
540
+ const sortedEvents = rangeAnalysis.trendEvents
532
541
  .map((event) => ({ ...event, timestampMs: finiteTimestamp(event.timestamp) }))
533
- .filter((event) => event.timestampMs !== null && event.timestampMs < endMs)
534
542
  .sort((left, right) => left.timestampMs - right.timestampMs);
535
543
  const points = [];
536
544
  const resets = [];
@@ -592,7 +600,10 @@ export function buildUsageTrend(snapshot = {}, bounds) {
592
600
  methods.add(allocation.method);
593
601
  }
594
602
  for (const [model, burnPoints] of allocation.contributions) {
595
- allocations.set(model, (allocations.get(model) ?? 0) + burnPoints);
603
+ allocations.set(
604
+ model,
605
+ checkedFiniteAdd(allocations.get(model) ?? 0, burnPoints),
606
+ );
596
607
  }
597
608
  if (delta > 0) {
598
609
  intervals.push({
@@ -617,6 +628,7 @@ export function buildUsageTrend(snapshot = {}, bounds) {
617
628
  }
618
629
  points.push({
619
630
  timestampMs: observation.timestampMs,
631
+ observedThroughMs: observation.observedThroughMs,
620
632
  cycle: observation.cycle,
621
633
  usedPercent: observation.normalizedUsedPercent,
622
634
  remainingPercent: 100 - observation.normalizedUsedPercent,
@@ -645,15 +657,46 @@ export function buildUsageTrend(snapshot = {}, bounds) {
645
657
  (point) => point.timestampMs > startMs && point.timestampMs < endMs,
646
658
  ),
647
659
  );
648
- const lastPoint = displayPoints.at(-1);
649
- if (lastPoint) {
650
- displayPoints.push({
651
- ...lastPoint,
652
- timestampMs: endMs,
653
- observed: false,
654
- carried: true,
655
- });
660
+
661
+ // Repeated equal meter readings are compacted into an observed span. Extend
662
+ // each displayed cycle only through its last real sample; never synthesize a
663
+ // flat line through the unobserved remainder of the report range.
664
+ const sourcePointsByCycle = new Map();
665
+ for (const point of points) {
666
+ const cyclePoints = sourcePointsByCycle.get(point.cycle) ?? [];
667
+ cyclePoints.push(point);
668
+ sourcePointsByCycle.set(point.cycle, cyclePoints);
656
669
  }
670
+ const displayedCycles = new Set(displayPoints.map((point) => point.cycle));
671
+ for (const cycle of displayedCycles) {
672
+ const cyclePoints = sourcePointsByCycle.get(cycle) ?? [];
673
+ const displayedCyclePoints = displayPoints.filter(
674
+ (point) => point.cycle === cycle,
675
+ );
676
+ const lastPoint = displayedCyclePoints.at(-1);
677
+ if (!lastPoint || !cyclePoints.length) continue;
678
+ const observedThroughMs = Math.max(
679
+ ...cyclePoints.map((point) => point.observedThroughMs),
680
+ );
681
+ const nextResetMs = resets.find((reset) => reset.cycle === cycle + 1)
682
+ ?.timestampMs;
683
+ const crossesNextReset = Number.isFinite(nextResetMs) &&
684
+ observedThroughMs >= nextResetMs;
685
+ const endpointMs = Math.min(observedThroughMs, endMs);
686
+ if (!crossesNextReset && endpointMs > lastPoint.timestampMs) {
687
+ displayPoints.push({
688
+ ...lastPoint,
689
+ timestampMs: endpointMs,
690
+ observedThroughMs: endpointMs,
691
+ observed: true,
692
+ carried: false,
693
+ confirmation: true,
694
+ });
695
+ }
696
+ }
697
+ displayPoints.sort(
698
+ (left, right) => left.timestampMs - right.timestampMs || left.cycle - right.cycle,
699
+ );
657
700
 
658
701
  const hasUnattributed = methods.has("unattributed");
659
702
  let allocationMethod = "unavailable";
@@ -699,12 +742,21 @@ export function buildUsageTrend(snapshot = {}, bounds) {
699
742
  ).length,
700
743
  allocationMethod,
701
744
  observedThroughMs:
702
- [...points].reverse().find((point) => point.timestampMs < endMs)
745
+ [...displayPoints].reverse().find((point) => point.observed)
703
746
  ?.timestampMs ?? null,
704
- rateCardAsOf: snapshot.provenance?.rateCardAsOf ?? RATE_CARD_AS_OF,
747
+ rateCardAsOf: CODEX_CREDIT_RATE_CARD_AS_OF,
705
748
  };
706
749
  }
707
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
+
708
760
  // Bin observed meter drain into calendar-day (or multi-day) columns in the
709
761
  // same percent unit as the meter line. Daily totals are the meter's own
710
762
  // observed drops; only the per-model split within a drop and the day
@@ -741,8 +793,8 @@ export function buildBurnDayBins(trend, bounds, { days, binSize = 1 } = {}) {
741
793
  )) {
742
794
  const share = burnPoints * fraction;
743
795
  if (!(share > 0)) continue;
744
- bin.values.set(model, (bin.values.get(model) ?? 0) + share);
745
- bin.totalPercent += share;
796
+ bin.values.set(model, checkedFiniteAdd(bin.values.get(model) ?? 0, share));
797
+ bin.totalPercent = checkedFiniteAdd(bin.totalPercent, share);
746
798
  }
747
799
  }
748
800
  }
@@ -751,8 +803,8 @@ export function buildBurnDayBins(trend, bounds, { days, binSize = 1 } = {}) {
751
803
  let totalPercent = 0;
752
804
  for (const bin of bins) {
753
805
  for (const [model, value] of bin.values) {
754
- totals.set(model, (totals.get(model) ?? 0) + value);
755
- totalPercent += value;
806
+ totals.set(model, checkedFiniteAdd(totals.get(model) ?? 0, value));
807
+ totalPercent = checkedFiniteAdd(totalPercent, value);
756
808
  }
757
809
  }
758
810
  return { bins, totals, totalPercent, binSize, binCount };