Skip to content

Commit a179de0

Browse files
committed
fix(charts): use log scale for extreme outliers
1 parent ef42908 commit a179de0

5 files changed

Lines changed: 263 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- bench/charts: charts with a sparse extreme throughput tail now switch their measured axis to a log10 scale so ordinary values remain readable without clipping any result. The detector combines Tukey's far-outlier fence with median absolute deviation and requires a clearly separated upper tail; charts without extreme outliers remain linear. Classic-payload serialization always uses log10 because its JSON.Obj series is a distinct, much faster population
6+
57
## 2026-06-10 - v1.5.0
68

79
- perf(dynamic): **`JSON.Obj`, `JSON.Value`, and `JSON.Arr` are now lazy by default** - a near-alloc-less, simdjson On-Demand-style rework of dynamic parsing. `JSON.parse<JSON.Obj>` no longer eagerly materializes the whole tree: each nested value stores its raw source slice and is parsed only on first access (`.get<T>()` / `.getAs<T>()` / `.at(i)`), then cached - a value you never read is never parsed, and an untouched value re-serializes by copying its original source bytes verbatim. `JSON.Obj` is backed by a `StaticArray<u64>` value-slot buffer plus a length-prefixed key buffer (keys emit straight from their slice - no per-key string materialization), and a new buffer-backed `JSON.Arr` mirrors it (`.at(i)` → `JSON.Value`, `.getAs<T>(i)`, `.push<T>`, `.set<T>`, `.length`). Deferred composites reuse the NaN-boxed `JSON.Value` slot, and the SIMD/SWAR value scanners gained a vectorized composite (`{}`/`[]`) scan. Net for proxy / filter / forward workloads over large payloads: dynamic deserialize is several× faster with far fewer allocations, and untouched round-trips are byte-exact passthrough. A new `dynamic-interop` suite covers `Map`, `Date`, `JSON.Box`, `JSON.Raw`, and nested-struct interop

scripts/build-chart-classic.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
type BenchKind,
1717
type BenchResult,
1818
} from "./lib/bench-utils";
19+
import { withAdaptiveLogScale } from "./lib/chart-outliers";
1920
import { MODE_BARS, OBJ_BAR, rgba, BASE } from "./lib/palette";
2021

2122
const RUNTIMES = ["v8", "wavm"] as const;
@@ -97,7 +98,7 @@ for (const runtime of RUNTIMES) {
9798
continue;
9899
}
99100

