diff options
| author | Adam Malczewski <[email protected]> | 2026-10-10 00:06:31 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-10-10 00:06:31 +0900 |
| commit | 9f2dcfaab421fa84837685a0b65678acf03d9ded (patch) | |
| tree | a1ef40fbbf98b136a471889c5b02795b739753be /src/core/metrics/format.ts | |
| parent | 591e2dbe5cbf2283d58debdf92943a075edc4569 (diff) | |
| download | dispatch-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.ts | 85 |
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), }; } |
