diff --git a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml index b95a27e700..114dcbfe23 100644 --- a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.md -2026-08-19-high-cache-hit-decimal-display.md: 29e0ebc11ae690b918c7e9364f29635de1e50f2a -2026-08-19-high-cache-hit-decimal-display.zh.md: 28cd99a4c321cc4da0ce03142e2fbf438539b0ff +2026-08-19-high-cache-hit-decimal-display.md: 952d838fdf330175915e13ef767fd80d145506b2 +2026-08-19-high-cache-hit-decimal-display.zh.md: 4a72becd608e756507f3d5a6fd051b4b2b437964 diff --git a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.md b/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.md index 29e0ebc11a..952d838fdf 100644 --- a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.md +++ b/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.md @@ -21,7 +21,7 @@ Users therefore could not distinguish a nearly complete cache hit from a true fu | Non-full ratio whose current rounding is 100% | Minimum decimal precision whose rounded result is below 100% | | 100% | `100%` | -Every non-empty ratio starts at zero decimal places. A non-full ratio increases precision one place at a time only while rounding would produce 100%, so `99.1%` and `99.49%` remain `99%`, while `99.5%`, `99.95%`, and `99.995%` retain one, two, and three decimal places respectively. `StatsLine` scales and rounds the integer token counts with `bigint`, which avoids floating-point formatting limits and imposes no precision cap or substitute label. A full hit does not carry a redundant decimal. The same derived string feeds the inline row and its overflow tooltip. +Every non-empty ratio starts at zero decimal places. A non-full ratio increases precision one place at a time only while rounding would produce 100%, so `99.1%` and `99.49%` remain `99%`, while `99.5%`, `99.95%`, and `99.995%` retain one, two, and three decimal places respectively. `StatsLine` uses exact small-factor comparisons over the safe-integer token counts, then scales the near-full gap only while the intermediate remains within that range. This avoids floating-point tie errors without imposing a precision cap or substitute label. A full hit does not carry a redundant decimal. The same derived string feeds the inline row and its overflow tooltip. ## Ownership and lifecycle @@ -31,7 +31,7 @@ Live updates, reload replay, and reconnect recovery all restore the same `tokenU ## Verification -The component spec pins the zero denominator, ordinary integer rounding, each precision boundary through three decimal places, a near-full cumulative sample that needs fourteen decimal places, the true `100%` result, both locales, and equality between inline and tooltip values. The assembled `lifecycle-chrome` replay sidecar selects `9,950 / 10,000 = 99.5%` as a deterministic ratio that integer rounding would misreport as 100% while the base session fixture remains recordable; the live assertion and post-reload browser snapshot both display `99.5%` without another model call. +The component spec pins the zero denominator, ordinary integer rounding, half-step rounding at several decimal precisions, each precision boundary through three decimal places, a near-full cumulative sample that needs fourteen decimal places, the true `100%` result, both locales, and equality between inline and tooltip values. The assembled `lifecycle-chrome` replay sidecar selects `9,950 / 10,000 = 99.5%` as a deterministic ratio that integer rounding would misreport as 100% while the base session fixture remains recordable; the live assertion and post-reload browser snapshot both display `99.5%` without another model call. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.zh.md b/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.zh.md index 28cd99a4c3..4a72becd60 100644 --- a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.zh.md @@ -21,7 +21,7 @@ Web 会话统计行会把所有非空缓存命中率舍入为整数。真实比 | 当前舍入结果为 100% 的非满命中 | 舍入结果低于 100% 所需的最少小数位 | | 100% | `100%` | -所有非空比率都从零位小数开始。非满命中只有在舍入结果会成为 100% 时才逐位增加精度,因此 `99.1%` 与 `99.49%` 仍显示为 `99%`,而 `99.5%`、`99.95%` 与 `99.995%` 分别保留一位、两位与三位小数。`StatsLine` 使用 `bigint` 缩放并舍入整数 token 计数,从而避开浮点格式化限制,且不设置精度上限或替代文案。真实满命中不会携带多余的小数。同一份派生字符串同时用于行内统计与溢出 tooltip。 +所有非空比率都从零位小数开始。非满命中只有在舍入结果会成为 100% 时才逐位增加精度,因此 `99.1%` 与 `99.49%` 仍显示为 `99%`,而 `99.5%`、`99.95%` 与 `99.995%` 分别保留一位、两位与三位小数。`StatsLine` 对安全整数 token 计数执行精确的小因子比较,并且只在中间值仍处于该范围内时缩放接近满命中的差值。该算法既避开浮点临界值误差,也不设置精度上限或替代文案。真实满命中不会携带多余的小数。同一份派生字符串同时用于行内统计与溢出 tooltip。 ## 归属与生命周期 @@ -31,7 +31,7 @@ token-meter 继续从完整持久会话日志折叠用量。标准投影值变 ## 验证 -组件测试固定了零分母、普通整数舍入、直至三位小数的各个精度边界、需要十四位小数的近满累计样本、真实 `100%`、两种 locale,以及行内值与 tooltip 值的一致性。组装后的 `lifecycle-chrome` replay sidecar 将 `9,950 / 10,000 = 99.5%` 选作确定性测试输入;该比率按整数舍入会误报为 100%,同时基础会话 fixture 仍可重录。活跃页面断言与刷新后的浏览器快照都会显示 `99.5%`,且不会产生额外模型调用。 +组件测试固定了零分母、普通整数舍入、多个小数精度上的半步舍入、直至三位小数的各个精度边界、需要十四位小数的近满累计样本、真实 `100%`、两种 locale,以及行内值与 tooltip 值的一致性。组装后的 `lifecycle-chrome` replay sidecar 将 `9,950 / 10,000 = 99.5%` 选作确定性测试输入;该比率按整数舍入会误报为 100%,同时基础会话 fixture 仍可重录。活跃页面断言与刷新后的浏览器快照都会显示 `99.5%`,且不会产生额外模型调用。 ## 备选方案 diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 022b71d890..2d9d14483b 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -101,6 +101,26 @@ export function formatDuration(ms: number): string { return `${Math.floor(whole / 60)}m${whole % 60}s` } +/** Round a cache-read ratio to an integer percentage, with positive ties rounded up. */ +function roundedIntegerPercent(cacheReadTokens: number, denominator: number): number { + const denominatorQuotient = Math.floor(denominator / 200) + const denominatorRemainder = denominator % 200 + let lower = 0 + let upper = 100 + while (lower < upper) { + const candidate = Math.floor((lower + upper + 1) / 2) + const factor = candidate * 2 - 1 + const threshold = factor * denominatorQuotient + + Math.ceil(factor * denominatorRemainder / 200) + if (cacheReadTokens >= threshold) { + lower = candidate + } else { + upper = candidate - 1 + } + } + return lower +} + /** * Display-ready cache-hit share of prompt-side input over the whole durable log. * @param usage - the session's token-usage projection value. @@ -109,26 +129,36 @@ export function formatDuration(ms: number): string { * 100, and no billed input returns null. */ export function cacheHitPercent(usage: TokenUsageProjection): string | null { - const cacheReadTokens = BigInt(usage.cacheReadTokens) - const denominator = BigInt(usage.uncachedInputTokens) - + cacheReadTokens - + BigInt(usage.cacheWriteTokens) - if (denominator === 0n) return null - if (cacheReadTokens === denominator) return '100' + const denominator = billedInputTokens(usage) + if (denominator === 0) return null + const missedInputTokens = usage.uncachedInputTokens + usage.cacheWriteTokens + if (missedInputTokens === 0) return '100' - let decimalPlaces = 0 - let decimalScale = 1n - while (true) { - const fullHit = 100n * decimalScale - const rounded = (2n * cacheReadTokens * fullHit + denominator) / (2n * denominator) - if (rounded < fullHit) { - if (decimalPlaces === 0) return rounded.toString() - const digits = rounded.toString().padStart(decimalPlaces + 1, '0') - return `${digits.slice(0, -decimalPlaces)}.${digits.slice(-decimalPlaces)}` - } + const integerPercent = roundedIntegerPercent(usage.cacheReadTokens, denominator) + if (integerPercent < 100) return String(integerPercent) + + // At the first distinguishing precision, the rounded result is 100 minus + // one to five units in the final decimal place. Scale only while the next + // multiplication remains at or below the denominator, then derive that + // final digit through exact small-factor comparisons. + let decimalPlaces = 1 + let scaledDoubleGap = missedInputTokens * 200 + const denominatorTens = Math.floor(denominator / 10) + while (scaledDoubleGap <= denominatorTens) { + scaledDoubleGap *= 10 decimalPlaces += 1 - decimalScale *= 10n } + const denominatorOnes = denominator % 10 + let roundedLoss = 5 + for (let loss = 1; loss < 5; loss += 1) { + const factor = loss * 2 + 1 + const threshold = factor * denominatorTens + Math.floor(factor * denominatorOnes / 10) + if (scaledDoubleGap <= threshold) { + roundedLoss = loss + break + } + } + return `99.${'9'.repeat(decimalPlaces - 1)}${10 - roundedLoss}` } /** diff --git a/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx b/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx index e0dbc6f723..be55ee0579 100644 --- a/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx @@ -216,7 +216,10 @@ describe('StatsLine', () => { { actual: '99.5%', tokenUsageValue: tokenUsage(995, 5), expected: 'Cache hit 99.5%' }, { actual: '99.94%', tokenUsageValue: tokenUsage(9_994, 6), expected: 'Cache hit 99.9%' }, { actual: '99.95%', tokenUsageValue: tokenUsage(9_995, 5), expected: 'Cache hit 99.95%' }, + { actual: '99.955%', tokenUsageValue: tokenUsage(19_991, 9), expected: 'Cache hit 99.96%' }, + { actual: '99.985%', tokenUsageValue: tokenUsage(19_997, 3), expected: 'Cache hit 99.99%' }, { actual: '99.995%', tokenUsageValue: tokenUsage(19_999, 1), expected: 'Cache hit 99.995%' }, + { actual: '99.9975%', tokenUsageValue: tokenUsage(39_999, 1), expected: 'Cache hit 99.998%' }, { actual: 'the closest non-full ratio available from safe integer cumulative counts', tokenUsageValue: tokenUsage(Number.MAX_SAFE_INTEGER - 1, 1),