diff --git a/src/components/ledger-table.tsx b/src/components/ledger-table.tsx index ef7729fd3..390cb715d 100644 --- a/src/components/ledger-table.tsx +++ b/src/components/ledger-table.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from "react"; import { rateDecimals, successTitle } from "@/lib/sample-precision"; import { rowHref } from "@/lib/row-link"; +import { barScale, fieldMean, sortableValue } from "@/lib/ledger-value"; import Link from "next/link"; import { ChevronDown, ChevronUp } from "lucide-react"; @@ -230,11 +231,25 @@ export function LedgerTable({ // window's first custom column, or `panel.values[slug]` when a panel // tab is active. Used for sort, filter, the inline data bar, and the // displayed value in the headline column. - const pickValue = (r: ProviderResult): number => { - if (activePanel) return activePanel.values[r.slug] ?? 0; - if (customCols) return colValueW(r, customCols[0]) ?? 0; + // The honest value, which can be absent. A panel holds one figure per + // provider and a provider we could not ask has none; coercing that to 0 + // publishes a refusal nobody gave. Bench 311 renders its capability + // figures as columns for exactly this reason, and said so in its + // methodology rather than fixing it here. + // + // Columns already distinguish the two: a custom cell renders "-" when its + // value is null. This brings the panel path to the same convention. + const pickValueOrNull = (r: ProviderResult): number | null => { + if (activePanel) return activePanel.values[r.slug] ?? null; + if (customCols) return colValueW(r, customCols[0]) ?? null; return r.ms.p50; }; + // Numeric form for the places that need a scalar and have no way to + // express absence: sort position, the data-bar scale. Missing sorts as + // the worst value rather than the best, which is what 0 was doing on a + // higher-is-better board. + const pickValue = (r: ProviderResult): number => + sortableValue(pickValueOrNull(r), higherIsBetter); // Detected from the first provider's results — if ANY provider declares // slot_p50/slot_p99 in its YAML queries, every row gets the column (with // "-" for providers that don't declare it). Used by Solana-native benches @@ -263,9 +278,9 @@ export function LedgerTable({ // Field-mean Δ — duplicated here so the sort comparator can rank by it // without waiting on per-Row computation. Kept aligned with the same // formula the Row uses for the displayed Δ% (see deltaPct below). - const fieldMeanRaw = - results.reduce((s, r) => s + pickValue(r), 0) / - Math.max(1, results.length); + // Rows with no value are excluded rather than counted as zero: a mean + // dragged down by absences makes every other row's delta wrong too. + const fieldMeanRaw = fieldMean(results.map((r) => pickValueOrNull(r))); const deltaForSort = (r: ProviderResult): number => { if (fieldMeanRaw === 0) return 0; return (pickValue(r) - fieldMeanRaw) / fieldMeanRaw; @@ -416,7 +431,7 @@ export function LedgerTable({ // Max + field-mean of the active column. Computed off `sorted` (the // scored set) so the field mean isn't pulled to zero by augmented // unavailable rows, which would flatten every delta to +999%. - const maxValue = Math.max(...sorted.map((r) => pickValue(r))) || 1; + const maxValue = barScale(sorted.map((r) => pickValueOrNull(r))); const fieldValue = sorted.reduce((s, r) => s + pickValue(r), 0) / Math.max(1, sorted.length); @@ -698,7 +713,7 @@ export function LedgerTable({ r={r} i={i} unit={unit} - value={pickValue(r)} + value={pickValueOrNull(r)} fieldValue={fieldValue} maxValue={maxValue} panelActive={panelActive} @@ -739,7 +754,7 @@ export function LedgerTable({ r={r} i={-1} unit={unit} - value={pickValue(r)} + value={pickValueOrNull(r)} fieldValue={fieldValue} maxValue={maxValue} panelActive={panelActive} @@ -825,7 +840,7 @@ function Row({ r: ProviderResult; i: number; unit: string; - value: number; + value: number | null; fieldValue: number; maxValue: number; panelActive: boolean; @@ -858,9 +873,10 @@ function Row({ const isUnranked = !isUnresponsive && !!r.unrankedLabel; const isOffline = !isUnresponsive && !isUnranked && r.availability === "unavailable"; const isMuted = isOffline || isUnresponsive; - const deltaPct = fieldValue > 0 ? ((value - fieldValue) / fieldValue) * 100 : 0; + const deltaPct = + value != null && fieldValue > 0 ? ((value - fieldValue) / fieldValue) * 100 : 0; const deltaSign = deltaPct > 0 ? "+" : deltaPct < 0 ? "−" : "±"; - const barPct = Math.max(2, (value / maxValue) * 100); + const barPct = value == null ? 0 : Math.max(2, (value / maxValue) * 100); return ( @@ -1134,9 +1150,11 @@ function Row({ ? customCells[0].v != null ? fmtUnit(customCells[0].v, customCells[0].unit) : "-" - : panelActive - ? fmtUnit(value, unit) - : fmtHeadline(value, { ...benchmark, unit }, r.slug)} + : value == null + ? "-" + : panelActive + ? fmtUnit(value, unit) + : fmtHeadline(value, { ...benchmark, unit }, r.slug)} diff --git a/src/lib/ledger-value.test.ts b/src/lib/ledger-value.test.ts new file mode 100644 index 000000000..3e189eef9 --- /dev/null +++ b/src/lib/ledger-value.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "bun:test"; + +import { barScale, fieldMean, sortableValue } from "@/lib/ledger-value"; + +/** + * The case these exist for, in full, because it is the reason bench 311 was + * built around the problem instead of through it. + * + * A metric panel holds one figure per provider. A provider we could not ask + * has none, and the table read that as `?? 0`. On a higher-is-better board a + * zero is not an absence, it is the worst possible reading, printed as a + * number with a bar beside it. Bench 311's methodology states the + * consequence plainly: "a provider with no reading for it is shown as a zero + * rather than left out, which would state a refusal we never received", and + * its capability figures were published as columns to avoid it. + * + * The same `?? 0` was also in the field mean, which is the denominator of + * every row's delta, so one unmeasured provider moved the percentage printed + * next to all the others. + */ +describe("sortableValue", () => { + it("sorts a missing value last on a lower-is-better board", () => { + // Latency. The worst place is the largest number, and 0 would have put an + // unmeasured provider at the top of the table. + expect(sortableValue(null, false)).toBe(Number.POSITIVE_INFINITY); + expect(sortableValue(null, false)).toBeGreaterThan(sortableValue(99999, false)); + }); + + it("sorts a missing value last on a higher-is-better board", () => { + // Capability, in percent. Here the worst place IS zero, which is why one + // sentinel cannot serve both directions. + expect(sortableValue(null, true)).toBe(0); + expect(sortableValue(null, true)).toBeLessThan(sortableValue(0.1, true)); + }); + + it("passes a real zero through unchanged", () => { + // A measured 0 is a refusal we did receive, and it must keep its place: + // a getLogs window of 0 blocks means the narrowest span was refused, + // which is a fact about the provider rather than about our access. + expect(sortableValue(0, true)).toBe(0); + expect(sortableValue(0, false)).toBe(0); + }); +}); + +describe("fieldMean", () => { + it("excludes missing rows instead of counting them as zero", () => { + // Four providers, one unmeasured. The mean of what was measured is 100. + // Counting the absence drags it to 75 and every other row's delta with it. + expect(fieldMean([100, 100, 100, null])).toBe(100); + }); + + it("is the plain mean when nothing is missing", () => { + expect(fieldMean([10, 20, 30])).toBe(20); + }); + + it("returns zero when nothing was measured", () => { + // The caller reads 0 as "no delta to show" rather than dividing by it. + expect(fieldMean([null, null])).toBe(0); + expect(fieldMean([])).toBe(0); + }); + + it("ignores the infinite sentinel if one ever reaches it", () => { + // sortableValue's lower-is-better sentinel is Infinity. If the two paths + // were ever crossed, a single Infinity would make the mean Infinity and + // every delta NaN, which renders as nothing at all and looks like a data + // outage rather than a bug. + expect(fieldMean([10, 20, Number.POSITIVE_INFINITY])).toBe(15); + }); +}); + +describe("barScale", () => { + it("scales to the largest measured value", () => { + expect(barScale([10, 40, null])).toBe(40); + }); + + it("never returns zero, so dividing by it stays safe", () => { + expect(barScale([])).toBe(1); + expect(barScale([null, null])).toBe(1); + expect(barScale([0, 0])).toBe(1); + }); + + it("refuses an infinite sentinel, which would flatten every real bar", () => { + // This is the failure that would be hardest to read on the page: every + // bar becomes a sliver and nothing errors. + expect(barScale([10, 40, Number.POSITIVE_INFINITY])).toBe(40); + }); +}); + +describe("the three together, on the shape that caused this", () => { + it("one unasked provider changes neither the mean nor the scale", () => { + // Bench 311's robinhood tab: five providers, one of which we could not + // ask on that chain. Measured values are the getLogs windows. + const measured = [500, 10_000, 10_000, 1_000_000]; + const withGap: (number | null)[] = [...measured, null]; + expect(fieldMean(withGap)).toBe(fieldMean(measured)); + expect(barScale(withGap)).toBe(barScale(measured)); + // And the unasked row is last rather than first on a board where more + // is better. + const keys = withGap.map((v) => sortableValue(v, true)); + expect(Math.min(...keys)).toBe(0); + expect(keys[keys.length - 1]).toBe(0); + }); +}); diff --git a/src/lib/ledger-value.ts b/src/lib/ledger-value.ts new file mode 100644 index 000000000..c820dc593 --- /dev/null +++ b/src/lib/ledger-value.ts @@ -0,0 +1,58 @@ +/** + * What the ledger does with a row that has no value. + * + * A metric panel holds one figure per provider, and a provider we could not + * ask has none. The table read that absence as `?? 0`, which on a + * higher-is-better board is not a missing reading, it is the worst possible + * one: a refusal nobody gave, ranked last, with a data bar of zero width and + * a number printed next to it. + * + * Bench 311 ran into this and routed around it rather than through it. Its + * capability figures are published as columns, and its methodology says why: + * "In a chart view the board renders one metric for every row, and a provider + * with no reading for it is shown as a zero rather than left out, which would + * state a refusal we never received." The columns were the workaround. This is + * the fix, and with it a capability or a window can live in the view selector + * on any board without lying about the rows it could not measure. + * + * Columns already had the convention right: a custom cell renders "-" when its + * value is null. Everything here brings the panel path to that same rule. + */ + +/** Sort key for a possibly-absent value. + * + * Absent must sort LAST in both directions, which a single sentinel cannot + * do: on a lower-is-better board the worst value is the largest, and on a + * higher-is-better board it is the smallest. Using 0 for both is what put an + * unmeasured provider at the top of a latency board. */ +export function sortableValue( + value: number | null, + higherIsBetter: boolean, +): number { + if (value != null) return value; + return higherIsBetter ? 0 : Number.POSITIVE_INFINITY; +} + +/** Mean over the rows that have a value. + * + * Counting an absence as zero does not only misplace that row: the field + * mean is the denominator of every other row's delta, so one unmeasured + * provider moves the percentage printed beside all the others. Returns 0 + * when nothing was measured, which the caller already treats as "no delta + * to show". */ +export function fieldMean(values: (number | null)[]): number { + const present = values.filter((v): v is number => v != null && Number.isFinite(v)); + if (present.length === 0) return 0; + return present.reduce((s, v) => s + v, 0) / present.length; +} + +/** Scale for the inline data bars: the largest value actually measured. + * + * Infinite sentinels from `sortableValue` must never reach this, or every + * real bar collapses to nothing. Returns 1 when there is nothing to scale + * against, so a division by it is still safe. */ +export function barScale(values: (number | null)[]): number { + const present = values.filter((v): v is number => v != null && Number.isFinite(v)); + if (present.length === 0) return 1; + return Math.max(...present, 0) || 1; +}