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