mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
This commit is contained in:
1 parent
81d318d7dc
commit
4706120879
2 files changed
+507
No files matched your search
@@ -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<string, Raw[]>();
|
||||
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
|
||||
});
|
||||
});
|
||||
@@ -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<typeof getPrismaClient>) {
|
||||
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<typeof getPrismaClient>,
|
||||
userId: string,
|
||||
type: string,
|
||||
source?: string,
|
||||
): Promise<LiveRegrRow> {
|
||||
const rows = await prisma.$queryRawUnsafe<LiveRegrRow[]>(
|
||||
`
|
||||
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<typeof getPrismaClient>,
|
||||
userId: string,
|
||||
type: string,
|
||||
source?: string,
|
||||
): Promise<RegressionAccumulators[]> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user