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

+200
View File
@@ -8357,6 +8357,28 @@ paths:
"403": *a6
"422": *a3
"429": *a4
/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:
$ref: "#/components/schemas/MoodPrognosisEnvelope"
"401": *a1
"422": *a3
"429": *a4
/api/mood-entries/bulk:
post:
tags:
@@ -32171,6 +32193,184 @@ components:
- generatedAt
additionalProperties: false
description: Per-analyte change between the two most-recent numeric lab panels. Neutral framing, never a diagnosis.
MoodPrognosisEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/MoodPrognosis"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MoodPrognosis:
anyOf:
- type: object
properties:
present:
type: boolean
const: true
date:
type: string
description: The local day this is about, `YYYY-MM-DD`.
predicted:
type: number
description: 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:
anyOf:
- type: number
- type: "null"
ciHigh:
anyOf:
- type: number
- type: "null"
description: 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:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: "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:
type: string
computedAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
ageDays:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
description: Whole days between that day and the reader's today.
current:
type: boolean
description: Whether it is fresh enough to back a present-tense sentence. False means the copy must name the day it is
about.
provisional:
type: boolean
description: "True below 60 days of history: label it as provisional."
stage:
type: string
enum:
- provisional
- regular
- seasonal
entries:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Days of history behind the ladder.
seasonalUnlocked:
type: boolean
selfAssessment:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
description: "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:
anyOf:
- type: string
enum:
- within
- above
- below
- type: "null"
description: 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:
anyOf:
- type: number
- type: "null"
description: How far outside the band, in scale points. `0` when inside.
contributions:
type: array
items:
$ref: "#/components/schemas/MoodPrognosisContribution"
required:
- present
- date
- predicted
- ciLow
- ciHigh
- n
- modelVersion
- computedAt
- ageDays
- current
- provisional
- stage
- entries
- seasonalUnlocked
- selfAssessment
- deviation
- deviationAmount
- contributions
additionalProperties: false
- type: object
properties:
present:
type: boolean
const: false
reason:
type: string
enum:
- no-output-yet
- learning-phase
- no-pattern
description: "`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:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
nextThreshold:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
description: Days at which the next step unlocks, when a count is missing.
required:
- present
- reason
- entries
- nextThreshold
additionalProperties: false
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."
MoodPrognosisContribution:
type: object
properties:
feature:
type: string
description: "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:
type: number
description: "Signed: how far this feature moved the expected value away from the account's own average day. Ordered by
absolute size, largest first."
required:
- feature
- contribution
additionalProperties: false
BulkMoodResponseEnvelope:
type: object
properties: