summaryrefslogtreecommitdiffhomepage
path: root/src/core/metrics/format.ts
blob: ee8db605f650f00547846a7778e66875240d2052 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
import type { StepMetrics, TurnMetrics, Usage } from "@dispatch/wire";
import type { CacheRateView, StepMetricsView, TurnMetricsView } from "./types";

function formatTokens(n: number): string {
	return n.toLocaleString("en-US");
}

function formatDuration(ms: number | undefined): string | null {
	if (ms === undefined || ms <= 0) return null;
	if (ms < 1000) return `${Math.round(ms)}ms`;
	return `${(ms / 1000).toFixed(1)}s`;
}

function formatTps(tps: number | null): string | null {
	if (tps === null) return null;
	if (tps < 10) return `${tps.toFixed(1)} tok/s`;
	return `${Math.round(tps)} tok/s`;
}

/** Compute tokens-per-second. Returns null when elapsed time is absent or zero. */
export function computeTps(outputTokens: number, elapsedMs: number | undefined): number | null {
	if (elapsedMs === undefined || elapsedMs <= 0) return null;
	return outputTokens / (elapsedMs / 1000);
}

function totalTokens(u: Usage): number {
	return u.inputTokens + u.outputTokens;
}

function formatBreakdown(u: Usage): string {
	let s = `${formatTokens(u.inputTokens)} in / ${formatTokens(u.outputTokens)} out`;
	if (u.cacheReadTokens !== undefined && u.cacheReadTokens > 0) {
		s += ` / ${formatTokens(u.cacheReadTokens)} cache`;
	}
	return s;
}

/** Build a formatted view of a single step's metrics. */
export function viewStepMetrics(step: StepMetrics, index: number): StepMetricsView {
	const total = totalTokens(step.usage);
	const tps = computeTps(step.usage.outputTokens, step.decodeMs ?? step.genTotalMs);
	return {
		label: `step ${index + 1}`,
		tokensLabel: `${formatTokens(total)} tok`,
		tps: formatTps(tps),
		ttft: formatDuration(step.ttftMs),
		decode: formatDuration(step.decodeMs),
		genTotal: formatDuration(step.genTotalMs),
	};
}

/**
 * Cache hit rate as a 0..100 integer percentage: `cacheReadTokens / inputTokens`,
 * clamped to [0,1]. Absent cache field counts as 0; a 0% rate is legitimate (not
 * missing data). Returns 0 when there are no input tokens.
 */
export function computeCachePct(u: Usage): number {
	const read = u.cacheReadTokens ?? 0;
	if (u.inputTokens <= 0) return 0;
	const rate = read / u.inputTokens;
	const clamped = rate < 0 ? 0 : rate > 1 ? 1 : rate;
	return Math.round(clamped * 100);
}

/** Colour severity for a cache hit percentage (badge colour). */
function cacheLevel(pct: number): "success" | "warning" | "error" {
	if (pct >= 66) return "success";
	if (pct >= 33) return "warning";
	return "error";
}

/** Build a view of a cache hit rate (percentage + colour level + hit flag). */
export function viewCacheRate(u: Usage): CacheRateView {
	const pct = computeCachePct(u);
	return { pct, level: cacheLevel(pct), isHit: (u.cacheReadTokens ?? 0) > 0 };
}

/**
 * Expected cache (retention): of the cache that existed going INTO this turn, how
 * much was read back — `clamp01(cacheRead_N / (cacheRead_{N-1} + cacheWrite_{N-1}))`.
 * The denominator is the PRIOR turn's cached prefix (what it read + what it wrote).
 * Ideally ~100% on every turn after the first; <100% = the cache busted/expired.
 *
 * Returns `null` when it cannot be derived: no prior turn (`prev === null`) or the
 * prior turn cached nothing (denominator <= 0) — distinct from a real 0%.
 */
export function computeExpectedCachePct(current: Usage, prev: Usage | null): number | null {
	if (prev === null) return null;
	const denom = (prev.cacheReadTokens ?? 0) + (prev.cacheWriteTokens ?? 0);
	if (denom <= 0) return null;
	const read = current.cacheReadTokens ?? 0;
	const rate = read / denom;
	const clamped = rate < 0 ? 0 : rate > 1 ? 1 : rate;
	return Math.round(clamped * 100);
}

/**
 * Build a view of the cross-turn retention (percentage + colour level + hit flag),
 * or `null` when it can't be derived (see `computeExpectedCachePct`).
 */
export function viewExpectedCache(current: Usage, prev: Usage | null): CacheRateView | null {
	const pct = computeExpectedCachePct(current, prev);
	if (pct === null) return null;
	return { pct, level: cacheLevel(pct), isHit: (current.cacheReadTokens ?? 0) > 0 };
}

/** Build a formatted view of a turn's aggregate metrics. */
export function viewTurnMetrics(turn: TurnMetrics): TurnMetricsView {
	const total = totalTokens(turn.usage);
	let totalGenMs: number | undefined;
	for (const step of turn.steps) {
		const stepMs = step.decodeMs ?? step.genTotalMs;
		if (stepMs !== undefined) {
			totalGenMs = (totalGenMs ?? 0) + stepMs;
		}
	}
	const tps = computeTps(turn.usage.outputTokens, totalGenMs);
	return {
		tokensLabel: `${formatTokens(total)} tok`,
		breakdown: formatBreakdown(turn.usage),
		tps: formatTps(tps),
		duration: formatDuration(turn.durationMs),
	};
}