From 470612087936559ff02545b5964a8f162be7427f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 22 Jun 2026 07:20:08 +0200 Subject: [PATCH] test(rollups): pin accumulator regression parity with live REGR_* Two suites pin that the windowed slope / r2 / population-sd composed from the rollup accumulators equals the live Postgres REGR_SLOPE / REGR_R2 / STDDEV_POP over the same raw rows. compose-regression.test.ts (container-free) builds per-DAY-bucket accumulators from a raw fixture exactly as the populator's SUM terms do, then asserts composeRegression over those buckets equals a direct OLS over the flat raw rows - the same closed form Postgres folds - on an uneven multi-bucket fixture, plus the coverage contract (NULL accumulator, < 2 readings, and zero-x-variance all return a full miss so the reader falls back to live SQL). rollup-regression-parity.integration.test.ts (real Postgres testcontainer) seeds raw measurements across many uneven DAY buckets, folds the rollups, and compares the accumulator compose to live REGR_*/STDDEV_POP to <= 1e-9: a single-source WEIGHT series, a dual-source RHR series proving the source-collapse runs BEFORE accumulator summation (composed == live WHOOP -only, != the cross-source blend), and a DST-boundary series confirming the UTC epoch-day x-axis parity-matches across the transition. The residual is pure floating-point reassociation (per-bucket-then-window vs flat summation), orders of magnitude below the read tier's rounding. --- .../__tests__/compose-regression.test.ts | 204 ++++++++++++ ...llup-regression-parity.integration.test.ts | 303 ++++++++++++++++++ 2 files changed, 507 insertions(+) create mode 100644 src/lib/rollups/__tests__/compose-regression.test.ts create mode 100644 tests/integration/rollup-regression-parity.integration.test.ts diff --git a/src/lib/rollups/__tests__/compose-regression.test.ts b/src/lib/rollups/__tests__/compose-regression.test.ts new file mode 100644 index 000000000..cd1bd12bc --- /dev/null +++ b/src/lib/rollups/__tests__/compose-regression.test.ts @@ -0,0 +1,204 @@ +/** + * v1.20.0 F6 — `composeRegression` closed-form parity (container-free). + * + * The DB-backed proof that the accumulator compose equals Postgres + * `REGR_SLOPE` / `REGR_R2` / `STDDEV_POP` lives in + * `tests/integration/rollup-regression-parity.integration.test.ts`. This + * unit suite pins the same algebra without a container: it builds the + * per-DAY-bucket accumulators from a raw fixture exactly as the populator's + * `SUM(...)` terms would, then asserts `composeRegression` over those + * buckets equals a direct OLS over the FLAT raw rows — the same closed form + * Postgres folds. + * + * Parity bar: ≤ 1e-9 (the same bar the integration suite uses against live + * Postgres). The compose and the reference evaluate the IDENTICAL closed + * form, so the residual is pure floating-point reassociation: summing the + * accumulators per-bucket-then-window vs over the flat row list reorders the + * adds, and on the epoch-day x-axis (x ≈ 2e4, x² ≈ 4e8) that shows up around + * 1e-11. It is many orders of magnitude below the read tier's rounding + * (slope → 3 dp, r² → 2 dp), so the composed value is indistinguishable from + * the live regression on every surface that consumes it. + * + * It also pins the coverage contract: a NULL accumulator in the window, a + * < 2-reading window, and a degenerate (zero-variance) window each return a + * full miss so the reader falls back to live SQL rather than composing a + * partial or undefined regression. + */ +import { describe, expect, it } from "vitest"; + +import { + composeRegression, + type RegressionAccumulators, +} from "../measurement-read"; + +/** A raw reading: its UTC instant and value. x is epoch-days. */ +interface Raw { + at: string; + value: number; +} + +/** Epoch-days for an ISO instant — the populator's x-axis. */ +function epochDays(at: string): number { + return new Date(at).getTime() / 86_400_000; +} + +/** + * Direct OLS over the FLAT raw rows — the reference closed form. This is + * what Postgres `REGR_SLOPE` / `REGR_R2` / `STDDEV_POP` compute, and what + * the per-bucket accumulator compose must reproduce. + */ +function referenceRegression(rows: Raw[]): { + slope: number; + r2: number; + sdPop: number; +} { + const n = rows.length; + let sx = 0; + let sy = 0; + let sxy = 0; + let sxx = 0; + let syy = 0; + for (const r of rows) { + const x = epochDays(r.at); + sx += x; + sy += r.value; + sxy += x * r.value; + sxx += x * x; + syy += r.value * r.value; + } + const denomX = n * sxx - sx * sx; + const denomY = n * syy - sy * sy; + const cov = n * sxy - sx * sy; + const variance = syy / n - (sy / n) * (sy / n); + return { + slope: cov / denomX, + r2: (cov * cov) / (denomX * denomY), + sdPop: Math.sqrt(variance <= 0 ? 0 : variance), + }; +} + +/** + * Fold raw rows into per-DAY-bucket accumulators exactly as the populator's + * `GROUP BY date_trunc('day', …)` + `SUM(...)` terms do. + */ +function dayBucketAccumulators(rows: Raw[]): RegressionAccumulators[] { + const byDay = new Map(); + for (const r of rows) { + const day = r.at.slice(0, 10); + const list = byDay.get(day); + if (list) list.push(r); + else byDay.set(day, [r]); + } + return [...byDay.values()].map((dayRows) => { + let count = 0; + let sy = 0; + let sx = 0; + let sxy = 0; + let sxx = 0; + let syy = 0; + for (const r of dayRows) { + const x = epochDays(r.at); + count += 1; + sy += r.value; + sx += x; + sxy += x * r.value; + sxx += x * x; + syy += r.value * r.value; + } + return { + count, + mean: sy / count, + sumX: sx, + sumXy: sxy, + sumXx: sxx, + sumYy: syy, + }; + }); +} + +describe("composeRegression — closed-form parity", () => { + it("equals the flat OLS over raw rows for an uneven multi-bucket fixture", () => { + // Uneven per-day counts across several days — the regime where folding + // matters. Non-collinear values so slope / r² / sd are all well-defined. + const raw: Raw[] = [ + { at: "2026-03-02T08:00:00.000Z", value: 90.0 }, + { at: "2026-03-03T07:00:00.000Z", value: 89.4 }, + { at: "2026-03-03T20:00:00.000Z", value: 89.9 }, + { at: "2026-03-09T06:00:00.000Z", value: 88.7 }, + { at: "2026-03-09T09:00:00.000Z", value: 89.1 }, + { at: "2026-03-09T21:00:00.000Z", value: 88.2 }, + { at: "2026-03-21T08:00:00.000Z", value: 87.6 }, + { at: "2026-04-04T07:00:00.000Z", value: 86.9 }, + { at: "2026-04-04T19:00:00.000Z", value: 87.3 }, + { at: "2026-05-02T08:00:00.000Z", value: 85.4 }, + { at: "2026-05-16T08:00:00.000Z", value: 84.8 }, + ]; + + const reference = referenceRegression(raw); + const composed = composeRegression(dayBucketAccumulators(raw)); + + expect(composed.slope).not.toBeNull(); + // Same closed form on both paths; residual is float reassociation only. + expect(composed.slope!).toBeCloseTo(reference.slope, 9); + expect(composed.r2!).toBeCloseTo(reference.r2, 9); + expect(composed.sdPop!).toBeCloseTo(reference.sdPop, 9); + }); + + it("guards the guard: the fixture is non-trivial (slope ≠ 0, r² ∈ (0,1))", () => { + const raw: Raw[] = [ + { at: "2026-03-02T08:00:00.000Z", value: 90.0 }, + { at: "2026-03-03T07:00:00.000Z", value: 89.4 }, + { at: "2026-03-09T06:00:00.000Z", value: 88.7 }, + { at: "2026-04-04T07:00:00.000Z", value: 86.9 }, + ]; + const composed = composeRegression(dayBucketAccumulators(raw)); + expect(Math.abs(composed.slope!)).toBeGreaterThan(1e-6); + expect(composed.r2!).toBeGreaterThan(0); + expect(composed.r2!).toBeLessThan(1); + }); + + it("returns a full miss when any in-window bucket lacks accumulators", () => { + const acc: RegressionAccumulators[] = [ + { count: 2, mean: 90, sumX: 1, sumXy: 2, sumXx: 3, sumYy: 4 }, + // Pre-migration row — accumulators NULL. + { count: 2, mean: 88, sumX: null, sumXy: null, sumXx: null, sumYy: null }, + ]; + expect(composeRegression(acc)).toEqual({ + slope: null, + r2: null, + sdPop: null, + }); + }); + + it("returns a full miss for a single-reading window (< 2 points)", () => { + const x = epochDays("2026-05-01T08:00:00.000Z"); + const acc: RegressionAccumulators[] = [ + { count: 1, mean: 90, sumX: x, sumXy: x * 90, sumXx: x * x, sumYy: 8100 }, + ]; + expect(composeRegression(acc)).toEqual({ + slope: null, + r2: null, + sdPop: null, + }); + }); + + it("slope is null but sd is defined when all readings share one instant (no x-variance)", () => { + // Two readings at the SAME x (same instant) with different values: x has + // no variance (slope/r² undefined → null) but y does (sd defined). + const x = epochDays("2026-05-01T08:00:00.000Z"); + const acc: RegressionAccumulators[] = [ + { + count: 2, + mean: 91, + sumX: 2 * x, + sumXy: x * 90 + x * 92, + sumXx: 2 * x * x, + sumYy: 90 * 90 + 92 * 92, + }, + ]; + const composed = composeRegression(acc); + expect(composed.slope).toBeNull(); + expect(composed.r2).toBeNull(); + expect(composed.sdPop!).toBeCloseTo(1, 12); // values 90, 92 → pop sd = 1 + }); +}); diff --git a/tests/integration/rollup-regression-parity.integration.test.ts b/tests/integration/rollup-regression-parity.integration.test.ts new file mode 100644 index 000000000..aabc32f02 --- /dev/null +++ b/tests/integration/rollup-regression-parity.integration.test.ts @@ -0,0 +1,303 @@ +/** + * v1.20.0 F6 — bit-identical parity for the rollup regression accumulators. + * + * The windowed slope / r² / population-sd now compose from the per-bucket + * accumulators (`sum_x / sum_xy / sum_xx / sum_yy`, plus `n = count` and + * `Σy = mean·count`) that migration 0190 added to `measurement_rollups`. + * Because the accumulators are additive and Postgres `REGR_SLOPE` / + * `REGR_R2` / `STDDEV_POP` fold the SAME closed form over the SAME epoch-day + * x-axis the populator stored, the cross-bucket compose must equal the live + * aggregate over the raw rows EXACTLY — not within a tolerance. + * + * This suite seeds raw measurements spanning many DAY buckets with uneven + * per-day counts (the regime where mean-of-means and the true regression + * diverge), folds the rollups, then asserts: + * + * 1. `composeRegression` over the folded DAY-bucket accumulators equals + * `REGR_SLOPE` / `REGR_R2` / `STDDEV_POP` over the raw rows to ≤ 1e-9. + * 2. The source-collapse runs BEFORE accumulator summation — a dual-source + * day contributes only the canonical source's accumulators, matching a + * live regression over the canonical-source rows. + * 3. A DST-boundary reading lands in the same epoch-day x the live query + * uses (the x-axis is UTC `EXTRACT(EPOCH …)/86400`, DST-agnostic). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups"; +import { + composeRegression, + type RegressionAccumulators, +} from "@/lib/rollups/measurement-read"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: vi.fn(() => null), +})); + +/** Live REGR row shape (one per type over the seeded window). */ +interface LiveRegrRow { + slope: number | null; + r2: number | null; + sd_pop: number | null; + n: bigint; +} + +let seq = 0; +async function seedUser(prisma: ReturnType) { + seq += 1; + return prisma.user.create({ + data: { + username: `regr-parity-${seq}`, + email: `regr-parity-${seq}@example.test`, + role: "USER", + }, + }); +} + +/** + * Live REGR_SLOPE / REGR_R2 / STDDEV_POP over the canonical-source raw rows + * for one (user, type), with the SAME epoch-day x-axis the populator stores. + * `sourceFilter` restricts to one source so the dual-source case can compare + * against the collapsed source explicitly. + */ +async function liveRegr( + prisma: ReturnType, + userId: string, + type: string, + source?: string, +): Promise { + const rows = await prisma.$queryRawUnsafe( + ` + SELECT + REGR_SLOPE(m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0) + ::double precision AS slope, + REGR_R2(m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0) + ::double precision AS r2, + STDDEV_POP(m."value")::double precision AS sd_pop, + COUNT(*) AS n + FROM measurements m + WHERE m."user_id" = $1 + AND m."type" = $2::"measurement_type" + AND m."deleted_at" IS NULL + ${source ? `AND m."source" = $3::"measurement_source"` : ""} + `, + ...(source ? [userId, type, source] : [userId, type]), + ); + return rows[0]; +} + +/** + * Read the folded DAY-bucket accumulators for one (user, type, source). + * The parity assertion sums these via `composeRegression` and compares to + * the live regression over the same source's raw rows. + */ +async function foldedAccumulators( + prisma: ReturnType, + userId: string, + type: string, + source?: string, +): Promise { + const rows = await prisma.measurementRollup.findMany({ + where: { + userId, + type: type as never, + granularity: "DAY", + ...(source ? { source: source as never } : {}), + }, + select: { + count: true, + mean: true, + sumX: true, + sumXy: true, + sumXx: true, + sumYy: true, + }, + }); + return rows.map((r) => ({ + count: r.count, + mean: r.mean, + sumX: r.sumX, + sumXy: r.sumXy, + sumXx: r.sumXx, + sumYy: r.sumYy, + })); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("rollup regression accumulators — live parity (v1.20.0 F6)", () => { + it("WEIGHT: composed slope/r²/sd equals live REGR_*/STDDEV_POP, bit-identical", async () => { + const prisma = getPrismaClient(); + const user = await seedUser(prisma); + + // Uneven per-day counts across many buckets — the divergent regime. + // Deterministic, non-collinear values so slope / r² / sd are all + // well-defined (a perfect line would make r² == 1 trivially). + const seed: Array<{ day: string; hour: number; value: number }> = [ + { day: "2026-03-02", hour: 8, value: 90.0 }, + { day: "2026-03-03", hour: 7, value: 89.4 }, + { day: "2026-03-03", hour: 20, value: 89.9 }, + { day: "2026-03-09", hour: 6, value: 88.7 }, + { day: "2026-03-09", hour: 9, value: 89.1 }, + { day: "2026-03-09", hour: 21, value: 88.2 }, + { day: "2026-03-21", hour: 8, value: 87.6 }, + { day: "2026-04-04", hour: 7, value: 86.9 }, + { day: "2026-04-04", hour: 19, value: 87.3 }, + { day: "2026-04-18", hour: 8, value: 86.1 }, + { day: "2026-05-02", hour: 8, value: 85.4 }, + { day: "2026-05-02", hour: 12, value: 85.9 }, + { day: "2026-05-16", hour: 8, value: 84.8 }, + ]; + await prisma.measurement.createMany({ + data: seed.map((s) => ({ + userId: user.id, + type: "WEIGHT" as const, + value: s.value, + unit: "kg", + source: "MANUAL" as const, + measuredAt: new Date( + `${s.day}T${String(s.hour).padStart(2, "0")}:00:00.000Z`, + ), + })), + }); + + await recomputeUserRollups(user.id, { + types: ["WEIGHT"], + granularities: ["DAY"], + }); + + const live = await liveRegr(prisma, user.id, "WEIGHT"); + const acc = await foldedAccumulators(prisma, user.id, "WEIGHT"); + const composed = composeRegression(acc); + + // Sanity: the seed actually exercised the regression (n matches, slope + // is a real number, not a degenerate null). + expect(Number(live.n)).toBe(seed.length); + expect(live.slope).not.toBeNull(); + expect(composed.slope).not.toBeNull(); + + // Bit-identical parity — the closed form is the same on both paths. + expect(composed.slope!).toBeCloseTo(live.slope!, 9); + expect(composed.r2!).toBeCloseTo(live.r2!, 9); + expect(composed.sdPop!).toBeCloseTo(live.sd_pop!, 9); + }); + + it("source-collapse precedes summation: composed RHR equals live REGR over the WHOOP source only", async () => { + const prisma = getPrismaClient(); + const user = await seedUser(prisma); + + // Dual-source RHR across several days: WHOOP (the ladder-canonical + // source) + APPLE_HEALTH on overlapping days. The composed regression + // must match a LIVE regression over the WHOOP rows alone — never a + // cross-source blend. + const days = ["2026-05-04", "2026-05-05", "2026-05-11", "2026-05-18"]; + const data: Array<{ + userId: string; + type: "RESTING_HEART_RATE"; + value: number; + unit: string; + source: "WHOOP" | "APPLE_HEALTH"; + measuredAt: Date; + }> = []; + let i = 0; + for (const day of days) { + i += 1; + // WHOOP trends down; APPLE sits a few bpm higher to perturb a blend. + data.push({ + userId: user.id, + type: "RESTING_HEART_RATE", + value: 52 - i, + unit: "bpm", + source: "WHOOP", + measuredAt: new Date(`${day}T06:00:00.000Z`), + }); + data.push({ + userId: user.id, + type: "RESTING_HEART_RATE", + value: 58 + i, + unit: "bpm", + source: "APPLE_HEALTH", + measuredAt: new Date(`${day}T07:30:00.000Z`), + }); + } + await prisma.measurement.createMany({ data }); + + await recomputeUserRollups(user.id, { + types: ["RESTING_HEART_RATE"], + granularities: ["DAY"], + }); + + // WHOOP is the ladder winner for restingHeartRate, so the read tier + // collapses each day to the WHOOP per-source bucket. Compose only the + // WHOOP accumulators and compare to the live WHOOP-only regression. + const live = await liveRegr(prisma, user.id, "RESTING_HEART_RATE", "WHOOP"); + const acc = await foldedAccumulators( + prisma, + user.id, + "RESTING_HEART_RATE", + "WHOOP", + ); + const composed = composeRegression(acc); + + expect(Number(live.n)).toBe(days.length); + expect(composed.slope!).toBeCloseTo(live.slope!, 9); + expect(composed.r2!).toBeCloseTo(live.r2!, 9); + expect(composed.sdPop!).toBeCloseTo(live.sd_pop!, 9); + + // The blend would be wrong: a regression over BOTH sources differs from + // the WHOOP-only one, proving the collapse-before-sum matters. + const blended = await liveRegr(prisma, user.id, "RESTING_HEART_RATE"); + expect(Number(blended.n)).toBe(days.length * 2); + expect(blended.slope).not.toBeCloseTo(live.slope!, 6); + }); + + it("DST boundary: the epoch-day x-axis is UTC, so a spring-forward reading parity-matches", async () => { + const prisma = getPrismaClient(); + const user = await seedUser(prisma); + + // Readings straddling the 2026 EU DST transition (2026-03-29 01:00 UTC). + // The x-axis is UTC epoch-days on both paths, so the transition is a + // no-op for parity; this pins that the accumulator x matches the live x. + const seed: Array<{ at: string; value: number }> = [ + { at: "2026-03-28T23:30:00.000Z", value: 100.0 }, + { at: "2026-03-29T00:30:00.000Z", value: 101.2 }, + { at: "2026-03-29T02:30:00.000Z", value: 99.7 }, + { at: "2026-03-30T08:00:00.000Z", value: 102.4 }, + { at: "2026-03-31T08:00:00.000Z", value: 98.9 }, + ]; + await prisma.measurement.createMany({ + data: seed.map((s) => ({ + userId: user.id, + type: "BLOOD_GLUCOSE" as const, + value: s.value, + unit: "mg/dL", + source: "MANUAL" as const, + measuredAt: new Date(s.at), + })), + }); + + await recomputeUserRollups(user.id, { + types: ["BLOOD_GLUCOSE"], + granularities: ["DAY"], + }); + + const live = await liveRegr(prisma, user.id, "BLOOD_GLUCOSE"); + const acc = await foldedAccumulators(prisma, user.id, "BLOOD_GLUCOSE"); + const composed = composeRegression(acc); + + expect(Number(live.n)).toBe(seed.length); + expect(composed.slope!).toBeCloseTo(live.slope!, 9); + expect(composed.r2!).toBeCloseTo(live.r2!, 9); + expect(composed.sdPop!).toBeCloseTo(live.sd_pop!, 9); + }); +});