/** * Usage aggregate — pure functions over a {@link UsageRecord} list (saga vr7kgx.20). * * Mirrors the compactor's split of I/O from logic (ARCH §11): `UsageLog` owns the append-only * JSONL; these functions own the time-bucketing + per-model grouping + totals. Pure, * deterministic, unit-testable — no I/O, no side effects. The bucketing/grouping logic can * change across versions and old logs still aggregate correctly ("data outlives behavior", * §1.4). * * Pipeline (analogous to `normalize → buildSections → formatSummary`): * 1. `bucketize(records, granularity)` → group by `(bucket_start, provider, model)`, accumulate. * 2. `summarize(series)` → per-model grand totals + a grand total across the range. * * The output shape mirrors the sibling tool-usage stats contract * (`src/contracts/tool-usage-stats.ts`): a per-model `totals` rollup (the summary table) + * a time `series` of buckets (the chart) + a `grand_totals` row. Bucket flooring is in the * daemon's *local* timezone (so "today" means the user's today); `bucket_start` is an * absolute epoch ms (TZ-independent in storage), formatted by the client. */ import type { UsageRecord } from '@/daemon/usage-log'; /** Time-bucket granularity for the aggregate. `total` collapses the whole range into one bucket. */ export type UsageBucket = 'hour' | 'day' | 'week' | 'month' | 'total'; /** The closed set of bucket granularities (for validation). */ export const USAGE_BUCKETS: readonly UsageBucket[] = ['hour', 'day', 'week', 'month', 'total']; /** Coerce an unknown value to a valid {@link UsageBucket}, or `undefined`. */ export function coerceBucket(value: unknown): UsageBucket | undefined { return typeof value === 'string' && (USAGE_BUCKETS as readonly string[]).includes(value) ? (value as UsageBucket) : undefined; } /** Per-model accumulated counts (the unit of both the per-bucket breakdown and the grand rollup). */ export interface ModelBreakdown { provider: string; model: string; input: number; cache_read: number; cache_write: number; output: number; cost: number; calls: number; } /** Totals across all models in a bucket (or the whole range). */ export interface UsageTotals { input: number; cache_read: number; cache_write: number; output: number; cost: number; calls: number; } /** One time bucket of the series. */ export interface UsageBucketSummary { /** Epoch ms of the bucket floor (0 when `granularity === 'total'`). */ bucket_start: number; /** Per-model breakdown within this bucket, sorted by total tokens (input + output) desc. */ by_model: ModelBreakdown[]; } /** The full aggregate: time series + a per-model grand rollup + a grand total. */ export interface UsageAggregate { /** Time-ordered buckets (ascending `bucket_start`). */ series: UsageBucketSummary[]; /** Per-model totals across the whole queried range, sorted by total tokens desc. */ totals: ModelBreakdown[]; /** Summed totals across all models (the "Totals:" row). */ grand_totals: UsageTotals; } const EMPTY_TOTALS: UsageTotals = { input: 0, cache_read: 0, cache_write: 0, output: 0, cost: 0, calls: 0, }; /** * Group usage records into time buckets keyed by `(bucket_start, provider, model)`, * accumulating input/cache_read/cache_write/output/cost/calls per group. * * Records missing `provider`/`model` aggregate under the sentinel `'(unknown)'` so they are * still counted (never dropped) and stay distinguishable from attributed models. * * Pure: deterministic given the records + granularity. Bucket flooring is in local time. */ export function bucketize(records: readonly UsageRecord[], granularity: UsageBucket): UsageBucketSummary[] { if (records.length === 0) return []; // group key → accumulator const groups = new Map(); for (const rec of records) { const bucketStart = floorBucket(rec.ts, granularity); const provider = rec.provider ?? '(unknown)'; const model = rec.model ?? '(unknown)'; const key = `${bucketStart}\u{1f}\u{1f}${provider}\u{1f}${model}`; let group = groups.get(key); if (group === undefined) { group = { bucket_start: bucketStart, provider, model, totals: { ...EMPTY_TOTALS } }; groups.set(key, group); } group.totals.input += rec.input; group.totals.cache_read += rec.cache_read ?? 0; group.totals.cache_write += rec.cache_write ?? 0; group.totals.output += rec.output; group.totals.cost += rec.cost ?? 0; group.totals.calls += 1; } // Collect into per-bucket summaries. const byBucket = new Map>(); for (const group of groups.values()) { let bucket = byBucket.get(group.bucket_start); if (bucket === undefined) { bucket = new Map(); byBucket.set(group.bucket_start, bucket); } bucket.set(`${group.provider}\u{1f}${group.model}`, { provider: group.provider, model: group.model, input: group.totals.input, cache_read: group.totals.cache_read, cache_write: group.totals.cache_write, output: group.totals.output, cost: group.totals.cost, calls: group.totals.calls, }); } const summaries: UsageBucketSummary[] = []; for (const [bucketStart, modelMap] of byBucket) { const byModel = [...modelMap.values()].sort(byTotalTokensDesc); summaries.push({ bucket_start: bucketStart, by_model: byModel }); } // Sort buckets chronologically (ascending by bucket_start). summaries.sort((a, b) => a.bucket_start - b.bucket_start); return summaries; } /** * Compute the per-model grand rollup + grand total across the whole queried range. Pure. * The `series` is passed through unchanged; this adds the cross-bucket rollup the summary * table renders. */ export function summarize(series: readonly UsageBucketSummary[]): UsageAggregate { // Roll up per-model totals across all buckets. const rollup = new Map(); const grandTotals: UsageTotals = { ...EMPTY_TOTALS }; for (const bucket of series) { for (const m of bucket.by_model) { const key = `${m.provider}\u{1f}${m.model}`; let acc = rollup.get(key); if (acc === undefined) { acc = { provider: m.provider, model: m.model, input: 0, cache_read: 0, cache_write: 0, output: 0, cost: 0, calls: 0 }; rollup.set(key, acc); } acc.input += m.input; acc.cache_read += m.cache_read; acc.cache_write += m.cache_write; acc.output += m.output; acc.cost += m.cost; acc.calls += m.calls; grandTotals.input += m.input; grandTotals.cache_read += m.cache_read; grandTotals.cache_write += m.cache_write; grandTotals.output += m.output; grandTotals.cost += m.cost; grandTotals.calls += m.calls; } } const totals = [...rollup.values()].sort(byTotalTokensDesc); return { series: [...series], totals, grand_totals: grandTotals }; } /** Convenience: bucketize + summarize in one step (the common query path). */ export function aggregate(records: readonly UsageRecord[], granularity: UsageBucket): UsageAggregate { return summarize(bucketize(records, granularity)); } /** Sort comparator: total tokens (input + output) descending, ties broken by provider/model. */ function byTotalTokensDesc(a: ModelBreakdown, b: ModelBreakdown): number { const aTotal = a.input + a.output; const bTotal = b.input + b.output; if (aTotal !== bTotal) return bTotal - aTotal; if (a.provider !== b.provider) return a.provider < b.provider ? -1 : 1; return a.model < b.model ? -1 : a.model > b.model ? 1 : 0; } /** * Floor an epoch-ms timestamp to the start of its bucket in the *local* timezone. * * `total` → 0 (a single collapsed bucket). For the time granularities, the floor is computed * by mutating a `Date` in local wall-clock components — robust to DST because it works in * wall-clock fields, not fixed offsets. The returned epoch ms is absolute (TZ-independent). */ function floorBucket(ts: number, granularity: UsageBucket): number { if (granularity === 'total') return 0; const d = new Date(ts); if (granularity === 'hour') { d.setMinutes(0, 0, 0); return d.getTime(); } if (granularity === 'day') { d.setHours(0, 0, 0, 0); return d.getTime(); } if (granularity === 'week') { // ISO week: Monday is the first day. Local Sunday (0) → back up 6 days; Monday (1) → 0. d.setHours(0, 0, 0, 0); const day = d.getDay(); const diff = (day === 0 ? 6 : day - 1); d.setDate(d.getDate() - diff); return d.getTime(); } // month: floor to the first of the local month. d.setHours(0, 0, 0, 0); d.setDate(1); return d.getTime(); }