diff --git a/src/__tests__/spread-single-implementation-guard.test.ts b/src/__tests__/spread-single-implementation-guard.test.ts new file mode 100644 index 000000000..46b5dfa5a --- /dev/null +++ b/src/__tests__/spread-single-implementation-guard.test.ts @@ -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 = { + "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); + }); +}); diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index b3abf5504..45bb64bb1 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -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) => { diff --git a/src/lib/analytics/glucose-metrics.ts b/src/lib/analytics/glucose-metrics.ts index a0f566735..06eca2cee 100644 --- a/src/lib/analytics/glucose-metrics.ts +++ b/src/lib/analytics/glucose-metrics.ts @@ -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"); } /** diff --git a/src/lib/analytics/mood-prognosis/features.ts b/src/lib/analytics/mood-prognosis/features.ts index 467b71236..53f012998 100644 --- a/src/lib/analytics/mood-prognosis/features.ts +++ b/src/lib/analytics/mood-prognosis/features.ts @@ -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 diff --git a/src/lib/analytics/score/activity.ts b/src/lib/analytics/score/activity.ts index 4601eb786..326363dfd 100644 --- a/src/lib/analytics/score/activity.ts +++ b/src/lib/analytics/score/activity.ts @@ -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]; diff --git a/src/lib/analytics/score/glycaemia.ts b/src/lib/analytics/score/glycaemia.ts index b62462a02..dca0bb893 100644 --- a/src/lib/analytics/score/glycaemia.ts +++ b/src/lib/analytics/score/glycaemia.ts @@ -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, diff --git a/src/lib/analytics/score/shared.ts b/src/lib/analytics/score/shared.ts index e5dfbbdfa..1fa3bff2d 100644 --- a/src/lib/analytics/score/shared.ts +++ b/src/lib/analytics/score/shared.ts @@ -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[] { diff --git a/src/lib/analytics/score/sleep.ts b/src/lib/analytics/score/sleep.ts index 086fd887c..bebc81462 100644 --- a/src/lib/analytics/score/sleep.ts +++ b/src/lib/analytics/score/sleep.ts @@ -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)!; diff --git a/src/lib/analytics/trends.ts b/src/lib/analytics/trends.ts index 242f30bea..08b17d4d7 100644 --- a/src/lib/analytics/trends.ts +++ b/src/lib/analytics/trends.ts @@ -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 []; diff --git a/src/lib/insights/__tests__/features.test.ts b/src/lib/insights/__tests__/features.test.ts index f43d56adf..0f582b50c 100644 --- a/src/lib/insights/__tests__/features.test.ts +++ b/src/lib/insights/__tests__/features.test.ts @@ -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); + }); +}); diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index cc24519de..ee6c70f76 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -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; diff --git a/src/lib/insights/metric-signal.ts b/src/lib/insights/metric-signal.ts index 1dbf261fd..8f943b256 100644 --- a/src/lib/insights/metric-signal.ts +++ b/src/lib/insights/metric-signal.ts @@ -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; diff --git a/src/lib/insights/mood-patterns.ts b/src/lib/insights/mood-patterns.ts index 7dc5fb218..289748095 100644 --- a/src/lib/insights/mood-patterns.ts +++ b/src/lib/insights/mood-patterns.ts @@ -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; diff --git a/src/lib/insights/seasonal-adjust.ts b/src/lib/insights/seasonal-adjust.ts index 4db5e1c67..5be724265 100644 --- a/src/lib/insights/seasonal-adjust.ts +++ b/src/lib/insights/seasonal-adjust.ts @@ -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, + }; } /** diff --git a/src/lib/insights/signals-of-day.ts b/src/lib/insights/signals-of-day.ts index b522db563..3be2c7e0c 100644 --- a/src/lib/insights/signals-of-day.ts +++ b/src/lib/insights/signals-of-day.ts @@ -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 diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index 4c7318286..53329ae9e 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -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; } /** diff --git a/src/lib/rollups/tiered-context.ts b/src/lib/rollups/tiered-context.ts index b73f6073d..931911e5b 100644 --- a/src/lib/rollups/tiered-context.ts +++ b/src/lib/rollups/tiered-context.ts @@ -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 }; } /** diff --git a/src/lib/stats/__tests__/spread.test.ts b/src/lib/stats/__tests__/spread.test.ts new file mode 100644 index 000000000..5192b17f7 --- /dev/null +++ b/src/lib/stats/__tests__/spread.test.ts @@ -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"); + }); +}); diff --git a/src/lib/stats/spread.ts b/src/lib/stats/spread.ts new file mode 100644 index 000000000..c06c944c5 --- /dev/null +++ b/src/lib/stats/spread.ts @@ -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); +} diff --git a/src/lib/targets/mood-builder.ts b/src/lib/targets/mood-builder.ts index 2e48e76c7..fdb917ae7 100644 --- a/src/lib/targets/mood-builder.ts +++ b/src/lib/targets/mood-builder.ts @@ -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",