feat(mood): let the Coach read the deviation, and publish the resolved forecast

The snapshot gains the day's two readings with the age of the second
one, through the same reader the page uses, so the page and the
assistant cannot disagree about what today's comparison is. The
freshness rule is the shared one: a comparison about the day before
yesterday carries the date it is about and an instruction not to state
it as today. Absence is a present field rather than a missing key —
a model told "there is none yet" says so, and a model told nothing
estimates one. The wording rule travels with the values, because a model
handed a number and no instruction writes the sentence its training
suggests, and about a forecast that sentence is a causal one.

The contract publishes the resolved value: the expected figure, the
band, the count, the deviation against the band, the age, and the
contributions that carried weight. Nothing is left for a client to
re-derive — two clients deriving the same figure about somebody's health
record is how they start disagreeing about it. The description carries
the observational wording and the display rule: the count goes wherever
the value goes.

The end-to-end proof runs against real Postgres and reads the table
rather than a return value: nothing written a day short of the
threshold, a banded and counted row on the day it crosses, the
provisional label dropped at sixty days, the rows cleared when the
module goes off, the rows cleared when entries are deleted back below
the threshold, and an older pass's row replaced rather than left beside
a fresh one. A delegate holding only the mind section is refused, and
the assertion is on the response bytes.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 12:15:18 +02:00
1 parent f48afed82d
commit c33f7f9f6a
6 files changed
+899 -1

No files matched your search

