fix(webui): compact-format precision bugs (review IMPORTANT-2/3)

formatCompactCount: Math.floor(scaled*factor)/factor under-reported by
one unit whenever the intermediate scaled=abs/divisor value was not
exactly representable in binary floating point (8_700_000 -> "8.69M",
not "8.7M" — 8.7 itself has no exact double representation, so the
original two-step division/floor landed on 869 instead of 870).
Reordered to floor(abs*factor/divisor) — multiplying the original
INTEGER first keeps every intermediate value exact for any realistic
count, since abs*factor stays within Number.MAX_SAFE_INTEGER. New rows:
8_700_000 -> "8.7M", 1_130_000 -> "1.13M". (review IMPORTANT-2)

formatContextWindow: always divided by 1024 (binary-K), which is right
for a genuinely binary context size (262144 -> "256k") but wrong for a
decimal, marketing-style round count (128000 -> "125k", an odd number
nobody would recognize as "128K"). A plain "tokens % 1024 == 0" check
cannot separate these — 128000 is ALSO an exact multiple of 1024 (125 x
1024) despite not being a power of two. The actual, consistent
discriminator is POWER OF TWO: 262144 = 2^18, 128000 = 2^10 x 5^3 (not a
power of two). Binary-K/M now applies only when tokens is an exact power
of two; decimal-K/M otherwise. Fixed rows: 128000 -> "128k" (was
"125k"), 1_000_000 -> "1M" (was "977k"), 2_000_000 -> "2M" (was
"1953k"); 262144 -> "256k" and 32768 -> "32k" unchanged. (review
IMPORTANT-3)
This commit is contained in:
2026-08-22 02:12:54 +01:00
parent e3996d88bf
commit 5e3cf919d9
2 changed files with 92 additions and 18 deletions
+29 -6
View File
@@ -54,15 +54,31 @@ describe('refreshDetailLabel', () => {
// cost). formatContextWindow is table-driven over good/edge cases;
// formatModelCostHover covers the free/priced split ModelChip's
// hover-detail relies on.
//
// Review fix: binary-K/M (÷1024) applies ONLY when tokens is an exact
// power of two; a decimal, round-thousands count uses decimal-K/M
// (÷1000) instead — see formatContextWindow's own doc comment for why a
// plain "tokens % 1024 == 0" check cannot tell these two cases apart
// (128000 divides evenly by 1024 too, yet must render as decimal "128k",
// not binary "125k").
describe('formatContextWindow', () => {
it.each([
{ tokens: 262144, want: '256k' },
{ tokens: 32768, want: '32k' },
{ tokens: 1_000_000, want: '977k' },
{ tokens: 8192, want: '8k' },
{ tokens: 512, want: '512' }, // under 1024: plain digits, no "k"
// Genuine binary sizes (exact powers of two) — binary-K/M.
{ tokens: 262144, want: '256k' }, // 2^18 — LM Studio's own worked example
{ tokens: 131072, want: '128k' }, // 2^17
{ tokens: 32768, want: '32k' }, // 2^15
{ tokens: 8192, want: '8k' }, // 2^13
{ tokens: 1024, want: '1k' }, // 2^10 — boundary
{ tokens: 1_048_576, want: '1M' }, // 2^20 — binary M-tier boundary
// Decimal, marketing-style round counts — NOT powers of two, even
// when they happen to be exact multiples of 1024 (128000 = 125 ×
// 1024) — decimal-K/M.
{ tokens: 128_000, want: '128k' }, // review regression: was "125k"
{ tokens: 1_000_000, want: '1M' }, // review regression: was "977k"
{ tokens: 2_000_000, want: '2M' }, // review regression: was "1953k"
// Plain digits under 1024, either path.
{ tokens: 512, want: '512' },
{ tokens: 0, want: '0' },
{ tokens: 1024, want: '1k' }, // boundary
])('formatContextWindow($tokens) = $want', ({ tokens, want }) => {
expect(formatContextWindow(tokens)).toBe(want)
})
@@ -105,6 +121,13 @@ describe('formatCompactCount', () => {
{ n: 1_234_567, want: '1.23M' },
{ n: 45_600_000, want: '45.6M' },
{ n: 999_999_999, want: '999M' }, // just under the 1e9 boundary
// Review fix: IEEE-754 precision regressions. 8_700_000 / 1_000_000
// is not exactly representable in binary floating point (the double
// closest to 8.7 sits a hair BELOW it); the old
// `Math.floor(scaled*factor)/factor` implementation floored that
// slightly-under value to 869 instead of 870, rendering "8.69M".
{ n: 8_700_000, want: '8.7M' },
{ n: 1_130_000, want: '1.13M' },
// B tier.
{ n: 1_000_000_000, want: '1B' }, // the 1e9 boundary itself
{ n: 1_230_000_000, want: '1.23B' },
+63 -12
View File
@@ -47,15 +47,30 @@ export function formatCompactCount(n: number): string {
const sign = n < 0 ? '-' : ''
const [divisor, suffix] = abs < 1_000_000 ? [1_000, 'k'] : abs < 1_000_000_000 ? [1_000_000, 'M'] : [1_000_000_000, 'B']
const scaled = abs / divisor
// Three significant figures total: the floored integer part's own
// digit count (1, 2, or 3) decides how many decimal places are left.
const intDigits = Math.floor(scaled) >= 100 ? 3 : Math.floor(scaled) >= 10 ? 2 : 1
// Uses `abs / divisor` ONLY to pick the digit-count tier, never as the
// value that gets floored — see the review fix below for why.
const roughScaled = abs / divisor
const intDigits = Math.floor(roughScaled) >= 100 ? 3 : Math.floor(roughScaled) >= 10 ? 2 : 1
const decimals = 3 - intDigits
const factor = 10 ** decimals
const floored = Math.floor(scaled * factor) / factor
// Review fix (IEEE-754 precision bug): computing `scaled = abs /
// divisor` first, THEN `Math.floor(scaled * factor)`, under-reports by
// one unit whenever the intermediate `scaled` is not exactly
// representable in binary floating point — 8_700_000 / 1_000_000
// rounds to a double a hair BELOW 8.7 (≈8.699999999999999), so
// flooring `that * 100` lands on 869, not 870, and displays "8.69M"
// instead of "8.7M". Multiplying the ORIGINAL INTEGER abs by factor
// FIRST keeps every intermediate value exact for any realistic count
// (abs*factor stays within Number.MAX_SAFE_INTEGER, 2^53, for abs up
// to roughly 9×10^13 — far beyond any token/request count this
// function will ever see) — only the final division by divisor can
// still land a hair off after toFixed's own rounding, which
// toFixed(decimals) then resolves correctly.
const floored = Math.floor((abs * factor) / divisor) / factor
let text = floored.toFixed(decimals)
if (decimals > 0) {
@@ -65,17 +80,53 @@ export function formatCompactCount(n: number): string {
}
/**
* formatContextWindow renders a token count as a compact "256k"-style
* label (feature v0.23's context_window/AdminModelMetaView.contextTokens
* unit — plain token count, not bytes) — binary-K (÷1024), matching the
* task brief's own worked example (262144 tokens -> "256k"). Plain
* digits, no suffix, for anything under 1024: a context window that
* small is rare (LM Studio's smallest observed embedding models run
* 512-8192) but must still render as a real number, not "0k".
* isPowerOfTwo reports whether n is an exact power of two (1, 2, 4, 8,
* ..., 262144, ...) — the classic bit trick: a power of two has exactly
* one set bit, so `n & (n - 1)` clears it and leaves zero. Requires n >
* 0 (0 and negative inputs are not powers of two here, by definition of
* a token count).
*/
function isPowerOfTwo(n: number): boolean {
return n > 0 && (n & (n - 1)) === 0
}
/**
* formatContextWindow renders a token count as a compact "128k"/"256k"-
* style label (feature v0.23's context_window/AdminModelMetaView.
* contextTokens unit — plain token count, not bytes).
*
* Review fix: the original version always divided by 1024 (binary-K),
* which is right for LM Studio's own genuinely binary context sizes
* (262144 -> "256k") but WRONG for a decimal, marketing-style count a
* model card advertises in round thousands (128000 -> "125k" under
* binary division, which reads as an odd, unexplained number — the
* model is advertised as "128K", not "125K"). A plain "tokens % 1024 ==
* 0" check cannot tell these apart either: 128000 is ALSO an exact
* multiple of 1024 (128000 = 125 × 1024), yet the two cases must render
* differently. The correct discriminator is POWER OF TWO, not "multiple
* of 1024": 262144 = 2^18 (a genuine binary size), 128000 factors as
* 2^10 × 5^3 (not a power of two, despite dividing evenly by 1024). So:
* binary-K/M (÷1024, ÷1024²) only when tokens is an exact power of two;
* decimal-K/M (÷1000, ÷1,000,000) otherwise. Verified against every
* worked example: 128000 -> "128k" (decimal), 1_000_000 -> "1M"
* (decimal), 2_000_000 -> "2M" (decimal), 262144 -> "256k" (binary,
* unchanged), 32768 -> "32k" (binary, unchanged).
*
* Plain digits, no suffix, for anything under 1024 either way: a
* context window that small is rare (LM Studio's smallest observed
* embedding models run 512-8192) but must still render as a real
* number, not "0k".
*/
export function formatContextWindow(tokens: number): string {
if (tokens < 1024) return `${tokens}`
return `${Math.round(tokens / 1024)}k`
if (isPowerOfTwo(tokens)) {
if (tokens < 1024 * 1024) return `${Math.round(tokens / 1024)}k`
return `${Math.round(tokens / (1024 * 1024))}M`
}
if (tokens < 1_000_000) return `${Math.round(tokens / 1_000)}k`
return `${Math.round(tokens / 1_000_000)}M`
}
/** formatUsdPerMTok renders a USD-per-million-tokens float as "$0.19" (2 decimals) — the shared building block for both the visible cost chip and ModelChip's hover detail. */