From a118d4810d8deec0077e432569c3f22c2073fbb7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 8 Aug 2026 11:10:25 +0200
Subject: [PATCH 01/13] feat(mood): add the prediction table, with one writer
and no writer yet
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`MoodPrediction` holds the expected value of a day beside the day's own
rating: one row per account per local day, with a band and the number of
days the fit used. Two tables rather than four columns on the entry,
because the point of the second value is that the person cannot correct
it into agreement with the first — the distance between them is the
finding, and a value the edit form can write is not a second reading.
The table stays out of the disaster-recovery backup and the reason is
written where the completeness guard reads it: every row is recomputable
from the entries, the contexts and the linked modules, and carrying one
would restore a forecast made by a model version that may no longer
exist against data the restore has just moved. It is in the wipe plan,
because a derived row is still a day-by-day statement about somebody.
The write-surface guard lands red on purpose and names what is missing:
`lib/jobs/mood-prognosis-refresh.ts` does not exist yet, so the writer
set is empty where it should hold exactly that one file. The job closes
it. The rest of the guard is green now — no route, page or component
writes the table, and the matcher proves itself against a call broken
across lines, an aliased client, a read and a different model.
---
.../0312_mood_prediction/migration.sql | 58 +++++++
prisma/schema.prisma | 63 ++++++++
...ood-prediction-write-surface-guard.test.ts | 145 ++++++++++++++++++
src/lib/data-wipe/wipe-plan.ts | 6 +
src/lib/export/backup-plan.ts | 2 +
5 files changed, 274 insertions(+)
create mode 100644 prisma/migrations/0312_mood_prediction/migration.sql
create mode 100644 src/__tests__/mood-prediction-write-surface-guard.test.ts
diff --git a/prisma/migrations/0312_mood_prediction/migration.sql b/prisma/migrations/0312_mood_prediction/migration.sql
new file mode 100644
index 000000000..941713ec1
--- /dev/null
+++ b/prisma/migrations/0312_mood_prediction/migration.sql
@@ -0,0 +1,58 @@
+-- v1.38 — the expected value of a day, beside the day's own rating.
+--
+-- One row per account per local day, holding what the account's own past days
+-- say a day like this one usually looks like. It is a second reading of a day,
+-- never a correction of the first: the self-assessment on `mood_entries` is
+-- stored exactly as the person set it and nothing here touches it. The
+-- distance between the two is the whole point of having both.
+--
+-- Its own table rather than four more columns on `mood_entries`, and the
+-- reason is a permission rather than a shape: a value the user must not be
+-- able to overwrite has no business sitting in the row their own edit form
+-- writes. One writer, one table.
+--
+-- Not carried in a disaster-recovery backup. Every value here is recomputable
+-- from `mood_entries` + `mood_contexts` and the measurement tables, and the
+-- nightly job rebuilds it. The verdict is written down in `DERIVED_MODELS`
+-- (`src/lib/export/backup-plan.ts`) where the completeness guard reads it,
+-- so a restore that comes back without these rows is doing what it was told.
+--
+-- No CHECK constraints on the ranges, matching `mood_contexts`: the bounds are
+-- server-side, and a later change to a scale must not strand rows written
+-- under the old one.
+
+CREATE TABLE "mood_predictions" (
+ "user_id" TEXT NOT NULL,
+ -- YYYY-MM-DD in the account's own zone, the same key `mood_entries.date`
+ -- carries, so the two readings of one day join without a second timezone
+ -- derivation.
+ "date" TEXT NOT NULL,
+
+ -- The expected pleasantness (A1), 0-10, and the band around it. The band
+ -- comes from the rolling-origin validation residuals, so it widens when
+ -- the model is bad instead of staying confidently narrow.
+ "predicted" DOUBLE PRECISION NOT NULL,
+ "ci_low" DOUBLE PRECISION,
+ "ci_high" DOUBLE PRECISION,
+ -- Complete days the fit was built from. Displayed wherever the value is.
+ "n" INTEGER NOT NULL,
+
+ -- Bumped when the feature set or the fit changes, so a re-fit is legible
+ -- in the stored rows rather than silently changing what they mean.
+ "model_version" TEXT NOT NULL,
+ -- JSON array of { feature, contribution }: the standardised coefficient
+ -- times that day's standardised value. Stored so the explanation line is
+ -- generated from what the model actually used.
+ "features" TEXT NOT NULL,
+ "computed_at" TIMESTAMPTZ(3) NOT NULL,
+
+ CONSTRAINT "mood_predictions_pkey" PRIMARY KEY ("user_id", "date")
+);
+
+-- The account cascade every other user-scoped table here carries. The primary
+-- key already leads with `user_id`, so the per-account sweep the job and the
+-- wipe both run needs no second index.
+ALTER TABLE "mood_predictions"
+ ADD CONSTRAINT "mood_predictions_user_id_fkey"
+ FOREIGN KEY ("user_id") REFERENCES "users"("id")
+ ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 92cb5c569..24eae540f 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -774,6 +774,7 @@ model User {
googleHealthOAuthStates GoogleHealthOAuthState[]
moodEntries MoodEntry[]
moodContexts MoodContext[]
+ moodPredictions MoodPrediction[]
mentalHealthAssessments MentalHealthAssessment[]
moodTagsCustom MoodTag[] @relation("UserMoodTags")
moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories")
@@ -4256,6 +4257,68 @@ model MoodContext {
@@map("mood_contexts")
}
+/// What the account's own past days say a day like this one usually looks
+/// like — one row per local day, written by a job and never by a person.
+///
+/// Four things about this table that a later reader would otherwise have to
+/// infer, and would infer wrongly:
+///
+/// 1. **Written by the `mood-prognosis-refresh` job only. Never by a route,
+/// and the user cannot overwrite it.** The self-assessment on `MoodEntry`
+/// and the value here are two separate readings of the same day and they
+/// stay separate: the deviation between them is the entire finding, and a
+/// surface that let somebody correct the prognosis into agreement would
+/// destroy it. Two tables make that structural rather than a convention,
+/// and `src/__tests__/mood-prediction-write-surface-guard.test.ts` holds
+/// the writer set to one file.
+/// 2. **`features` holds the evidence, not a hint.** It is the JSON list of
+/// the standardised contributions the fit actually used for that day, so
+/// the "what the model weighted" line is generated from what happened
+/// rather than recomputed at render time against a model that may since
+/// have been re-fitted.
+/// 3. **Deliberately not in the disaster-recovery backup.** It is a derived
+/// artefact, recomputable in full from `MoodEntry` + `MoodContext` and the
+/// linked modules, and the nightly job rebuilds it after a restore. The
+/// verdict is written down in `DERIVED_MODELS`
+/// (`src/lib/export/backup-plan.ts`), where the completeness guard reads
+/// it, so this is a decision rather than an omission. It IS in the wipe
+/// plan: a derived row is still a statement about somebody's days.
+/// 4. **`n` and the band are part of the value.** A consumer that renders
+/// `predicted` without `ciLow` / `ciHigh` / `n` is a defect: a forecast
+/// built from thirty days and one built from three hundred look identical
+/// on screen and mean very different things.
+model MoodPrediction {
+ userId String @map("user_id")
+ /// YYYY-MM-DD in the account's own zone — the same day key and the same
+ /// anchoring `MoodEntry.date` carries, so the two readings of a day join
+ /// without a second timezone derivation.
+ date String
+
+ /// The expected pleasantness (A1) for that day, 0-10, on the account's own
+ /// past pattern. A counterfactual, never a measurement.
+ predicted Float
+ /// The band around it, from the rolling-origin validation residuals. Null
+ /// only when the validation produced no residuals at all, which the write
+ /// path treats as "no forecast" rather than as a bare point estimate.
+ ciLow Float? @map("ci_low")
+ ciHigh Float? @map("ci_high")
+ /// Complete days the fit was built from. Shown wherever `predicted` is.
+ n Int
+
+ /// Bumped whenever the feature set or the fit changes, so a re-fit is
+ /// legible in the stored rows instead of silently changing their meaning.
+ modelVersion String @map("model_version")
+ /// JSON array of `{ feature, contribution }` — the standardised coefficient
+ /// times that day's standardised value, per feature.
+ features String
+ computedAt DateTime @map("computed_at")
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@id([userId, date])
+ @@map("mood_predictions")
+}
+
// ─── Mood Tag Taxonomy (v1.8.5 W6-tax) ────────────────────────
//
// Structured, icon-bearing tag catalog modelled on the standalone mood
diff --git a/src/__tests__/mood-prediction-write-surface-guard.test.ts b/src/__tests__/mood-prediction-write-surface-guard.test.ts
new file mode 100644
index 000000000..3bacd4a4f
--- /dev/null
+++ b/src/__tests__/mood-prediction-write-surface-guard.test.ts
@@ -0,0 +1,145 @@
+/**
+ * `MoodPrediction` has exactly one writer, and it is not a request.
+ *
+ * The product rule underneath this guard: a day carries two readings — the
+ * one the person gave it and the one their own past days imply — and they are
+ * never merged. The self-assessment leads and is stored exactly as it was
+ * given; the prognosis is computed, carries a band and a case count, and the
+ * user cannot overwrite it. A route that wrote `moodPrediction` would make the
+ * second value editable, and an editable prognosis is not a prognosis: the
+ * deviation between the two, which is the only thing either number is for,
+ * would quietly become a number somebody could tune to zero.
+ *
+ * The second failure the single writer prevents is the one the cycle module
+ * already paid for: a forecast written as a side effect of a page view makes
+ * the output a function of browsing rather than of data
+ * (`src/lib/cycle/prediction-cache.ts`, `src/lib/jobs/cycle-prediction-refresh.ts`).
+ *
+ * **Every matcher here asserts a non-zero match count.** Legs (a) and (b)
+ * expect an EMPTY offender set, so "found nothing" and "nothing is wrong" look
+ * identical from the outside — which is precisely how the bearer-scope guard
+ * stayed green for months while matching nothing at all. Each leg therefore
+ * pins a separate positive: the number of files SCANNED, and the write calls
+ * the legitimate writer really does make. `describe("the matcher sees what it
+ * looks for")` proves the regex against synthetic sources, including the
+ * broken-across-lines form that defeated an earlier guard in this repository.
+ */
+import { existsSync, readFileSync, readdirSync } from "node:fs";
+import { join, sep } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+const SRC = join(process.cwd(), "src");
+
+/**
+ * A write against the `moodPrediction` delegate, on any receiver.
+ *
+ * Whitespace-tolerant in every joint: Prettier breaks a long call across
+ * lines, and a matcher demanding `prisma.moodPrediction.upsert(` on one line
+ * is a matcher that stops matching the day the line gets long enough. The
+ * receiver is left open (`prisma`, `tx`, `db`, the worker client) because
+ * pinning one name would let a rename walk straight past.
+ */
+const WRITE_CALL =
+ /\bmoodPrediction\s*\.\s*(createMany|createManyAndReturn|create|upsert|updateMany|update|deleteMany|delete)\s*\(/g;
+
+/** The one file allowed to write, relative to `src/`. */
+const SOLE_WRITER = join("lib", "jobs", "mood-prognosis-refresh.ts");
+
+function sourceFiles(dir: string): string[] {
+ const out: string[] = [];
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name === "generated" || entry.name === "node_modules") continue;
+ out.push(...sourceFiles(full));
+ continue;
+ }
+ if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
+ out.push(full);
+ }
+ }
+ return out;
+}
+
+function writeCallsIn(source: string): string[] {
+ return [...source.matchAll(WRITE_CALL)].map((m) => m[1]);
+}
+
+const ALL_FILES = sourceFiles(SRC);
+const PRODUCTION_FILES = ALL_FILES.filter(
+ (f) => !f.includes(`${sep}__tests__${sep}`) && !f.endsWith(".test.ts"),
+);
+
+describe("MoodPrediction is written by one file", () => {
+ it("scans a plausible number of source files", () => {
+ // The positive control for legs (a) and (b) below, both of which pass on
+ // an empty result. If this collapses, they are asserting nothing.
+ expect(ALL_FILES.length).toBeGreaterThan(1000);
+ expect(PRODUCTION_FILES.length).toBeGreaterThan(800);
+ });
+
+ it("(a) no route, page or component writes it", () => {
+ const offenders = PRODUCTION_FILES.filter(
+ (f) =>
+ f.startsWith(join(SRC, "app")) || f.startsWith(join(SRC, "components")),
+ ).filter((f) => writeCallsIn(readFileSync(f, "utf8")).length > 0);
+
+ expect(
+ offenders.map((f) => f.slice(SRC.length + 1)),
+ "a request-time surface writes the prognosis — it is computed by the nightly job and the user cannot overwrite it",
+ ).toEqual([]);
+ });
+
+ it("(b) the writer set is exactly the refresh job", () => {
+ const writers = PRODUCTION_FILES.filter(
+ (f) => writeCallsIn(readFileSync(f, "utf8")).length > 0,
+ ).map((f) => f.slice(SRC.length + 1));
+
+ expect(writers.sort()).toEqual([SOLE_WRITER]);
+ });
+
+ it("(c) that writer really does write — the matcher is not matching nothing", () => {
+ expect(
+ existsSync(join(SRC, SOLE_WRITER)),
+ `${SOLE_WRITER} does not exist — the table has a guard and no writer`,
+ ).toBe(true);
+ const source = readFileSync(join(SRC, SOLE_WRITER), "utf8");
+ const calls = writeCallsIn(source);
+ expect(
+ calls.length,
+ "the sole writer makes no write call the matcher can see; leg (b) is then satisfied by an empty set rather than by the truth",
+ ).toBeGreaterThan(0);
+ // It replaces rather than accumulates: a pass writes the current window
+ // and clears whatever fell out of it.
+ expect(calls).toContain("deleteMany");
+ });
+});
+
+describe("the matcher sees what it looks for", () => {
+ it("matches a call Prettier broke across lines", () => {
+ const wrapped = [
+ "await prisma.moodPrediction",
+ " .upsert(",
+ " { where: { userId_date: { userId, date } } },",
+ " );",
+ ].join("\n");
+ expect(writeCallsIn(wrapped)).toEqual(["upsert"]);
+ });
+
+ it("matches through an aliased client", () => {
+ expect(writeCallsIn("tx.moodPrediction.deleteMany({ where })")).toEqual([
+ "deleteMany",
+ ]);
+ });
+
+ it("does not match a read", () => {
+ expect(
+ writeCallsIn("const rows = await prisma.moodPrediction.findMany({})"),
+ ).toEqual([]);
+ });
+
+ it("does not match a different model", () => {
+ expect(writeCallsIn("prisma.moodEntry.create({ data })")).toEqual([]);
+ });
+});
diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts
index 0d8441dac..c62c6f8a1 100644
--- a/src/lib/data-wipe/wipe-plan.ts
+++ b/src/lib/data-wipe/wipe-plan.ts
@@ -75,6 +75,12 @@ export const WIPE_MODELS = [
// but a parent deleted first leaves the child's own count reporting zero for
// rows that existed, and the confirmation screen quotes those counts.
"MoodContext",
+ // Derived, and wiped anyway. The forecast rows are recomputable from the
+ // entries above — which is why they stay out of the backup — but they are
+ // still a day-by-day statement about somebody's mood, and leaving them
+ // behind after a screen that promised to delete all health data would leave
+ // the shape of the record standing where the record used to be.
+ "MoodPrediction",
"MoodEntry",
"MoodEntryRollup",
"MoodReminderDispatch",
diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts
index 84bc08a66..8462a2f14 100644
--- a/src/lib/export/backup-plan.ts
+++ b/src/lib/export/backup-plan.ts
@@ -342,6 +342,8 @@ export const DERIVED_MODELS: Readonly> = {
"Recomputed from `MedicationIntakeEvent` against the schedule by the compliance analytics tier.",
MoodEntryRollup:
"A per-day reconstruction of `MoodEntry`, rebuilt by the same rollup tier that rebuilds the measurement one.",
+ MoodPrediction:
+ "What the account's own past days imply about a day, fitted from `MoodEntry` + `MoodContext` and the linked modules. The nightly `mood-prognosis-refresh` job rebuilds it after a restore, and rebuilding it is better than carrying it: a restored row would state a forecast made by a model version that may no longer exist, against data the restore has just moved.",
CyclePrediction:
"A forecast derived from `MenstrualCycle` history; regenerated on the next cycle read.",
StrainTrimpCache:
From 0aeb63cbeb1ccb523a2c2a06cad64cc903c3dea8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 8 Aug 2026 11:15:00 +0200
Subject: [PATCH 02/13] feat(mood): the minimum-n ladder, as constants with
boundary tests
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Five rungs — nothing at all below fifteen entries, descriptive hints to
thirty, a provisional forecast to sixty, a plain one to ninety, weekday
and seasonal comparisons above it — plus the occurrence floor a value
must clear before it may be named at all.
Every floor is an exported constant and every one has a test on both
sides of its own boundary, because the failure here is invisible: a
forecast built from eight days renders exactly like one built from
eighty, and nobody reading the screen can tell. Each assertion names the
threshold it is about, so a red says which floor moved.
Absence carries its reason and the count rather than being a bare false,
so a surface can say how far along the account is instead of showing an
error. Two numbers are deliberately not minted here: the paired-value
floor stays `MIN_PAIRED_N` in the correlations module, and the
occurrence floor is the crosstab's own, taken by assignment. A test
reads the file back and fails if either turns into a local copy.
---
.../mood-prognosis/__tests__/gates.test.ts | 224 ++++++++++++++++++
.../analytics/mood-prognosis/thresholds.ts | 166 +++++++++++++
2 files changed, 390 insertions(+)
create mode 100644 src/lib/analytics/mood-prognosis/__tests__/gates.test.ts
create mode 100644 src/lib/analytics/mood-prognosis/thresholds.ts
diff --git a/src/lib/analytics/mood-prognosis/__tests__/gates.test.ts b/src/lib/analytics/mood-prognosis/__tests__/gates.test.ts
new file mode 100644
index 000000000..40be4d6b7
--- /dev/null
+++ b/src/lib/analytics/mood-prognosis/__tests__/gates.test.ts
@@ -0,0 +1,224 @@
+/**
+ * The minimum-n ladder, at every boundary, from both sides.
+ *
+ * Each case names the threshold it is about, so a red here says which floor
+ * moved rather than "expected false to be true". That matters more than usual:
+ * the thing these gates prevent is invisible on screen, so the test output is
+ * the only place the failure is ever legible.
+ *
+ * Every one of these has been watched failing — the comparison flipped from
+ * `>=` to `>`, the red read, the file restored and the hash checked — and one
+ * check was deleted outright to prove the absence of a gate fails too. The log
+ * is in the phase's execution notes.
+ *
+ * The assertions name the REASON, not just the absence: "no forecast" and "no
+ * forecast because there are only eleven entries and fifteen is where anything
+ * starts" are different answers, and only the second one can be shown to
+ * somebody.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import {
+ MIN_ENTRIES_FOR_FORECAST,
+ MIN_ENTRIES_FOR_REGULAR_FORECAST,
+ MIN_ENTRIES_FOR_SEASONAL,
+ MIN_ENTRIES_LEARNING_PHASE,
+ MIN_TAG_OCCURRENCES,
+ MIN_WEEKDAY_OBSERVATIONS,
+ forecastGate,
+ occurrencesSuffice,
+ prognosisStage,
+ seasonalComparisonsUnlocked,
+ weekdayObservationsSuffice,
+} from "../thresholds";
+import { CONTEXT_MIN_PRESENT_DAYS } from "@/lib/insights/mood-context-crosstab";
+
+describe("the ladder is ordered and its rungs are the documented numbers", () => {
+ it("holds the values the concept asks for", () => {
+ expect(MIN_ENTRIES_LEARNING_PHASE).toBe(15);
+ expect(MIN_ENTRIES_FOR_FORECAST).toBe(30);
+ expect(MIN_ENTRIES_FOR_REGULAR_FORECAST).toBe(60);
+ expect(MIN_ENTRIES_FOR_SEASONAL).toBe(90);
+ expect(MIN_WEEKDAY_OBSERVATIONS).toBe(4);
+ });
+
+ it("rises strictly, so no rung can be reached before the one below it", () => {
+ expect(MIN_ENTRIES_LEARNING_PHASE).toBeLessThan(MIN_ENTRIES_FOR_FORECAST);
+ expect(MIN_ENTRIES_FOR_FORECAST).toBeLessThan(
+ MIN_ENTRIES_FOR_REGULAR_FORECAST,
+ );
+ expect(MIN_ENTRIES_FOR_REGULAR_FORECAST).toBeLessThan(
+ MIN_ENTRIES_FOR_SEASONAL,
+ );
+ });
+});
+
+describe("MIN_ENTRIES_LEARNING_PHASE — below it, nothing at all", () => {
+ it(`refuses at ${MIN_ENTRIES_LEARNING_PHASE - 1}, and says why`, () => {
+ const gate = forecastGate(MIN_ENTRIES_LEARNING_PHASE - 1);
+ expect(gate.present, "MIN_ENTRIES_LEARNING_PHASE let n-1 through").toBe(
+ false,
+ );
+ expect(gate).toEqual({
+ present: false,
+ reason: "no-output-yet",
+ stage: "insufficient",
+ entries: MIN_ENTRIES_LEARNING_PHASE - 1,
+ nextThreshold: MIN_ENTRIES_LEARNING_PHASE,
+ });
+ });
+
+ it(`reaches the learning phase at exactly ${MIN_ENTRIES_LEARNING_PHASE}`, () => {
+ const gate = forecastGate(MIN_ENTRIES_LEARNING_PHASE);
+ expect(
+ gate,
+ "MIN_ENTRIES_LEARNING_PHASE did not admit its own value",
+ ).toEqual({
+ present: false,
+ reason: "learning-phase",
+ stage: "learning",
+ entries: MIN_ENTRIES_LEARNING_PHASE,
+ nextThreshold: MIN_ENTRIES_FOR_FORECAST,
+ });
+ });
+
+ it("an empty account is absence, never a zero", () => {
+ const gate = forecastGate(0);
+ expect(gate.present).toBe(false);
+ expect(gate).not.toHaveProperty("predicted");
+ if (!gate.present) expect(gate.entries).toBe(0);
+ });
+});
+
+describe("MIN_ENTRIES_FOR_FORECAST — below it, no forecast", () => {
+ it(`refuses at ${MIN_ENTRIES_FOR_FORECAST - 1}, naming the learning phase`, () => {
+ const gate = forecastGate(MIN_ENTRIES_FOR_FORECAST - 1);
+ expect(gate.present, "MIN_ENTRIES_FOR_FORECAST let n-1 through").toBe(
+ false,
+ );
+ if (!gate.present) {
+ expect(gate.reason).toBe("learning-phase");
+ expect(gate.nextThreshold).toBe(MIN_ENTRIES_FOR_FORECAST);
+ expect(gate.entries).toBe(MIN_ENTRIES_FOR_FORECAST - 1);
+ }
+ });
+
+ it(`forecasts at exactly ${MIN_ENTRIES_FOR_FORECAST}, labelled provisional`, () => {
+ const gate = forecastGate(MIN_ENTRIES_FOR_FORECAST);
+ expect(gate.present, "MIN_ENTRIES_FOR_FORECAST refused its own value").toBe(
+ true,
+ );
+ if (gate.present) {
+ expect(gate.stage).toBe("provisional");
+ expect(gate.provisional).toBe(true);
+ }
+ });
+});
+
+describe("MIN_ENTRIES_FOR_REGULAR_FORECAST — below it, provisional", () => {
+ it(`is still provisional at ${MIN_ENTRIES_FOR_REGULAR_FORECAST - 1}`, () => {
+ const gate = forecastGate(MIN_ENTRIES_FOR_REGULAR_FORECAST - 1);
+ expect(gate.present).toBe(true);
+ if (gate.present) {
+ expect(
+ gate.provisional,
+ "MIN_ENTRIES_FOR_REGULAR_FORECAST dropped the provisional label at n-1",
+ ).toBe(true);
+ expect(gate.stage).toBe("provisional");
+ }
+ });
+
+ it(`drops the label at exactly ${MIN_ENTRIES_FOR_REGULAR_FORECAST}`, () => {
+ const gate = forecastGate(MIN_ENTRIES_FOR_REGULAR_FORECAST);
+ expect(gate.present).toBe(true);
+ if (gate.present) {
+ expect(
+ gate.provisional,
+ "MIN_ENTRIES_FOR_REGULAR_FORECAST kept the provisional label at its own value",
+ ).toBe(false);
+ expect(gate.stage).toBe("regular");
+ }
+ });
+});
+
+describe("MIN_ENTRIES_FOR_SEASONAL — below it, no weekday or seasonal view", () => {
+ it(`refuses the comparison at ${MIN_ENTRIES_FOR_SEASONAL - 1}`, () => {
+ expect(
+ seasonalComparisonsUnlocked(MIN_ENTRIES_FOR_SEASONAL - 1),
+ "MIN_ENTRIES_FOR_SEASONAL unlocked the comparison at n-1",
+ ).toBe(false);
+ expect(prognosisStage(MIN_ENTRIES_FOR_SEASONAL - 1)).toBe("regular");
+ });
+
+ it(`unlocks it at exactly ${MIN_ENTRIES_FOR_SEASONAL}`, () => {
+ expect(
+ seasonalComparisonsUnlocked(MIN_ENTRIES_FOR_SEASONAL),
+ "MIN_ENTRIES_FOR_SEASONAL refused its own value",
+ ).toBe(true);
+ expect(prognosisStage(MIN_ENTRIES_FOR_SEASONAL)).toBe("seasonal");
+ });
+});
+
+describe("MIN_WEEKDAY_OBSERVATIONS — a weekday needs a month of itself", () => {
+ it(`refuses at ${MIN_WEEKDAY_OBSERVATIONS - 1}`, () => {
+ expect(
+ weekdayObservationsSuffice(MIN_WEEKDAY_OBSERVATIONS - 1),
+ "MIN_WEEKDAY_OBSERVATIONS admitted n-1 observations of one weekday",
+ ).toBe(false);
+ });
+
+ it(`admits at exactly ${MIN_WEEKDAY_OBSERVATIONS}`, () => {
+ expect(
+ weekdayObservationsSuffice(MIN_WEEKDAY_OBSERVATIONS),
+ "MIN_WEEKDAY_OBSERVATIONS refused its own value",
+ ).toBe(true);
+ });
+});
+
+describe("MIN_TAG_OCCURRENCES — a value seen four times is not evidence", () => {
+ it(`refuses at ${MIN_TAG_OCCURRENCES - 1}`, () => {
+ expect(
+ occurrencesSuffice(MIN_TAG_OCCURRENCES - 1),
+ "MIN_TAG_OCCURRENCES admitted a value seen n-1 times",
+ ).toBe(false);
+ });
+
+ it(`admits at exactly ${MIN_TAG_OCCURRENCES}`, () => {
+ expect(
+ occurrencesSuffice(MIN_TAG_OCCURRENCES),
+ "MIN_TAG_OCCURRENCES refused its own value",
+ ).toBe(true);
+ });
+
+ it("is the crosstab's floor rather than a second opinion about it", () => {
+ expect(MIN_TAG_OCCURRENCES).toBe(CONTEXT_MIN_PRESENT_DAYS);
+ });
+});
+
+describe("no second number is minted for an idea that already has one", () => {
+ const THRESHOLDS = readFileSync(join(__dirname, "../thresholds.ts"), "utf8");
+
+ it("the file really is the one under test", () => {
+ // The positive control: the two checks below pass on an empty read, so
+ // each needs proof the file was found and holds what it claims to.
+ expect(THRESHOLDS.length).toBeGreaterThan(1000);
+ expect(THRESHOLDS).toContain("MIN_ENTRIES_FOR_SEASONAL");
+ });
+
+ it("declares no paired-observation floor of its own", () => {
+ // `MIN_PAIRED_N` lives in `correlations.ts` and the screening imports it
+ // from there. A constant here named for the same idea would be a second
+ // answer to one question, and the two would drift.
+ expect(THRESHOLDS).not.toMatch(/export\s+const\s+MIN_PAIRED/);
+ expect(THRESHOLDS).not.toMatch(/export\s+const\s+MIN_PAIRS/);
+ });
+
+ it("takes the occurrence floor from the crosstab rather than restating it", () => {
+ expect(THRESHOLDS).toMatch(
+ /MIN_TAG_OCCURRENCES\s*=\s*CONTEXT_MIN_PRESENT_DAYS/,
+ );
+ });
+});
diff --git a/src/lib/analytics/mood-prognosis/thresholds.ts b/src/lib/analytics/mood-prognosis/thresholds.ts
new file mode 100644
index 000000000..1d0d8e973
--- /dev/null
+++ b/src/lib/analytics/mood-prognosis/thresholds.ts
@@ -0,0 +1,166 @@
+/**
+ * How much history a prognosis needs before it is allowed to say anything.
+ *
+ * This is the most dangerous file in the feature, and it is dangerous for one
+ * reason: **the failure mode is silent.** A forecast built from eight days
+ * renders exactly like a forecast built from eighty. Nobody looking at the
+ * screen can tell the difference, and the person reading it is being told
+ * something about their own health record. So every floor is a named constant
+ * with a boundary test, and every one of those tests has been watched failing
+ * — a gate nobody has seen go red is a decoration, not a gate.
+ *
+ * The ladder has five rungs, and each rung says what the surface may claim:
+ *
+ * | entries | stage | what may be shown |
+ * |---------|---------------|------------------------------------------------|
+ * | 0-14 | `insufficient`| nothing beyond the self-assessment trend |
+ * | 15-29 | `learning` | descriptive hints, labelled as a learning phase |
+ * | 30-59 | `provisional` | a forecast, labelled provisional |
+ * | 60-89 | `regular` | a forecast without the provisional label |
+ * | 90+ | `seasonal` | plus weekday and seasonal comparisons |
+ *
+ * Absence is `{ present: false }` with a reason and the count, never `0` and
+ * never an empty forecast object with a null inside it. That shape is a
+ * contract elsewhere in the product already — the MCP surface means exactly
+ * "honestly not recorded" by it — and a surface that answered zero here would
+ * be claiming a bad day where the truth is that nothing can be said yet.
+ *
+ * **Two numbers are deliberately NOT minted here.** The paired-observation
+ * floor for a correlation is `MIN_PAIRED_N` in
+ * `src/lib/insights/correlations.ts`, and the screening in `features.ts`
+ * imports it from there; a second constant for the same idea is how two
+ * surfaces start disagreeing about what counts as enough. The occurrence floor
+ * below is the crosstab's own floor by assignment rather than by coincidence,
+ * for the same reason — Phase 2's context comparison says at its declaration
+ * that this file would import it, and this file does.
+ */
+import { CONTEXT_MIN_PRESENT_DAYS } from "@/lib/insights/mood-context-crosstab";
+
+/**
+ * Below this, no output at all — not even a descriptive hint.
+ *
+ * Fifteen days of a person's life is not a pattern, and a surface that
+ * described one would be describing noise back to somebody who would
+ * reasonably believe it.
+ */
+export const MIN_ENTRIES_LEARNING_PHASE = 15;
+
+/**
+ * Below this, descriptive hints only, labelled as a learning phase. At or
+ * above it, a fitted forecast is allowed.
+ */
+export const MIN_ENTRIES_FOR_FORECAST = 30;
+
+/** Below this, a forecast is shown but labelled provisional. */
+export const MIN_ENTRIES_FOR_REGULAR_FORECAST = 60;
+
+/** Below this, no weekday and no seasonal comparison. */
+export const MIN_ENTRIES_FOR_SEASONAL = 90;
+
+/**
+ * How often a tag or a context value must have been recorded before it may
+ * appear in an explanation at all.
+ *
+ * The crosstab's floor, taken rather than re-declared: it is the same
+ * question ("has this been seen often enough to be worth a sentence") asked
+ * by a different surface, and two answers to it would drift.
+ */
+export const MIN_TAG_OCCURRENCES = CONTEXT_MIN_PRESENT_DAYS;
+
+/**
+ * Observations of one weekday before that weekday may be compared to the
+ * others. Four is a month of Mondays.
+ */
+export const MIN_WEEKDAY_OBSERVATIONS = 4;
+
+/** Where an account sits on the ladder. */
+export type PrognosisStage =
+ "insufficient" | "learning" | "provisional" | "regular" | "seasonal";
+
+/**
+ * Why there is no forecast. The reason travels with the count and with the
+ * next rung, so a surface can say "eleven of fifteen" instead of an error.
+ */
+export type PrognosisAbsenceReason = "no-output-yet" | "learning-phase";
+
+export interface PrognosisAbsent {
+ present: false;
+ reason: PrognosisAbsenceReason;
+ stage: Extract;
+ /** Qualifying entries the account has. */
+ entries: number;
+ /** Entries at which the next thing becomes available. */
+ nextThreshold: number;
+}
+
+export interface PrognosisAvailable {
+ present: true;
+ stage: Extract;
+ entries: number;
+ /** The forecast carries a provisional label below the regular threshold. */
+ provisional: boolean;
+}
+
+export type PrognosisGate = PrognosisAbsent | PrognosisAvailable;
+
+/**
+ * Which rung this many qualifying entries reaches.
+ *
+ * Every comparison is `>=`, so the constant names the first value that
+ * qualifies rather than the last that does not. The boundary tests pin both
+ * sides of each one.
+ */
+export function prognosisStage(entries: number): PrognosisStage {
+ if (entries >= MIN_ENTRIES_FOR_SEASONAL) return "seasonal";
+ if (entries >= MIN_ENTRIES_FOR_REGULAR_FORECAST) return "regular";
+ if (entries >= MIN_ENTRIES_FOR_FORECAST) return "provisional";
+ if (entries >= MIN_ENTRIES_LEARNING_PHASE) return "learning";
+ return "insufficient";
+}
+
+/**
+ * Whether a forecast may be shown for an account with this many qualifying
+ * entries, and if not, why not and what would change it.
+ */
+export function forecastGate(entries: number): PrognosisGate {
+ const stage = prognosisStage(entries);
+ if (stage === "insufficient") {
+ return {
+ present: false,
+ reason: "no-output-yet",
+ stage,
+ entries,
+ nextThreshold: MIN_ENTRIES_LEARNING_PHASE,
+ };
+ }
+ if (stage === "learning") {
+ return {
+ present: false,
+ reason: "learning-phase",
+ stage,
+ entries,
+ nextThreshold: MIN_ENTRIES_FOR_FORECAST,
+ };
+ }
+ return {
+ present: true,
+ stage,
+ entries,
+ provisional: stage === "provisional",
+ };
+}
+
+/** Whether weekday and seasonal comparisons have unlocked for this account. */
+export function seasonalComparisonsUnlocked(entries: number): boolean {
+ return entries >= MIN_ENTRIES_FOR_SEASONAL;
+}
+
+/** Whether one weekday has been seen often enough to be compared. */
+export function weekdayObservationsSuffice(observations: number): boolean {
+ return observations >= MIN_WEEKDAY_OBSERVATIONS;
+}
+
+/** Whether a tag or context value has been recorded often enough to name. */
+export function occurrencesSuffice(occurrences: number): boolean {
+ return occurrences >= MIN_TAG_OCCURRENCES;
+}
From 64c077f7acac7a021cbd398b84115a072b6e270d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 8 Aug 2026 11:25:34 +0200
Subject: [PATCH 03/13] feat(mood): build the day matrix and fit it without
letting it see ahead
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The target is the day's own pleasantness; the inputs are the other four
self-ratings, the numbers and closed vocabularies of the day's context,
and the figures the sleep, activity and recovery modules already hold.
Nothing is imputed. A day missing one of the selected inputs is a day
the fit does not see, and a feature nobody recorded on most days never
becomes a column in the first place — the coverage floor is what keeps
the complete-case rule from quietly emptying the matrix. A day with no
context row contributes no work-status column at all, rather than a zero
that would read as "worked a regular day".
Columns are standardised against the account's own window, so a person
whose stress never passes six does not have six read as the maximum.
Candidates are screened with the machinery the rest of the product
already runs — Pearson at the shared paired floor, Benjamini-Hochberg at
the shared FDR, the shared effect-size floor on the shrunk estimate —
because a sweep over sixty features at raw p < 0.05 finds three things
that are not there.
The fit is a ridge solved in closed form, validated on expanding windows
in date order. The training range ends exactly where the validation
range starts, asserted on the fold indices and on the day keys, not on
the resulting error: a leaking split passes an error check whenever the
leak is small, which is when nobody looks. The band is the central 80%
of the validation residuals, so it widens when the model is bad instead
of staying confidently narrow.
The window read is the linked resolver's own engine, extended to answer
many days in one query rather than growing a second opinion about what a
Tuesday looked like.
---
.../mood-prognosis/__tests__/features.test.ts | 281 ++++++++++
.../__tests__/rolling-origin.test.ts | 269 ++++++++++
.../analytics/mood-prognosis/feature-keys.ts | 98 ++++
src/lib/analytics/mood-prognosis/features.ts | 489 ++++++++++++++++++
src/lib/analytics/mood-prognosis/ridge.ts | 173 +++++++
src/lib/analytics/mood-prognosis/validate.ts | 202 ++++++++
src/lib/mood/linked-context.ts | 173 ++++++-
7 files changed, 1684 insertions(+), 1 deletion(-)
create mode 100644 src/lib/analytics/mood-prognosis/__tests__/features.test.ts
create mode 100644 src/lib/analytics/mood-prognosis/__tests__/rolling-origin.test.ts
create mode 100644 src/lib/analytics/mood-prognosis/feature-keys.ts
create mode 100644 src/lib/analytics/mood-prognosis/features.ts
create mode 100644 src/lib/analytics/mood-prognosis/ridge.ts
create mode 100644 src/lib/analytics/mood-prognosis/validate.ts
diff --git a/src/lib/analytics/mood-prognosis/__tests__/features.test.ts b/src/lib/analytics/mood-prognosis/__tests__/features.test.ts
new file mode 100644
index 000000000..19379217c
--- /dev/null
+++ b/src/lib/analytics/mood-prognosis/__tests__/features.test.ts
@@ -0,0 +1,281 @@
+/**
+ * The matrix: what becomes a column, what never does, and what happens to a
+ * day that is missing something.
+ *
+ * The cases that matter are the absences. A day with no context row must not
+ * contribute a zero to a work-status column, because zero there means "worked
+ * a regular day and not overtime" and the truth is "nobody said". That
+ * distinction is invisible in the fitted numbers and would quietly teach the
+ * model that every unlogged day was a day off.
+ */
+import { describe, expect, it } from "vitest";
+
+import {
+ MAX_MODEL_FEATURES,
+ MIN_FEATURE_COVERAGE,
+ buildFeatureMatrix,
+ extractDayFeatures,
+ screenFeatures,
+ standardiseDay,
+ type PrognosisContextInput,
+ type PrognosisDayInput,
+} from "../features";
+import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
+
+const EMPTY_LINKED = {
+ sleepAsleep: null,
+ steps: null,
+ activeEnergy: null,
+ restingHeartRate: null,
+ heartRateVariability: null,
+};
+
+function context(
+ overrides: Partial = {},
+): PrognosisContextInput {
+ return {
+ workStatus: null,
+ workMinutes: null,
+ overtimeMinutes: null,
+ workLoad: null,
+ workSatisfaction: null,
+ contactCircles: [],
+ contactForm: null,
+ contactExtent: null,
+ contactQuality: null,
+ contactSupport: null,
+ leisureCategories: [],
+ leisureMinutes: null,
+ leisureJoy: null,
+ leisureRecovery: null,
+ eventType: null,
+ eventValence: null,
+ ...overrides,
+ };
+}
+
+function day(
+ index: number,
+ overrides: Partial = {},
+): PrognosisDayInput {
+ const date = new Date(Date.UTC(2026, 0, 1 + index));
+ return {
+ day: date.toISOString().slice(0, 10),
+ a1: 5,
+ dimensions: {},
+ context: null,
+ linked: { ...EMPTY_LINKED },
+ ...overrides,
+ };
+}
+
+describe("extractDayFeatures", () => {
+ it("takes A2 through the dimension table's inverse flag, not inline", () => {
+ const a2 = MOOD_DIMENSIONS.find((d) => d.key === "a2");
+ expect(a2?.inverse, "A2 is the inverse dimension this case is about").toBe(
+ true,
+ );
+ const features = extractDayFeatures(
+ day(0, { dimensions: { a2: 8, a3: 8 } }),
+ );
+ // Stress 8 is a heavy day, so on an up-is-better axis it reads as 2.
+ expect(features.get("dimension:a2")).toBe(2);
+ // A3 is not inverse and is carried literally.
+ expect(features.get("dimension:a3")).toBe(8);
+ });
+
+ it("never makes A1 an input", () => {
+ const features = extractDayFeatures(
+ day(0, { a1: 7, dimensions: { a1: 7, a2: 3 } }),
+ );
+ expect(features.has("dimension:a1")).toBe(false);
+ });
+
+ it("a day with no context contributes no context column at all", () => {
+ const features = extractDayFeatures(day(0, { context: null }));
+ expect(
+ [...features.keys()].filter((k) => k.startsWith("context:")),
+ ).toEqual([]);
+ });
+
+ it("an unanswered field contributes no column; an answered one contributes zeros and a one", () => {
+ const answered = extractDayFeatures(
+ day(0, { context: context({ workStatus: "overtime" }) }),
+ );
+ expect(answered.get("context:workStatus=overtime")).toBe(1);
+ expect(answered.get("context:workStatus=off")).toBe(0);
+
+ const unanswered = extractDayFeatures(
+ day(0, { context: context({ workLoad: 4 }) }),
+ );
+ expect(unanswered.has("context:workStatus=overtime")).toBe(false);
+ expect(unanswered.has("context:workStatus=off")).toBe(false);
+ expect(unanswered.get("context:workLoad")).toBe(4);
+ });
+
+ it("a recorded zero is a value, and absence is absence", () => {
+ const zero = extractDayFeatures(
+ day(0, { linked: { ...EMPTY_LINKED, steps: 0 } }),
+ );
+ expect(zero.get("linked:steps")).toBe(0);
+ const absent = extractDayFeatures(day(0));
+ expect(absent.has("linked:steps")).toBe(false);
+ });
+
+ it("an empty multi-select says nothing rather than saying no to everything", () => {
+ const features = extractDayFeatures(
+ day(0, { context: context({ contactCircles: [] }) }),
+ );
+ expect(features.has("context:contactCircles=partner")).toBe(false);
+ });
+});
+
+describe("screening", () => {
+ /** A2 drives A1 exactly; everything else is constant or noise. */
+ function drivenDays(count: number): PrognosisDayInput[] {
+ return Array.from({ length: count }, (_, i) => {
+ const stress = i % 11;
+ return day(i, {
+ // Up-is-better A2 is `10 - stress`, and the target follows it.
+ a1: 10 - stress,
+ dimensions: { a2: stress },
+ });
+ });
+ }
+
+ it("keeps a feature that tracks the target", () => {
+ const screened = screenFeatures(drivenDays(40));
+ const a2 = screened.find((s) => s.key === "dimension:a2");
+ expect(a2?.selected, "a perfectly tracking feature was screened out").toBe(
+ true,
+ );
+ expect(a2?.pairs).toBe(40);
+ });
+
+ it("refuses a feature recorded on too few of the days, before testing it", () => {
+ const days = drivenDays(40);
+ // Recorded on a quarter of the days — under the coverage floor whatever
+ // it correlates with.
+ const covered = Math.floor(40 * MIN_FEATURE_COVERAGE) - 5;
+ for (let i = 0; i < covered; i++) {
+ days[i] = { ...days[i], linked: { ...EMPTY_LINKED, steps: 1000 + i } };
+ }
+ const screened = screenFeatures(days);
+ const steps = screened.find((s) => s.key === "linked:steps");
+ expect(steps?.selected).toBe(false);
+ expect(steps?.reason).toBe("coverage");
+ });
+
+ it("refuses a one-hot seen fewer times than the occurrence floor", () => {
+ const days = drivenDays(40).map((d, i) => ({
+ ...d,
+ // Everybody worked; two days were overtime. The column is present on
+ // every day, so coverage passes and the occurrence floor is what
+ // refuses it.
+ context: context({ workStatus: i < 2 ? "overtime" : "regular" }),
+ }));
+ const screened = screenFeatures(days);
+ const overtime = screened.find(
+ (s) => s.key === "context:workStatus=overtime",
+ );
+ expect(overtime?.selected).toBe(false);
+ expect(overtime?.reason).toBe("occurrences");
+ });
+
+ it("refuses pure noise — the whole reason the FDR layer is here", () => {
+ // A deterministic pseudo-random series, so the case cannot flake.
+ let seed = 42;
+ const random = () => {
+ seed = (seed * 1103515245 + 12345) % 2147483648;
+ return seed / 2147483648;
+ };
+ const days = Array.from({ length: 60 }, (_, i) =>
+ day(i, {
+ a1: Math.round(random() * 10),
+ dimensions: { a2: Math.round(random() * 10) },
+ linked: { ...EMPTY_LINKED, steps: Math.round(random() * 10000) },
+ }),
+ );
+ const selected = screenFeatures(days).filter((s) => s.selected);
+ expect(selected.map((s) => s.key)).toEqual([]);
+ });
+
+ it("caps the column count", () => {
+ expect(MAX_MODEL_FEATURES).toBe(12);
+ });
+});
+
+describe("buildFeatureMatrix", () => {
+ function drivenDays(count: number): PrognosisDayInput[] {
+ return Array.from({ length: count }, (_, i) =>
+ day(i, { a1: 10 - (i % 11), dimensions: { a2: i % 11 } }),
+ );
+ }
+
+ it("standardises per user, so the columns are that person's own units", () => {
+ const matrix = buildFeatureMatrix(drivenDays(40));
+ expect(matrix).not.toBeNull();
+ if (!matrix) return;
+ const column = matrix.rows.map((row) => row[0]);
+ const mean = column.reduce((s, v) => s + v, 0) / column.length;
+ const sd = Math.sqrt(
+ column.reduce((s, v) => s + (v - mean) * (v - mean), 0) / column.length,
+ );
+ expect(mean).toBeCloseTo(0, 6);
+ expect(sd).toBeCloseTo(1, 6);
+ // The standardisation is kept so a later day can be scored on the same
+ // scale rather than re-standardised against itself.
+ expect(matrix.standardisation[0].key).toBe(matrix.features[0]);
+ expect(matrix.standardisation[0].sd).toBeGreaterThan(0);
+ });
+
+ it("orders rows oldest first, which is what makes the validation honest", () => {
+ const matrix = buildFeatureMatrix(drivenDays(40));
+ expect(matrix).not.toBeNull();
+ if (!matrix) return;
+ const sorted = [...matrix.days].sort();
+ expect(matrix.days).toEqual(sorted);
+ });
+
+ it("drops a day that is missing a selected feature rather than filling it in", () => {
+ const days = drivenDays(40);
+ days[5] = { ...days[5], dimensions: {} };
+ const matrix = buildFeatureMatrix(days);
+ expect(matrix).not.toBeNull();
+ if (!matrix) return;
+ expect(matrix.days).not.toContain(days[5].day);
+ expect(matrix.rows).toHaveLength(39);
+ // Nothing was imputed in its place.
+ expect(matrix.targets).toHaveLength(39);
+ });
+
+ it("refuses when nothing survives the screening", () => {
+ const flat = Array.from({ length: 40 }, (_, i) => day(i, { a1: 5 }));
+ expect(buildFeatureMatrix(flat)).toBeNull();
+ });
+
+ it("refuses a constant target instead of dividing by zero", () => {
+ const constant = Array.from({ length: 40 }, (_, i) =>
+ day(i, { a1: 5, dimensions: { a2: i % 7 } }),
+ );
+ expect(buildFeatureMatrix(constant)).toBeNull();
+ });
+});
+
+describe("standardiseDay", () => {
+ it("scores a day on the stored scale", () => {
+ const row = standardiseDay(day(0, { dimensions: { a2: 5 } }), [
+ { key: "dimension:a2", mean: 5, sd: 2 },
+ ]);
+ // Up-is-better A2 of a stress-5 day is 5, the mean, so zero.
+ expect(row).toEqual([0]);
+ });
+
+ it("refuses a day missing one of the fit's features", () => {
+ expect(
+ standardiseDay(day(0, { dimensions: {} }), [
+ { key: "dimension:a2", mean: 5, sd: 2 },
+ ]),
+ ).toBeNull();
+ });
+});
diff --git a/src/lib/analytics/mood-prognosis/__tests__/rolling-origin.test.ts b/src/lib/analytics/mood-prognosis/__tests__/rolling-origin.test.ts
new file mode 100644
index 000000000..53f93277a
--- /dev/null
+++ b/src/lib/analytics/mood-prognosis/__tests__/rolling-origin.test.ts
@@ -0,0 +1,269 @@
+/**
+ * The fit, and the one property that makes its band worth reading.
+ *
+ * The leakage case is the reason this file exists. A random k-fold over days
+ * produces a model that scores beautifully and predicts nothing, and there is
+ * nothing in the reported error that says so — which is why the assertion is
+ * on the FOLD INDICES and on the day keys themselves rather than on the error.
+ * An error-based check passes a leaking split whenever the leak happens to be
+ * small, which is exactly when nobody would look.
+ *
+ * Watched red: the split was replaced with a shuffle, and
+ * `describe("no training fold contains a day after its validation fold")`
+ * went red naming a future day inside a training fold. Reverted, hash checked.
+ */
+import { describe, expect, it } from "vitest";
+
+import { buildFeatureMatrix, type PrognosisDayInput } from "../features";
+import { fitRidge, predictWithFit, solveLinearSystem } from "../ridge";
+import {
+ BAND_LOWER_QUANTILE,
+ BAND_UPPER_QUANTILE,
+ LAMBDA_GRID,
+ MIN_VALIDATION_ROWS,
+ MODEL_VERSION,
+ fitPrognosisModel,
+ quantile,
+ rollingOriginFolds,
+} from "../validate";
+
+const EMPTY_LINKED = {
+ sleepAsleep: null,
+ steps: null,
+ activeEnergy: null,
+ restingHeartRate: null,
+ heartRateVariability: null,
+};
+
+function dayKey(index: number): string {
+ return new Date(Date.UTC(2026, 0, 1 + index)).toISOString().slice(0, 10);
+}
+
+/** A deterministic pseudo-random series — a flaky statistics test is useless. */
+function seeded(seed: number): () => number {
+ let state = seed;
+ return () => {
+ state = (state * 1103515245 + 12345) % 2147483648;
+ return state / 2147483648;
+ };
+}
+
+/**
+ * Days whose target is a clean linear function of one dimension.
+ *
+ * One driver rather than two, and that is a statement about the screening
+ * rather than about the fit: the screening is univariate, so a feature that
+ * only matters jointly with another is refused before the fit ever sees it.
+ * A two-driver fixture measures that property, not this one.
+ */
+function linearDays(count: number): PrognosisDayInput[] {
+ return Array.from({ length: count }, (_, i) => {
+ const stress = i % 11;
+ return {
+ day: dayKey(i),
+ // 0.8 × up-is-better stress, plus one — inside the 0-10 scale.
+ a1: 0.8 * (10 - stress) + 1,
+ dimensions: { a2: stress, a3: (i * 7) % 11 },
+ context: null,
+ linked: { ...EMPTY_LINKED },
+ };
+ });
+}
+
+/** Days whose target is noise and whose inputs are unrelated noise. */
+function noisyDays(count: number, seed = 7): PrognosisDayInput[] {
+ const random = seeded(seed);
+ return Array.from({ length: count }, (_, i) => ({
+ day: dayKey(i),
+ a1: Math.round(random() * 10),
+ dimensions: {
+ a2: Math.round(random() * 10),
+ a3: Math.round(random() * 10),
+ },
+ context: null,
+ linked: { ...EMPTY_LINKED },
+ }));
+}
+
+describe("no training fold contains a day after its validation fold", () => {
+ const ROWS = 60;
+ const folds = rollingOriginFolds(ROWS);
+
+ it("produces folds at all — the assertions below pass on an empty list", () => {
+ expect(folds.length).toBeGreaterThan(0);
+ expect(folds.length).toBeLessThanOrEqual(4);
+ // Every validation row is covered exactly once and the last fold reaches
+ // the end, so no day is silently dropped from the measurement.
+ expect(folds[folds.length - 1].validationEnd).toBe(ROWS);
+ });
+
+ it("trains on a prefix that ends exactly where the validation starts", () => {
+ for (const fold of folds) {
+ expect(fold.trainEnd).toBe(fold.validationStart);
+ expect(fold.trainEnd).toBeGreaterThan(0);
+ expect(fold.validationEnd).toBeGreaterThan(fold.validationStart);
+ }
+ });
+
+ it("holds on the day keys, not just on the indices", () => {
+ // The indices are only meaningful because the matrix is time-ordered, so
+ // the property is asserted against the dates the rows actually carry.
+ const matrix = buildFeatureMatrix(linearDays(ROWS));
+ expect(matrix).not.toBeNull();
+ if (!matrix) return;
+
+ for (const fold of rollingOriginFolds(matrix.rows.length)) {
+ const trainingDays = matrix.days.slice(0, fold.trainEnd);
+ const validationDays = matrix.days.slice(
+ fold.validationStart,
+ fold.validationEnd,
+ );
+ const latestTrainingDay = trainingDays[trainingDays.length - 1];
+ const earliestValidationDay = validationDays[0];
+ const leaked = trainingDays.filter((d) => d >= earliestValidationDay);
+ expect(
+ leaked,
+ `a training fold holds ${leaked.length} day(s) dated at or after the validation fold that starts on ${earliestValidationDay}`,
+ ).toEqual([]);
+ expect(latestTrainingDay < earliestValidationDay).toBe(true);
+ }
+ });
+
+ it("expands rather than slides — later folds train on more history", () => {
+ const sizes = folds.map((f) => f.trainEnd);
+ for (let i = 1; i < sizes.length; i++) {
+ expect(sizes[i]).toBeGreaterThan(sizes[i - 1]);
+ }
+ });
+
+ it("refuses to split a window too short to measure anything on", () => {
+ expect(rollingOriginFolds(MIN_VALIDATION_ROWS - 1)).toEqual([]);
+ });
+});
+
+describe("the ridge solve", () => {
+ it("recovers the coefficients of a clean linear signal", () => {
+ // y = 2*x1 - 3*x2, standardised columns, tiny penalty.
+ const rows: number[][] = [];
+ const targets: number[] = [];
+ for (let i = 0; i < 40; i++) {
+ const x1 = ((i % 7) - 3) / 2;
+ const x2 = ((i % 5) - 2) / 1.5;
+ rows.push([x1, x2]);
+ targets.push(2 * x1 - 3 * x2 + 5);
+ }
+ const fit = fitRidge(rows, targets, 1e-6);
+ expect(fit).not.toBeNull();
+ if (!fit) return;
+ expect(fit.coefficients[0]).toBeCloseTo(2, 2);
+ expect(fit.coefficients[1]).toBeCloseTo(-3, 2);
+ // The intercept is the mean of the training targets, and the columns are
+ // centred against their own means, so a row at the column means predicts
+ // the average day exactly.
+ expect(predictWithFit(fit, fit.columnMeans)).toBeCloseTo(fit.intercept, 6);
+ // And every training row is predicted back to its own target.
+ rows.forEach((row, i) => {
+ expect(predictWithFit(fit, row)).toBeCloseTo(targets[i], 2);
+ });
+ });
+
+ it("does not divide by zero on a constant target", () => {
+ const rows = Array.from({ length: 20 }, (_, i) => [i / 10, (i % 3) / 2]);
+ const fit = fitRidge(rows, new Array(20).fill(4), 1);
+ expect(fit).not.toBeNull();
+ if (!fit) return;
+ expect(fit.coefficients.every(Number.isFinite)).toBe(true);
+ expect(fit.intercept).toBe(4);
+ expect(predictWithFit(fit, [0, 0])).toBeCloseTo(4, 6);
+ });
+
+ it("refuses a singular system rather than answering zeros", () => {
+ // Two identical columns and no penalty: the system has no unique answer,
+ // and a vector of zeros would look exactly like "these features do not
+ // matter".
+ expect(
+ solveLinearSystem(
+ [
+ [1, 1],
+ [1, 1],
+ ],
+ [1, 1],
+ ),
+ ).toBeNull();
+ });
+
+ it("penalises: a larger lambda pulls the coefficients towards zero", () => {
+ const rows = Array.from({ length: 30 }, (_, i) => [((i % 7) - 3) / 2]);
+ const targets = rows.map((r) => 4 * r[0] + 5);
+ const light = fitRidge(rows, targets, 0.01);
+ const heavy = fitRidge(rows, targets, 100);
+ expect(light).not.toBeNull();
+ expect(heavy).not.toBeNull();
+ if (!light || !heavy) return;
+ expect(Math.abs(heavy.coefficients[0])).toBeLessThan(
+ Math.abs(light.coefficients[0]),
+ );
+ });
+});
+
+describe("the band reacts to how wrong the model is", () => {
+ it("is narrow on a clean signal and wide on noise", () => {
+ const clean = buildFeatureMatrix(linearDays(80));
+ expect(clean).not.toBeNull();
+ if (!clean) return;
+ const cleanModel = fitPrognosisModel(clean);
+ expect(cleanModel).not.toBeNull();
+ if (!cleanModel) return;
+ const cleanWidth = cleanModel.bandHigh - cleanModel.bandLow;
+
+ // Noise cannot go through the screening — it is refused before a fit is
+ // attempted, which is the stronger answer. So the band's reaction is
+ // measured on the matrix directly, with the screening bypassed by handing
+ // the fit a matrix built from the noisy targets and the clean columns.
+ const noisy = buildFeatureMatrix(linearDays(80));
+ expect(noisy).not.toBeNull();
+ if (!noisy) return;
+ const random = seeded(11);
+ const noisyMatrix = {
+ ...noisy,
+ targets: noisy.targets.map(() => Math.round(random() * 10)),
+ };
+ const noisyModel = fitPrognosisModel(noisyMatrix);
+ expect(noisyModel).not.toBeNull();
+ if (!noisyModel) return;
+ const noisyWidth = noisyModel.bandHigh - noisyModel.bandLow;
+
+ expect(
+ noisyWidth,
+ "the band did not widen on noise — a confident wrong number is the failure this band exists to prevent",
+ ).toBeGreaterThan(cleanWidth * 2);
+ expect(cleanWidth).toBeLessThan(2);
+ });
+
+ it("refuses noise at the screening, before any fit is attempted", () => {
+ expect(buildFeatureMatrix(noisyDays(80))).toBeNull();
+ });
+
+ it("takes the band from the validation residuals, at the stated quantiles", () => {
+ const matrix = buildFeatureMatrix(linearDays(80));
+ expect(matrix).not.toBeNull();
+ if (!matrix) return;
+ const model = fitPrognosisModel(matrix);
+ expect(model).not.toBeNull();
+ if (!model) return;
+ expect(model.bandLow).toBeLessThanOrEqual(model.bandHigh);
+ expect(BAND_LOWER_QUANTILE).toBe(0.1);
+ expect(BAND_UPPER_QUANTILE).toBe(0.9);
+ expect(model.n).toBe(matrix.rows.length);
+ expect(model.modelVersion).toBe(MODEL_VERSION);
+ expect(LAMBDA_GRID).toContain(model.fit.lambda);
+ // Never zero: an unpenalised solve memorises the training days.
+ expect(Math.min(...LAMBDA_GRID)).toBeGreaterThan(0);
+ });
+
+ it("quantile interpolates rather than rounding to a sample", () => {
+ expect(quantile([0, 10], 0.5)).toBe(5);
+ expect(quantile([1, 2, 3, 4], 0)).toBe(1);
+ expect(quantile([1, 2, 3, 4], 1)).toBe(4);
+ });
+});
diff --git a/src/lib/analytics/mood-prognosis/feature-keys.ts b/src/lib/analytics/mood-prognosis/feature-keys.ts
new file mode 100644
index 000000000..3f51c71b5
--- /dev/null
+++ b/src/lib/analytics/mood-prognosis/feature-keys.ts
@@ -0,0 +1,98 @@
+/**
+ * The names the model's inputs are known by, everywhere.
+ *
+ * One string per feature, stored in `MoodPrediction.features`, read back by
+ * the surface that names what the model weighted, and compared against by the
+ * tests. It is therefore a wire format in all but name: renaming a key
+ * silently orphans every stored row that used it, which is why the encoding is
+ * a function here rather than a template literal at four call sites.
+ *
+ * The shape is `:`, and the four families are the four places an
+ * input can come from:
+ *
+ * * `dimension:a2` — a level-A self-rating (A1 is the target)
+ * * `context:workLoad` — a numeric field of the day's context
+ * * `context:workStatus=off` — one value of a closed context vocabulary
+ * * `linked:steps` — a figure read from the module that owns it
+ *
+ * Nothing here resolves a label. The label is a locale lookup and this module
+ * is pure and bundle-safe, so the resolver lives beside the component that
+ * renders it and takes a parsed key from here.
+ */
+import type { MoodDimensionKey } from "@/lib/mood/dimensions";
+
+/** The linked figures the model may read, and the day-figure each names. */
+export const LINKED_FEATURE_METRICS = [
+ "sleepAsleep",
+ "steps",
+ "activeEnergy",
+ "restingHeartRate",
+ "heartRateVariability",
+] as const;
+
+export type LinkedFeatureMetric = (typeof LINKED_FEATURE_METRICS)[number];
+
+export type ParsedFeatureKey =
+ | { family: "dimension"; dimension: MoodDimensionKey }
+ | { family: "context"; field: string; value: null }
+ | { family: "context"; field: string; value: string }
+ | { family: "linked"; metric: LinkedFeatureMetric };
+
+/** `dimension:a2` */
+export function dimensionFeatureKey(dimension: MoodDimensionKey): string {
+ return `dimension:${dimension}`;
+}
+
+/** `context:workLoad` — a numeric field. */
+export function contextNumericFeatureKey(field: string): string {
+ return `context:${field}`;
+}
+
+/** `context:workStatus=off` — one value of a closed vocabulary. */
+export function contextValueFeatureKey(field: string, value: string): string {
+ return `context:${field}=${value}`;
+}
+
+/** `linked:steps` */
+export function linkedFeatureKey(metric: LinkedFeatureMetric): string {
+ return `linked:${metric}`;
+}
+
+const DIMENSION_KEYS = new Set(["a1", "a2", "a3", "a4", "a5"]);
+const LINKED_KEYS = new Set(LINKED_FEATURE_METRICS);
+
+/**
+ * Read a stored key back.
+ *
+ * `null` for anything this version does not recognise, and that case is real
+ * rather than defensive: a row written by an older `modelVersion` can name a
+ * feature the current build has dropped, and the surface has to skip it rather
+ * than render `undefined` at somebody.
+ */
+export function parseFeatureKey(key: string): ParsedFeatureKey | null {
+ const separator = key.indexOf(":");
+ if (separator < 0) return null;
+ const family = key.slice(0, separator);
+ const rest = key.slice(separator + 1);
+ if (rest.length === 0) return null;
+
+ if (family === "dimension") {
+ return DIMENSION_KEYS.has(rest)
+ ? { family: "dimension", dimension: rest as MoodDimensionKey }
+ : null;
+ }
+ if (family === "linked") {
+ return LINKED_KEYS.has(rest)
+ ? { family: "linked", metric: rest as LinkedFeatureMetric }
+ : null;
+ }
+ if (family === "context") {
+ const equals = rest.indexOf("=");
+ if (equals < 0) return { family: "context", field: rest, value: null };
+ const field = rest.slice(0, equals);
+ const value = rest.slice(equals + 1);
+ if (field.length === 0 || value.length === 0) return null;
+ return { family: "context", field, value };
+ }
+ return null;
+}
diff --git a/src/lib/analytics/mood-prognosis/features.ts b/src/lib/analytics/mood-prognosis/features.ts
new file mode 100644
index 000000000..467b71236
--- /dev/null
+++ b/src/lib/analytics/mood-prognosis/features.ts
@@ -0,0 +1,489 @@
+/**
+ * One row per day, one column per thing the day was made of.
+ *
+ * The target is the day's own pleasantness (A1) and everything else is an
+ * input: the other four level-A dimensions, the numbers and the closed
+ * vocabularies of the day's context, and the figures the sleep, activity and
+ * recovery modules already hold for that day. A1 is the only target on
+ * purpose — five targets would be five ways to be wrong and five bands to
+ * explain.
+ *
+ * Four rules this file is built on, each of which has a way of being got
+ * wrong that looks fine on screen:
+ *
+ * 1. **Nothing is imputed. Ever.** A day with a missing input is not a day
+ * with an average input. The stated rule is complete cases: a day enters
+ * the fit only if it carries the target and every SELECTED feature, and a
+ * feature only becomes a candidate if it was recorded on at least
+ * {@link MIN_FEATURE_COVERAGE} of the days that carry a target. The
+ * coverage floor is what keeps the complete-case rule from quietly
+ * emptying the matrix: without it, one field somebody filled in twice
+ * would exclude every other day they ever logged.
+ * 2. **Standardised per user, not min-max.** A person whose stress never
+ * exceeds six must not have six read as "maximum stress". The mean and the
+ * spread come from their own fit window, so every feature is expressed in
+ * that person's own units — which is also what makes the stored
+ * contributions comparable to each other.
+ * 3. **Screened before it is fitted, with the multiple-comparison correction
+ * the rest of the product already runs.** A sweep over sixty context
+ * features at raw p < 0.05 finds three things that are not there. Pearson
+ * with the shared `MIN_PAIRED_N` floor, Benjamini-Hochberg at the shared
+ * `FDR_Q`, and the shared effect-size floor on the shrunk estimate — all
+ * imported, none re-tuned here.
+ * 4. **Orientation comes from the table that owns it.** A2 is inverse and is
+ * flipped through `MOOD_DIMENSIONS[i].inverse`, never inline, so the chart
+ * and the model read one source. Context ratings are NOT flipped: no table
+ * states their orientation, and inventing one here would be exactly the
+ * second opinion the dimension table exists to prevent. It costs nothing
+ * mathematically — a flip only changes the sign of a coefficient, and the
+ * stored contribution is signed anyway.
+ */
+import {
+ EFFECT_SIZE_FLOOR,
+ FDR_Q,
+ benjaminiHochberg,
+ shrinkEstimate,
+} from "@/lib/insights/correlation-discovery";
+import {
+ MAX_P_VALUE,
+ MIN_PAIRED_N,
+ pearson,
+} from "@/lib/insights/correlations";
+import {
+ CONTACT_CIRCLE_KEYS,
+ CONTACT_EXTENT_KEYS,
+ CONTACT_FORM_KEYS,
+ EVENT_TYPE_KEYS,
+ LEISURE_CATEGORY_KEYS,
+ WORK_STATUS_KEYS,
+} from "@/lib/mood/context-vocabulary";
+import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
+import type { MoodDimensionKey } from "@/lib/mood/dimensions";
+
+import {
+ contextNumericFeatureKey,
+ contextValueFeatureKey,
+ dimensionFeatureKey,
+ linkedFeatureKey,
+ LINKED_FEATURE_METRICS,
+} from "./feature-keys";
+import { occurrencesSuffice } from "./thresholds";
+
+/**
+ * Most features the fit may carry.
+ *
+ * Twelve over thirty days is already generous — a ridge penalty is what makes
+ * that survivable at all — and the cap is what stops a well-logged account
+ * from being fitted with sixty columns and a hundred rows.
+ */
+export const MAX_MODEL_FEATURES = 12;
+
+/**
+ * Share of target-carrying days a feature must be recorded on to be a
+ * candidate at all.
+ *
+ * The other half of the complete-case rule. A field somebody filled in for a
+ * fortnight and then stopped is not a column of the matrix; it is a fortnight,
+ * and admitting it would silently discard every day outside that fortnight.
+ */
+export const MIN_FEATURE_COVERAGE = 0.6;
+
+/** The day's context as the model reads it. The note never comes here. */
+export interface PrognosisContextInput {
+ workStatus: string | null;
+ workMinutes: number | null;
+ overtimeMinutes: number | null;
+ workLoad: number | null;
+ workSatisfaction: number | null;
+ contactCircles: string[];
+ contactForm: string | null;
+ contactExtent: string | null;
+ contactQuality: number | null;
+ contactSupport: number | null;
+ leisureCategories: string[];
+ leisureMinutes: number | null;
+ leisureJoy: number | null;
+ leisureRecovery: number | null;
+ eventType: string | null;
+ eventValence: number | null;
+}
+
+/** One day, as everything upstream of the fit has resolved it. */
+export interface PrognosisDayInput {
+ /** Local day key, `YYYY-MM-DD`. */
+ day: string;
+ /** The target: the day's own pleasantness, 0-10. `null` days are not fitted. */
+ a1: number | null;
+ /** The other four self-ratings, literal as the person set them. */
+ dimensions: Partial>;
+ /** The day's stored context, or `null` when no section was opened. */
+ context: PrognosisContextInput | null;
+ /** What the owning modules hold for the day. `null` is absence. */
+ linked: {
+ sleepAsleep: number | null;
+ steps: number | null;
+ activeEnergy: number | null;
+ restingHeartRate: number | null;
+ heartRateVariability: number | null;
+ };
+}
+
+/** The numeric context fields, in capture order. */
+const CONTEXT_NUMERIC_FIELDS = [
+ "workMinutes",
+ "overtimeMinutes",
+ "workLoad",
+ "workSatisfaction",
+ "contactQuality",
+ "contactSupport",
+ "leisureMinutes",
+ "leisureJoy",
+ "leisureRecovery",
+ "eventValence",
+] as const satisfies readonly (keyof PrognosisContextInput)[];
+
+/** The single-valued vocabularies, each expanded to one column per value. */
+const CONTEXT_SINGLE_VOCABULARIES = [
+ { field: "workStatus", keys: WORK_STATUS_KEYS },
+ { field: "contactForm", keys: CONTACT_FORM_KEYS },
+ { field: "contactExtent", keys: CONTACT_EXTENT_KEYS },
+ { field: "eventType", keys: EVENT_TYPE_KEYS },
+] as const;
+
+/** The multi-select vocabularies, same expansion. */
+const CONTEXT_MULTI_VOCABULARIES = [
+ { field: "contactCircles", keys: CONTACT_CIRCLE_KEYS },
+ { field: "leisureCategories", keys: LEISURE_CATEGORY_KEYS },
+] as const;
+
+function isNumber(value: unknown): value is number {
+ return typeof value === "number" && Number.isFinite(value);
+}
+
+/**
+ * Every feature this day carries a value for.
+ *
+ * A feature the day says nothing about is ABSENT from the map — it is not
+ * zero. The difference is the whole of rule 1: a day with no context row has
+ * no work-status column at all, while a day whose work status is `off` has a
+ * zero in every other work-status column and a one in that one.
+ */
+export function extractDayFeatures(
+ day: PrognosisDayInput,
+): Map {
+ const out = new Map();
+
+ for (const dimension of MOOD_DIMENSIONS) {
+ // A1 is the target, not an input. Fitting a value on itself would produce
+ // a perfect model of nothing.
+ if (dimension.key === "a1") continue;
+ const raw = day.dimensions[dimension.key];
+ if (!isNumber(raw)) continue;
+ // The orientation flip goes through the table that declares it, so a
+ // dimension that becomes inverse later moves the chart and the model
+ // together.
+ out.set(
+ dimensionFeatureKey(dimension.key),
+ dimension.inverse ? dimension.max - raw : raw,
+ );
+ }
+
+ const context = day.context;
+ if (context) {
+ for (const field of CONTEXT_NUMERIC_FIELDS) {
+ const raw = context[field];
+ if (isNumber(raw)) out.set(contextNumericFeatureKey(field), raw);
+ }
+ for (const { field, keys } of CONTEXT_SINGLE_VOCABULARIES) {
+ const value = context[field];
+ // A field nobody answered contributes no columns. Zeroing them would
+ // tell the fit "not overtime" on a day nobody said anything about work.
+ if (typeof value !== "string" || value.length === 0) continue;
+ for (const key of keys) {
+ out.set(contextValueFeatureKey(field, key), value === key ? 1 : 0);
+ }
+ }
+ for (const { field, keys } of CONTEXT_MULTI_VOCABULARIES) {
+ const values = context[field];
+ if (!Array.isArray(values) || values.length === 0) continue;
+ const chosen = new Set(values);
+ for (const key of keys) {
+ out.set(contextValueFeatureKey(field, key), chosen.has(key) ? 1 : 0);
+ }
+ }
+ }
+
+ for (const metric of LINKED_FEATURE_METRICS) {
+ const raw = day.linked[metric];
+ if (isNumber(raw)) out.set(linkedFeatureKey(metric), raw);
+ }
+
+ return out;
+}
+
+/** What the screening decided about one candidate, kept for the tests. */
+export interface ScreenedFeature {
+ key: string;
+ /** Days carrying both the feature and the target. */
+ pairs: number;
+ r: number;
+ /** The shrunk estimate the effect floor is applied to. */
+ shrunk: number;
+ pValue: number;
+ qValue: number;
+ selected: boolean;
+ /** Why it was refused, when it was. */
+ reason?:
+ | "coverage"
+ | "too-few-pairs"
+ | "no-variance"
+ | "p-value"
+ | "fdr"
+ | "effect-size"
+ | "occurrences"
+ | "cap";
+}
+
+/**
+ * Which candidates earn a column, and why the others did not.
+ *
+ * Order of refusal matters and is the order below: coverage, then pairs, then
+ * the raw p, then the family-wide FDR, then the effect floor, then the cap.
+ * A feature that fails an earlier gate is not tested against a later one, so
+ * the family the FDR corrects over is the set that was actually tested.
+ */
+export function screenFeatures(
+ days: readonly PrognosisDayInput[],
+): ScreenedFeature[] {
+ const targetDays = days.filter((d) => isNumber(d.a1));
+ const perDay = new Map>();
+ for (const day of targetDays) perDay.set(day.day, extractDayFeatures(day));
+
+ const candidates = new Set();
+ for (const features of perDay.values()) {
+ for (const key of features.keys()) candidates.add(key);
+ }
+
+ const coverageFloor = targetDays.length * MIN_FEATURE_COVERAGE;
+ const refused: ScreenedFeature[] = [];
+ const tested: Array<{
+ key: string;
+ pairs: number;
+ r: number;
+ shrunk: number;
+ pValue: number;
+ }> = [];
+
+ for (const key of [...candidates].sort()) {
+ const xs: number[] = [];
+ const ys: number[] = [];
+ let occurrences = 0;
+ for (const day of targetDays) {
+ const value = perDay.get(day.day)?.get(key);
+ if (value === undefined) continue;
+ xs.push(value);
+ ys.push(day.a1 as number);
+ if (value !== 0) occurrences += 1;
+ }
+
+ if (xs.length < coverageFloor) {
+ refused.push(zero(key, xs.length, "coverage"));
+ continue;
+ }
+ // A one-hot column seen four times is not evidence, however well it
+ // correlates. This is the ladder's occurrence floor, not a second one.
+ if (!occurrencesSuffice(occurrences)) {
+ refused.push(zero(key, xs.length, "occurrences"));
+ continue;
+ }
+
+ const result = pearson({ xs, ys, minPairs: MIN_PAIRED_N });
+ if (result.status === "insufficient") {
+ refused.push(
+ zero(
+ key,
+ result.n,
+ result.reason === "no_variance" ? "no-variance" : "too-few-pairs",
+ ),
+ );
+ continue;
+ }
+ if (result.pValue >= MAX_P_VALUE) {
+ refused.push({
+ key,
+ pairs: result.n,
+ r: result.r,
+ shrunk: shrinkEstimate(result.r, result.n),
+ pValue: result.pValue,
+ qValue: 1,
+ selected: false,
+ reason: "p-value",
+ });
+ continue;
+ }
+ tested.push({
+ key,
+ pairs: result.n,
+ r: result.r,
+ shrunk: shrinkEstimate(result.r, result.n),
+ pValue: result.pValue,
+ });
+ }
+
+ const qValues = benjaminiHochberg(tested.map((t) => t.pValue));
+ const survivors: ScreenedFeature[] = [];
+ tested.forEach((t, index) => {
+ const qValue = qValues[index] ?? 1;
+ if (qValue > FDR_Q) {
+ survivors.push({ ...t, qValue, selected: false, reason: "fdr" });
+ return;
+ }
+ if (Math.abs(t.shrunk) < EFFECT_SIZE_FLOOR) {
+ survivors.push({ ...t, qValue, selected: false, reason: "effect-size" });
+ return;
+ }
+ survivors.push({ ...t, qValue, selected: true });
+ });
+
+ // The cap keeps the strongest, and a stable tie-break by key keeps two runs
+ // over the same data returning the same columns.
+ const admitted = survivors
+ .filter((s) => s.selected)
+ .sort(
+ (a, b) =>
+ Math.abs(b.shrunk) - Math.abs(a.shrunk) || a.key.localeCompare(b.key),
+ );
+ for (const feature of admitted.slice(MAX_MODEL_FEATURES)) {
+ feature.selected = false;
+ feature.reason = "cap";
+ }
+
+ return [...survivors, ...refused].sort((a, b) => a.key.localeCompare(b.key));
+}
+
+function zero(
+ key: string,
+ pairs: number,
+ reason: ScreenedFeature["reason"],
+): ScreenedFeature {
+ return {
+ key,
+ pairs,
+ r: 0,
+ shrunk: 0,
+ pValue: 1,
+ qValue: 1,
+ selected: false,
+ reason,
+ };
+}
+
+/** Per-feature centre and spread, over this account's own fit window. */
+export interface FeatureStandardisation {
+ key: string;
+ mean: number;
+ sd: number;
+}
+
+export interface FeatureMatrix {
+ /** The selected feature keys, in column order. */
+ features: string[];
+ /** Standardised rows, one per complete day, oldest first. */
+ rows: number[][];
+ /** The target for each row, unstandardised (0-10). */
+ targets: number[];
+ /** The day each row is, so the validation can stay time-ordered. */
+ days: string[];
+ /** What each column was standardised by, needed to score a new day. */
+ standardisation: FeatureStandardisation[];
+}
+
+/**
+ * Build the matrix: screen, then keep the complete days, then standardise.
+ *
+ * Returns `null` when nothing survives the screening or when no day carries
+ * every surviving feature. That is a refusal, not an empty matrix — the caller
+ * writes no forecast rather than one built on nothing.
+ */
+export function buildFeatureMatrix(
+ days: readonly PrognosisDayInput[],
+): FeatureMatrix | null {
+ const screened = screenFeatures(days);
+ const selected = screened.filter((s) => s.selected).map((s) => s.key);
+ if (selected.length === 0) return null;
+
+ const ordered = [...days]
+ .filter((d) => isNumber(d.a1))
+ .sort((a, b) => a.day.localeCompare(b.day));
+
+ const rawRows: number[][] = [];
+ const targets: number[] = [];
+ const dayKeys: string[] = [];
+ for (const day of ordered) {
+ const features = extractDayFeatures(day);
+ const row: number[] = [];
+ let complete = true;
+ for (const key of selected) {
+ const value = features.get(key);
+ if (value === undefined) {
+ complete = false;
+ break;
+ }
+ row.push(value);
+ }
+ // Complete cases only. A day missing one selected feature is a day the fit
+ // does not see; nothing is filled in for it.
+ if (!complete) continue;
+ rawRows.push(row);
+ targets.push(day.a1 as number);
+ dayKeys.push(day.day);
+ }
+
+ if (rawRows.length === 0) return null;
+
+ 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) };
+ });
+
+ // A column with no spread inside the complete-case set carries no
+ // information and would divide by zero. It goes, and if that empties the
+ // set the whole fit is refused.
+ const keep = standardisation
+ .map((s, index) => ({ ...s, index }))
+ .filter((s) => s.sd > 0);
+ if (keep.length === 0) return null;
+
+ return {
+ features: keep.map((k) => k.key),
+ rows: rawRows.map((row) => keep.map((k) => (row[k.index] - k.mean) / k.sd)),
+ targets,
+ days: dayKeys,
+ standardisation: keep.map(({ key, mean, sd }) => ({ key, mean, sd })),
+ };
+}
+
+/**
+ * Standardise one day's raw features against a stored fit, for scoring.
+ *
+ * `null` when the day does not carry every feature the fit uses — the same
+ * complete-case rule the matrix applies, asked of a single day. A day that
+ * cannot be scored gets no forecast, which is the honest answer.
+ */
+export function standardiseDay(
+ day: PrognosisDayInput,
+ standardisation: readonly FeatureStandardisation[],
+): number[] | null {
+ const features = extractDayFeatures(day);
+ const row: number[] = [];
+ for (const { key, mean, sd } of standardisation) {
+ const value = features.get(key);
+ if (value === undefined || sd <= 0) return null;
+ row.push((value - mean) / sd);
+ }
+ return row;
+}
diff --git a/src/lib/analytics/mood-prognosis/ridge.ts b/src/lib/analytics/mood-prognosis/ridge.ts
new file mode 100644
index 000000000..8f5335a4f
--- /dev/null
+++ b/src/lib/analytics/mood-prognosis/ridge.ts
@@ -0,0 +1,173 @@
+/**
+ * Ridge regression, closed form, in about sixty lines of arithmetic.
+ *
+ * `(XᵀX + λI)β = Xᵀy`, solved by Gaussian elimination with partial pivoting.
+ * That is the whole model. It is here rather than in a dependency because a
+ * dozen standardised columns is a 12×12 system, and adding a package to solve
+ * one of those would be adding a supply chain to avoid writing a loop.
+ *
+ * Two properties the rest of the feature relies on:
+ *
+ * * **The intercept is not penalised, because there is no intercept in the
+ * system.** The target is centred before the solve and the mean is added
+ * back afterwards, which is the standard treatment: penalising the
+ * intercept would shrink every prediction towards zero rather than
+ * towards the person's own average day, and towards zero on a 0-10
+ * pleasantness scale means "towards the worst day imaginable".
+ * * **λ is never zero.** With more columns than the ridge penalty can
+ * afford, an unpenalised solve is a memoriser: it fits the training days
+ * perfectly and says nothing about any other day. The grid the validation
+ * searches starts above zero for that reason.
+ *
+ * This is arithmetic, not an AI surface. No provider, no consent gate, no
+ * egress — see the note at the top of the refresh job.
+ */
+
+/** A fitted model: one coefficient per standardised column, plus the mean. */
+export interface RidgeFit {
+ /** One per column of the matrix it was fitted on, in the same order. */
+ coefficients: number[];
+ /** The mean of the training targets, added back on every prediction. */
+ intercept: number;
+ /**
+ * The mean of each training column, subtracted before every prediction.
+ *
+ * Usually zero to floating point, because the matrix arrives z-scored. It is
+ * carried anyway rather than assumed: centring the target while leaving the
+ * columns uncentred biases every coefficient, and the bias is small enough
+ * to look like rounding. Somebody handing this function a raw matrix should
+ * get the right answer, not a plausible one.
+ */
+ columnMeans: number[];
+ /** The penalty this fit used. */
+ lambda: number;
+}
+
+/**
+ * Solve `A x = b` by Gaussian elimination with partial pivoting.
+ *
+ * `null` when the system is singular to working precision. The caller treats
+ * that as "no fit" rather than as a fit of zeros: a singular system means the
+ * columns are telling the same story twice, and a zero vector would silently
+ * become a model that predicts the average and calls it a pattern.
+ */
+export function solveLinearSystem(
+ matrix: readonly number[][],
+ vector: readonly number[],
+): number[] | null {
+ const n = vector.length;
+ if (matrix.length !== n) return null;
+ // Copy: the caller's XᵀX is reused across the λ grid and elimination is
+ // destructive.
+ const a = matrix.map((row, i) => [...row, vector[i]]);
+
+ for (let column = 0; column < n; column++) {
+ let pivot = column;
+ for (let row = column + 1; row < n; row++) {
+ if (Math.abs(a[row][column]) > Math.abs(a[pivot][column])) pivot = row;
+ }
+ if (Math.abs(a[pivot][column]) < 1e-10) return null;
+ if (pivot !== column) {
+ const swap = a[pivot];
+ a[pivot] = a[column];
+ a[column] = swap;
+ }
+ const pivotValue = a[column][column];
+ for (let row = column + 1; row < n; row++) {
+ const factor = a[row][column] / pivotValue;
+ if (factor === 0) continue;
+ for (let k = column; k <= n; k++) a[row][k] -= factor * a[column][k];
+ }
+ }
+
+ const solution = new Array(n).fill(0);
+ for (let row = n - 1; row >= 0; row--) {
+ let sum = a[row][n];
+ for (let k = row + 1; k < n; k++) sum -= a[row][k] * solution[k];
+ solution[row] = sum / a[row][row];
+ }
+ return solution.every((v) => Number.isFinite(v)) ? solution : null;
+}
+
+/**
+ * Fit `rows` against `targets` at one penalty.
+ *
+ * `rows` are already standardised per user (see `features.ts`), so the columns
+ * are comparable and one λ is a defensible penalty across all of them —
+ * which is not true of raw minutes beside a 0-10 rating.
+ */
+export function fitRidge(
+ rows: readonly (readonly number[])[],
+ targets: readonly number[],
+ lambda: number,
+): RidgeFit | null {
+ const n = rows.length;
+ if (n === 0 || targets.length !== n) return null;
+ const columns = rows[0].length;
+ if (columns === 0) return null;
+ if (rows.some((row) => row.length !== columns)) return null;
+
+ const intercept = targets.reduce((sum, v) => sum + v, 0) / n;
+ const centredTargets = targets.map((v) => v - intercept);
+ const columnMeans = Array.from({ length: columns }, (_, j) => {
+ let sum = 0;
+ for (let i = 0; i < n; i++) sum += rows[i][j];
+ return sum / n;
+ });
+ const centredRows = rows.map((row) => row.map((v, j) => v - columnMeans[j]));
+
+ const xtx: number[][] = Array.from({ length: columns }, () =>
+ new Array(columns).fill(0),
+ );
+ const xty = new Array(columns).fill(0);
+ for (let i = 0; i < n; i++) {
+ const row = centredRows[i];
+ for (let j = 0; j < columns; j++) {
+ xty[j] += row[j] * centredTargets[i];
+ for (let k = j; k < columns; k++) {
+ const product = row[j] * row[k];
+ xtx[j][k] += product;
+ if (k !== j) xtx[k][j] += product;
+ }
+ }
+ }
+ for (let j = 0; j < columns; j++) xtx[j][j] += lambda;
+
+ const coefficients = solveLinearSystem(xtx, xty);
+ if (coefficients === null) return null;
+ return { coefficients, intercept, columnMeans, lambda };
+}
+
+/** Score one standardised row. */
+export function predictWithFit(
+ fit: RidgeFit,
+ row: readonly number[],
+): number | null {
+ if (row.length !== fit.coefficients.length) return null;
+ let sum = fit.intercept;
+ for (let i = 0; i < row.length; i++) {
+ sum += fit.coefficients[i] * (row[i] - fit.columnMeans[i]);
+ }
+ return Number.isFinite(sum) ? sum : null;
+}
+
+/**
+ * The per-feature contributions of one scored day.
+ *
+ * `coefficient × the day's standardised value`, which is what the stored
+ * `features` column holds and what the explanation line is generated from.
+ * Signed, so a feature that pulled the expected value down reads as having
+ * pulled it down, and the parts sum to the distance between the prediction and
+ * the person's own average day.
+ */
+export function featureContributions(
+ fit: RidgeFit,
+ featureKeys: readonly string[],
+ row: readonly number[],
+): Array<{ feature: string; contribution: number }> {
+ return featureKeys.map((feature, index) => ({
+ feature,
+ contribution:
+ fit.coefficients[index] * (row[index] - fit.columnMeans[index]),
+ }));
+}
diff --git a/src/lib/analytics/mood-prognosis/validate.ts b/src/lib/analytics/mood-prognosis/validate.ts
new file mode 100644
index 000000000..d5568c2e4
--- /dev/null
+++ b/src/lib/analytics/mood-prognosis/validate.ts
@@ -0,0 +1,202 @@
+/**
+ * Choosing the penalty, and measuring how wrong the model is, without letting
+ * it see the future.
+ *
+ * **Rolling origin, expanding window. Never a random k-fold.** Days are not
+ * exchangeable: a Tuesday sits between a Monday and a Wednesday that resemble
+ * it, and a shuffled split puts Wednesday in the training set and asks the
+ * model about Tuesday. The result is a model that looks excellent and is
+ * useless, and nothing about the number it reports says so. So the folds are
+ * ranges of a time-ordered array, the training range always ends where the
+ * validation range starts, and `rolling-origin.test.ts` asserts that on the
+ * fold indices and on the day keys themselves rather than on the resulting
+ * error — an error-based assertion would pass a leaking split whenever the
+ * leak happened to be small.
+ *
+ * **The band comes from the validation residuals**, not from a textbook
+ * standard error on the training fit. A training-fit interval on a model with
+ * twelve columns and thirty rows is narrow by construction and says nothing;
+ * the spread of the errors the model actually made on days it had not seen is
+ * the honest width. It widens when the model is bad, which is the property the
+ * surface depends on to report low confidence instead of a confident wrong
+ * number.
+ */
+import { fitRidge, predictWithFit, type RidgeFit } from "./ridge";
+import type { FeatureMatrix } from "./features";
+
+/**
+ * The stored model identity. Bump it whenever the feature set, the screening
+ * or the fit changes, so a re-fit is legible in the rows rather than silently
+ * changing what they mean. Rows carrying an older version stay readable; the
+ * surface skips a feature key the current build no longer knows.
+ */
+export const MODEL_VERSION = "mood-ridge-1";
+
+/**
+ * The penalties the validation searches.
+ *
+ * Never zero: with a dozen standardised columns over a few dozen days, an
+ * unpenalised solve memorises the training days and describes no other day.
+ * The grid is coarse on purpose — the difference between λ=8 and λ=10 is far
+ * below the noise in thirty days of self-ratings, and a fine grid would only
+ * be a slower way to overfit the validation set.
+ */
+export const LAMBDA_GRID = [0.5, 1, 2, 5, 10, 25, 50] as const;
+
+/** How many validation blocks the window is cut into. */
+export const VALIDATION_FOLDS = 4;
+
+/**
+ * The band is the central 80% of the validation residuals: the 10th and the
+ * 90th percentile. A range wide enough to be honest and narrow enough to be
+ * worth reading — and empirical, so an asymmetric error stays asymmetric
+ * instead of being averaged into a symmetric claim.
+ */
+export const BAND_LOWER_QUANTILE = 0.1;
+export const BAND_UPPER_QUANTILE = 0.9;
+
+/** Fewest rows a fold split is attempted on at all. */
+export const MIN_VALIDATION_ROWS = 10;
+
+/** One expanding-window split. Every index is into the time-ordered matrix. */
+export interface RollingOriginFold {
+ /** Training rows are `[0, trainEnd)`. */
+ trainEnd: number;
+ /** Validation rows are `[validationStart, validationEnd)`. */
+ validationStart: number;
+ validationEnd: number;
+}
+
+/**
+ * Cut a time-ordered window into expanding-window folds.
+ *
+ * `trainEnd === validationStart` in every fold, which is the whole invariant:
+ * the training range is a prefix and the validation range starts exactly where
+ * it ends. No gap (a gap would waste days), no overlap (an overlap is the
+ * leak).
+ */
+export function rollingOriginFolds(
+ rowCount: number,
+ folds: number = VALIDATION_FOLDS,
+): RollingOriginFold[] {
+ if (rowCount < MIN_VALIDATION_ROWS || folds < 1) return [];
+ const block = Math.floor(rowCount / (folds + 1));
+ if (block < 1) return [];
+
+ const out: RollingOriginFold[] = [];
+ for (let index = 1; index <= folds; index++) {
+ const validationStart = block * index;
+ const validationEnd =
+ index === folds ? rowCount : Math.min(rowCount, block * (index + 1));
+ if (validationEnd <= validationStart) continue;
+ out.push({ trainEnd: validationStart, validationStart, validationEnd });
+ }
+ return out;
+}
+
+/** Linear-interpolated quantile of an unsorted sample. */
+export function quantile(values: readonly number[], q: number): number {
+ if (values.length === 0) return 0;
+ const sorted = [...values].sort((a, b) => a - b);
+ if (sorted.length === 1) return sorted[0];
+ const position = (sorted.length - 1) * Math.min(1, Math.max(0, q));
+ const lower = Math.floor(position);
+ const upper = Math.ceil(position);
+ if (lower === upper) return sorted[lower];
+ return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
+}
+
+/** What one penalty scored across every fold. */
+export interface LambdaScore {
+ lambda: number;
+ /** Residuals (`actual − predicted`) from validation rows only. */
+ residuals: number[];
+ meanAbsoluteError: number;
+}
+
+/**
+ * Score one penalty over the folds. Training folds never contain a row dated
+ * after their validation fold, by construction of {@link rollingOriginFolds}.
+ */
+export function scoreLambda(
+ matrix: FeatureMatrix,
+ lambda: number,
+ folds: readonly RollingOriginFold[],
+): LambdaScore | null {
+ const residuals: number[] = [];
+ for (const fold of folds) {
+ const fit = fitRidge(
+ matrix.rows.slice(0, fold.trainEnd),
+ matrix.targets.slice(0, fold.trainEnd),
+ lambda,
+ );
+ if (fit === null) continue;
+ for (let i = fold.validationStart; i < fold.validationEnd; i++) {
+ const predicted = predictWithFit(fit, matrix.rows[i]);
+ if (predicted === null) continue;
+ residuals.push(matrix.targets[i] - predicted);
+ }
+ }
+ if (residuals.length === 0) return null;
+ return {
+ lambda,
+ residuals,
+ meanAbsoluteError:
+ residuals.reduce((sum, r) => sum + Math.abs(r), 0) / residuals.length,
+ };
+}
+
+/** A model, ready to score a day, with the band its own errors earned it. */
+export interface PrognosisModel {
+ fit: RidgeFit;
+ features: string[];
+ standardisation: FeatureMatrix["standardisation"];
+ /** Rows the final fit used. This is the `n` every surface must show. */
+ n: number;
+ /** Offsets from a prediction to the edges of the band, in target units. */
+ bandLow: number;
+ bandHigh: number;
+ /** Mean absolute error over the validation folds, for the record. */
+ meanAbsoluteError: number;
+ modelVersion: string;
+}
+
+/**
+ * Validate, choose λ, refit on the whole window, and take the band from the
+ * validation residuals.
+ *
+ * `null` is a refusal and is a normal outcome: too few complete days to split,
+ * a singular system, or a fold set that produced no residual at all. The
+ * caller writes no forecast rather than a forecast nobody measured.
+ */
+export function fitPrognosisModel(
+ matrix: FeatureMatrix,
+ lambdaGrid: readonly number[] = LAMBDA_GRID,
+): PrognosisModel | null {
+ const folds = rollingOriginFolds(matrix.rows.length);
+ if (folds.length === 0) return null;
+
+ let best: LambdaScore | null = null;
+ for (const lambda of lambdaGrid) {
+ const score = scoreLambda(matrix, lambda, folds);
+ if (score === null) continue;
+ if (best === null || score.meanAbsoluteError < best.meanAbsoluteError) {
+ best = score;
+ }
+ }
+ if (best === null) return null;
+
+ const fit = fitRidge(matrix.rows, matrix.targets, best.lambda);
+ if (fit === null) return null;
+
+ return {
+ fit,
+ features: matrix.features,
+ standardisation: matrix.standardisation,
+ n: matrix.rows.length,
+ bandLow: quantile(best.residuals, BAND_LOWER_QUANTILE),
+ bandHigh: quantile(best.residuals, BAND_UPPER_QUANTILE),
+ meanAbsoluteError: best.meanAbsoluteError,
+ modelVersion: MODEL_VERSION,
+ };
+}
diff --git a/src/lib/mood/linked-context.ts b/src/lib/mood/linked-context.ts
index f7c858c41..318dcc6cd 100644
--- a/src/lib/mood/linked-context.ts
+++ b/src/lib/mood/linked-context.ts
@@ -99,6 +99,20 @@ export interface LinkedBody {
episodeId: string | null;
}
+/**
+ * The five linked figures of one day as plain numbers, for callers that need
+ * a window of days rather than one day's blocks — the prognosis fit is the
+ * only one so far. `null` is absence, and a module switched off makes its own
+ * figures absent rather than zero.
+ */
+export interface LinkedDayFigures {
+ sleepAsleep: number | null;
+ steps: number | null;
+ activeEnergy: number | null;
+ restingHeartRate: number | null;
+ heartRateVariability: number | null;
+}
+
export interface LinkedDayContext {
/** The entry's local day, resolved through the entry's own `tz`. */
day: string;
@@ -396,6 +410,17 @@ export async function resolveLinkedDayContext(
}
: MODULE_OFF;
+ const bodyBlock = await resolveBody(userId, day, enabled);
+
+ return { day, sleep, activity, vitals, body: bodyBlock };
+}
+
+/** The illness day-log block, split out so the day loop above reads straight. */
+async function resolveBody(
+ userId: string,
+ day: string,
+ enabled: ReadonlyMap,
+): Promise> {
let body: LinkedBlock = MODULE_OFF;
if (enabled.get("illness") ?? true) {
// Read only. The illness module owns symptom capture and its severity
@@ -419,6 +444,152 @@ export async function resolveLinkedDayContext(
episodeId: dayLog?.episodeId ?? null,
};
}
+ return body;
+}
- return { day, sleep, activity, vitals, body };
+/**
+ * The five linked figures of one day, as plain numbers, for a whole window.
+ *
+ * The same figures the block above answers with, resolved for many days in
+ * one query instead of one query per day. It exists because the prognosis fits
+ * over a year of days and the per-day resolver would have run several hundred
+ * round trips to build one matrix.
+ *
+ * It is the SAME engine, deliberately: the module gates come from
+ * `moduleForMeasurementType` through `typeAvailable`, the cross-source de-dup
+ * runs through `canonicalRowsOfDay`, the night reconstruction is the one in
+ * `sleep-night.ts`, and the day totals are the same `sumOfDay` / `latestOfDay`
+ * the single-day path uses. A second implementation of "what did this day look
+ * like" would be a second answer, and the mood sheet and the model would start
+ * disagreeing about the same Tuesday.
+ *
+ * A switched-off module answers `null` for its metrics, not zero and not a
+ * value the owner declined to surface: the model must not learn from a module
+ * whose figures the account asked not to see.
+ */
+export async function resolveLinkedDayFigures(
+ userId: string,
+ days: readonly string[],
+ tz: string,
+): Promise
@@ -164,7 +171,7 @@ export function MoodPrognosisView({ reading }: { reading: PrognosisReading }) {
className="text-muted-foreground text-xs"
data-slot="mood-prognosis-age"
>
- {t("insights.mood.prognosis.notToday", { date: data.date })}
+ {t("insights.mood.prognosis.notToday", { date: day })}
) : null}
diff --git a/src/lib/analytics/mood-prognosis/load.ts b/src/lib/analytics/mood-prognosis/load.ts
index eb2ba1b99..f21bc319a 100644
--- a/src/lib/analytics/mood-prognosis/load.ts
+++ b/src/lib/analytics/mood-prognosis/load.ts
@@ -44,6 +44,17 @@ export const PROGNOSIS_FIT_WINDOW_DAYS = 365;
*/
export const PROGNOSIS_WRITE_WINDOW_DAYS = 7;
+/**
+ * Hard cap on the rows one fit reads.
+ *
+ * A year holds 365 days and the fold keeps one entry per day, so the cap only
+ * ever bites on an account that logs many times a day — and there it bites on
+ * the oldest days, which is the right end to lose. It is here because an
+ * unbounded `findMany` in a nightly job over an account with years of history
+ * is a table scan waiting for the account that has one.
+ */
+export const MAX_PROGNOSIS_ROWS = 5000;
+
/** `YYYY-MM-DD`, this many days before the given instant, in UTC terms. */
export function dayKeyBefore(now: Date, days: number): string {
return new Date(now.getTime() - days * 86_400_000).toISOString().slice(0, 10);
@@ -67,10 +78,13 @@ export async function loadPrognosisDays(
const since = dayKeyBefore(now, windowDays);
const entries = await prisma.moodEntry.findMany({
where: { userId, deletedAt: null, date: { gte: since } },
- // Ascending, so a later entry on the same day overwrites the earlier one
- // in the fold below and the "last entry of the day wins" rule is the
- // read order rather than a comparison.
- orderBy: [{ date: "asc" }, { moodLoggedAt: "asc" }],
+ // Descending, and the fold below keeps the FIRST row it sees per day, so
+ // "the last entry of a day wins" is the read order rather than a
+ // comparison. Descending also puts the cap on the right end: an account
+ // that logs many times a day loses its oldest days to the limit, never
+ // its newest ones, and the newest are the days a forecast is written for.
+ orderBy: [{ date: "desc" }, { moodLoggedAt: "desc" }],
+ take: MAX_PROGNOSIS_ROWS,
select: {
date: true,
moodA1: true,
@@ -107,6 +121,9 @@ export async function loadPrognosisDays(
const byDay = new Map();
for (const entry of entries) {
+ // The rows arrive newest first, so the first one seen for a day is that
+ // day's last entry.
+ if (byDay.has(entry.date)) continue;
byDay.set(entry.date, {
day: entry.date,
a1: entry.moodA1,
@@ -185,6 +202,9 @@ export async function countQualifyingDays(
},
distinct: ["date"],
select: { date: true },
+ // Bounded by the window either way — a year holds 366 distinct day keys —
+ // and the cap says so rather than trusting the window to be small.
+ take: 400,
});
return days.length;
}
From 84dae1668adc1dfc5cfc3167be2dd97c534395eb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 8 Aug 2026 12:38:27 +0200
Subject: [PATCH 10/13] docs(mood): say what the seasonal floor gates, and what
it deliberately does not
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
It gates the flag the forecast publishes, so a surface asking whether the
history supports a weekday claim about THIS comparison asks one place.
It does not retro-gate the weekday board the insights page has carried
for releases — that board is built from the entry aggregates and has its
own floors, and hiding it from accounts under ninety days would take a
surface away from people already reading it to enforce a rule about a
different calculation. And it does not justify building a second one.
---
.../analytics/mood-prognosis/thresholds.ts | 21 ++++++++++++++++++-
1 file changed, 20 insertions(+), 1 deletion(-)
diff --git a/src/lib/analytics/mood-prognosis/thresholds.ts b/src/lib/analytics/mood-prognosis/thresholds.ts
index 1d0d8e973..ebfbca2ee 100644
--- a/src/lib/analytics/mood-prognosis/thresholds.ts
+++ b/src/lib/analytics/mood-prognosis/thresholds.ts
@@ -54,7 +54,26 @@ export const MIN_ENTRIES_FOR_FORECAST = 30;
/** Below this, a forecast is shown but labelled provisional. */
export const MIN_ENTRIES_FOR_REGULAR_FORECAST = 60;
-/** Below this, no weekday and no seasonal comparison. */
+/**
+ * Below this, a weekday or seasonal comparison built on the forecast may not
+ * be shown.
+ *
+ * Two things it does NOT do, both worth stating because the omission is a
+ * decision rather than an oversight:
+ *
+ * * It does not retro-gate the weekday board the mood insights page has
+ * carried for releases. That board is built from the entry aggregates,
+ * not from this fit, and it has its own floors; hiding it from accounts
+ * under ninety days would take a surface away from people who have been
+ * reading it, to enforce a rule about a different calculation.
+ * * It does not build a second weekday board. One exists, and a second
+ * answer to "how do your Mondays go" is exactly the drift this milestone
+ * spent its effort avoiding.
+ *
+ * What it does is gate the `seasonalUnlocked` flag the forecast publishes, so
+ * any surface that wants to hang a weekday claim off THIS comparison — the
+ * clients included — asks one place whether the history supports one.
+ */
export const MIN_ENTRIES_FOR_SEASONAL = 90;
/**
From 9a47b4db49712e93c70253d782abc20f6e473acf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 8 Aug 2026 15:30:00 +0200
Subject: [PATCH 11/13] fix(mood): refuse a forecast below the data floor on
the read path, not only in the job
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The job clears a below-floor account's rows nightly, but the read path
trusted whatever row was on disk between passes. So for up to a day
after an account deleted entries back under the floor, GET
/api/mood/prognosis returned present:true with a value the account no
longer had the data to support — and worse, under a stage the row
implied ("insufficient" / "learning") that is not in the published enum,
which fails a strict client decode of the whole payload.
The read path now re-reads the ladder and returns the absence shape
below the floor, exactly as the job would, so the two agree and no
out-of-enum stage can reach the wire. The present type's stage is
narrowed to the three rungs that carry a value, so the compiler enforces
it too.
The integration case that missed this ran the job before reading, which
is precisely why it was silent. The new case deletes entries back into
the learning band and reads WITHOUT running the job, with the row still
proven on disk, and asserts present:false with no stage. Watched red:
before the fix it returned present:true with a predicted value.
---
src/lib/analytics/mood-prognosis/read.ts | 46 ++++++++++++----
.../integration/mood-prognosis-ladder.test.ts | 52 +++++++++++++++++++
2 files changed, 87 insertions(+), 11 deletions(-)
diff --git a/src/lib/analytics/mood-prognosis/read.ts b/src/lib/analytics/mood-prognosis/read.ts
index 6bf0d16ab..480ea9fe5 100644
--- a/src/lib/analytics/mood-prognosis/read.ts
+++ b/src/lib/analytics/mood-prognosis/read.ts
@@ -57,7 +57,13 @@ export interface PrognosisPresent {
current: boolean;
/** Below the regular threshold the forecast is labelled provisional. */
provisional: boolean;
- stage: PrognosisStage;
+ /**
+ * The rung the value is shown at. Only the three rungs that CARRY a value —
+ * a below-floor reading never reaches here, it becomes the absence shape —
+ * so the type matches the published enum exactly and an `insufficient` /
+ * `learning` stage cannot be constructed for a present reading.
+ */
+ stage: Extract;
/** Days of history the ladder was read against. */
entries: number;
/** Whether weekday and seasonal comparisons have unlocked. */
@@ -166,18 +172,32 @@ export async function readMoodPrognosis(
};
}
- const [entries, entry] = await Promise.all([
- countQualifyingDays(userId, now),
- prisma.moodEntry.findFirst({
- where: { userId, date: row.date, deletedAt: null, moodA1: { not: null } },
- orderBy: { moodLoggedAt: "desc" },
- select: { moodA1: true },
- }),
- ]);
+ const entries = await countQualifyingDays(userId, now);
+
+ // A row on disk is not enough to answer with. The job writes nightly, so
+ // between an entry deletion and the next pass a row outlives its own
+ // eligibility. The read path re-reads the ladder and refuses below the
+ // floor, exactly as the job would — otherwise this route serves a value the
+ // account no longer has the data to support for up to a day, and it serves
+ // it under a stage ("insufficient" / "learning") that is not in the
+ // published enum, which fails a strict client decode of the whole payload.
+ const gate = forecastGate(entries);
+ if (!gate.present) {
+ return {
+ present: false,
+ reason: gate.reason,
+ entries,
+ nextThreshold: gate.nextThreshold,
+ };
+ }
const today = moodDateKey(now, timezone ?? DEFAULT_TIMEZONE);
const ageDays = dayKeyAgeInDays(row.date, today);
- const gate = forecastGate(entries);
+ const entry = await prisma.moodEntry.findFirst({
+ where: { userId, date: row.date, deletedAt: null, moodA1: { not: null } },
+ orderBy: { moodLoggedAt: "desc" },
+ select: { moodA1: true },
+ });
const selfAssessment = entry?.moodA1 ?? null;
const against =
selfAssessment === null
@@ -195,7 +215,11 @@ export async function readMoodPrognosis(
computedAt: row.computedAt.toISOString(),
ageDays,
current: isCurrentForTodayClaim(ageDays),
- provisional: gate.present ? gate.provisional : true,
+ // `gate` is the present variant here — the absence arm returned above — so
+ // the stage is one of provisional / regular / seasonal, never the
+ // out-of-enum "insufficient" or "learning" a stale row could otherwise
+ // carry onto the wire.
+ provisional: gate.provisional,
stage: gate.stage,
entries,
seasonalUnlocked: seasonalComparisonsUnlocked(entries),
diff --git a/tests/integration/mood-prognosis-ladder.test.ts b/tests/integration/mood-prognosis-ladder.test.ts
index 53314ae2c..888e69581 100644
--- a/tests/integration/mood-prognosis-ladder.test.ts
+++ b/tests/integration/mood-prognosis-ladder.test.ts
@@ -317,6 +317,58 @@ describe("GET /api/mood/prognosis", () => {
expect(typeof body.data.ciLow).toBe("number");
});
+ it("refuses below the floor even while a stale row survives, and never emits an out-of-enum stage", async () => {
+ // The gap the read path has to close on its own. The job runs nightly, so
+ // between an entry deletion and the next 04:35 pass a MoodPrediction row
+ // outlives its own eligibility. If the read path trusted the row it would
+ // answer present:true with a value the account no longer has the data to
+ // support — and with stage "insufficient", which is not in the published
+ // enum, so a strict client decode fails the whole payload. The read path
+ // must agree with the job's own stale rule: below the floor is absence.
+ await seedUser();
+ await seedDays(40);
+ await runJob();
+ expect((await storedRows()).length).toBeGreaterThan(0);
+
+ // Delete back to 20 rated days — under the 30-day forecast floor, but
+ // still inside the learning band, so the refusal reason is a real rung
+ // rather than the empty-account one. Deliberately DO NOT run the job: the
+ // point is what the route answers in the window before the sweep clears
+ // the row. The existing "clears a stale forecast" case runs the job before
+ // reading, which is exactly why it never saw this.
+ await getPrismaClient().moodEntry.deleteMany({
+ where: { userId: USER, date: { gte: dayKey(20) } },
+ });
+ expect(
+ (await storedRows()).length,
+ "the row must still be on disk — otherwise this proves nothing about the read path",
+ ).toBeGreaterThan(0);
+
+ await signIn(USER);
+ const { GET } = await import("@/app/api/mood/prognosis/route");
+ const res = await GET(
+ new NextRequest("http://localhost/api/mood/prognosis"),
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ data: {
+ present: boolean;
+ reason?: string;
+ stage?: string;
+ predicted?: number;
+ };
+ };
+ expect(
+ body.data.present,
+ "the read path served a forecast to an account below every floor",
+ ).toBe(false);
+ expect(body.data).not.toHaveProperty("predicted");
+ // The out-of-enum stage a strict Codable decode chokes on must never leave
+ // the route at all.
+ expect(body.data.stage).toBeUndefined();
+ expect(body.data.reason).toBe("learning-phase");
+ });
+
it("refuses a delegate whose grant does not open the whole record", async () => {
await seedUser();
await seedDays(40);
From d58edc5debb624e267fead615796586bff1bc313 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 8 Aug 2026 15:30:20 +0200
Subject: [PATCH 12/13] fix(mood): floor the fit on complete rows, not only on
rated days
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The ladder gate counts days carrying a rating. But a day missing one of
the fit's selected features is dropped rather than imputed, so a
sparse-context account can clear thirty rated days and be fitted on far
fewer complete rows — a forecast "built from twenty days" past a floor
meant to say thirty. n beside the value and a wide band on a small fit
keep it from being dishonest, but the floor should still hold.
The number of complete rows the fit actually used now has to clear the
same threshold as the rated-day count; below it the account gets the
absence shape, and the read path then reports "not enough of a pattern
yet" rather than a value resting on a handful of days.
Watched red with a fixture of forty rated days carrying the driving
feature on only the twenty-five most recent — enough to screen the
feature in and to score the recent days, so the refusal is about the
complete-row count and nothing else. Before the floor it wrote n=25.
---
.../mood-prognosis/__tests__/forecast.test.ts | 71 +++++++++++++++++++
src/lib/analytics/mood-prognosis/forecast.ts | 14 +++-
2 files changed, 84 insertions(+), 1 deletion(-)
create mode 100644 src/lib/analytics/mood-prognosis/__tests__/forecast.test.ts
diff --git a/src/lib/analytics/mood-prognosis/__tests__/forecast.test.ts b/src/lib/analytics/mood-prognosis/__tests__/forecast.test.ts
new file mode 100644
index 000000000..78c7df9f6
--- /dev/null
+++ b/src/lib/analytics/mood-prognosis/__tests__/forecast.test.ts
@@ -0,0 +1,71 @@
+/**
+ * `computeForecast` — the two floors a fit has to clear, and the refusals.
+ *
+ * The first floor is the ladder, on rated days. The second is the one this
+ * file exists for: a complete-case matrix can shrink well below the rated-day
+ * count, because a day missing one of the fit's inputs is dropped rather than
+ * imputed. An account with thirty rated days but a feature recorded on only
+ * two thirds of them fits on twenty complete rows, and a forecast "built from
+ * twenty days" past a floor that was supposed to mean thirty is a floor that
+ * does not hold. The fit is refused below `MIN_ENTRIES_FOR_FORECAST` complete
+ * rows, not only below thirty rated days.
+ */
+import { describe, expect, it } from "vitest";
+
+import { computeForecast } from "../forecast";
+import type { PrognosisDayInput } from "../features";
+import { MIN_ENTRIES_FOR_FORECAST } from "../thresholds";
+
+const EMPTY_LINKED = {
+ sleepAsleep: null,
+ steps: null,
+ activeEnergy: null,
+ restingHeartRate: null,
+ heartRateVariability: null,
+};
+
+function dayKey(index: number): string {
+ return new Date(Date.UTC(2026, 0, 1 + index)).toISOString().slice(0, 10);
+}
+
+/**
+ * `rated` days carrying a rating driven by A2, of which the most recent
+ * `withFeature` actually carry the A2 slider. The rest are older face-only
+ * days: rated, but with no input the fit can use, so they drop out of the
+ * complete-case matrix. The feature sits on the RECENT days on purpose, so the
+ * days the write window would score are scorable — otherwise the refusal is
+ * "no recent day to score" rather than the "too few complete rows" this file
+ * is about.
+ */
+function sparseDays(rated: number, withFeature: number): PrognosisDayInput[] {
+ return Array.from({ length: rated }, (_, i) => {
+ const stress = i % 11;
+ const hasFeature = i >= rated - withFeature;
+ return {
+ day: dayKey(i),
+ a1: Math.round(0.8 * (10 - stress) + 1),
+ dimensions: hasFeature ? { a2: stress } : {},
+ context: null,
+ linked: { ...EMPTY_LINKED },
+ };
+ });
+}
+
+describe("computeForecast complete-row floor", () => {
+ it("refuses a fit that clears the rated-day floor but not the complete-row one", () => {
+ // 40 rated days, A2 on 25 of them: coverage 62.5% clears the candidate
+ // floor, so A2 is screened in, but the complete-case matrix is 25 rows —
+ // under the 30-row floor.
+ const result = computeForecast(sparseDays(40, 25));
+ expect(result.status, JSON.stringify(result)).toBe("refused");
+ });
+
+ it("writes once the complete rows themselves reach the floor", () => {
+ // Every rated day carries the feature, so complete rows equal rated days.
+ const result = computeForecast(sparseDays(40, 40));
+ expect(result.status).toBe("written");
+ if (result.status === "written") {
+ expect(result.n).toBeGreaterThanOrEqual(MIN_ENTRIES_FOR_FORECAST);
+ }
+ });
+});
diff --git a/src/lib/analytics/mood-prognosis/forecast.ts b/src/lib/analytics/mood-prognosis/forecast.ts
index e5cc768b1..5a9ec91e4 100644
--- a/src/lib/analytics/mood-prognosis/forecast.ts
+++ b/src/lib/analytics/mood-prognosis/forecast.ts
@@ -23,7 +23,7 @@ import {
} from "./features";
import { PROGNOSIS_WRITE_WINDOW_DAYS } from "./load";
import { featureContributions, predictWithFit } from "./ridge";
-import { forecastGate } from "./thresholds";
+import { MIN_ENTRIES_FOR_FORECAST, forecastGate } from "./thresholds";
import { fitPrognosisModel } from "./validate";
/** The lowest and highest value the target scale can express. */
@@ -101,6 +101,18 @@ export function computeForecast(
const model = matrix === null ? null : fitPrognosisModel(matrix);
if (model === null) return { status: "refused", reason: "no-pattern" };
+ // The second floor, and the one the rated-day gate above cannot see. A day
+ // missing one of the selected features is dropped rather than imputed, so a
+ // sparse-context account can clear thirty rated days and still fit on a
+ // handful of complete rows. `n` shown beside the value and a wide band on a
+ // small fit keep it from being dishonest, but a forecast "built from twelve
+ // days" past a floor that was supposed to mean thirty is a floor that does
+ // not hold. The number of days the fit ACTUALLY used has to clear the same
+ // threshold, not only the number of days that carried a rating.
+ if (model.n < MIN_ENTRIES_FOR_FORECAST) {
+ return { status: "refused", reason: "no-pattern" };
+ }
+
const rows: ForecastRow[] = [];
for (const day of rated.slice(-writeWindowDays)) {
const standardised = standardiseDay(day, model.standardisation);
From 75c43c74bce8c76d08a01d0ac3f212d5f64daae4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 8 Aug 2026 15:30:38 +0200
Subject: [PATCH 13/13] docs(mood): state the validation's real leak boundary,
drop the unused weekday floor, correct two comments
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Four honesty fixes with no behaviour change to the math.
The validation header claimed more than it proves. The ridge FIT is
out-of-sample and no training fold sees ahead — that holds. But feature
SELECTION and per-user standardisation both run over the whole window
before the split, a small documented optimism (measured at about one
part in sixty on the error). The header now says exactly what is and is
not leak-free, and says to nest the screening if the feature set ever
grows or the optimism is ever measured larger.
The per-weekday observation floor had no consumer: no weekday board is
built on this fit, the forecast only publishes seasonalUnlocked and
leaves the weekday view to the insights page, which has its own floors.
Removed rather than left as a number nothing reads, with a note to bring
it back alongside such a board.
Two comments were wrong and are now true: the route's parameter is
declared for the exported type, not to steer a method check the wrapper
resolves from the Next.js request with a GET fallback; and the label
surface imports the key parser from feature-keys.ts, not features.ts.
And the write-surface guard now states its own structural blind spot,
the way the session-surface guard does: the wipe path deletes this table
through a dynamically-resolved delegate the literal matcher cannot see,
which is why that deletion is correctly not counted as a writer.
The Coach staleness fixture moves above the forecast floor, because the
read path now refuses below it: a one-entry account correctly gets an
absence rather than the present forecast that case exercises.
---
...ood-prediction-write-surface-guard.test.ts | 13 +++++++++
src/app/api/mood/prognosis/route.ts | 16 ++++++-----
.../mood-prognosis/__tests__/gates.test.ts | 19 -------------
src/lib/analytics/mood-prognosis/load.ts | 11 +++++---
.../analytics/mood-prognosis/thresholds.ts | 16 ++++-------
src/lib/analytics/mood-prognosis/validate.ts | 28 +++++++++++++++++++
.../mood-dimension-features.test.ts | 19 ++++++++++++-
7 files changed, 81 insertions(+), 41 deletions(-)
diff --git a/src/__tests__/mood-prediction-write-surface-guard.test.ts b/src/__tests__/mood-prediction-write-surface-guard.test.ts
index 3bacd4a4f..af83e70d0 100644
--- a/src/__tests__/mood-prediction-write-surface-guard.test.ts
+++ b/src/__tests__/mood-prediction-write-surface-guard.test.ts
@@ -23,6 +23,19 @@
* the legitimate writer really does make. `describe("the matcher sees what it
* looks for")` proves the regex against synthetic sources, including the
* broken-across-lines form that defeated an earlier guard in this repository.
+ *
+ * **What this guard structurally cannot see, stated like the session-surface
+ * guard states its own limit.** The matcher keys on a literal `moodPrediction`
+ * delegate access. The "Delete All Data" wipe DOES delete this table — the
+ * model is in `WIPE_MODELS` (`src/lib/data-wipe/wipe-plan.ts`) — but it does so
+ * through a delegate resolved dynamically from the model name (`client[key]
+ * .deleteMany`), so no `moodPrediction.deleteMany(` literal exists for the
+ * matcher to catch, and that deletion is correctly NOT counted as a writer.
+ * The wipe path's own completeness is proven elsewhere, by
+ * `data-wipe-completeness.test.ts` enumerating the schema; this guard is only
+ * the one about request-time writers, and it cannot be the gate on a dynamic
+ * delegate. A future writer that reached the table through a computed property
+ * would slip this matcher for the same reason.
*/
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join, sep } from "node:path";
diff --git a/src/app/api/mood/prognosis/route.ts b/src/app/api/mood/prognosis/route.ts
index 2129e61e7..8296d7ef3 100644
--- a/src/app/api/mood/prognosis/route.ts
+++ b/src/app/api/mood/prognosis/route.ts
@@ -36,13 +36,15 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
-// The request is declared although the handler reads nothing off it: the
-// wrapper takes the method and the path from it for the wide event, and
-// `requireRecordAuth` reads the method to decide whether a `read` declaration
-// really is a read. A handler with no parameter types as zero-arity, and a
-// caller that then omits the request turns every method check into "unknown",
-// which escalates a read to a write and refuses a delegate for the wrong
-// reason.
+// The parameter is declared although the handler body reads nothing off it,
+// and the reason is the exported type rather than the runtime. `apiHandler`
+// infers the type of `GET` from this callback, so a zero-argument handler
+// would type `GET` as taking no arguments — and Next.js, and the integration
+// tests, call it WITH the request. The wide event's method and path, and the
+// read-vs-write check `requireRecordAuth` runs, both come from the request the
+// wrapper receives from Next.js (not from this parameter), and both fall back
+// to `GET` when no request is present — so an absent request keeps a read a
+// read rather than escalating it.
export const GET = apiHandler(async (_request: NextRequest) => {
// `record`: see the file header. A scoped grant is refused here by
// construction rather than served a narrowed payload.
diff --git a/src/lib/analytics/mood-prognosis/__tests__/gates.test.ts b/src/lib/analytics/mood-prognosis/__tests__/gates.test.ts
index 40be4d6b7..7895459d0 100644
--- a/src/lib/analytics/mood-prognosis/__tests__/gates.test.ts
+++ b/src/lib/analytics/mood-prognosis/__tests__/gates.test.ts
@@ -27,12 +27,10 @@ import {
MIN_ENTRIES_FOR_SEASONAL,
MIN_ENTRIES_LEARNING_PHASE,
MIN_TAG_OCCURRENCES,
- MIN_WEEKDAY_OBSERVATIONS,
forecastGate,
occurrencesSuffice,
prognosisStage,
seasonalComparisonsUnlocked,
- weekdayObservationsSuffice,
} from "../thresholds";
import { CONTEXT_MIN_PRESENT_DAYS } from "@/lib/insights/mood-context-crosstab";
@@ -42,7 +40,6 @@ describe("the ladder is ordered and its rungs are the documented numbers", () =>
expect(MIN_ENTRIES_FOR_FORECAST).toBe(30);
expect(MIN_ENTRIES_FOR_REGULAR_FORECAST).toBe(60);
expect(MIN_ENTRIES_FOR_SEASONAL).toBe(90);
- expect(MIN_WEEKDAY_OBSERVATIONS).toBe(4);
});
it("rises strictly, so no rung can be reached before the one below it", () => {
@@ -162,22 +159,6 @@ describe("MIN_ENTRIES_FOR_SEASONAL — below it, no weekday or seasonal view", (
});
});
-describe("MIN_WEEKDAY_OBSERVATIONS — a weekday needs a month of itself", () => {
- it(`refuses at ${MIN_WEEKDAY_OBSERVATIONS - 1}`, () => {
- expect(
- weekdayObservationsSuffice(MIN_WEEKDAY_OBSERVATIONS - 1),
- "MIN_WEEKDAY_OBSERVATIONS admitted n-1 observations of one weekday",
- ).toBe(false);
- });
-
- it(`admits at exactly ${MIN_WEEKDAY_OBSERVATIONS}`, () => {
- expect(
- weekdayObservationsSuffice(MIN_WEEKDAY_OBSERVATIONS),
- "MIN_WEEKDAY_OBSERVATIONS refused its own value",
- ).toBe(true);
- });
-});
-
describe("MIN_TAG_OCCURRENCES — a value seen four times is not evidence", () => {
it(`refuses at ${MIN_TAG_OCCURRENCES - 1}`, () => {
expect(
diff --git a/src/lib/analytics/mood-prognosis/load.ts b/src/lib/analytics/mood-prognosis/load.ts
index f21bc319a..930326756 100644
--- a/src/lib/analytics/mood-prognosis/load.ts
+++ b/src/lib/analytics/mood-prognosis/load.ts
@@ -1,10 +1,13 @@
/**
* The database read behind the fit, and nothing else.
*
- * Separate from `features.ts` on purpose: that module is pure and is imported
- * by the surface that labels a stored feature key, and a Prisma import in it
- * would pull the database client towards a client bundle. The split is the
- * boundary, not a preference.
+ * Separate from `features.ts` on purpose. `features.ts` is pure, and it shares
+ * `feature-keys.ts` with the client component that labels a stored feature key
+ * (`components/insights/mood/prognosis-feature-label.ts` imports the parser
+ * from there) — so those two modules form a Prisma-free island the browser
+ * bundle can reach. A database import in `features.ts` would drag the client
+ * towards that bundle; keeping the read here is what holds the line. The split
+ * is the boundary, not a preference.
*
* **One row per day, taken whole from one entry.** A day with two entries
* contributes the later one — its target, its dimensions and its context
diff --git a/src/lib/analytics/mood-prognosis/thresholds.ts b/src/lib/analytics/mood-prognosis/thresholds.ts
index ebfbca2ee..88aab2821 100644
--- a/src/lib/analytics/mood-prognosis/thresholds.ts
+++ b/src/lib/analytics/mood-prognosis/thresholds.ts
@@ -86,11 +86,12 @@ export const MIN_ENTRIES_FOR_SEASONAL = 90;
*/
export const MIN_TAG_OCCURRENCES = CONTEXT_MIN_PRESENT_DAYS;
-/**
- * Observations of one weekday before that weekday may be compared to the
- * others. Four is a month of Mondays.
- */
-export const MIN_WEEKDAY_OBSERVATIONS = 4;
+// A per-weekday observation floor was drafted here for a weekday-comparison
+// board built on the forecast. That board was not built — the forecast
+// publishes `seasonalUnlocked` and leaves the weekday view to the mood
+// insights page, which has its own floors — so the constant had no consumer
+// and was removed rather than left as a number nothing reads. If a weekday
+// comparison on THIS fit is ever built, reintroduce the floor with it.
/** Where an account sits on the ladder. */
export type PrognosisStage =
@@ -174,11 +175,6 @@ export function seasonalComparisonsUnlocked(entries: number): boolean {
return entries >= MIN_ENTRIES_FOR_SEASONAL;
}
-/** Whether one weekday has been seen often enough to be compared. */
-export function weekdayObservationsSuffice(observations: number): boolean {
- return observations >= MIN_WEEKDAY_OBSERVATIONS;
-}
-
/** Whether a tag or context value has been recorded often enough to name. */
export function occurrencesSuffice(occurrences: number): boolean {
return occurrences >= MIN_TAG_OCCURRENCES;
diff --git a/src/lib/analytics/mood-prognosis/validate.ts b/src/lib/analytics/mood-prognosis/validate.ts
index d5568c2e4..fb86a9752 100644
--- a/src/lib/analytics/mood-prognosis/validate.ts
+++ b/src/lib/analytics/mood-prognosis/validate.ts
@@ -20,6 +20,34 @@
* the honest width. It widens when the model is bad, which is the property the
* surface depends on to report low confidence instead of a confident wrong
* number.
+ *
+ * **What is out-of-sample here, and what is not — stated exactly, because the
+ * boundary is easy to overclaim.** The RIDGE FIT is honest: within every fold
+ * the coefficients are solved on the training prefix alone and scored on
+ * validation rows the fit never saw, and no training fold holds a row dated
+ * after its validation fold. That is the claim `rolling-origin.test.ts`
+ * proves, and it is the one that matters for "does λ generalise".
+ *
+ * Two steps sit OUTSIDE that guarantee, on purpose:
+ *
+ * * **Feature selection.** The screening in `features.ts` runs once over the
+ * whole window before the split, so the feature set a fold trains on was
+ * chosen with that fold's validation rows in view. This is the classic
+ * "screen, then cross-validate" optimism.
+ * * **Standardisation.** The per-user mean and spread are taken over the
+ * whole window too, so a validation row is z-scored against statistics its
+ * own value helped compute.
+ *
+ * Both make the reported error slightly better than a fully-nested procedure
+ * would — measured here at roughly one part in sixty on the mean absolute
+ * error and a couple of points of coverage, i.e. small enough that the band is
+ * not dishonest but not zero. Nesting the screening and the standardisation
+ * inside each fold would remove even that, at the cost of a per-fold feature
+ * set and a materially larger amount of machinery; the trade was taken the
+ * cheap way deliberately, and this comment is the price of taking it — a
+ * reader must not read "no fold sees ahead" as covering the SELECTION, only
+ * the FIT. If the feature set ever grows past a handful of strong columns, or
+ * the optimism is ever measured larger, nest it.
*/
import { fitRidge, predictWithFit, type RidgeFit } from "./ridge";
import type { FeatureMatrix } from "./features";
diff --git a/tests/integration/mood-dimension-features.test.ts b/tests/integration/mood-dimension-features.test.ts
index 1baadaf42..2dac517fe 100644
--- a/tests/integration/mood-dimension-features.test.ts
+++ b/tests/integration/mood-dimension-features.test.ts
@@ -177,7 +177,24 @@ describe("level-A blocks reach the feature set with their own ages", () => {
it("carries the day's forecast with its own age, and its own instruction", async () => {
await seedUser();
- await seedEntry(0, { mood: "GUT", score: 4, a1: 7, a2: 3 });
+ // Enough rated days that the account is above the forecast floor: the read
+ // path re-checks the ladder and refuses below it, stale row or not, so a
+ // one-entry account would (correctly) get an absence here rather than the
+ // present forecast this case is about. One statement rather than a loop of
+ // awaits, to keep the write short.
+ await getPrismaClient().moodEntry.createMany({
+ data: Array.from({ length: 35 }, (_, d) => ({
+ userId: USER,
+ date: shiftDateKey(todayKey(), -d),
+ tz: TZ,
+ mood: "GUT",
+ score: 4,
+ moodA1: 7,
+ stressA2: 3,
+ source: "MANUAL" as const,
+ moodLoggedAt: new Date(Date.now() - d * DAY_MS),
+ })),
+ });
// A forecast about the day before yesterday. Written straight to the
// table: the job's own path is proven in `mood-prognosis-ladder.test.ts`,
// and what this case is about is what the assistant is handed.