+10 -1
View File
@@ -26,6 +26,8 @@
* overwrite them — `src/__tests__/mood-prediction-write-surface-guard.test.ts`
* holds that, and this file is one of the ones it scans.
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { readMoodPrognosis } from "@/lib/analytics/mood-prognosis/read";
@@ -34,7 +36,14 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
// 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.
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.
const { user } = await requireRecordAuth("read", "record");
+119
View File
@@ -17,6 +17,7 @@ import { userDayKey } from "@/lib/tz/format";
import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness";
import { computeMoodDimensionSeries } from "@/lib/insights/mood-dimension-series";
import { computeContextMoodComparison } from "@/lib/insights/mood-context-crosstab";
import { readMoodPrognosis } from "@/lib/analytics/mood-prognosis/read";
import { MOOD_DIMENSIONS } from "@/lib/mood/dimensions";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
@@ -321,6 +322,113 @@ async function buildMoodDimensionBlock(userId: string): Promise<{
};
}
/**
* The day's two readings for the model, with the age of the second one.
*
* Absence is a present field rather than a missing one: `{ present: false }`
* with the reason and the count, so the model says "not enough entries yet"
* instead of inventing a number. That shape is a contract elsewhere in the
* product already and it is the reason this block is built even when there is
* nothing to report.
*
* The wording rule travels with the values. A model handed a number and no
* instruction writes the sentence its training suggests, and the sentence its
* training suggests about a forecast is a causal one.
*/
interface MoodPrognosisFeature {
present: boolean;
/** Why there is nothing, when there is nothing. */
reason?: string;
/** Days of the account's own history the ladder was read against. */
entries: number;
/** The day the forecast is about. */
date?: string;
/** The expected value, 0-10. Never to be stated without `n` and the band. */
predicted?: number;
ciLow?: number | null;
ciHigh?: number | null;
/** Complete days the fit used. State it in any sentence that uses the value. */
n?: number;
modelVersion?: string;
/** The person's own rating for that day, unchanged. */
selfAssessment?: number | null;
/** Where that rating sat against the band: within, above or below. */
deviation?: string | null;
/** Whole days between the forecast's day and today. */
daysAgo?: number | null;
/** Whether it is fresh enough to back a present-tense sentence. */
current?: boolean;
/** Present only when it is not, and says in words what the flag says. */
staleNotice?: string;
/** The wording rule, stated rather than implied. */
note: string;
}
/**
* The forecast block, or an honest absence.
*
* One reader for every surface: this calls the same `readMoodPrognosis` the
* route calls, so the Coach and the page cannot disagree about what today's
* comparison is. The freshness rule is the shared one — `isCurrentForTodayClaim`
* against the same `TODAY_CLAIM_MAX_AGE_DAYS` — because a second constant for
* "how old may a value be before today stops being true about it" is how two
* surfaces start narrating the same reading differently.
*/
async function buildMoodPrognosisBlock(userId: string): Promise<{
prognosis?: MoodPrognosisFeature;
}> {
const reading = await readMoodPrognosis(
userId,
new Date(),
await resolveUserTimezone(userId),
);
if (!reading.present) {
return {
prognosis: {
present: false,
reason: reading.reason,
entries: reading.entries,
note:
"There is no expected-value comparison for this account yet. Say so " +
"plainly if it comes up; do not estimate one, and do not describe " +
"the absence as a bad sign.",
},
};
}
return {
prognosis: {
present: true,
entries: reading.entries,
date: reading.date,
predicted: reading.predicted,
ciLow: reading.ciLow,
ciHigh: reading.ciHigh,
n: reading.n,
modelVersion: reading.modelVersion,
selfAssessment: reading.selfAssessment,
deviation: reading.deviation,
daysAgo: reading.ageDays,
current: reading.current,
...(reading.current
? {}
: {
staleNotice: `This comparison is about ${reading.date}. State it as that day, with the date; do not describe it as today.`,
}),
note:
"Two separate readings of one day: `selfAssessment` is what the person " +
"recorded and leads; `predicted` is what a comparison with their own " +
"past days would have expected, built from `n` days. Never merge them, " +
"never present `predicted` as the person's mood, and never state it " +
"without `n` and the band. Phrase it as a counterfactual — a value " +
"around X would have been expected — and never say that anything " +
"caused anything: the comparison is an association over the account's " +
"own days and supports no cause.",
},
};
}
export interface AggregatedFeatures {
weight?: {
latest: number;
@@ -467,6 +575,16 @@ export interface AggregatedFeatures {
withoutAvg: number;
}>;
};
/**
* v1.38 — the day's two readings and the distance between them.
*
* Always present once the mood module is on, because an honest absence is
* information: `present: false` with a reason and a count keeps the model
* from filling the gap with an estimate. The block carries its own age, so
* a comparison about the day before yesterday cannot back a sentence about
* today.
*/
prognosis?: MoodPrognosisFeature;
};
sleep?: {
avg7: number | null;
@@ -1363,6 +1481,7 @@ export async function extractFeatures(
totalEntries: moodTotalEntries,
...(await buildMoodDimensionBlock(userId)),
...(await buildMoodContextBlock(userId)),
...(await buildMoodPrognosisBlock(userId)),
coverage: {
count: moodTotalEntries,
spanDays,
+127
View File
@@ -386,7 +386,134 @@ const bulkMoodResponse = z
})
.meta({ id: "BulkMoodResponse" });
// ── The day's two readings ───────────────────────────────────────────
//
// Published as the RESOLVED value. The server computes the expected value, the
// band, the count, the deviation and the age; the client renders them. Nothing
// here is a hint the client is meant to re-derive — two clients deriving the
// same figure is how they start disagreeing about it, and this figure is about
// somebody's health record.
const moodPrognosisContribution = z
.object({
feature: z
.string()
.describe(
"Stable feature identifier: `dimension:a2`, `context:workLoad`, `context:workStatus=off` or `linked:steps`. A client that does not recognise one skips it — an older row can name a feature a later model dropped.",
),
contribution: z
.number()
.describe(
"Signed: how far this feature moved the expected value away from the account's own average day. Ordered by absolute size, largest first.",
),
})
.meta({ id: "MoodPrognosisContribution" });
const moodPrognosisPresent = z.object({
present: z.literal(true),
date: z.string().describe("The local day this is about, `YYYY-MM-DD`."),
predicted: z
.number()
.describe(
"The expected pleasantness (A1) for that day, 0-10, on the account's own past pattern. A counterfactual, never a measurement — and never to be displayed without `n` and the band.",
),
ciLow: z.number().nullable(),
ciHigh: z
.number()
.nullable()
.describe(
"The band, from the validation residuals. It widens when the model fits badly, so a wide band is the signal to present the value cautiously.",
),
n: z
.number()
.int()
.describe(
"Complete days the fit used. Display it WHEREVER `predicted` is displayed: a value built from 30 days and one built from 300 look identical otherwise.",
),
modelVersion: z.string(),
computedAt: z.iso.datetime({ offset: true }),
ageDays: z
.number()
.int()
.nullable()
.describe("Whole days between that day and the reader's today."),
current: z
.boolean()
.describe(
"Whether it is fresh enough to back a present-tense sentence. False means the copy must name the day it is about.",
),
provisional: z
.boolean()
.describe("True below 60 days of history: label it as provisional."),
stage: z.enum(["provisional", "regular", "seasonal"]),
entries: z.number().int().describe("Days of history behind the ladder."),
seasonalUnlocked: z.boolean(),
selfAssessment: z
.number()
.int()
.nullable()
.describe(
"The person's own rating for that day, stored exactly as they gave it. It LEADS: it is the primary value on any surface that shows both, and it is never merged with `predicted`.",
),
deviation: z
.enum(["within", "above", "below"])
.nullable()
.describe(
"Where the self-assessment sat against the BAND, not against `predicted`. `within` means the day went as the account's own past days suggest.",
),
deviationAmount: z
.number()
.nullable()
.describe("How far outside the band, in scale points. `0` when inside."),
contributions: z.array(moodPrognosisContribution),
});
const moodPrognosisAbsent = z.object({
present: z.literal(false),
reason: z
.enum(["no-output-yet", "learning-phase", "no-pattern"])
.describe(
"`no-output-yet`: too few days for anything. `learning-phase`: enough to describe, not to compare. `no-pattern`: enough days, nothing steady enough in them. None of the three is an error and none is a zero.",
),
entries: z.number().int(),
nextThreshold: z
.number()
.int()
.nullable()
.describe("Days at which the next step unlocks, when a count is missing."),
});
const moodPrognosisResponse = z
.union([moodPrognosisPresent, moodPrognosisAbsent])
.meta({
id: "MoodPrognosis",
description:
"Two separate readings of one day: what the person recorded, and what a comparison with their own past days would have expected. They are never merged — the distance between them is the finding, and a single blended figure destroys it. The comparison is a regularised regression over the account's own history: observational only, never causal. Any copy built on it must state the value as a counterfactual (a value around X would have been expected), must carry `n`, and must not say that anything caused anything.",
});
export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/mood/prognosis": {
get: {
tags: ["Mood"],
summary: "The day's own rating and what the account's days imply (v1.38)",
description:
"The newest stored comparison for the account, with the person's own rating for that day beside it. Written nightly by a job and never by a request: the value cannot be overwritten, which is what keeps the two readings separate. Absence is explicit (`present: false` with a reason and a count) and is a normal answer, not an error — the comparison refuses when the history does not support one. Whole-record read: the fit takes the day's context AND the sleep, activity and recovery figures the owning modules hold, and it names them, so a section-scoped grant is refused rather than served a filtered list. Gated: `module.disabled` 403 when the mood module is off.",
responses: {
"200": {
description: "The day's two readings, or an explained absence.",
content: {
"application/json": {
schema: dataEnvelope(
moodPrognosisResponse,
"MoodPrognosisEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/mood-entries/bulk": {
post: {
tags: ["Mood"],