mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
fix(rollups): stabilise composed regression via centered identities
Reformulate composeRegression from the determinant form (n*Sxx - Sx^2) to the mean-centered corrected-sum identities (Sxx = Sxx - Sx^2/n, ...). Algebraically identical to the prior form and to Postgres REGR_* / STDDEV_POP, but it subtracts the x mean at matched scale and avoids the catastrophic cancellation the determinant form suffers on the ~1e10 epoch-day x-axis. Also read the exact stored sum_value accumulator for the Sy term instead of reconstructing it as mean*count, dropping the AVG-to-multiply float residual; fall back to mean*count when the column is null. Composition-only: no migration, no schema change. The WEIGHT live-parity case stays bit-identical at the 1e-9 relative bound. The near-flat DST parity seed improves by ~30x but still exceeds 1e-9, so it remains quarantined with an updated note pointing at a window-local x-rescale as the real fix.
This commit is contained in:
1 parent
a20e140a2f
commit
672e3f0e07
2 files changed
+78
-26
No files matched your search
@@ -63,8 +63,19 @@ export interface DailyMeanRow {
|
||||
*/
|
||||
export interface RegressionAccumulators {
|
||||
count: number;
|
||||
/** Σy = mean·count (the writer stores mean + count; Σy is derived). */
|
||||
/**
|
||||
* Per-bucket mean. Kept for the count/min/max/mean composition and as the
|
||||
* Σy fallback (`mean·count`) when the exact `sumValue` accumulator is null.
|
||||
*/
|
||||
mean: number;
|
||||
/**
|
||||
* Exact Σy = SUM(value) over the bucket's rows. The writer stores this in
|
||||
* `sum_value` (migration pre-0190; `schema.prisma` `sumValue`); composing Σy
|
||||
* from it instead of `mean·count` removes the ~1-ULP float-reorder residual
|
||||
* the AVG→multiply round trip introduces. `null` on a row whose write
|
||||
* predates the column, in which case the composer falls back to `mean·count`.
|
||||
*/
|
||||
sumValue?: number | null;
|
||||
sumX: number | null;
|
||||
sumXy: number | null;
|
||||
sumXx: number | null;
|
||||
@@ -83,15 +94,32 @@ export interface ComposedRegression {
|
||||
* v1.20.0 F6 — compose a windowed OLS slope / r² / population-sd from the
|
||||
* summed regression accumulators of a set of DAY buckets.
|
||||
*
|
||||
* The six terms (`n = Σcount`, `Σx`, `Σy = Σ(mean·count)`, `Σxy`, `Σxx`,
|
||||
* `Σyy`) are ADDITIVE across buckets, so summing the per-bucket
|
||||
* accumulators over the window and evaluating the closed form yields a
|
||||
* result BIT-IDENTICAL to Postgres `REGR_SLOPE` / `REGR_R2` / `STDDEV_POP`
|
||||
* over the same raw rows (Postgres folds the same accumulators):
|
||||
* The six terms (`n = Σcount`, `Σx`, `Σy`, `Σxy`, `Σxx`, `Σyy`) are ADDITIVE
|
||||
* across buckets, so summing the per-bucket accumulators over the window and
|
||||
* evaluating the closed form yields a result that matches Postgres
|
||||
* `REGR_SLOPE` / `REGR_R2` / `STDDEV_POP` over the same raw rows (Postgres
|
||||
* folds the same accumulators).
|
||||
*
|
||||
* slope = (n·Σxy − Σx·Σy) / (n·Σxx − Σx²)
|
||||
* r² = (n·Σxy − Σx·Σy)² / ((n·Σxx − Σx²)(n·Σyy − Σy²))
|
||||
* sd_pop = sqrt(Σyy/n − (Σy/n)²)
|
||||
* The composition uses the MEAN-CENTERED (corrected-sum) identities rather
|
||||
* than the textbook determinant form `n·Σxx − Σx²`:
|
||||
*
|
||||
* Sxx = Σxx − Σx²/n
|
||||
* Sxy = Σxy − Σx·Σy/n
|
||||
* Syy = Σyy − Σy²/n
|
||||
* slope = Sxy / Sxx
|
||||
* r² = Sxy² / (Sxx·Syy)
|
||||
* sd_pop = sqrt(Syy / n)
|
||||
*
|
||||
* This is algebraically identical to the determinant form (multiply numerator
|
||||
* and denominator by n) and to Postgres' REGR_* / STDDEV_POP, but numerically
|
||||
* stable: on the epoch-day x-axis (x ≈ 20 540) the determinant form computes
|
||||
* `n·Σxx − Σx²` as a difference of two ~1e10 magnitudes and sheds ~10
|
||||
* significant digits to catastrophic cancellation before the divide. The
|
||||
* centered form subtracts `Σx²/n` from `Σxx` at the same scale per term, so
|
||||
* the cancellation is bounded by the true x-variance, not the absolute x
|
||||
* magnitude. Σy is read from the exact stored `sumValue` accumulator when
|
||||
* present (falling back to `mean·count`), removing the AVG→multiply ULP
|
||||
* residual on the y side too.
|
||||
*
|
||||
* The caller MUST collapse overlapping sources to the canonical source
|
||||
* BEFORE handing rows here — the accumulators are per-source, so summing a
|
||||
@@ -138,9 +166,10 @@ export function composeRegression(
|
||||
}
|
||||
n += b.count;
|
||||
sumX += b.sumX;
|
||||
// Σy is reconstructed from the stored mean·count, matching the live
|
||||
// SUM(value) over the same rows.
|
||||
sumY += b.mean * b.count;
|
||||
// Σy is the exact stored SUM(value) when the bucket carries it; fall back
|
||||
// to mean·count for pre-`sumValue` rows. The exact accumulator removes the
|
||||
// AVG→multiply float-reorder residual.
|
||||
sumY += b.sumValue ?? b.mean * b.count;
|
||||
sumXy += b.sumXy;
|
||||
sumXx += b.sumXx;
|
||||
sumYy += b.sumYy;
|
||||
@@ -148,18 +177,22 @@ export function composeRegression(
|
||||
|
||||
if (n < 2) return MISS;
|
||||
|
||||
const denomX = n * sumXx - sumX * sumX;
|
||||
const denomY = n * sumYy - sumY * sumY;
|
||||
const cov = n * sumXy - sumX * sumY;
|
||||
// Mean-centered (corrected-sum) identities. Sxx/Sxy/Syy are the determinant
|
||||
// terms divided by n — algebraically identical to `n·Σxx − Σx²` etc., but
|
||||
// they subtract `Σx²/n` from `Σxx` at matched scale, avoiding the
|
||||
// catastrophic cancellation the determinant form suffers on the ~1e10
|
||||
// epoch-day x-axis. See the function header for the full rationale.
|
||||
const sxx = sumXx - (sumX * sumX) / n;
|
||||
const syy = sumYy - (sumY * sumY) / n;
|
||||
const sxy = sumXy - (sumX * sumY) / n;
|
||||
|
||||
const slope = denomX === 0 ? null : cov / denomX;
|
||||
const slope = sxx === 0 ? null : sxy / sxx;
|
||||
// REGR_R2 is null when either variance is zero (matches Postgres).
|
||||
const r2 =
|
||||
denomX === 0 || denomY === 0 ? null : (cov * cov) / (denomX * denomY);
|
||||
const r2 = sxx === 0 || syy === 0 ? null : (sxy * sxy) / (sxx * syy);
|
||||
|
||||
// Population variance: Σyy/n − (Σy/n)². Clamp tiny negative values that
|
||||
// Population variance: Syy/n = Σyy/n − (Σy/n)². Clamp tiny negative values
|
||||
// float rounding can produce when the variance is effectively zero.
|
||||
const variance = sumYy / n - (sumY / n) * (sumY / n);
|
||||
const variance = syy / n;
|
||||
const sdPop = variance <= 0 ? 0 : Math.sqrt(variance);
|
||||
|
||||
return { slope, r2, sdPop };
|
||||
|
||||
@@ -135,6 +135,7 @@ async function foldedAccumulators(
|
||||
select: {
|
||||
count: true,
|
||||
mean: true,
|
||||
sumValue: true,
|
||||
sumX: true,
|
||||
sumXy: true,
|
||||
sumXx: true,
|
||||
@@ -144,6 +145,7 @@ async function foldedAccumulators(
|
||||
return rows.map((r) => ({
|
||||
count: r.count,
|
||||
mean: r.mean,
|
||||
sumValue: r.sumValue,
|
||||
sumX: r.sumX,
|
||||
sumXy: r.sumXy,
|
||||
sumXx: r.sumXx,
|
||||
@@ -285,12 +287,29 @@ describe("rollup regression accumulators — live parity (v1.20.0 F6)", () => {
|
||||
expect(blended.slope).not.toBeCloseTo(live.slope!, 6);
|
||||
});
|
||||
|
||||
// TODO(v1.20.1): rebuild this cross-implementation REGR parity assertion. It
|
||||
// is a test-harness artifact on the DST same-UTC-day reading pair, not a
|
||||
// product bug — the production rollup math is covered bit-identically by the
|
||||
// WEIGHT case above (it folds the same raw rows the live REGR reads). See
|
||||
// .planning/v1200-backlog.md. Quarantined so the otherwise-complete, audited
|
||||
// release is not blocked; restore once the harness scope is reconstructed.
|
||||
// TODO(v1.20.1, still quarantined): the DST residual is a test-harness
|
||||
// conditioning artifact, NOT a product bug — production math is covered
|
||||
// bit-identically by the WEIGHT case above (which folds the same raw rows
|
||||
// live REGR reads and passes at the tight 1e-9 relative bound).
|
||||
//
|
||||
// Root cause (per the v1.20.1 perf-parity verification): the original
|
||||
// quarantine attributed the residual to catastrophic cancellation in the
|
||||
// determinant form `n·Σxx − Σx²` on the ~1e10 epoch-day x-axis.
|
||||
// `composeRegression` now evaluates the mean-centered (corrected-sum)
|
||||
// identities (`Sxx = Σxx − Σx²/n`, …), which subtract the x mean at matched
|
||||
// scale and shrink this residual by ~30× (was failing at ~1e-6-class noise;
|
||||
// now ~9e-8). It is still above the 1e-9 relative gauge for THIS deliberately
|
||||
// near-flat 5-point seed: the remaining gap is the difference between the
|
||||
// composed closed form and Postgres' OWN internal REGR accumulator algorithm
|
||||
// on an ill-conditioned (near-zero x-variance) window, not a divergence in
|
||||
// the stored data. Both answers round to the same value at the read tier's
|
||||
// 2–3 dp.
|
||||
//
|
||||
// The real close is the migration-based x-rescale noted for backlog: store /
|
||||
// compose with x offset to a window-local origin (Σx → O(window) instead of
|
||||
// ~20 540) so the products never reach 1e10. Until that lands the test stays
|
||||
// quarantined rather than masked behind a loosened tolerance. See
|
||||
// .planning/v1200-backlog.md.
|
||||
it.skip("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);
|
||||
|
||||
Reference in new issue
Block a user