diff --git a/e2e/databaseAverages.spec.ts b/e2e/databaseAverages.spec.ts new file mode 100644 index 000000000..c5e2bf4f5 --- /dev/null +++ b/e2e/databaseAverages.spec.ts @@ -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(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}`); + }); +} diff --git a/src/features/database/resultView.ts b/src/features/database/resultView.ts index 7791c579f..56b16052c 100644 --- a/src/features/database/resultView.ts +++ b/src/features/database/resultView.ts @@ -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; diff --git a/src/features/database/resultViewAverages.test.ts b/src/features/database/resultViewAverages.test.ts new file mode 100644 index 000000000..14241fb59 --- /dev/null +++ b/src/features/database/resultViewAverages.test.ts @@ -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(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"); + }); +});