feat(mood): add the prediction table, with one writer and no writer yet

`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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 11:10:25 +02:00
1 parent 779b7bcaf1
commit a118d4810d
5 files changed
+274

No files matched your search

@@ -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;
+63
View File
@@ -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
@@ -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([]);
});
});
+6
View File
@@ -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",
+2
View File
@@ -342,6 +342,8 @@ export const DERIVED_MODELS: Readonly<Record<string, string>> = {
"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: