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:
Marc-André Bombeck committed 2026-10-20 04:36:54 +02:00
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);
});
});
+3 -4
View File
@@ -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) => {
+2 -9
View File
@@ -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");
}
/**
+2 -4
View File
@@ -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
+2 -2
View File
@@ -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];
+2 -2
View File
@@ -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,
+3 -4
View File
@@ -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[] {
+2 -2
View File
@@ -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 -3
View File
@@ -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);
});
});
+17 -7
View File
@@ -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;
+3 -11
View File
@@ -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;
+6 -5
View File
@@ -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;
+7 -4
View File
@@ -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,
};
}
/**
+14 -3
View File
@@ -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
+3 -4
View File
@@ -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;
}
/**
+3 -3
View File
@@ -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 };
}
/**
+23
View File
@@ -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");
});
});
+49
View File
@@ -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);
}
+13 -29
View File
@@ -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",