summaryrefslogtreecommitdiffhomepage
path: root/src/core/metrics/format.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-10 00:06:31 +0900
committerAdam Malczewski <[email protected]>2026-10-10 00:06:31 +0900
commit9f2dcfaab421fa84837685a0b65678acf03d9ded (patch)
treea1ef40fbbf98b136a471889c5b02795b739753be /src/core/metrics/format.ts
parent591e2dbe5cbf2283d58debdf92943a075edc4569 (diff)
downloaddispatch-web-9f2dcfaab421fa84837685a0b65678acf03d9ded.tar.gz
dispatch-web-9f2dcfaab421fa84837685a0b65678acf03d9ded.zip
fix(turn-metrics): exact per-turn placement via turnId, decode-only TPS, step-level retention
Placement - Chunks carry their producing turn: StoredChunk.turnId (backend [email protected]) for committed rows (persisted in the IDB cache; old cached rows are upgraded on sync) and the live event's turnId for provisional ones (the optimistic echo is tagged on turn-start). - interleaveTurnMetrics attributes every group to its turn (turnId, else the turn owning its stepId) and puts the turn total after the turn's LAST visible group. No user-message counting: sending, streaming, steering, images, errored turns and chat-limit unloading can no longer shift metrics between turns — at any point during a turn, not just after it ends. A turn cut in half by the chat limit keeps its total under its visible tail. - Legacy rows (no turnId) fall back to an order-preserving, count-guarded match that leaves a turn blank rather than show another turn's numbers. - A turn that errors re-syncs metrics (the backend now persists them). TPS - Step TPS = outputTokens / decodeMs only (no genTotalMs fallback — it includes time-to-first-token). Turn TPS sums tokens and decode time over timed steps only. Tool execution is excluded by the backend. Display - Retention = this turn's first-step cache reads / previous turn's last-step prompt (step-level; turn sums made it pin at 100%). - Turn row shows output tokens + input processed (with cached), instead of a misleading 'total tok' that summed every step's full prompt.
Diffstat (limited to 'src/core/metrics/format.ts')
-rw-r--r--src/core/metrics/format.ts85
1 files changed, 52 insertions, 33 deletions
diff --git a/src/core/metrics/format.ts b/src/core/metrics/format.ts
index 894bd54..5a13a9a 100644
--- a/src/core/metrics/format.ts
+++ b/src/core/metrics/format.ts
@@ -77,26 +77,31 @@ export function computeTps(outputTokens: number, elapsedMs: number | undefined):
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`;
+ let s = `${formatTokens(u.inputTokens)} in`;
if (u.cacheReadTokens !== undefined && u.cacheReadTokens > 0) {
- s += ` / ${formatTokens(u.cacheReadTokens)} cache`;
+ s += ` (${formatTokens(u.cacheReadTokens)} cached)`;
}
return s;
}
+/**
+ * A step's tokens-per-second: `outputTokens / decodeMs`. Decode time is the
+ * model's generation window (first output token → stream end) with tool
+ * execution excluded by the backend. `genTotalMs` is deliberately NOT a
+ * fallback: it includes time-to-first-token (prompt processing / queueing),
+ * which would understate the rate by multiples. No decode time → no TPS.
+ */
+function stepTps(step: StepMetrics): number | null {
+ return computeTps(step.usage.outputTokens, step.decodeMs);
+}
+
/** 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),
+ tokensLabel: `${formatTokens(step.usage.inputTokens)} in / ${formatTokens(step.usage.outputTokens)} out`,
+ tps: formatTps(stepTps(step)),
ttft: formatDuration(step.ttftMs),
decode: formatDuration(step.decodeMs),
genTotal: formatDuration(step.genTotalMs),
@@ -130,19 +135,25 @@ export function viewCacheRate(u: Usage): CacheRateView {
}
/**
- * 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.
+ * Expected cache (retention): of the prompt the PREVIOUS turn last sent (its
+ * final step's input), how much this turn's FIRST step read back from cache —
+ * `clamp01(firstStep.cacheRead / prevLastStep.input)`. Everything in that
+ * prompt is a prefix of this turn's first prompt, so ~100% means the cache
+ * survived between turns; lower means it busted/expired. Step-level on both
+ * sides: comparing whole-turn sums is meaningless (a multi-step turn reads
+ * the same prefix once per step).
*
- * 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%.
+ * Returns `null` when it cannot be derived: no earlier turn, no first step,
+ * or the previous prompt was empty — 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);
+export function computeExpectedCachePct(
+ firstStep: Usage | undefined,
+ prevLastStep: Usage | null,
+): number | null {
+ if (prevLastStep === null || firstStep === undefined) return null;
+ const denom = prevLastStep.inputTokens;
if (denom <= 0) return null;
- const read = current.cacheReadTokens ?? 0;
+ const read = firstStep.cacheReadTokens ?? 0;
const rate = read / denom;
const clamped = rate < 0 ? 0 : rate > 1 ? 1 : rate;
return Math.round(clamped * 100);
@@ -152,28 +163,36 @@ export function computeExpectedCachePct(current: Usage, prev: Usage | null): num
* 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);
+export function viewExpectedCache(
+ firstStep: Usage | undefined,
+ prevLastStep: Usage | null,
+): CacheRateView | null {
+ const pct = computeExpectedCachePct(firstStep, prevLastStep);
if (pct === null) return null;
- return { pct, level: cacheLevel(pct), isHit: (current.cacheReadTokens ?? 0) > 0 };
+ return { pct, level: cacheLevel(pct), isHit: (firstStep?.cacheReadTokens ?? 0) > 0 };
}
-/** Build a formatted view of a turn's aggregate metrics. */
+/**
+ * Build a formatted view of a turn's aggregate metrics.
+ *
+ * Turn TPS = output tokens ÷ decode time, summed over ONLY the steps that
+ * report a decode time (both numerator and denominator), so a step without
+ * timing can neither inflate nor deflate it. Tool execution between steps is
+ * never part of any step's decode time.
+ */
export function viewTurnMetrics(turn: TurnMetrics, turnNumber?: number): TurnMetricsView {
- const total = totalTokens(turn.usage);
- let totalGenMs: number | undefined;
+ let timedOut = 0;
+ let decodeMs: number | undefined;
for (const step of turn.steps) {
- const stepMs = step.decodeMs ?? step.genTotalMs;
- if (stepMs !== undefined) {
- totalGenMs = (totalGenMs ?? 0) + stepMs;
- }
+ if (step.decodeMs === undefined || step.decodeMs <= 0) continue;
+ timedOut += step.usage.outputTokens;
+ decodeMs = (decodeMs ?? 0) + step.decodeMs;
}
- const tps = computeTps(turn.usage.outputTokens, totalGenMs);
return {
label: turnNumber !== undefined ? `turn ${turnNumber}` : "turn",
- tokensLabel: `${formatTokens(total)} tok`,
+ tokensLabel: `${formatTokens(turn.usage.outputTokens)} tok out`,
breakdown: formatBreakdown(turn.usage),
- tps: formatTps(tps),
+ tps: formatTps(computeTps(timedOut, decodeMs)),
duration: formatDuration(turn.durationMs),
};
}