fix(database): keep terminating averages exact (#3303)

Selection and chart means stopped after 12 fractional digits even when the division terminates, which could also change an integer mean (1220703130000 instead of 1220703125000). The shared decimal division now continues until the remainder is zero when the reduced denominator has only factors 2 and 5; repeating quotients keep their rounding.

Co-authored-by: LunarFeller <269972968+LunarFeller@users.noreply.github.com>
This commit is contained in:
LunarFeller
2026-11-14 15:10:35 +08:00
committed by GitHub
co-authored by LunarFeller
parent 2430c892ba
commit f4502ffffe
3 changed files with 222 additions and 1 deletions
+79
View File
@@ -0,0 +1,79 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./fixtures";
const cases = [
{ name: "positive fraction", count: 8192, total: "1", expected: "0.0001220703125" },
{ name: "negative fraction", count: 8192, total: "-1", expected: "-0.0001220703125" },
{ name: "integer result", count: 8192, total: "10000000000000000", expected: "1220703125000" },
{ name: "cancelled divisor factor", count: 24576, total: "3", expected: "0.0001220703125" },
];
async function seedResult(page: Page, count: number, total: string, chart: boolean) {
await page.evaluate(
async ({ count, total, chart }) => {
const storePath = "/src/features/database/store.ts";
const contributionsPath = "/src/workbench/contributions.ts";
const { useDatabase, RESULTS_VIEW } = await import(storePath);
const { showServiceView } = await import(contributionsPath);
const values = [total, ...Array<string>(count - 1).fill("0"), null, null];
const columns = [{ name: "value", typeName: "NUMERIC" }];
if (chart) columns.unshift({ name: "category", typeName: "TEXT" });
const rows = values.map((value) => (chart ? ["all", value] : [value]));
const text = "SELECT value FROM finite_means";
useDatabase.setState({
loaded: true,
sources: [{ id: "finite", name: "Finite means", driver: "postgres", readOnly: true }],
consoles: { "finite-means.sql": "finite" },
results: [
{
id: 1,
title: "Result 1",
path: "finite-means.sql",
sourceId: "finite",
outcome: {
statement: { text, from: 0, to: text.length },
error: null,
result: {
statement: text,
columns,
rows,
affected: null,
hasMore: false,
elapsedMs: 1,
executionMs: 1,
},
},
page: 0,
pageSize: rows.length,
},
],
activeResult: 1,
});
showServiceView(RESULTS_VIEW);
},
{ count, total, chart },
);
await expect(page.getByRole("grid", { name: "Result 1", exact: true }).getByRole("gridcell").first()).toBeVisible();
}
for (const { name, count, total, expected } of cases) {
test(`selection mean stays exact: ${name}`, async ({ workbench: page }) => {
await seedResult(page, count, total, false);
const grid = page.getByRole("grid", { name: "Result 1", exact: true });
await grid.getByRole("gridcell").first().click();
await page.keyboard.press("Meta+a");
await page.getByRole("button", { name: "Show Record / Value / Aggregates", exact: true }).click();
const panel = page.getByRole("complementary", { name: "Value panel", exact: true });
await panel.getByRole("tab", { name: "Aggregates", exact: true }).click();
await expect(panel.getByRole("row", { name: /^Avg / }).getByRole("cell")).toHaveText(expected);
await expect(panel.getByRole("row", { name: "Nulls 2", exact: true })).toBeVisible();
});
test(`chart mean stays exact: ${name}`, async ({ workbench: page }) => {
await seedResult(page, count, total, true);
await page.getByRole("button", { name: "Chart", exact: true }).click();
const chart = page.getByRole("img", { name: "value (mean) by category", exact: true });
await expect(chart).toBeVisible();
await expect(chart.locator("rect title")).toHaveText(`all: ${expected}`);
});
}
+5 -1
View File
@@ -191,10 +191,14 @@ function sumDecimal(texts: readonly string[]): Decimal | null {
/** a / divisor (divisor > 0): exact when the division terminates, else the fraction rounds half-up at `SIGNIFICANT` digits. */
function divideDecimal(a: Decimal, divisor: bigint): Decimal {
if (!a.digits) return { negative: false, digits: 0n, exponent: 0 };
// A finite decimal requires the coefficient to cancel every divisor factor except 2 and 5.
let denominator = divisor;
for (const factor of [2n, 5n]) while (denominator % factor === 0n) denominator /= factor;
const terminating = a.digits % denominator === 0n;
let rest = a.digits % divisor;
let text = (a.digits / divisor).toString();
let fraction = "";
while (rest && fraction.length < SIGNIFICANT) {
while (rest && (terminating || fraction.length < SIGNIFICANT)) {
rest *= 10n;
fraction += (rest / divisor).toString();
rest %= divisor;
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import type { Cell } from "./model";
import { aggregates, chartSeries } from "./resultView";
const finiteMeans = [
{ count: 1, total: "1", expected: "1" },
{ count: 125, total: "1", expected: "0.008" },
{ count: 4096, total: "1", expected: "0.000244140625" },
{ count: 8192, total: "1", expected: "0.0001220703125" },
{ count: 8192, total: "-1", expected: "-0.0001220703125" },
{ count: 16384, total: "1", expected: "0.00006103515625" },
{ count: 32768, total: "1", expected: "0.000030517578125" },
{ count: 40960, total: "1", expected: "0.0000244140625" },
{ count: 24576, total: "3", expected: "0.0001220703125" },
{ count: 8192, total: "10000000000000000", expected: "1220703125000" },
{ count: 8192, total: "-1e16", expected: "-1220703125000" },
{ count: 8192, total: "1e400", expected: "1.220703125e396" },
{ count: 8192, total: "1e-400", expected: "1.220703125e-404" },
{ count: 8192, total: "0001.000e0", expected: "0.0001220703125" },
{ count: 8192, total: "1.0000000000000000001", expected: "0.00012207031250000000001220703125" },
{ count: 8192, total: "-0.00", expected: "0" },
];
function cellsWithTotal(count: number, total: string): string[] {
return [total, ...Array<string>(count - 1).fill("0")];
}
const meanOf = {
selection: (cells: readonly Cell[]) => aggregates(cells).avg,
chart: (cells: readonly Cell[]) =>
chartSeries(
cells.map((cell) => ["same", cell]),
0,
1,
"avg",
)[0]?.text ?? null,
};
describe.each(Object.entries(meanOf))("%s means", (_, mean) => {
it.each(finiteMeans)("keeps $total / $count exact", ({ count, total, expected }) => {
expect(mean(cellsWithTotal(count, total))).toBe(expected);
});
it("excludes NULL from the divisor", () => {
expect(mean([null, ...cellsWithTotal(8192, "1"), null])).toBe("0.0001220703125");
});
it("preserves cancellation before dividing", () => {
const cells = cellsWithTotal(8192, "9007199254740993");
cells[1] = "-9007199254740992";
expect(mean(cells)).toBe("0.0001220703125");
});
it.each([
{ count: 3, total: "1", expected: "0.333333333333" },
{ count: 3, total: "-2", expected: "-0.666666666667" },
{ count: 17, total: "1", expected: "0.058823529412" },
{ count: 3, total: "3000000000001", expected: "1000000000000.333333333333" },
{ count: 24576, total: "1", expected: "0.000040690104" },
{ count: 73728, total: "3", expected: "0.000040690104" },
])("retains the existing repeating quotient $total / $count", ({ count, total, expected }) => {
expect(mean(cellsWithTotal(count, total))).toBe(expected);
});
});
function rational(text: string): { numerator: bigint; denominator: bigint } {
const [mantissa, exponent = "0"] = text.toLowerCase().split("e");
const [whole, fraction = ""] = mantissa!.split(".");
const coefficient = BigInt(whole! + fraction);
const scale = fraction.length - Number(exponent);
return scale >= 0
? { numerator: coefficient, denominator: 10n ** BigInt(scale) }
: { numerator: coefficient * 10n ** BigInt(-scale), denominator: 1n };
}
describe("finite means against independent rational arithmetic", () => {
const counts = [1, 2, 5, 16, 25, 40, 125, 4096, 8192, 16384];
it.each(Array.from({ length: 60 }, (_, i) => i))("preserves the exact fraction in sample %i", (sample) => {
let seed = 0x6f31ad82 ^ sample;
const random = () => (seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0);
const count = counts[sample % counts.length]!;
const a = (2n ** 63n + BigInt(random())) * (sample % 2 ? -1n : 1n);
const b = count > 1 ? (2n ** 60n + BigInt(random())) * (sample % 3 ? 1n : -1n) : 0n;
const exponent = (random() % 801) - 400;
const cells = cellsWithTotal(count, `${a}e${exponent}`);
if (count > 1) cells[count - 1] = `${b}e${exponent}`;
const numerator = (a + b) * 10n ** BigInt(Math.max(0, exponent));
const denominator = BigInt(count) * 10n ** BigInt(Math.max(0, -exponent));
for (const mean of Object.values(meanOf)) {
const actual = rational(mean(cells)!);
expect(actual.numerator * denominator).toBe(numerator * actual.denominator);
}
});
});
describe("mean calculation regressions", () => {
it.each(([[], [null], [null, null], ["text"], ["1", "text"]] satisfies Cell[][]).map((cells) => ({ cells })))(
"keeps a non-numeric selection disabled: $cells",
({ cells }) => {
expect(aggregates(cells).avg).toBeNull();
},
);
it("uses each chart group's numeric cells for its mean", () => {
const rows: Cell[][] = [
...cellsWithTotal(8192, "1").map((value) => ["fraction", value]),
["fraction", null],
["fraction", "text"],
["ordinary", "0.1"],
["ordinary", "0.2"],
];
expect(chartSeries(rows, 0, 1, "avg")).toEqual([
{ label: "fraction", text: "0.0001220703125", value: 0.0001220703125 },
{ label: "ordinary", text: "0.15", value: 0.15 },
]);
});
it("retains exact odd and even medians", () => {
expect(aggregates(["9007199254740993", "9007199254740992"]).median).toBe("9007199254740992.5");
expect(aggregates(["1e-400", "2e-400"]).median).toBe("1.5e-400");
expect(aggregates(["1", "2", "3"]).median).toBe("2");
});
it("retains the existing fallback for an excessive exponent gap", () => {
expect(aggregates(["1e2000000000", "1"]).avg).toBe("Infinity");
expect(
chartSeries(
[
["same", "1e2000000000"],
["same", "1"],
],
0,
1,
"avg",
)[0]?.text,
).toBe("Infinity");
});
});