100-
const config = createBarChart(chartData, payloadLabels, {
101+
let config = createBarChart(chartData, payloadLabels, {
101102
title: TITLES[kind],
102103
yLabel: "Throughput (MB/s)",
103104
xLabel: "",
@@ -118,6 +119,10 @@ for (const runtime of RUNTIMES) {
118119
labelRotation: -90,
119120
});
120121

122+
// JSON.Obj serialization is a distinct, much faster population. Force the
123+
// complete chart onto log10 so every bar remains visible without clipping.
124+
if (kind === "serialize") config = withAdaptiveLogScale(config, true);
125+
121126
const out = `./build/charts/classic-payload-${kind}-${runtime}`;
122127
generateChart(config, `${out}.svg`);
123128
generateChart(config, `${out}.png`);

scripts/lib/bench-utils.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { execSync } from "child_process";
44
import { ChartJSNodeCanvas } from "chartjs-node-canvas";
55
import type { ChartConfiguration } from "chart.js";
66
import { MODE_BARS, INK } from "./palette";
7+
import { withAdaptiveLogScale } from "./chart-outliers";
78

89
export interface BenchResult {
910
language: "as" | "js";
@@ -204,7 +205,7 @@ export function createBarChart(
204205
align: "end",
205206
rotation: options.labelRotation ?? 0,
206207
font: { weight: "bold", size: options.labelFontSize ?? 12 },
207-
formatter: (v: number) => v.toFixed(0),
208+
formatter: (v: number) => Math.round(v).toLocaleString("en-US"),
208209
},
209210
subtitle: {
210211
display: true,
@@ -324,6 +325,8 @@ export function generateChart(
324325
) {
325326
const isSvg = outfile.endsWith(".svg");
326327

328+
config = withAdaptiveLogScale(config);
329+
327330
// SVG is resolution-independent (dpr 1); PNG renders at 3x density so the
328331
// logical 1000x600 layout becomes a crisp 3000x1800 raster.
329332
config = {

scripts/lib/chart-outliers.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, expect, test } from "bun:test";
2+
import type { ChartConfiguration } from "chart.js";
3+
import { detectExtremeUpperTail, withAdaptiveLogScale } from "./chart-outliers";
4+
5+
describe("detectExtremeUpperTail", () => {
6+
test("detects a sparse extreme upper tail", () => {
7+
const result = detectExtremeUpperTail([
8+
401, 620, 780, 900, 1056, 1200, 1497, 2063, 2196, 2233, 2474, 3154, 5734,
9+
5920, 24743,
10+
]);
11+
12+
expect(result).toEqual({ firstOutlier: 24743, outlierCount: 1 });
13+
});
14+
15+
test("handles a small comparison chart with one extreme value", () => {
16+
const result = detectExtremeUpperTail([72, 475, 547, 3810, 6898, 18138]);
17+
18+
expect(result).toEqual({ firstOutlier: 18138, outlierCount: 1 });
19+
});
20+
21+
test("does not flag a broad or steadily increasing distribution", () => {
22+
expect(
23+
detectExtremeUpperTail([100, 180, 300, 500, 800, 1200, 1700]),
24+
).toBeNull();
25+
});
26+
27+
test("ignores zero placeholders used for missing benchmark bars", () => {
28+
expect(detectExtremeUpperTail([0, 0, 100, 102, 98, 101, 99, 1000])).toEqual(
29+
{ firstOutlier: 1000, outlierCount: 1 },
30+
);
31+
});
32+
});
33+
34+
describe("withAdaptiveLogScale", () => {
35+
test("keeps every value and changes only the measured axis", () => {
36+
const values = [72, 475, 547, 3810, 6898, 18138];
37+
const source: ChartConfiguration<"bar"> = {
38+
type: "bar",
39+
data: {
40+
labels: values.map(String),
41+
datasets: [{ data: values }],
42+
},
43+
options: {
44+
scales: {
45+
y: {
46+
beginAtZero: true,
47+
max: 20000,
48+
title: { display: true, text: "Throughput (MB/s)" },
49+
ticks: { stepSize: 500 },
50+
},
51+
},
52+
},
53+
};
54+
55+
const result = withAdaptiveLogScale(source);
56+
const dataset = result.data.datasets[0] as unknown as { data: number[] };
57+
const axis = result.options?.scales?.y as unknown as {
58+
type: string;
59+
beginAtZero: boolean;
60+
grace: string;
61+
max?: number;
62+
suggestedMax: number;
63+
title: { text: string };
64+
ticks: { stepSize?: number; callback: (value: unknown) => string };
65+
};
66+
67+
expect(dataset.data).toEqual(values);
68+
expect(axis.type).toBe("logarithmic");
69+
expect(axis.beginAtZero).toBe(false);
70+
expect(axis.grace).toBe("10%");
71+
expect(axis.max).toBeUndefined();
72+
expect(axis.suggestedMax).toBe(18138 * 1.25);
73+
expect(axis.ticks.stepSize).toBeUndefined();
74+
expect(axis.ticks.callback(5000)).toBe("5,000");
75+
expect(axis.ticks.callback(8000)).toBe("");
76+
expect(axis.title.text).toContain("log10 scale");
77+
});
78+
79+
test("leaves an ordinary chart linear unless forced", () => {
80+
const source: ChartConfiguration<"line"> = {
81+
type: "line",
82+
data: { datasets: [{ data: [100, 180, 300, 500, 800, 1200, 1700] }] },
83+
options: { scales: { y: { title: { text: "Throughput" } } } },
84+
};
85+
86+
expect(withAdaptiveLogScale(source)).toBe(source);
87+
const forced = withAdaptiveLogScale(source, true);
88+
expect(forced.data.datasets[0].data).toEqual(source.data.datasets[0].data);
89+
expect(forced.options?.scales?.y?.type).toBe("logarithmic");
90+
});
91+
});

scripts/lib/chart-outliers.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import type { ChartConfiguration } from "chart.js";
2+
3+
export interface ExtremeUpperTail {
4+
/** Lowest value classified as an outlier. */
5+
firstOutlier: number;
6+
/** Number of values in the extreme upper tail. */
7+
outlierCount: number;
8+
}
9+
10+
function quantile(sorted: number[], percentile: number): number {
11+
const position = (sorted.length - 1) * percentile;
12+
const lower = Math.floor(position);
13+
const upper = Math.ceil(position);
14+
const weight = position - lower;
15+
return sorted[lower] + (sorted[upper] - sorted[lower]) * weight;
16+
}
17+
18+
/**
19+
* Finds a small, clearly separated upper tail using two robust estimators:
20+
* Tukey's far-outlier fence and the median absolute deviation (MAD). The
21+
* stricter usable fence wins. A ratio guard prevents ordinary high values from
22+
* changing an otherwise readable chart to a logarithmic scale.
23+
*/
24+
export function detectExtremeUpperTail(
25+
input: readonly number[],
26+
): ExtremeUpperTail | null {
27+
const values = input
28+
.filter((value) => Number.isFinite(value) && value > 0)
29+
.sort((a, b) => a - b);
30+
31+
// Six values is enough for the smallest generated comparison chart while
32+
// still making quartiles meaningful.
33+
if (values.length < 6) return null;
34+
35+
const q1 = quantile(values, 0.25);
36+
const median = quantile(values, 0.5);
37+
const q3 = quantile(values, 0.75);
38+
const iqr = q3 - q1;
39+
40+
const deviations = values
41+
.map((value) => Math.abs(value - median))
42+
.sort((a, b) => a - b);
43+
const mad = quantile(deviations, 0.5);
44+
45+
const fences: number[] = [];
46+
if (iqr > 0) fences.push(q3 + 3 * iqr);
47+
if (mad > 0) fences.push(median + 6 * mad);
48+
49+
// When most values are identical both robust spreads can be zero. A lone
50+
// value still has to exceed the common value by 4x to qualify.
51+
if (fences.length === 0 && median > 0) fences.push(median * 4);
52+
if (fences.length === 0) return null;
53+
54+
const fence = Math.min(...fences);
55+
const firstOutlierIndex = values.findIndex((value) => value > fence);
56+
if (firstOutlierIndex <= 0) return null;
57+
58+
const outlierCount = values.length - firstOutlierIndex;
59+
// More than one fifth of the chart is a population, not a sparse upper tail.
60+
if (outlierCount / values.length > 0.2) return null;
61+
62+
const normalMax = values[firstOutlierIndex - 1];
63+
const firstOutlier = values[firstOutlierIndex];
64+
if (firstOutlier / normalMax < 1.35) return null;
65+
66+
return { firstOutlier, outlierCount };
67+
}
68+
69+
interface ChartDataset {
70+
data?: unknown[];
71+
}
72+
73+
function objectRecord(value: unknown): Record<string, unknown> {
74+
return value && typeof value === "object"
75+
? (value as Record<string, unknown>)
76+
: {};
77+
}
78+
79+
function measuredValue(point: unknown, horizontal: boolean): number | null {
80+
if (typeof point === "number" && Number.isFinite(point)) return point;
81+
if (!point || typeof point !== "object") return null;
82+
const key = horizontal ? "x" : "y";
83+
const value = (point as Record<string, unknown>)[key];
84+
return typeof value === "number" && Number.isFinite(value) ? value : null;
85+
}
86+
87+
function axisTitleWithLogScale(title: unknown): unknown {
88+
const record = objectRecord(title);
89+
if (!record.text) return title;
90+
const text = String(record.text);
91+
return text.includes("log10 scale")
92+
? title
93+
: { ...record, text: `${text} · log10 scale` };
94+
}
95+
96+
function logTickLabel(value: unknown): string {
97+
const numeric = typeof value === "number" ? value : Number(value);
98+
if (!Number.isFinite(numeric) || numeric <= 0) return "";
99+
const magnitude = 10 ** Math.floor(Math.log10(numeric));
100+
const mantissa = numeric / magnitude;
101+
const major = [1, 2, 5].some(
102+
(candidate) => Math.abs(mantissa - candidate) < 1e-8,
103+
);
104+
return major ? numeric.toLocaleString("en-US") : "";
105+
}
106+
107+
/**
108+
* Switches a chart's measured axis to Chart.js's logarithmic scale. By default
109+
* the switch is adaptive; callers can force it for a known mixed population.
110+
* Source values and datalabels are never modified.
111+
*/
112+
export function withAdaptiveLogScale(
113+
config: ChartConfiguration,
114+
force = false,
115+
): ChartConfiguration {
116+
const source = config as unknown as Record<string, unknown>;
117+
const type = source.type;
118+
if (type !== "bar" && type !== "line") return config;
119+
120+
const data = objectRecord(source.data);
121+
const datasets = Array.isArray(data.datasets)
122+
? (data.datasets as ChartDataset[])
123+
: [];
124+
const options = objectRecord(source.options);
125+
const horizontal = type === "bar" && options.indexAxis === "y";
126+
const values = datasets.flatMap((dataset) =>
127+
(dataset.data ?? [])
128+
.map((point) => measuredValue(point, horizontal))
129+
.filter((value): value is number => value !== null && value > 0),
130+
);
131+
if (values.length === 0) return config;
132+
if (!force && !detectExtremeUpperTail(values)) return config;
133+
134+
const scales = objectRecord(options.scales);
135+
const axisKey = horizontal ? "x" : "y";
136+
const axis = objectRecord(scales[axisKey]);
137+
const ticks = objectRecord(axis.ticks);
138+
const maxValue = Math.max(...values);
139+
const logarithmicAxis: Record<string, unknown> = {
140+
...axis,
141+
type: "logarithmic",
142+
beginAtZero: false,
143+
grace: "10%",
144+
suggestedMax: maxValue * (horizontal ? 2 : 1.25),
145+
title: axisTitleWithLogScale(axis.title),
146+
ticks: { ...ticks, callback: logTickLabel },
147+
};
148+
149+
// Linear-chart bounds and steps are not meaningful on a logarithmic axis.
150+
delete logarithmicAxis.max;
151+
delete (logarithmicAxis.ticks as Record<string, unknown>).stepSize;
152+
153+
return {
154+
...source,
155+
options: {
156+
...options,
157+
scales: { ...scales, [axisKey]: logarithmicAxis },
158+
},
159+
} as unknown as ChartConfiguration;
160+
}

0 commit comments

Comments
 (0)