mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(stats): one standard deviation with its kind named at the call site
About a dozen hand-rolled spreads each picked population or sample variance without saying so, and the two surfaces a person compares picked differently: the series API reported the population SD of a blood-pressure series, the MCP tools and the AI payload the sample SD of the same readings, about 22 % apart at three readings. `lib/stats/spread` holds `variance` and `standardDeviation` with an explicit `population | sample` kind and `READING_SPREAD`, the kind every surface uses to report the spread of a person's readings. The series route and the feature payload both report it; the spreads that feed a verdict (the normal-swing checks, the changepoint threshold) and the clinical glucose SD stay the sample estimate, now said out loud. A guard fails on a new hand-rolled spread and on the two surfaces drifting apart.
This commit is contained in:
1 parent
7535235b82
commit
ac86199808
20 files changed
+263
-96
No files matched your search
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Structural guard: a standard deviation is computed in `lib/stats/spread`,
|
||||
* with its kind named at the call site.
|
||||
*
|
||||
* About a dozen hand-rolled spreads each picked population (÷ n) or sample
|
||||
* (÷ n − 1) without saying so, and the two surfaces a person compares picked
|
||||
* differently: the series API reported the population SD of a blood-pressure
|
||||
* series, the MCP and AI payload the sample SD of the same readings, about
|
||||
* 22 % apart at three readings. The reading spread both report is now
|
||||
* `READING_SPREAD`.
|
||||
*
|
||||
* The matcher flags a squared deviation from a mean in a file that also takes
|
||||
* a square root. The files below compute one for a test statistic or a fit,
|
||||
* never as a reported spread, and say so. The doctor report's glucose panel
|
||||
* composes its SD from per-bucket sums of squares (sample, the same rule as
|
||||
* `glucoseSD`) and is out of the matcher's reach by shape. Comments are
|
||||
* stripped first; the planted case proves the matcher fires.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
const ALLOWED: Record<string, string> = {
|
||||
"lib/stats/spread.ts": "The implementation.",
|
||||
"lib/insights/correlations.ts":
|
||||
"The correlation engine's centred sums and the Welch t-test variances.",
|
||||
"lib/insights/mood-tag-influence.ts": "Welch t-test variances.",
|
||||
"lib/insights/mood-pattern-cards.ts": "A pooled variance for a t statistic.",
|
||||
"lib/insights/derived/trajectory.ts":
|
||||
"The residual standard error of a regression fit (÷ n − 2).",
|
||||
};
|
||||
|
||||
const SQUARED_DEVIATION =
|
||||
/\(\s*[\w.[\]]+\s*-\s*[\w.[\]]+\s*\)\s*\*\*\s*2|\(\s*([\w.[\]]+)\s*-\s*([\w.[\]]+)\s*\)\s*\*\s*\(\s*\1\s*-\s*\2\s*\)/;
|
||||
|
||||
function stripComments(text: string): string {
|
||||
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
function computesSpread(code: string): boolean {
|
||||
return SQUARED_DEVIATION.test(code) && code.includes("Math.sqrt(");
|
||||
}
|
||||
|
||||
function read(file: string): string {
|
||||
return stripComments(readFileSync(join(SRC, file), "utf8"));
|
||||
}
|
||||
|
||||
describe("one standard deviation", () => {
|
||||
it("the matcher fires on a hand-rolled spread", () => {
|
||||
expect(
|
||||
computesSpread(
|
||||
"const v = xs.reduce((s, x) => s + (x - m) ** 2, 0) / n; Math.sqrt(v);",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
computesSpread("acc += (v - mean) * (v - mean); Math.sqrt(acc / n);"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("no file outside the list computes its own", () => {
|
||||
const offenders = walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter(
|
||||
(file) =>
|
||||
!file.startsWith("generated/") &&
|
||||
!file.includes("__tests__/") &&
|
||||
!/\.test\.tsx?$/.test(file),
|
||||
)
|
||||
.filter((file) => computesSpread(read(file)) && !(file in ALLOWED));
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("the series API and the AI and MCP payload report the same spread", () => {
|
||||
expect(read("app/api/measurements/series/route.ts")).toMatch(
|
||||
/standardDeviation\(\s*values,\s*READING_SPREAD\s*\)/,
|
||||
);
|
||||
const features = read("lib/insights/features.ts");
|
||||
expect(
|
||||
features.match(/roundedSpread\(\s*vals,\s*READING_SPREAD\s*\)/g),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
import { resolveSeriesReferenceLines } from "@/lib/measurements/series-reference-lines";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { resolveJobLocale } from "@/lib/i18n/job-locale";
|
||||
import { READING_SPREAD, standardDeviation } from "@/lib/stats/spread";
|
||||
|
||||
/**
|
||||
* v1.11.4 — sleep is read row-per-stage (not from the day rollup), so its
|
||||
@@ -209,10 +210,8 @@ interface SeriesPoint {
|
||||
|
||||
function stdDev(values: number[]): number {
|
||||
if (values.length < 2) return 0;
|
||||
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
|
||||
const variance =
|
||||
values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length;
|
||||
return Math.round(Math.sqrt(variance) * 100) / 100;
|
||||
const sd = standardDeviation(values, READING_SPREAD) ?? 0;
|
||||
return Math.round(sd * 100) / 100;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
*/
|
||||
|
||||
import { GLUCOSE_TIR_CUT_POINTS } from "@/lib/glucose";
|
||||
import { standardDeviation } from "@/lib/stats/spread";
|
||||
|
||||
// ── Inputs ───────────────────────────────────────────────
|
||||
|
||||
@@ -236,15 +237,7 @@ function mean(values: number[]): number {
|
||||
* Returns null for n < 2 (undefined sample SD).
|
||||
*/
|
||||
export function glucoseSD(values: number[]): number | null {
|
||||
const n = values.length;
|
||||
if (n < 2) return null;
|
||||
const m = mean(values);
|
||||
let ss = 0;
|
||||
for (const v of values) {
|
||||
const d = v - m;
|
||||
ss += d * d;
|
||||
}
|
||||
return Math.sqrt(ss / (n - 1));
|
||||
return standardDeviation(values, "sample");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
LINKED_FEATURE_METRICS,
|
||||
} from "./feature-keys";
|
||||
import { occurrencesSuffice } from "./thresholds";
|
||||
import { standardDeviation } from "@/lib/stats/spread";
|
||||
|
||||
/**
|
||||
* Most features the fit may carry.
|
||||
@@ -444,10 +445,7 @@ export function buildFeatureMatrix(
|
||||
const standardisation = selected.map((key, column) => {
|
||||
const values = rawRows.map((row) => row[column]);
|
||||
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
|
||||
const variance =
|
||||
values.reduce((sum, v) => sum + (v - mean) * (v - mean), 0) /
|
||||
values.length;
|
||||
return { key, mean, sd: Math.sqrt(variance) };
|
||||
return { key, mean, sd: standardDeviation(values, "population") ?? 0 };
|
||||
});
|
||||
|
||||
// A column with no spread inside the complete-case set carries no
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
insufficientPillar,
|
||||
mean,
|
||||
okPillar,
|
||||
standardDeviation,
|
||||
readingSpread,
|
||||
} from "./shared";
|
||||
|
||||
export const ACTIVITY_WINDOW_DAYS = 28;
|
||||
@@ -74,7 +74,7 @@ export function computeActivityPillar(
|
||||
const score = clampScore((observed / plateau) * 100);
|
||||
const noiseFloor = Math.max(
|
||||
1,
|
||||
Math.round(((0.5 * standardDeviation(values)) / plateau) * 100),
|
||||
Math.round(((0.5 * readingSpread(values)) / plateau) * 100),
|
||||
);
|
||||
const latestDay = [...points].sort((a, b) => b.day.localeCompare(a.day))[0];
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
median,
|
||||
okPillar,
|
||||
scoreReferenceBand,
|
||||
standardDeviation,
|
||||
readingSpread,
|
||||
uniqueSources,
|
||||
withinWindow,
|
||||
} from "./shared";
|
||||
@@ -126,7 +126,7 @@ export function computeGlycaemiaPillar(
|
||||
const observed = median(values);
|
||||
// The behavioural/device noise convention from the design is one half of
|
||||
// within-person SD, translated onto this pillar's proportional score scale.
|
||||
const halfSd = standardDeviation(values) * 0.5;
|
||||
const halfSd = readingSpread(values) * 0.5;
|
||||
const floorScore = scoreReferenceBand(
|
||||
Math.max(0, observed - halfSd),
|
||||
FASTING_GLUCOSE_REFERENCE.low,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from "@/lib/insights/derived/types";
|
||||
|
||||
import type { PillarValue, ScoreBand, ScorePillarId } from "./types";
|
||||
import { READING_SPREAD, standardDeviation } from "@/lib/stats/spread";
|
||||
|
||||
export const DAY_MS = 86_400_000;
|
||||
|
||||
@@ -88,11 +89,9 @@ export function median(values: readonly number[]): number {
|
||||
: ordered[middle];
|
||||
}
|
||||
|
||||
export function standardDeviation(values: readonly number[]): number {
|
||||
export function readingSpread(values: readonly number[]): number {
|
||||
if (values.length < 2) return 0;
|
||||
const center = mean(values);
|
||||
const variance = mean(values.map((value) => (value - center) ** 2));
|
||||
return Math.sqrt(variance);
|
||||
return standardDeviation(values, READING_SPREAD) ?? 0;
|
||||
}
|
||||
|
||||
export function uniqueSources(values: readonly string[]): string[] {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
insufficientPillar,
|
||||
mean,
|
||||
okPillar,
|
||||
standardDeviation,
|
||||
readingSpread,
|
||||
} from "./shared";
|
||||
|
||||
export const SLEEP_WINDOW_DAYS = 28;
|
||||
@@ -75,7 +75,7 @@ export function computeSleepPillar(
|
||||
: Math.round(sufficiency * 0.6 + regularity * 0.4);
|
||||
const noiseFloor = Math.max(
|
||||
1,
|
||||
Math.round(((0.5 * standardDeviation(asleepValues)) / need) * 100),
|
||||
Math.round(((0.5 * readingSpread(asleepValues)) / need) * 100),
|
||||
);
|
||||
const latest = nights.at(-1)!;
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* All functions are pure and work on sorted arrays of {date, value} pairs.
|
||||
*/
|
||||
|
||||
import { READING_SPREAD, standardDeviation } from "@/lib/stats/spread";
|
||||
|
||||
export interface DataPoint {
|
||||
date: Date;
|
||||
value: number;
|
||||
@@ -95,9 +97,7 @@ export function detectAnomalies(data: DataPoint[], threshold = 2.0): Anomaly[] {
|
||||
|
||||
const values = data.map((p) => p.value);
|
||||
const mean = values.reduce((s, v) => s + v, 0) / values.length;
|
||||
const variance =
|
||||
values.reduce((s, v) => s + (v - mean) ** 2, 0) / values.length;
|
||||
const stdDev = Math.sqrt(variance);
|
||||
const stdDev = standardDeviation(values, READING_SPREAD) ?? 0;
|
||||
|
||||
if (stdDev === 0) return [];
|
||||
|
||||
|
||||
@@ -682,3 +682,24 @@ describe("extractFeatures — a day of pulse is the mean of its hours' means", (
|
||||
expect(f.historicalComparison?.pulse?.current7dAvg).toBe(71.25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractFeatures — blood-pressure spread", () => {
|
||||
it("reports the same population spread the series API shows", async () => {
|
||||
// 120 / 130 / 140 over the last three days: population SD 8.165, the
|
||||
// sample SD (the old rule here) would be 10.
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
const rows = [120, 130, 140].map((value, i) => ({
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
value,
|
||||
measuredAt: new Date(Date.now() - (i + 1) * day),
|
||||
sleepStage: null,
|
||||
source: "MANUAL",
|
||||
deviceType: null,
|
||||
}));
|
||||
prismaMock.measurement.findMany.mockResolvedValue(rows);
|
||||
|
||||
const features = await extractFeatures("user-1", false);
|
||||
|
||||
expect(features.bloodPressure?.sdSys30).toBe(8.2);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,11 @@ import {
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
|
||||
import {
|
||||
READING_SPREAD,
|
||||
standardDeviation,
|
||||
type VarianceKind,
|
||||
} from "@/lib/stats/spread";
|
||||
import {
|
||||
pairByTimestamp,
|
||||
significantPearsonCorrelation,
|
||||
@@ -894,12 +899,17 @@ export class FeaturesPayloadTooLargeError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function stdDev(values: number[]): number | null {
|
||||
/**
|
||||
* A spread rounded to the payload's one decimal, `null` below two values: a
|
||||
* single reading has no spread to report, not a spread of zero.
|
||||
*/
|
||||
export function roundedSpread(
|
||||
values: readonly number[],
|
||||
kind: VarianceKind,
|
||||
): number | null {
|
||||
if (values.length < 2) return null;
|
||||
const mean = values.reduce((s, v) => s + v, 0) / values.length;
|
||||
const variance =
|
||||
values.reduce((s, v) => s + (v - mean) ** 2, 0) / (values.length - 1);
|
||||
return Math.round(Math.sqrt(variance) * 10) / 10;
|
||||
const sd = standardDeviation(values, kind);
|
||||
return sd === null ? null : Math.round(sd * 10) / 10;
|
||||
}
|
||||
|
||||
export function toDataPoints(
|
||||
@@ -1468,14 +1478,14 @@ export async function extractFeatures(
|
||||
const vals = sysData
|
||||
.filter((m) => m.measuredAt.getTime() >= fromMs)
|
||||
.map((m) => m.value);
|
||||
return stdDev(vals);
|
||||
return roundedSpread(vals, READING_SPREAD);
|
||||
})(),
|
||||
sdDia30: (() => {
|
||||
const fromMs = now - 30 * 24 * 60 * 60 * 1000;
|
||||
const vals = diaData
|
||||
.filter((m) => m.measuredAt.getTime() >= fromMs)
|
||||
.map((m) => m.value);
|
||||
return stdDev(vals);
|
||||
return roundedSpread(vals, READING_SPREAD);
|
||||
})(),
|
||||
pulsePressure30: (() => {
|
||||
const avgSys = sysSummary?.avg30 ?? null;
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
*/
|
||||
import type { GradedSeries } from "@/lib/insights/graded-series";
|
||||
import type { NormRange } from "@/lib/insights/derived/norms";
|
||||
import { standardDeviation } from "@/lib/stats/spread";
|
||||
|
||||
/** Favourable-direction framing — mirrors the metric-status registry. */
|
||||
export type MetricDirection = "higher-better" | "lower-better" | "target-band";
|
||||
@@ -96,16 +97,6 @@ function meanOf(values: number[]): number | null {
|
||||
return sum / values.length;
|
||||
}
|
||||
|
||||
/** Sample standard deviation, or null with fewer than two points. */
|
||||
function sampleSd(values: number[]): number | null {
|
||||
if (values.length < 2) return null;
|
||||
const m = meanOf(values);
|
||||
if (m === null) return null;
|
||||
let acc = 0;
|
||||
for (const v of values) acc += (v - m) ** 2;
|
||||
return Math.sqrt(acc / (values.length - 1));
|
||||
}
|
||||
|
||||
/** Placement of a value against a coarse normal range. */
|
||||
export function placeInBand(
|
||||
value: number,
|
||||
@@ -169,7 +160,8 @@ export function buildMetricSignal(
|
||||
}
|
||||
|
||||
const baseline = meanOf(baselineMeans);
|
||||
const spread = sampleSd(baselineMeans);
|
||||
// The spread decides the verdict, so it is the sample estimate.
|
||||
const spread = standardDeviation(baselineMeans, "sample");
|
||||
|
||||
let delta: number | null = null;
|
||||
let deltaPct: number | null = null;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { round } from "@/lib/insights/status-shared";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { READING_SPREAD, standardDeviation } from "@/lib/stats/spread";
|
||||
|
||||
// --- In-target % over the last 30 daily points (lifted from mood-status) ---
|
||||
|
||||
@@ -289,11 +290,11 @@ export function computeMoodStability(
|
||||
): MoodStability | null {
|
||||
if (daily.length < STABILITY_MIN_DAYS) return null;
|
||||
|
||||
const values = daily.map((bucket) => bucket.value);
|
||||
const avg = values.reduce((sum, v) => sum + v, 0) / values.length;
|
||||
const variance =
|
||||
values.reduce((sum, v) => sum + (v - avg) ** 2, 0) / values.length;
|
||||
const stdDev = Math.sqrt(variance);
|
||||
const stdDev =
|
||||
standardDeviation(
|
||||
daily.map((bucket) => bucket.value),
|
||||
READING_SPREAD,
|
||||
) ?? 0;
|
||||
|
||||
const normalised =
|
||||
Math.min(stdDev, STABILITY_SD_FULL_SCALE) / STABILITY_SD_FULL_SCALE;
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
* the regression runs over the days that exist and nothing is imputed.
|
||||
*/
|
||||
|
||||
import { variance as varianceOf } from "@/lib/stats/spread";
|
||||
|
||||
/** Span (first to last paired day, inclusive) below which only the trend is removed. */
|
||||
export const SEASONAL_MIN_SPAN_DAYS = 120;
|
||||
|
||||
@@ -205,10 +207,11 @@ function densify(
|
||||
dense[days[i] - lo] = values[i];
|
||||
sum += values[i];
|
||||
}
|
||||
const mean = sum / values.length;
|
||||
let ss = 0;
|
||||
for (const v of values) ss += (v - mean) ** 2;
|
||||
return { dense, mean, variance: ss / values.length };
|
||||
return {
|
||||
dense,
|
||||
mean: sum / values.length,
|
||||
variance: varianceOf(values, "population") ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
* so every existing call site keeps importing from there.
|
||||
*/
|
||||
import { trendSlope } from "@/lib/analytics/trends";
|
||||
import { avgInWindow, stdDev, toDataPoints } from "@/lib/insights/features";
|
||||
import {
|
||||
avgInWindow,
|
||||
roundedSpread,
|
||||
toDataPoints,
|
||||
} from "@/lib/insights/features";
|
||||
import {
|
||||
SAME_HOURS_MIN_DAYS,
|
||||
SAME_HOURS_TYPES,
|
||||
@@ -177,7 +181,11 @@ function buildSignal(
|
||||
// spread and the anomaly stay over the readings.
|
||||
const avg7 = avgInWindow(records, now, 7, 0, day);
|
||||
const avg30 = avgInWindow(records, now, 30, 0, day);
|
||||
const spread30 = stdDev(win30.map((rec) => rec.value));
|
||||
// The spread decides the verdict below, so it is the sample estimate.
|
||||
const spread30 = roundedSpread(
|
||||
win30.map((rec) => rec.value),
|
||||
"sample",
|
||||
);
|
||||
const deltaVs7 = avg7 !== null ? shown(newest.value - avg7) : null;
|
||||
const deltaVs30 = avg30 !== null ? shown(newest.value - avg30) : null;
|
||||
const outsideNormalSwing =
|
||||
@@ -310,7 +318,10 @@ function buildSameHoursSignal(
|
||||
|
||||
const avg30 = mean(earlier.map((d) => d.value));
|
||||
const avg7 = mean(earlier.filter((d) => d.age <= 7).map((d) => d.value));
|
||||
const spread30 = stdDev(earlier.map((d) => d.value));
|
||||
const spread30 = roundedSpread(
|
||||
earlier.map((d) => d.value),
|
||||
"sample",
|
||||
);
|
||||
const latest = today.value;
|
||||
const outsideNormalSwing =
|
||||
avg30 !== null && spread30 !== null && spread30 > 0
|
||||
|
||||
@@ -63,6 +63,7 @@ import { sanitizeValueText } from "@/lib/ai/coach/labs-snapshot";
|
||||
import { scrubFenceMarkers } from "@/lib/ai/coach/data-fence";
|
||||
import { encodeOffsetCursor } from "@/lib/mcp/pagination";
|
||||
import type { CoachScopeWindow } from "@/lib/ai/coach/types";
|
||||
import { standardDeviation } from "@/lib/stats/spread";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -1158,11 +1159,9 @@ function mean(xs: number[]): number {
|
||||
return xs.reduce((s, x) => s + x, 0) / xs.length;
|
||||
}
|
||||
|
||||
/** The CUSUM threshold estimates the series' noise, so it is the sample SD. */
|
||||
function stdDev(xs: number[]): number {
|
||||
if (xs.length < 2) return 0;
|
||||
const m = mean(xs);
|
||||
const variance = xs.reduce((s, x) => s + (x - m) ** 2, 0) / (xs.length - 1);
|
||||
return Math.sqrt(variance);
|
||||
return standardDeviation(xs, "sample") ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,6 +43,7 @@ import { readDayAggregates } from "@/lib/measurements/day-aggregates";
|
||||
import { dayWeightedRowsSql, windowMeanSql } from "@/lib/measurements/day-mean";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { READING_SPREAD, standardDeviation } from "@/lib/stats/spread";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -203,9 +204,8 @@ function windowStats(buckets: RollupBucket[]): {
|
||||
const means = usable.map((b) => b.mean);
|
||||
const mean = means.reduce((s, m) => s + m, 0) / means.length;
|
||||
if (means.length < 2) return { mean: round(mean), sd: null, n };
|
||||
const variance =
|
||||
means.reduce((s, m) => s + (m - mean) ** 2, 0) / means.length;
|
||||
return { mean: round(mean), sd: round(Math.sqrt(variance)), n };
|
||||
const sd = standardDeviation(means, READING_SPREAD) ?? 0;
|
||||
return { mean: round(mean), sd: round(sd), n };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { READING_SPREAD, standardDeviation, variance } from "../spread";
|
||||
|
||||
describe("spread", () => {
|
||||
const values = [120, 130, 140];
|
||||
|
||||
it("divides by n for a population and by n - 1 for a sample", () => {
|
||||
expect(variance(values, "population")).toBeCloseTo(200 / 3, 10);
|
||||
expect(variance(values, "sample")).toBe(100);
|
||||
expect(standardDeviation(values, "sample")).toBe(10);
|
||||
});
|
||||
|
||||
it("is undefined below what the kind needs", () => {
|
||||
expect(variance([], "population")).toBeNull();
|
||||
expect(variance([5], "population")).toBe(0);
|
||||
expect(variance([5], "sample")).toBeNull();
|
||||
});
|
||||
|
||||
it("reports readings as a population", () => {
|
||||
expect(READING_SPREAD).toBe("population");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Variance and standard deviation, with the kind named at every call site.
|
||||
*
|
||||
* - `population` divides by n: the spread of exactly these readings. It is
|
||||
* what a surface reports when it describes a series the person can see.
|
||||
* - `sample` divides by n − 1: an estimate of the spread that readings like
|
||||
* these come from. It belongs where the spread feeds a decision (a
|
||||
* significance verdict, a changepoint threshold) or where a clinical
|
||||
* definition asks for it.
|
||||
*
|
||||
* The two differ by sqrt(n / (n − 1)), about 22 % at n = 3, so one series
|
||||
* shown with both rules looks like two different series. Every hand-rolled
|
||||
* spread used to pick its rule silently; the API reported the population SD
|
||||
* of a blood-pressure series while the MCP and AI payload reported the
|
||||
* sample SD of the same readings.
|
||||
*/
|
||||
|
||||
export type VarianceKind = "population" | "sample";
|
||||
|
||||
/**
|
||||
* The kind every surface uses to report the spread of a person's readings,
|
||||
* so the API, the MCP tools, the Coach and the AI payload say the same
|
||||
* number for the same series.
|
||||
*/
|
||||
export const READING_SPREAD: VarianceKind = "population";
|
||||
|
||||
/** `null` when the kind is undefined for this many values (n < 1, or n < 2 for a sample). */
|
||||
export function variance(
|
||||
values: readonly number[],
|
||||
kind: VarianceKind,
|
||||
): number | null {
|
||||
const n = values.length;
|
||||
const divisor = kind === "sample" ? n - 1 : n;
|
||||
if (divisor < 1) return null;
|
||||
let sum = 0;
|
||||
for (const v of values) sum += v;
|
||||
const mean = sum / n;
|
||||
let squares = 0;
|
||||
for (const v of values) squares += (v - mean) ** 2;
|
||||
return squares / divisor;
|
||||
}
|
||||
|
||||
export function standardDeviation(
|
||||
values: readonly number[],
|
||||
kind: VarianceKind,
|
||||
): number | null {
|
||||
const v = variance(values, kind);
|
||||
return v === null ? null : Math.sqrt(v);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
TargetMoodRollup,
|
||||
TargetTrend,
|
||||
} from "./types";
|
||||
import { READING_SPREAD, standardDeviation } from "@/lib/stats/spread";
|
||||
|
||||
interface MoodTargetInput {
|
||||
moodRollups: TargetMoodRollup[];
|
||||
@@ -24,9 +25,6 @@ export function buildMoodTargets({
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
let dailyEvents: Array<{ measuredAt: Date; value: number }>;
|
||||
let entryCount: number;
|
||||
let sumScores: number;
|
||||
let varianceCount: number;
|
||||
let sumSquares: number;
|
||||
|
||||
if (moodRollups.length > 0) {
|
||||
entryCount = moodRollups.reduce((sum, rollup) => sum + rollup.count, 0);
|
||||
@@ -36,12 +34,6 @@ export function buildMoodTargets({
|
||||
measuredAt: rollup.bucketStart,
|
||||
value: rollup.mean,
|
||||
}));
|
||||
sumScores = dailyEvents.reduce((sum, event) => sum + event.value, 0);
|
||||
varianceCount = dailyEvents.length;
|
||||
sumSquares = dailyEvents.reduce(
|
||||
(sum, event) => sum + event.value * event.value,
|
||||
0,
|
||||
);
|
||||
} else if (latestMoodEntry !== null) {
|
||||
const rawMood = recentRawMood ?? [];
|
||||
entryCount = rawMood.length;
|
||||
@@ -49,26 +41,20 @@ export function buildMoodTargets({
|
||||
measuredAt: entry.moodLoggedAt,
|
||||
value: entry.score,
|
||||
}));
|
||||
sumScores = rawMood.reduce((sum, entry) => sum + entry.score, 0);
|
||||
varianceCount = rawMood.length;
|
||||
sumSquares = rawMood.reduce(
|
||||
(sum, entry) => sum + entry.score * entry.score,
|
||||
0,
|
||||
);
|
||||
} else {
|
||||
entryCount = 0;
|
||||
dailyEvents = [];
|
||||
sumScores = 0;
|
||||
varianceCount = 0;
|
||||
sumSquares = 0;
|
||||
}
|
||||
const values = dailyEvents.map((event) => event.value);
|
||||
|
||||
if (entryCount < 3) return [];
|
||||
|
||||
const current = latestMoodEntry?.score ?? null;
|
||||
const average30 =
|
||||
dailyEvents.length > 0
|
||||
? Math.round((sumScores / dailyEvents.length) * 10) / 10
|
||||
values.length > 0
|
||||
? Math.round(
|
||||
(values.reduce((sum, v) => sum + v, 0) / values.length) * 10,
|
||||
) / 10
|
||||
: null;
|
||||
let trend: TargetTrend = null;
|
||||
if (dailyEvents.length >= 4) {
|
||||
@@ -118,23 +104,21 @@ export function buildMoodTargets({
|
||||
},
|
||||
];
|
||||
|
||||
if (varianceCount >= 5) {
|
||||
const mean = sumScores / varianceCount;
|
||||
const variance = sumSquares / varianceCount - mean * mean;
|
||||
const standardDeviation =
|
||||
Math.round(Math.sqrt(Math.max(0, variance)) * 100) / 100;
|
||||
if (values.length >= 5) {
|
||||
const spread =
|
||||
Math.round((standardDeviation(values, READING_SPREAD) ?? 0) * 100) / 100;
|
||||
targets.push({
|
||||
type: "MOOD_STABILITY",
|
||||
label: "Mood stability",
|
||||
current: standardDeviation,
|
||||
average30: standardDeviation,
|
||||
current: spread,
|
||||
average30: spread,
|
||||
trend: null,
|
||||
unit: "σ",
|
||||
range: { min: 0, max: 0.5 },
|
||||
classification:
|
||||
standardDeviation <= 0.5
|
||||
spread <= 0.5
|
||||
? { category: "Very stable", color: "var(--success)" }
|
||||
: standardDeviation <= 1
|
||||
: spread <= 1
|
||||
? { category: "Stable", color: "var(--dracula-yellow)" }
|
||||
: { category: "Fluctuating", color: "var(--destructive)" },
|
||||
source: "Mood entries",
|
||||
|
||||
Reference in new issue
Block a user