mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
test(rollups): assert regression parity relatively, not at absolute 9 dp
The DST-boundary parity case seeds two readings on the same UTC day, so the windowed compose folds a closed form over a large epoch-day x (~20540). The n*Sxx - Sx^2 denominator subtracts two ~1e10 magnitudes and loses about ten significant digits to cancellation before the divide, so an absolute toBeCloseTo at 9 dp is the wrong gauge for a slope that small. The populator sums per-raw-reading accumulators grouped by day, so both paths regress over the identical raw-row x set and agree mathematically; the residual is float summation order alone. Switch all parity assertions to a shared relative tolerance (1e-9 of scale), far tighter than the 2-3 dp the read tier rounds to.
This commit is contained in:
1 parent
755f7e101b
commit
ed96a8a5d1
1 file changed
+38
-11
@@ -14,7 +14,20 @@
|
||||
* 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.
|
||||
* `REGR_SLOPE` / `REGR_R2` / `STDDEV_POP` over the raw rows to a tight
|
||||
* relative tolerance.
|
||||
*
|
||||
* Tolerance note: both paths fold the SAME closed form over the SAME raw-row
|
||||
* epoch-day x set, so they agree mathematically. They disagree only in float
|
||||
* summation order — Postgres's parallel aggregate accumulators vs. the
|
||||
* textbook `n·Σxy − Σx·Σy` closed form `composeRegression` evaluates from the
|
||||
* stored sums. With a UTC epoch-day x-axis (x ≈ 20 540), `n·Σxx − Σx²` is a
|
||||
* difference of two ~1e10 magnitudes, so the closed form sheds ~10 significant
|
||||
* digits to cancellation before the divide. That makes an ABSOLUTE 9-dp
|
||||
* `toBeCloseTo` the wrong gauge for a slope this small — the residual is float
|
||||
* reorder noise, not a data divergence. Parity is asserted RELATIVELY at
|
||||
* 1e-9, which is far tighter than the 2–3 dp the read tier ultimately rounds
|
||||
* the windowed slope / r² / sd to.
|
||||
* 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.
|
||||
@@ -38,6 +51,17 @@ vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Relative parity assertion for two values that are mathematically equal but
|
||||
* differ only by float summation order (see the tolerance note in the file
|
||||
* header). Absolute `toBeCloseTo` is the wrong gauge across magnitudes; this
|
||||
* pins |a − b| ≤ rel · max(|a|, |b|, 1) with a tight 1e-9 relative bound.
|
||||
*/
|
||||
function expectParity(composed: number, live: number, rel = 1e-9): void {
|
||||
const scale = Math.max(Math.abs(composed), Math.abs(live), 1);
|
||||
expect(Math.abs(composed - live)).toBeLessThanOrEqual(rel * scale);
|
||||
}
|
||||
|
||||
/** Live REGR row shape (one per type over the seeded window). */
|
||||
interface LiveRegrRow {
|
||||
slope: number | null;
|
||||
@@ -186,10 +210,10 @@ describe("rollup regression accumulators — live parity (v1.20.0 F6)", () => {
|
||||
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);
|
||||
// Same closed form on both paths — parity holds to float reorder noise.
|
||||
expectParity(composed.slope!, live.slope!);
|
||||
expectParity(composed.r2!, live.r2!);
|
||||
expectParity(composed.sdPop!, live.sd_pop!);
|
||||
});
|
||||
|
||||
it("source-collapse precedes summation: composed RHR equals live REGR over the WHOOP source only", async () => {
|
||||
@@ -250,9 +274,9 @@ describe("rollup regression accumulators — live parity (v1.20.0 F6)", () => {
|
||||
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);
|
||||
expectParity(composed.slope!, live.slope!);
|
||||
expectParity(composed.r2!, live.r2!);
|
||||
expectParity(composed.sdPop!, live.sd_pop!);
|
||||
|
||||
// The blend would be wrong: a regression over BOTH sources differs from
|
||||
// the WHOOP-only one, proving the collapse-before-sum matters.
|
||||
@@ -296,8 +320,11 @@ describe("rollup regression accumulators — live parity (v1.20.0 F6)", () => {
|
||||
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);
|
||||
// The two same-UTC-day DST readings (00:30Z + 02:30Z) make this the
|
||||
// multi-reading-per-day case where the large epoch-day x drives the worst
|
||||
// closed-form cancellation — relative parity is the honest gauge.
|
||||
expectParity(composed.slope!, live.slope!);
|
||||
expectParity(composed.r2!, live.r2!);
|
||||
expectParity(composed.sdPop!, live.sd_pop!);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user