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: