Merge pull request #854 from MBombeck/feat/health-score-adaptive-eligibility

feat: the health score adapts to the data a person actually has
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-30 14:59:29 +02:00
commit 320788a984
34 files changed
+997 -230

No files matched your search

+62 -7
View File
@@ -6454,11 +6454,13 @@ paths:
"422":
description: "The write was refused and nothing was stored. `meta.errorCode` distinguishes the cases:
`health_score_config.too_narrow` — the selection cannot produce a score (`meta.reason` is
`measured_physiological_domain_required` or `three_domains_required`, and the `error` string is the sentence
to show the person); `health_score_config.invalid` — the body failed validation, with the per-issue list
under `details.issues`; `invalid_base_updated_at` — `baseUpdatedAt` was present but unparseable, `null`
included, which is rejected rather than falling back to the unconditional write. A body that is not JSON at
all returns the bare message `health-score-config.body.invalid_json` with no errorCode."
`no_pillars_selected`, and the `error` string is the sentence to show the person). Since v1.38 the empty
selection is the ONLY one refused: two areas of health, or one, now save and produce a score that labels its
own breadth, so a client must not pre-empt the write with a three-area rule of its own.
`health_score_config.invalid` — the body failed validation, with the per-issue list under `details.issues`;
`invalid_base_updated_at` — `baseUpdatedAt` was present but unparseable, `null` included, which is rejected
rather than falling back to the unconditional write. A body that is not JSON at all returns the bare message
`health-score-config.body.invalid_json` with no errorCode."
content:
application/json:
schema:
@@ -48915,6 +48917,12 @@ components:
configuration itself is never on this wire. Optional so older cached snapshots without the field
stay valid."
type: boolean
scoreBasis:
description: What the number rests on. Since v1.38 a score is served from one or two areas of health as well as three,
computed identically at each, so this is the only field that tells a narrow score from a broad one —
render it beside the number. Optional so older cached snapshots without the field stay valid; the
live builder always sets it.
$ref: "#/components/schemas/HealthScoreBasis"
deltaReason:
anyOf:
- type: string
@@ -49301,6 +49309,43 @@ components:
- score
- band
additionalProperties: false
HealthScoreBasis:
type: object
properties:
domains:
type: integer
minimum: 1
maximum: 9007199254740991
description: Distinct areas of health counted. NOT the length of `composition` — three of the seven pillars share the
cardiometabolic area, so a three-pillar score can rest on one area.
recommended:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: "How many distinct areas the method recommends. A recommendation, not a floor: a score below it is computed
and served, it is just narrower."
tier:
type: string
enum:
- full
- partial
- minimal
description: "`full` = at or above the recommended breadth, `partial` = two areas, `minimal` = one. The arithmetic is
identical at every tier — the same mean, the same worst-pillar band drag, the same noise floor — so this is
the only thing that distinguishes a narrow score from a broad one on the wire. Render it beside the number."
physiological:
type: boolean
description: "Whether any counted pillar rests on a physiological measurement (blood pressure, glucose, sleep, body
shape, cholesterol). False means the score is built from activity and/or wellbeing alone. A statement of
scope, not a warning: the score still exists and is still honest about what produced it."
required:
- domains
- recommended
- tier
- physiological
additionalProperties: false
description: What the Health Score rests on, resolved server-side. Present on every score computed from at least one
usable pillar; optional on every shape that carries it, so payloads cached before v1.38 stay valid.
InsightsComprehensiveResponseEnvelope:
type: object
properties:
@@ -50152,6 +50197,11 @@ components:
type: boolean
description: True when the account's own recipe narrows the composition below what its defaults would resolve to today.
The configuration itself never rides this wire.
scoreBasis:
description: What the number rests on. Since v1.38 a score is produced from one or two areas of health as well as three,
with identical arithmetic at each, so this block is the only thing on the wire that distinguishes
them. Optional so a composite cached before v1.38 stays valid; the live scorer always sets it.
$ref: "#/components/schemas/HealthScoreBasis"
noiseFloor:
type: number
scoreVersion:
@@ -64143,14 +64193,19 @@ components:
type: array
items:
type: string
scoreBasis:
description: What the value rests on. Since v1.38 a score is produced from one or two areas of health as well as three,
so this is what distinguishes a narrow score from a broad one. Optional so older cached digests
without the field stay valid.
$ref: "#/components/schemas/HealthScoreBasis"
required:
- value
- band
- delta
additionalProperties: false
- type: "null"
description: Health score + band + week-over-week delta, plus whether the account authored the composition; null when
none.
description: Health score + band + week-over-week delta, plus whether the account authored the composition and how broad
the set behind it is; null when none.
topSignal:
anyOf:
- type: object
+15 -15
View File
@@ -13,12 +13,12 @@ import { healthScoreAlgorithmItemKey } from "@/lib/daily/priority-item-key";
* at any width and in any locale (only the copy assertions read English, and
* the suite pins the English locale through the storage state).
*
* The refusal case is driven through the REAL write. Deselecting down to
* activity and wellbeing genuinely fails the server's breadth rule, and a
* refused write persists nothing, so the case leaves the shared account
* exactly as it found it. Mocking the 422 would have proved that the page
* can render a message, not that the rule is enforced or that the page reads
* the reason the server actually sends.
* The refusal case is driven through the REAL write. Deselecting every
* pillar genuinely fails the server's breadth rule, and a refused write
* persists nothing, so the case leaves the shared account exactly as it
* found it. Mocking the 422 would have proved that the page can render a
* message, not that the rule is enforced or that the page reads the reason
* the server actually sends.
*
* The analytics read IS mocked, because it is the page's secondary read and
* the states it drives — waiting for data, safety signposting — depend on
@@ -47,7 +47,7 @@ function scoreReport() {
provenance,
});
return {
composite: insufficient("three_domains_required"),
composite: insufficient("no_usable_data"),
pillars: [
{
id: "LIPIDS",
@@ -237,16 +237,16 @@ test.describe("Settings → Health Score", () => {
const card = page.locator('[data-slot="score-config-card"]');
await settleBeforeMeasure(page, card);
// Start from everything, then take out every pillar except activity and
// wellbeing: two areas of health and no physical measurement, which the
// server refuses.
// v1.38 — this case used to deselect down to activity and wellbeing,
// which the server refused as two areas with no physical
// measurement. That selection saves and scores now, so it no longer
// exercises a refusal at all. Take every pillar out instead: the
// empty selection is the one the scorer genuinely cannot answer, and
// it is what this spec exists to prove is enforced server-side.
await page.locator('[data-slot="score-config-preset-all"]').click();
const keep = new Set(["ACTIVITY", "WELLBEING"]);
const rows = page.locator('[data-slot="score-pillar-row"]');
for (let index = 0; index < (await rows.count()); index += 1) {
const row = rows.nth(index);
const pillar = await row.getAttribute("data-pillar");
if (!pillar || keep.has(pillar)) continue;
if ((await row.getAttribute("data-counts")) === "false") continue;
await row.locator('[data-slot="score-pillar-switch"]').click();
}
@@ -258,8 +258,8 @@ test.describe("Settings → Health Score", () => {
await expect(refusal).toHaveAttribute("role", "alert");
// The server's machine reason, rendered as a sentence a person can act
// on, not as an error code and not as the server's English fallback.
await expect(refusal).toContainText("physical measurement");
await expect(refusal).not.toContainText("_required");
await expect(refusal).toContainText("at least one area of health");
await expect(refusal).not.toContainText("_selected");
});
test("keeps the content column the same width as its neighbours", async ({
+1 -2
View File
@@ -5263,8 +5263,7 @@
"omitted": "{count} Bereiche stehen nicht in der Liste, weil ihre Erfassung abgeschaltet ist. Schalte ein Modul wieder ein, dann kommt sein Bereich zurück.",
"omittedLink": "Module öffnen",
"refusal": {
"physiological": "Ein Health Score braucht mindestens einen körperlich gemessenen Wert. Behalte Blutdruck, Glukose, Schlaf, Körperform oder Cholesterin in deiner Auswahl, zusätzlich zu allem anderen, was du wählst.",
"domains": "Ein Health Score braucht mindestens drei verschiedene Felder der Gesundheit. Blutdruck, Glukose und Cholesterin beschreiben dasselbe Feld, eine Auswahl nur daraus zählt also als eines."
"empty": "Ein Health Score braucht mindestens ein Feld der Gesundheit, das er bewerten kann. Behalte mindestens eines ausgewählt — der Score nennt selbst, worauf er beruht."
},
"notice": {
"upgrade": {
+1 -2
View File
@@ -5263,8 +5263,7 @@
"omitted": "{count} pillars are not listed because their tracking is switched off. Turn a module back on and its pillar comes back.",
"omittedLink": "Open modules",
"refusal": {
"physiological": "A health score needs at least one physical measurement. Keep one of blood pressure, glucose, sleep, body shape or cholesterol in your selection, alongside whatever else you choose.",
"domains": "A health score needs at least three different areas of health. Blood pressure, glucose and cholesterol all describe the same area, so a selection made only of those counts as one."
"empty": "A health score needs at least one area of health to score. Keep one selected — the score will say which areas it is built from."
},
"notice": {
"upgrade": {
+1 -2
View File
@@ -5263,8 +5263,7 @@
"omitted": "{count} pilares no aparecen porque su seguimiento está desactivado. Vuelve a activar un módulo y su pilar regresa.",
"omittedLink": "Abrir módulos",
"refusal": {
"physiological": "Una puntuación de salud necesita al menos una medición física. Mantén en tu selección la presión arterial, la glucosa, el sueño, la forma corporal o el colesterol, además de lo demás que elijas.",
"domains": "Una puntuación de salud necesita al menos tres ámbitos de salud distintos. La presión arterial, la glucosa y el colesterol describen el mismo ámbito, así que una selección hecha solo con ellos cuenta como uno."
"empty": "Una puntuación de salud necesita al menos un ámbito de salud que puntuar. Deja al menos uno seleccionado: la puntuación dirá en qué ámbitos se basa."
},
"notice": {
"upgrade": {
+1 -2
View File
@@ -5263,8 +5263,7 @@
"omitted": "{count} piliers ne sont pas listés parce que leur suivi est désactivé. Réactivez un module et son pilier revient.",
"omittedLink": "Ouvrir les modules",
"refusal": {
"physiological": "Un score de santé a besoin d'au moins une mesure physique. Gardez la tension, la glycémie, le sommeil, la silhouette ou le cholestérol dans votre sélection, en plus de ce que vous choisissez par ailleurs.",
"domains": "Un score de santé a besoin d'au moins trois domaines de santé différents. La tension, la glycémie et le cholestérol décrivent le même domaine, une sélection composée uniquement de ceux-là compte donc pour un."
"empty": "Un score de santé a besoin d'au moins un domaine de santé à évaluer. Gardez-en au moins un sélectionné : le score indiquera sur quels domaines il repose."
},
"notice": {
"upgrade": {
+1 -2
View File
@@ -5263,8 +5263,7 @@
"omitted": "{count} pilastri non compaiono perché il loro monitoraggio è disattivato. Riattiva un modulo e il suo pilastro torna.",
"omittedLink": "Apri i moduli",
"refusal": {
"physiological": "Un punteggio di salute ha bisogno di almeno una misurazione fisica. Tieni nella selezione la pressione, la glicemia, il sonno, la forma corporea o il colesterolo, oltre a ciò che scegli d'altro.",
"domains": "Un punteggio di salute ha bisogno di almeno tre ambiti di salute diversi. Pressione, glicemia e colesterolo descrivono lo stesso ambito, quindi una selezione fatta solo di questi conta come uno."
"empty": "Un punteggio di salute ha bisogno di almeno un ambito di salute da valutare. Tienine almeno uno selezionato: il punteggio dirà su quali ambiti si basa."
},
"notice": {
"upgrade": {
+1 -2
View File
@@ -5263,8 +5263,7 @@
"omitted": "{count}개 항목은 기록이 꺼져 있어서 목록에 없어요. 모듈을 다시 켜면 해당 항목도 돌아와요.",
"omittedLink": "모듈 열기",
"refusal": {
"physiological": "건강 점수에는 신체 측정값이 최소 하나 필요해요. 다른 항목을 어떻게 고르든 혈압, 혈당, 수면, 체형, 콜레스테롤 중 하나는 선택에 남겨 두세요.",
"domains": "건강 점수에는 서로 다른 건강 영역이 최소 세 개 필요해요. 혈압, 혈당, 콜레스테롤은 모두 같은 영역이라, 이 셋만 고르면 하나로 쳐요."
"empty": "건강 점수에는 점수를 매길 건강 영역이 최소 하나 필요해요. 하나는 선택해 두세요 — 점수가 어떤 영역을 바탕으로 하는지 알려 줘요."
},
"notice": {
"upgrade": {
+1 -2
View File
@@ -5263,8 +5263,7 @@
"omitted": "{count} filarów nie ma na liście, bo ich śledzenie jest wyłączone. Włącz moduł z powrotem, a jego filar wróci.",
"omittedLink": "Otwórz moduły",
"refusal": {
"physiological": "Wynik zdrowia potrzebuje co najmniej jednego pomiaru fizycznego. Zostaw w wyborze ciśnienie, glukozę, sen, sylwetkę albo cholesterol, obok wszystkiego innego, co wybierzesz.",
"domains": "Wynik zdrowia potrzebuje co najmniej trzech różnych obszarów zdrowia. Ciśnienie, glukoza i cholesterol opisują ten sam obszar, więc wybór złożony tylko z nich liczy się jako jeden."
"empty": "Wynik zdrowia potrzebuje co najmniej jednego obszaru zdrowia do oceny. Zostaw wybrany przynajmniej jeden — wynik sam powie, na których obszarach się opiera."
},
"notice": {
"upgrade": {
@@ -0,0 +1,190 @@
/**
* v1.38 — `scoreBasis` is additive on every wire it rides, and stays so.
*
* The block is new on three shapes that already ship: the full score
* report's composite, the dashboard snapshot's flattened hero block, and
* the daily digest's score. All three are `additionalProperties: false`,
* and two of the three are served out of a CACHE — a snapshot or digest
* written before this release has no way to grow a field. Declare
* `scoreBasis` as required on any of them and every cached payload in
* existence becomes invalid against the document that describes it on
* the day the document is published, with nothing failing anywhere: the
* generator is happy, `openapi:check` is happy, and the only symptom is
* a strict client-side decoder rejecting real responses.
*
* That is the class this file pins. Every carrier must declare the
* property (so a registry edit that drops it is not a green pass), must
* NOT list it in `required`, and must point at the one shared component
* rather than a fourth hand-rolled copy of the same object. The fields
* that shipped before it stay required, because "additive" is a claim
* about both directions.
*
* The document is BUILT rather than read off disk, the way the sharing
* components guard does it: `scripts/check-openapi.ts` already pins
* `docs/api/openapi.yaml` to exactly this document and CI fails on
* drift, so building it here checks the same artifact without parsing
* two megabytes of YAML.
*
* ## Mutation check
*
* Drop `.optional()` from any of the three registry declarations and the
* matching `required` assertion goes red. Delete a declaration entirely
* and the presence assertion goes red first.
*/
import { describe, expect, it } from "vitest";
import { buildOpenApiDocument } from "@/lib/openapi/registry";
type Schema = {
type?: string;
properties?: Record<string, Schema>;
required?: string[];
items?: Schema;
anyOf?: Schema[];
allOf?: Schema[];
oneOf?: Schema[];
$ref?: string;
description?: string;
enum?: string[];
nullable?: boolean;
};
const doc = buildOpenApiDocument() as unknown as {
components?: { schemas?: Record<string, Schema> };
};
const schemas = doc.components?.schemas ?? {};
const BASIS_REF = "#/components/schemas/HealthScoreBasis";
/**
* Walk a schema tree and collect every object that declares a
* `scoreBasis` property, wherever it is nested.
*
* Written as a walk rather than a path lookup on purpose: the three
* shapes sit at three very different depths (a component's union arm, a
* deeply nested snapshot object, a nullable digest member), and a
* hand-written path per surface is a fourth restatement of the contract
* that can rot without anybody noticing.
*/
function objectsCarryingScoreBasis(root: unknown): Schema[] {
const found: Schema[] = [];
const seen = new Set<unknown>();
const visit = (node: unknown): void => {
if (node === null || typeof node !== "object") return;
if (seen.has(node)) return;
seen.add(node);
if (Array.isArray(node)) {
for (const child of node) visit(child);
return;
}
const schema = node as Schema;
if (schema.properties && "scoreBasis" in schema.properties) {
found.push(schema);
}
for (const value of Object.values(node as Record<string, unknown>)) {
visit(value);
}
};
visit(root);
return found;
}
describe("the score basis is published, and published as optional", () => {
it("declares the shared component once", () => {
const basis = schemas.HealthScoreBasis;
expect(
basis,
"HealthScoreBasis is not a published component",
).toBeDefined();
expect(Object.keys(basis?.properties ?? {}).sort()).toEqual([
"domains",
"physiological",
"recommended",
"tier",
]);
// The tier vocabulary is the whole label. A client reads it verbatim,
// so the members are pinned rather than left to the enum's shape.
expect(basis?.properties?.tier?.enum).toEqual([
"full",
"partial",
"minimal",
]);
// Inside the component the four fields ARE required — the block is
// all-or-nothing. Optionality lives on the shapes that carry it, not
// on its own members, and confusing the two is how a "yes it is
// optional" answer ends up being about the wrong object.
expect([...(basis?.required ?? [])].sort()).toEqual([
"domains",
"physiological",
"recommended",
"tier",
]);
});
it("is carried by every score-bearing shape, and required by none", () => {
const carriers = objectsCarryingScoreBasis(schemas);
const composites = carriers.filter(
(carrier) => carrier.properties?.scoreBasis?.$ref === BASIS_REF,
);
// The non-zero control, and it does double duty. Three surfaces carry
// a composite score — the report's composite, the dashboard
// snapshot's hero block, the daily digest's score — and each is
// counted only if it points at the shared component, so an inlined
// fourth copy of the object drops the count rather than passing as a
// carrier. A walk that stopped matching reports zero and fails,
// instead of reporting a green run over nothing.
expect(composites.length).toBeGreaterThanOrEqual(3);
for (const carrier of carriers) {
expect(carrier.required ?? []).not.toContain("scoreBasis");
}
});
it("does not confuse the composite's basis with a pillar's", () => {
// `scoreBasis` is an older field name on `PillarValue`, where blood
// pressure uses it to say which of its two axes produced the number.
// Same key, different object, on a shape that also rides the score
// report — so the walk above finds it, and the assertion that every
// carrier `$ref`s the composite component would have been wrong.
// Pinned here so a future reader does not "fix" that by widening the
// composite component to swallow the pillar's field.
const carriers = objectsCarryingScoreBasis(schemas);
const pillarBasis = carriers.filter(
(carrier) => carrier.properties?.scoreBasis?.$ref !== BASIS_REF,
);
expect(pillarBasis.length).toBeGreaterThan(0);
for (const carrier of pillarBasis) {
expect(carrier.properties?.scoreBasis?.type).toBe("object");
expect(
Object.keys(carrier.properties?.scoreBasis?.properties ?? {}),
).toContain("axis");
}
});
it("keeps the fields that shipped before it required", () => {
// The other half of "additive": adding a field must not have
// loosened anything that was already load-bearing. `composition` and
// `configured` are read by iOS on the composite, and a silently
// optional `composition` would be a contract break wearing the
// additive change's clothes.
const composite = schemas.DerivedHealthScoreComposite;
expect(composite, "DerivedHealthScoreComposite is missing").toBeDefined();
const okArm = (composite?.anyOf ?? composite?.oneOf ?? []).find((arm) =>
arm.properties?.value?.properties
? "scoreBasis" in arm.properties.value.properties
: false,
);
expect(okArm, "the ok arm of the composite is missing").toBeDefined();
const value = okArm?.properties?.value;
expect([...(value?.required ?? [])].sort()).toEqual([
"band",
"bandSetter",
"composition",
"configured",
"noiseFloor",
"score",
"scoreVersion",
]);
});
});
@@ -258,45 +258,64 @@ describe("PATCH /api/auth/me/health-score-config", () => {
});
});
/**
* v1.38 — what the write still refuses, after the breadth floor became a
* recommendation.
*
* Two of the three refusals this block used to pin are gone. The route
* turned away `["ACTIVITY", "WELLBEING"]` with
* `measured_physiological_domain_required` and the cardiometabolic
* triple with `three_domains_required`, both on the ground that the
* selection could not produce a score. It can now — the scorer computes
* from whatever is readable and labels how broad the set was — and a
* write that kept refusing what the read is willing to serve would be
* the two layers disagreeing, which is the drift the shared rule exists
* to prevent. So those two assertions are inverted to acceptances.
*
* The empty selection stays refused, and that case is why this is a
* change of rule rather than a removal of one.
*/
describe("PATCH — the breadth rule", () => {
it("refuses activity and wellbeing alone, in words a person can act on", async () => {
it("accepts activity and wellbeing alone, and stores them", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ["ACTIVITY", "WELLBEING"] }),
);
expect(res.status).toBe(422);
const env = (await res.json()) as {
error: string;
meta?: { errorCode?: string; reason?: string };
};
expect(env.meta?.errorCode).toBe("health_score_config.too_narrow");
expect(env.meta?.reason).toBe("measured_physiological_domain_required");
expect(env.error).toContain("at least one physical measurement");
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
expect(invalidateUserHealthScore).not.toHaveBeenCalled();
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(invalidateUserHealthScore).toHaveBeenCalledWith("user-1");
});
it("refuses the cardiometabolic triple, which is three pillars in one area", async () => {
it("accepts the cardiometabolic triple, which is three pillars in one area", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"] }),
);
expect(res.status).toBe(422);
const env = (await res.json()) as {
error: string;
meta?: { reason?: string };
};
expect(env.meta?.reason).toBe("three_domains_required");
expect(env.error).toContain("three different areas");
expect(prisma.user.update).not.toHaveBeenCalled();
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
});
it("accepts a single pillar", async () => {
// The narrowest selection that can still be scored, and the boundary
// the one surviving refusal sits against.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ pillars: ["ACTIVITY"] }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledTimes(1);
});
it("refuses an empty selection rather than storing a score that cannot exist", async () => {
// The counter-case. A write that accepted everything would have
// stopped enforcing anything, and this is the one selection the
// scorer genuinely cannot answer.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
@@ -304,10 +323,19 @@ describe("PATCH — the breadth rule", () => {
mkPatch({ pillars: [] }),
);
expect(res.status).toBe(422);
const env = (await res.json()) as {
error: string;
meta?: { errorCode?: string; reason?: string };
};
expect(env.meta?.errorCode).toBe("health_score_config.too_narrow");
expect(env.meta?.reason).toBe("no_pillars_selected");
expect(env.error).toContain("at least one area of health");
expect(prisma.user.update).not.toHaveBeenCalled();
expect(prisma.user.updateMany).not.toHaveBeenCalled();
expect(invalidateUserHealthScore).not.toHaveBeenCalled();
});
it("accepts the smallest selection that clears the rule", async () => {
it("accepts a three-area selection", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
primeUser(null);
@@ -449,9 +477,9 @@ describe("the published contract matches what the route serves", () => {
}
};
// Too narrow, both reasons.
await record({ pillars: ["ACTIVITY"] });
await record({ pillars: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"] });
// Too narrow. One reason since v1.38, because one selection is: the
// empty one.
await record({ pillars: [] });
// Body that fails validation.
await record({ pillars: ["NOT_A_PILLAR"] });
// Present-but-unparseable base token.
@@ -19,15 +19,17 @@
*
* Two things this route is responsible for beyond storing a list.
*
* **It refuses a selection that cannot produce a score.** Three distinct
* areas of health and at least one physical measurement, or the
* composite does not exist. Saving such a selection would leave the
* person with a settings page that says one thing and a score that has
* silently vanished, so the write is refused with a sentence they can
* act on. The scorer applies the same rule again at read time through
* the same function, deliberately: a config written before a rule change
* or edited outside the app still meets an honest refusal rather than a
* missing number.
* **It refuses a selection that cannot produce a score.** As of v1.38
* that is one selection: the empty one. Three areas of health is what
* the score recommends, not what it demands — a two-area or one-area
* recipe now saves and produces a score that says on its face how broad
* it is, so refusing the write would refuse something the scorer is
* perfectly willing to compute. Taking every pillar out is different in
* kind: there is nothing left to average, and storing that recipe would
* leave the person with a settings page that says one thing and a score
* that has silently vanished. The scorer applies the same rule again at
* read time through the same function, deliberately, so a config edited
* outside the app meets an honest refusal rather than a missing number.
*
* **It evicts the score caches.** The person just changed what the
* number means; serving the old one for the next hour would make the
@@ -82,14 +84,11 @@ const healthScoreConfigPatchSchema = z
/**
* Why a selection cannot produce a score, said the way a person would
* say it. The cardiometabolic sentence is the one that surprises people:
* blood pressure, glucose and cholesterol are three pillars but one area.
* say it. One entry, because one selection cannot: the empty one.
*/
const BREADTH_REFUSAL = {
measured_physiological_domain_required:
"A health score needs at least one physical measurement. Keep one of blood pressure, glucose, sleep, body shape or cholesterol in your selection, alongside whatever else you choose.",
three_domains_required:
"A health score needs at least three different areas of health. Blood pressure, glucose and cholesterol all describe the same area, so a selection made only of those counts as one.",
no_pillars_selected:
"A health score needs at least one area of health to score. Keep one selected — the score will say which areas it is built from.",
} as const;
export const GET = apiHandler(async () => {
@@ -207,7 +207,7 @@ function report(overrides: Partial<HealthScoreReport> = {}): HealthScoreReport {
status: "insufficient",
coverage,
provenance,
reason: "three_domains_required",
reason: "no_usable_data",
},
pillars: [BLOOD_PRESSURE, ACTIVITY_BELOW_FLOOR],
delta: null,
@@ -107,9 +107,13 @@ const routeReasons = objectKeys(parse(ROUTE_FILE), "BREADTH_REFUSAL");
describe("every refusal the rule can give reaches the reader", () => {
it("reads the rule's own list rather than a copy of it", () => {
// A matcher that finds nothing agrees with everything.
// A matcher that finds nothing agrees with everything. The anchor
// moved from `three_domains_required` to `no_pillars_selected` in
// v1.38: three areas of health stopped being the price of a score,
// so the write stopped refusing narrow selections and the only
// refusal left is the empty one.
expect(ruleReasons.length).toBeGreaterThan(0);
expect(ruleReasons).toContain("three_domains_required");
expect(ruleReasons).toContain("no_pillars_selected");
});
it("gives the route a sentence for each of them", () => {
+11 -10
View File
@@ -97,24 +97,25 @@ interface HealthScoreConfigPayload {
}
/**
* The two refusals the write can return, in the reader's language.
* The refusals the write can return, in the reader's language.
*
* One, since v1.38: a selection with nothing in it. Two areas or one now
* save and score, so there is no longer anything to explain about them.
*
* Keyed on `meta.reason`, which is the machine-readable half of the
* server's answer. The server's own English sentence is not shown: it
* exists for callers without a locale, and rendering it would leave five
* of the six locales reading English at exactly the moment the person
* exists for callers without a locale, and rendering it would leave six
* of the seven locales reading English at exactly the moment the person
* needs to understand something.
*
* Typed as a total map over `ScoreBreadthFailure` rather than over
* `string`, so a third refusal reason added to the rule cannot ship with
* this surface quietly falling through to a generic "couldn't save". The
* TYPE is imported, never the rule: the breadth question is answered
* server-side and answered once.
* `string`, so a second refusal reason added to the rule cannot ship
* with this surface quietly falling through to a generic "couldn't
* save". The TYPE is imported, never the rule: the breadth question is
* answered server-side and answered once.
*/
export const REFUSAL_KEYS: Record<ScoreBreadthFailure, string> = {
measured_physiological_domain_required:
"settings.sections.score.refusal.physiological",
three_domains_required: "settings.sections.score.refusal.domains",
no_pillars_selected: "settings.sections.score.refusal.empty",
};
function verdictsFrom(
+201 -51
View File
@@ -1,20 +1,37 @@
/**
* v1.35.0 — the breadth rule, and the proof that both layers apply the
* same one.
* v1.38 — the breadth rule, now a grading rather than a gate, and the
* proof that both layers still read the same one.
*
* The settings write refuses a selection that cannot produce a score;
* the scorer refuses to produce one from a set that fails the same rule.
* Two layers, on purpose. The danger in two layers is that they drift,
* so the last block below drives the real `computeComposite` and asserts
* it refuses exactly the sets `evaluateScoreBreadth` refuses, with the
* same reason.
* This file used to pin the two refusals (`three_domains_required`,
* `measured_physiological_domain_required`) as the rule's whole output.
* They are gone: three areas of health is what the score RECOMMENDS, and
* a set below it is scored and labelled instead of refused. So the cases
* that asserted a refusal now assert a TIER, which is the same question
* asked of a rule that answers it differently.
*
* What survives unchanged is the reason the file exists. Two layers
* apply the rule — the settings write and the scorer — and two layers
* drift. The middle block below drives the real `computeComposite` and
* asserts it produces a score for exactly the sets the rule admits, and
* refuses exactly the one it does not.
*
* The last block is the counter-case, and it is the one that would catch
* this change going too far. A rule that has stopped refusing anything
* is not a graded rule, it is a deleted one, so the empty set must still
* come back refused — with a reason, from a verdict that carries no
* tier — and the composite must still say `no_usable_data` rather than
* inventing a score out of nothing.
*/
import { describe, expect, it } from "vitest";
import { buildOk, deriveCoverage } from "@/lib/insights/derived/coverage";
import type { Derived } from "@/lib/insights/derived/types";
import { evaluateScoreBreadth } from "../breadth";
import {
evaluateScoreBreadth,
SCORE_RECOMMENDED_DOMAINS,
type ScoreBreadthTier,
} from "../breadth";
import { computeComposite } from "../composite";
import {
SCORE_PILLAR_DOMAINS,
@@ -68,77 +85,119 @@ function pillar(id: ScorePillarId, score = 80): ScorePillarResult {
return { id, domain: SCORE_PILLAR_DOMAINS[id], result };
}
/** The sets the rule has an opinion about, each with the expected verdict. */
/**
* The sets the rule has an opinion about, each with the tier it should
* reach and whether the set carries a physiological measurement.
*
* `tier: null` means refused. Exactly one case is refused, on purpose:
* every other row in this table used to be a refusal too, and each is
* now a number somebody gets to see.
*/
const CASES: Array<{
what: string;
ids: ScorePillarId[];
reason: string | null;
tier: ScoreBreadthTier | null;
physiological: boolean;
}> = [
{
what: "the two non-physiological pillars alone",
ids: ["ACTIVITY", "WELLBEING"],
reason: "measured_physiological_domain_required",
},
{
what: "activity and wellbeing plus nothing measured",
ids: ["ACTIVITY"],
reason: "measured_physiological_domain_required",
},
{
what: "an empty selection",
ids: [],
reason: "measured_physiological_domain_required",
tier: null,
physiological: false,
},
{
what: "the whole cardiometabolic triple, which is one domain",
what: "activity alone, which measures nothing physiological",
ids: ["ACTIVITY"],
tier: "minimal",
physiological: false,
},
{
what: "the two non-physiological pillars together",
ids: ["ACTIVITY", "WELLBEING"],
tier: "partial",
physiological: false,
},
{
what: "the whole cardiometabolic triple, which is one area",
ids: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"],
reason: "three_domains_required",
tier: "minimal",
physiological: true,
},
{
what: "two domains with a physiological pillar",
what: "one physiological pillar alone",
ids: ["BLOOD_PRESSURE"],
tier: "minimal",
physiological: true,
},
{
what: "two areas with a physiological pillar",
ids: ["BLOOD_PRESSURE", "ACTIVITY"],
reason: "three_domains_required",
tier: "partial",
physiological: true,
},
{
what: "three domains including a physiological pillar",
what: "three areas including a physiological pillar",
ids: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
reason: null,
tier: "full",
physiological: true,
},
{
what: "three domains where two pillars share one",
what: "three areas where two pillars share one",
ids: ["BLOOD_PRESSURE", "LIPIDS", "ACTIVITY", "SLEEP"],
reason: null,
tier: "full",
physiological: true,
},
{
what: "every pillar",
ids: [...SCORE_PILLAR_IDS],
tier: "full",
physiological: true,
},
{ what: "every pillar", ids: [...SCORE_PILLAR_IDS], reason: null },
];
describe("evaluateScoreBreadth", () => {
for (const { what, ids, reason } of CASES) {
it(`${reason ? "refuses" : "accepts"} ${what}`, () => {
for (const { what, ids, tier, physiological } of CASES) {
it(`${tier === null ? "refuses" : `grades ${tier} for`} ${what}`, () => {
const verdict = evaluateScoreBreadth(ids);
expect(verdict.ok).toBe(reason === null);
expect(verdict.reason).toBe(reason);
expect(verdict.ok).toBe(tier !== null);
expect(verdict.tier).toBe(tier);
expect(verdict.physiological).toBe(physiological);
expect(verdict.reason).toBe(tier === null ? "no_pillars_selected" : null);
});
}
it("names the missing physiological measure before the missing domain", () => {
// Three domains, none of them a physiological measure is impossible
// with the current registry, so the precedence shows on the set that
// fails both: one non-physiological pillar, one domain.
expect(evaluateScoreBreadth(["ACTIVITY"]).reason).toBe(
"measured_physiological_domain_required",
);
it("grades on distinct areas, not on how many pillars were named", () => {
// The whole point of the domain map. Three pillars, one area: this is
// the set the old rule refused outright and the set most likely to be
// graded `full` by a client that counts `composition` instead.
const triple = evaluateScoreBreadth([
"BLOOD_PRESSURE",
"GLYCAEMIA",
"LIPIDS",
]);
expect(triple.tier).toBe("minimal");
expect(triple.domains).toEqual(["cardiometabolic"]);
});
it("reports the distinct domains the set spans", () => {
it("puts the recommendation at three areas and reaches full there", () => {
expect(SCORE_RECOMMENDED_DOMAINS).toBe(3);
expect(
evaluateScoreBreadth(["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"]).domains,
).toEqual(["cardiometabolic"]);
evaluateScoreBreadth(["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"]).domains,
).toHaveLength(SCORE_RECOMMENDED_DOMAINS);
});
it("reports a missing physiological measure without withholding the score", () => {
// The old rule's sharpest edge, inverted on purpose: activity alone
// used to be told "no score for you" and is now told what its score
// rests on. `physiological: false` is the whole of that caveat.
const verdict = evaluateScoreBreadth(["ACTIVITY"]);
expect(verdict.ok).toBe(true);
expect(verdict.physiological).toBe(false);
});
});
describe("the scorer refuses exactly what the write refuses", () => {
for (const { what, ids } of CASES) {
describe("the scorer grades exactly what the rule grades", () => {
for (const { what, ids, tier, physiological } of CASES) {
it(`agrees on ${what}`, () => {
const composite = computeComposite({
pillars: ids.map((id) => pillar(id)),
@@ -148,11 +207,101 @@ describe("the scorer refuses exactly what the write refuses", () => {
});
const verdict = evaluateScoreBreadth(ids);
expect(composite.status).toBe(verdict.ok ? "ok" : "insufficient");
if (composite.status === "insufficient") {
expect(composite.reason).toBe(verdict.reason);
if (composite.status === "ok") {
expect(composite.value.scoreBasis).toEqual({
domains: verdict.domains.length,
recommended: SCORE_RECOMMENDED_DOMAINS,
tier,
physiological,
});
}
});
}
it("computes the same number at every tier", () => {
// No discount for the pillars somebody does not track. A mean of 80
// and 60 is 70 whether it is drawn from two areas or five, because
// any other answer would be a target nobody set.
const narrow = computeComposite({
pillars: [pillar("BLOOD_PRESSURE", 80), pillar("ACTIVITY", 60)],
availablePillars: ["BLOOD_PRESSURE", "ACTIVITY"],
asOf: NOW,
configured: false,
});
const broad = computeComposite({
pillars: [
pillar("BLOOD_PRESSURE", 80),
pillar("ACTIVITY", 60),
pillar("SLEEP", 70),
],
availablePillars: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
asOf: NOW,
configured: false,
});
expect(narrow.status).toBe("ok");
expect(broad.status).toBe("ok");
if (narrow.status !== "ok" || broad.status !== "ok") return;
expect(narrow.value.score).toBe(70);
expect(broad.value.score).toBe(70);
expect(narrow.value.scoreBasis?.tier).toBe("partial");
expect(broad.value.scoreBasis?.tier).toBe("full");
});
});
describe("the refusal that is left still refuses", () => {
// A rule that grades everything has stopped being a rule. These are the
// assertions that fail if the grading is widened until nothing can be
// turned away.
it("gives the empty set a reason and no tier", () => {
const verdict = evaluateScoreBreadth([]);
expect(verdict.ok).toBe(false);
if (verdict.ok) return;
expect(verdict.reason).toBe("no_pillars_selected");
expect(verdict.tier).toBeNull();
expect(verdict.domains).toEqual([]);
});
it("refuses a composite whose pillars are all insufficient", () => {
// The realistic shape of nothing: pillars are SELECTED and graded,
// every one of them came back short. An empty `availablePillars`
// array would prove less, because a set nobody chose is trivially
// empty; this is an account that chose four and has data for none.
const selection: ScorePillarId[] = [
"BLOOD_PRESSURE",
"ACTIVITY",
"SLEEP",
"WELLBEING",
];
const composite = computeComposite({
pillars: selection.map((id) => ({
id,
domain: SCORE_PILLAR_DOMAINS[id],
result: {
status: "insufficient",
coverage: deriveCoverage({
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: [id],
fullHistoryDays: 28,
}).coverage,
provenance: {
inputs: [],
source: "none",
windowDays: 28,
computedAt: NOW.toISOString(),
},
reason: "below_day_floor_or_stale",
},
})),
availablePillars: selection,
asOf: NOW,
configured: false,
});
expect(composite.status).toBe("insufficient");
if (composite.status !== "insufficient") return;
expect(composite.reason).toBe("no_usable_data");
});
});
describe("the pillar-to-domain map", () => {
@@ -163,8 +312,9 @@ describe("the pillar-to-domain map", () => {
});
it("keeps the three cardiometabolic pillars in one domain", () => {
// The triple-count is the reason a five-pillar selection can still
// fail the rule, so it is pinned rather than left to the map's shape.
// The triple-count is the reason a four-pillar selection can still be
// a one-area score, so it is pinned rather than left to the map's
// shape.
expect(SCORE_PILLAR_DOMAINS.BLOOD_PRESSURE).toBe("cardiometabolic");
expect(SCORE_PILLAR_DOMAINS.GLYCAEMIA).toBe("cardiometabolic");
expect(SCORE_PILLAR_DOMAINS.LIPIDS).toBe("cardiometabolic");
@@ -135,8 +135,10 @@ describe("the band the mean alone gives", () => {
it("moved the method version with it", () => {
// A stored day keeps the band it was shown under, and the notice key
// carries this number, so each account hears once that the rules
// moved. A threshold edit without a version bump is a silent one.
expect(SCORE_VERSION).toBeGreaterThan(2);
// moved. A threshold edit without a version bump is a silent one, and
// so is an eligibility edit: v1.38 widened who gets a score at all,
// which is why this floor moved from 2 to 3.
expect(SCORE_VERSION).toBeGreaterThan(3);
});
});
@@ -198,21 +200,43 @@ describe("the worst pillar still holds the band down", () => {
});
describe("reference-score composite", () => {
it("requires three domains and one measured physiological domain", () => {
const tooNarrow = evaluation(
it("scores two areas of health, and says it was two", () => {
// Inverted from the v1.35 pin, which asserted this exact pair came
// back `insufficient`. Three areas is the recommendation now, so a
// person with two gets their number and a basis block that does not
// overstate it. Note `physiological: false` — neither activity nor
// wellbeing is a measurement, and the old rule refused the set on
// that ground alone.
const partial = evaluation(
[pillar("ACTIVITY", 90), pillar("WELLBEING", 80)],
NOW,
);
expect(tooNarrow.status).toBe("insufficient");
expect(partial.status).toBe("ok");
if (partial.status !== "ok") return;
expect(partial.value.score).toBe(85);
expect(partial.value.scoreBasis).toEqual({
domains: 2,
recommended: 3,
tier: "partial",
physiological: false,
});
const eligible = evaluation(
const full = evaluation(
[pillar("ACTIVITY", 90), pillar("WELLBEING", 80), pillar("SLEEP", 70)],
NOW,
);
expect(eligible.status).toBe("ok");
expect(full.status).toBe("ok");
if (full.status !== "ok") return;
expect(full.value.scoreBasis?.tier).toBe("full");
expect(full.value.scoreBasis?.physiological).toBe(true);
});
it("reports breadth coverage as distinct eligible domains", () => {
// Same fixture as the v1.35 version — four pillars, two areas — and
// the same coverage arithmetic. What changed is the outcome. The
// fraction used to be a floor every scored account had already
// cleared; it is a real moving number on a scored account now, which
// is what makes the coverage meter say something.
const result = evaluation(
[
pillar("BLOOD_PRESSURE", 90),
@@ -223,12 +247,52 @@ describe("reference-score composite", () => {
NOW,
);
expect(result.status).toBe("insufficient");
expect(result.status).toBe("ok");
expect(result.coverage.requiredInputs).toBe(3);
expect(result.coverage.presentInputs).toBe(2);
if (result.status === "insufficient") {
expect(result.reason).toBe("three_domains_required");
}
if (result.status !== "ok") return;
expect(result.value.scoreBasis).toEqual({
domains: 2,
recommended: 3,
tier: "partial",
physiological: true,
});
// The narrower set does not read as fully confident, and nothing new
// was built for that: the existing coverage blend already does it.
expect(result.confidence.score).toBeLessThan(100);
});
it("scores one area of health, at the tier that says so", () => {
// The bottom tier boundary, on the shape most likely to fool a
// client that counts `composition` instead of areas: three pillars,
// one area.
const result = evaluation(
[
pillar("BLOOD_PRESSURE", 90),
pillar("GLYCAEMIA", 84),
pillar("LIPIDS", 81),
],
NOW,
);
expect(result.status).toBe("ok");
if (result.status !== "ok") return;
expect(result.value.score).toBe(85);
expect(result.value.composition).toHaveLength(3);
expect(result.value.scoreBasis).toEqual({
domains: 1,
recommended: 3,
tier: "minimal",
physiological: true,
});
});
it("refuses when not one pillar is usable, and says why", () => {
// The counter-case. Grading instead of gating is only honest while
// something is still turned away; this is the something.
const result = evaluation([], NOW);
expect(result.status).toBe("insufficient");
if (result.status !== "insufficient") return;
expect(result.reason).toBe("no_usable_data");
});
it("uses equal weights in registry order", () => {
@@ -21,9 +21,9 @@
* they switched off is not waiting for anything.
*
* 3. **One breadth rule, applied twice.** The settings write refuses a
* selection that cannot produce a score; the reader refuses to
* produce one from a set that fails the same rule. The block at the
* bottom drives the REAL reader and asserts its refusal reason is
* selection that cannot produce a score; the reader grades the set it
* resolved through the same rule. The block at the bottom drives the
* REAL reader and asserts the basis it published is
* `evaluateScoreBreadth`'s verdict on the reader's own resolved set,
* so a second copy of the rule that disagreed would show up here.
*
@@ -480,10 +480,16 @@ describe("selected and waiting for data versus switched off", () => {
});
});
describe("the breadth rule still refuses at read time", () => {
describe("the breadth rule still grades at read time", () => {
// Inverted from the v1.35 pins, which asserted all three of these
// recipes came back `insufficient`. Two of them produce a score now.
// What the block proves has not moved — the reader publishes the shared
// rule's verdict on the reader's own resolved set, so a second copy of
// the rule that disagreed would still show up here — but the agreement
// is over a tier rather than over a refusal.
const NARROW: Array<{ what: string; selection: ScorePillarId[] }> = [
{
what: "a recipe that leaves two scoring domains",
what: "a recipe that leaves two scoring areas",
selection: ["ACTIVITY", "ADIPOSITY", "BLOOD_PRESSURE"],
},
{
@@ -497,7 +503,7 @@ describe("the breadth rule still refuses at read time", () => {
];
for (const { what, selection } of NARROW) {
it(`refuses ${what}, with the reason the shared rule gives`, async () => {
it(`agrees with the shared rule on ${what}`, async () => {
const report = await score({
modules: ALL_MODULES_ON,
healthScoreConfigJson: recipe(selection),
@@ -508,10 +514,23 @@ describe("the breadth rule still refuses at read time", () => {
.map((pillar) => pillar.id);
const verdict = evaluateScoreBreadth(scoring);
expect(verdict.ok).toBe(false);
expect(report.composite.status).toBe("insufficient");
if (report.composite.status !== "insufficient") return;
expect(report.composite.reason).toBe(verdict.reason);
expect(report.composite.status).toBe(verdict.ok ? "ok" : "insufficient");
if (report.composite.status === "ok") {
// The published basis IS the shared rule's verdict on the
// reader's set, not a second count taken on the way out.
expect(report.composite.value.scoreBasis?.tier).toBe(verdict.tier);
expect(report.composite.value.scoreBasis?.domains).toBe(
verdict.domains.length,
);
expect(report.composite.value.scoreBasis?.physiological).toBe(
verdict.physiological,
);
} else {
// The one refusal that survives, reached the honest way: a recipe
// whose three pillars all came back without data.
expect(verdict.ok).toBe(false);
expect(report.composite.reason).toBe("no_usable_data");
}
});
}
@@ -246,7 +246,7 @@ import { MODULE_KEYS } from "@/lib/modules/registry";
import { healthScoreConfigFromSelection } from "../config";
import { computeUserHealthScore } from "../reader";
import { SCORE_PILLAR_IDS } from "../types";
import { SCORE_PILLAR_IDS, SCORE_VERSION } from "../types";
/** The recipe as the settings write would have stored it. */
function recipe(changedAt: Date): unknown {
@@ -523,16 +523,16 @@ describe("a settings change is never narrated as a health event", () => {
const unconfigured = await computeUserHealthScore(scoreInput(null));
expect(configured.algorithmNotice!.itemKey).toBe(
"health_score_algorithm:3:config:3",
`health_score_algorithm:${SCORE_VERSION}:config:3`,
);
expect(configured.algorithmNotice!.dismissed).toBe(false);
// An account that never chose keeps the bare method key, with no
// recipe suffix on it.
expect(unconfigured.algorithmNotice!.itemKey).toBe(
"health_score_algorithm:3",
`health_score_algorithm:${SCORE_VERSION}`,
);
dismissedKeys.add("health_score_algorithm:3:config:3");
dismissedKeys.add(`health_score_algorithm:${SCORE_VERSION}:config:3`);
expect(
(await computeUserHealthScore(scoreInput(JUST_CHANGED))).algorithmNotice!
.dismissed,
@@ -550,7 +550,7 @@ describe("a settings change is never narrated as a health event", () => {
),
);
expect(next.algorithmNotice!.itemKey).toBe(
"health_score_algorithm:3:config:4",
`health_score_algorithm:${SCORE_VERSION}:config:4`,
);
expect(next.algorithmNotice!.dismissed).toBe(false);
});
@@ -301,14 +301,35 @@ describe("building the row", () => {
expect(draft.composition).not.toContain("ACTIVITY");
});
it("records a day built from a single pillar", () => {
// Inverted from the v1.35 pin, which used a two-pillar fixture to
// assert that a below-the-floor day stored nothing. The floor is
// gone, so the day HAS a number and the table has to keep it: an
// account that only tracks steps now has a history, and one that
// begins on one pillar and widens later has an unbroken series
// rather than a hole where its narrow days were.
const single = report([pillar("ACTIVITY", 90)]);
expect(single.composite.status).toBe("ok");
const draft = buildHealthScoreRecord(single, {
timezone: "UTC",
now: NOW,
config: UNCONFIGURED_SCORE_BOUNDARY,
});
expect(draft).not.toBeNull();
if (!draft) return;
expect(draft.composite).toBe(90);
expect(draft.composition).toEqual(["ACTIVITY"]);
expect(draft.pillarScores).toEqual({ ACTIVITY: 90 });
});
it("records nothing when the composite did not resolve", () => {
// Two domains: below the breadth floor, so there is no number. Absence has
// to reach the table as absence — a stored 0 would be a day the person is
// told they scored zero.
const thin = report([pillar("ACTIVITY", 90), pillar("WELLBEING", 80)]);
expect(thin.composite.status).toBe("insufficient");
// Absence still has to reach the table as absence — a stored 0 would
// be a day the person is told they scored zero. What produces that
// absence has narrowed to one case: not one usable pillar.
const empty = report([]);
expect(empty.composite.status).toBe("insufficient");
expect(
buildHealthScoreRecord(thin, {
buildHealthScoreRecord(empty, {
timezone: "UTC",
now: NOW,
config: UNCONFIGURED_SCORE_BOUNDARY,
@@ -383,9 +404,24 @@ describe("writing the row", () => {
).toBe("already_recorded");
});
it("writes the row for a day that rests on one pillar", async () => {
// The other half of the inverted pin: the narrow day does not just
// build a draft, it reaches the table.
const { db, createMany } = delegate(1);
const single = report([pillar("ACTIVITY", 90)]);
expect(
await recordHealthScore(db, "user-1", single, {
timezone: "UTC",
now: NOW,
config: UNCONFIGURED_SCORE_BOUNDARY,
}),
).toBe("written");
expect(createMany).toHaveBeenCalledTimes(1);
});
it("never touches the database when there is no score to record", async () => {
const { db, createMany } = delegate(1);
const thin = report([pillar("ACTIVITY", 90), pillar("WELLBEING", 80)]);
const thin = report([]);
expect(
await recordHealthScore(db, "user-1", thin, {
timezone: "UTC",
+82 -29
View File
@@ -1,14 +1,31 @@
/**
* v1.35.0 — the breadth rule, in one place.
* v1.38 — the breadth rule, in one place, and no longer a floor.
*
* A composite Health Score exists only when the pillars behind it span
* at least three distinct domains and include at least one physiological
* measure. Before per-user composition the rule lived inside
* `computeComposite` alone, because nothing else could narrow the set.
* Now two layers apply it: the settings write refuses a selection that
* cannot produce a score, and the scorer refuses to produce one anyway.
* That is deliberate defence in depth, and it is only honest if both
* layers ask the same question, so both call this function.
* Until now a composite Health Score existed only when the pillars behind
* it spanned at least three distinct domains and included at least one
* physiological measure. Everything below that line got "not enough data
* yet" — including accounts that had a perfectly readable standing in one
* or two areas. The refusal told those people nothing they could not
* already see, and it hid a number they had earned.
*
* So three domains stops being the price of admission and becomes the
* recommendation it always described: the score is computed the same way
* at every breadth, and the verdict says how broad it is. `full` is the
* old rule, `partial` is two domains, `minimal` is one. Refusal survives
* for the one case where there is genuinely nothing to average — no ok
* pillar at all.
*
* The physiological question moves with it. It is now a labelling fact
* rather than a veto: an account whose only ok pillar is activity or
* wellbeing gets a `minimal` score that says on its face what it rests
* on, which is more than the old refusal ever said. The arithmetic does
* not change with the tier — inventing a discount for the pillars
* somebody does not track would be exactly the made-up target this score
* spent two releases removing.
*
* Both layers still call this function. The settings write refuses an
* empty selection, the scorer refuses an empty ok set, and neither
* restates the rule.
*/
import {
SCORE_PILLAR_DOMAINS,
@@ -16,12 +33,20 @@ import {
type ScorePillarId,
} from "./types";
export const SCORE_MIN_ELIGIBLE_DOMAINS = 3;
/**
* The breadth the score recommends, not the breadth it requires.
*
* Still the denominator `deriveCoverage` counts distinct domains
* against, which is what makes a partial score read as partial without
* any new machinery: one domain of three lands at low confidence through
* the blend that was already there.
*/
export const SCORE_RECOMMENDED_DOMAINS = 3;
/**
* Pillars that rest on a physiological measurement. `ACTIVITY` and
* `WELLBEING` are the only two that do not, which is why a selection of
* those two alone yields no score however many domains it spans.
* `WELLBEING` are the only two that do not. This no longer decides
* whether a score exists; it decides what the score says about itself.
*/
const PHYSIOLOGICAL_PILLARS: ReadonlySet<ScorePillarId> = new Set([
"BLOOD_PRESSURE",
@@ -31,40 +56,68 @@ const PHYSIOLOGICAL_PILLARS: ReadonlySet<ScorePillarId> = new Set([
"LIPIDS",
]);
export type ScoreBreadthFailure =
"three_domains_required" | "measured_physiological_domain_required";
/**
* How broad the set behind a score is. Ordered by breadth, and named
* rather than numbered so a surface can speak about it without
* re-deriving the thresholds.
*/
export type ScoreBreadthTier = "full" | "partial" | "minimal";
/**
* The one thing that still refuses. A set with no ok pillar has nothing
* to average, and a mean over nothing is not a low score, it is an
* absent one.
*/
export type ScoreBreadthFailure = "no_pillars_selected";
/**
* Discriminated on `ok` so a caller that has checked cannot then read a
* reason that is not there, and cannot skip the check to get one.
* `domains` carries the distinct domains the set spans, for a caller
* that has to explain the refusal.
* reason that is not there, and cannot skip the check to get a tier.
* `domains` carries the distinct domains the set spans; `physiological`
* carries whether any of them rests on a measurement, for the label.
*/
export type ScoreBreadthVerdict =
| { ok: true; reason: null; domains: ScoreDomain[] }
| { ok: false; reason: ScoreBreadthFailure; domains: ScoreDomain[] };
| {
ok: true;
reason: null;
tier: ScoreBreadthTier;
domains: ScoreDomain[];
physiological: boolean;
}
| {
ok: false;
reason: ScoreBreadthFailure;
tier: null;
domains: ScoreDomain[];
physiological: boolean;
};
/**
* Judge a pillar set against the breadth rule.
*
* The missing-physiological reason wins when both fail, because it is
* the more specific thing to tell someone: adding a third domain that
* is neither blood pressure nor a lab still leaves them without a score.
* The tier is a function of DOMAINS, not pillars, which is why blood
* pressure, glucose and cholesterol together are `minimal`: three
* pillars, one area of health.
*/
export function evaluateScoreBreadth(
ids: readonly ScorePillarId[],
): ScoreBreadthVerdict {
const domains = [...new Set(ids.map((id) => SCORE_PILLAR_DOMAINS[id]))];
const hasPhysiological = ids.some((id) => PHYSIOLOGICAL_PILLARS.has(id));
if (!hasPhysiological) {
const physiological = ids.some((id) => PHYSIOLOGICAL_PILLARS.has(id));
if (domains.length === 0) {
return {
ok: false,
reason: "measured_physiological_domain_required",
reason: "no_pillars_selected",
tier: null,
domains,
physiological,
};
}
if (domains.length < SCORE_MIN_ELIGIBLE_DOMAINS) {
return { ok: false, reason: "three_domains_required", domains };
}
return { ok: true, reason: null, domains };
const tier: ScoreBreadthTier =
domains.length >= SCORE_RECOMMENDED_DOMAINS
? "full"
: domains.length === 2
? "partial"
: "minimal";
return { ok: true, reason: null, tier, domains, physiological };
}
+16 -5
View File
@@ -15,15 +15,15 @@ import {
type ScorePillarResult,
} from "./types";
import { mean, provenance, scoreBand } from "./shared";
import { evaluateScoreBreadth, SCORE_MIN_ELIGIBLE_DOMAINS } from "./breadth";
import { evaluateScoreBreadth, SCORE_RECOMMENDED_DOMAINS } from "./breadth";
import type { ScoreConfigBoundary } from "./config";
export const SCORE_ALGORITHM_CHANGED_AT = "2026-07-28T00:00:00.000Z";
export const SCORE_ALGORITHM_CHANGED_AT = "2026-08-30T00:00:00.000Z";
// The rule itself lives in `./breadth`, where the settings write reads it
// too. Re-exported here because this is where every existing caller looks
// for it.
export { SCORE_MIN_ELIGIBLE_DOMAINS };
export { SCORE_RECOMMENDED_DOMAINS };
const BAND_RANK: Record<ScoreBand, number> = {
green: 2,
@@ -74,7 +74,7 @@ export function computeComposite(
Number.POSITIVE_INFINITY,
);
const { coverage, confidence } = deriveCoverage({
requiredInputs: SCORE_MIN_ELIGIBLE_DOMAINS,
requiredInputs: SCORE_RECOMMENDED_DOMAINS,
presentInputs: eligibleDomains.size,
historyDays: Number.isFinite(historyDays) ? historyDays : 0,
missing,
@@ -100,11 +100,13 @@ export function computeComposite(
asOf: input.asOf,
});
// The only refusal left. Not "too narrow" — nothing readable at all,
// and a mean over nothing is an absent score rather than a low one.
if (!breadth.ok) {
return buildInsufficient({
coverage,
provenance: compositeProvenance,
reason: breadth.reason,
reason: "no_usable_data",
});
}
@@ -148,6 +150,15 @@ export function computeComposite(
: null,
composition,
configured: input.configured,
// Resolved here for the same reason `configured` is: the count is
// over DOMAINS, and a client counting `composition` would get it
// wrong every time a cardiometabolic pair is in play.
scoreBasis: {
domains: breadth.domains.length,
recommended: SCORE_RECOMMENDED_DOMAINS,
tier: breadth.tier,
physiological: breadth.physiological,
},
noiseFloor,
scoreVersion: SCORE_VERSION,
},
+7 -4
View File
@@ -52,10 +52,13 @@
* comparison window still straddles the change, with the reason
* `config_changed` — the person changed what counts, and a
* subtraction across that is not a health event;
* * the next stored day is marked as a series break, so the composite
* line is drawn in two segments rather than one line through the
* change (`./series.ts`), while the per-pillar rows run straight
* through it;
* * the next stored day carries the new recipe version and its change
* date (`./record.ts`), so a reader of the stored series can draw
* the composite line in two segments rather than one line through
* the change, while the per-pillar rows run straight through it.
* This used to point at `./series.ts`, which was deleted as dead
* because nothing rendered it; the columns it described are still
* written, which is what the sentence was ever about;
* * the notice key gains the recipe version, so the "you changed what
* counts" note is raised once and dismissed once per version, and a
* dismissal of one version never silences the next.
+48 -5
View File
@@ -4,6 +4,10 @@ import type {
Derived,
DerivedProvenanceSource,
} from "@/lib/insights/derived/types";
// Type-only, so the rule module can keep importing this one without a
// runtime cycle. The tier's definition belongs beside the rule that
// produces it; its NAME belongs here, where the wire shape is declared.
import type { ScoreBreadthTier } from "./breadth";
/**
* The scoring method's identity.
@@ -13,11 +17,17 @@ import type {
* rules moved (`healthScoreNoticeItemKey`). It is NOT the schema version
* of the report.
*
* 4 — v1.38: three domains stopped being the price of admission. A score
* is computed from whatever is readable and says how broad that is
* (`scoreBasis`); only an account with nothing readable at all is
* refused. Accounts that were below the old floor start their series
* here, and no v3 number changes meaning — the arithmetic is the
* same, the set it runs over is what widened.
* 3 — v1.35.1: green starts at 70 rather than 75, matching the two other
* scores in the app, and the fitness pillar left the catalogue.
* 2 — the composite the previous line shipped.
*/
export const SCORE_VERSION = 3 as const;
export const SCORE_VERSION = 4 as const;
/**
* The pillars a score can be built from.
@@ -59,10 +69,11 @@ export type ScoreDomain =
"cardiometabolic" | "activity" | "sleep" | "adiposity" | "wellbeing";
/**
* Which domain each pillar speaks to. The composite needs three
* distinct domains, so this map is part of the breadth rule and not
* decoration: three of the seven pillars share the cardiometabolic
* domain, which is why a selection of four pillars can still fail.
* Which domain each pillar speaks to. The composite recommends three
* distinct domains and reports how many it had, so this map is part of
* the breadth rule and not decoration: three of the seven pillars share
* the cardiometabolic domain, which is why a selection of four pillars
* can still be a one-area score.
*
* One map, read by the scorer when it builds a pillar result and by the
* breadth rule when it judges a selection, so the two can never drift
@@ -128,6 +139,30 @@ export interface ScorePillarResult {
result: Derived<PillarValue>;
}
/**
* v1.38 — what the number rests on, resolved on the server.
*
* The score is computed the same way at every breadth, so this block is
* the only thing that tells a partial score apart from a full one. It is
* resolved beside `configured` and for the same reason: no client
* re-derives it, and no client counts domains out of `composition`
* (three of the seven pillars share one area, so that count would be
* wrong more often than right).
*
* `physiological` is a fact about the set, not a warning. An account
* scoring on activity alone reads false and still gets its number; the
* old rule refused that account outright and told it nothing.
*/
export interface ScoreBasis {
/** Distinct domains counted. */
domains: number;
/** The breadth the method recommends — `SCORE_RECOMMENDED_DOMAINS`. */
recommended: number;
tier: ScoreBreadthTier;
/** Whether any counted pillar rests on a physiological measurement. */
physiological: boolean;
}
export interface CompositeValue {
score: number;
band: ScoreBand;
@@ -143,6 +178,14 @@ export interface CompositeValue {
* owns the definition and the reasons behind it.
*/
configured: boolean;
/**
* v1.38 — how broad the set behind the number is. Optional on the type
* so an older cached composite or a fixture written before the field
* existed stays valid (the additive-contract pattern the snapshot and
* digest blocks have followed six times); `computeComposite` always
* sets it on the ok arm.
*/
scoreBasis?: ScoreBasis;
/** Equal-weighted floor across delta-eligible pillars. */
noiseFloor: number;
scoreVersion: typeof SCORE_VERSION;
+9
View File
@@ -27,6 +27,7 @@ import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
import type { ModuleKey } from "@/lib/modules/registry";
import type { ServerTranslator } from "@/lib/i18n/server-translator";
import type {
ScoreBasis,
ScoreDeltaReason,
ScorePillarId,
} from "@/lib/analytics/score/types";
@@ -113,6 +114,14 @@ export interface DailyDigestScore {
* cached digests, like its siblings above.
*/
configured?: boolean;
/**
* v1.38 — how broad the set behind the number is, carried straight
* from the snapshot's health-score block. Since v1.38 a score exists
* at one or two areas of health as well as three and is computed the
* same way at each, so this block is what tells a narrow score from a
* broad one. Omitted by older cached digests, like its siblings above.
*/
scoreBasis?: ScoreBasis;
}
/** A broken integration, deterministically derived from `IntegrationStatus`. */
+1
View File
@@ -479,6 +479,7 @@ export async function loadDailyDigest(
scoreVersion: snapshot.healthScore.scoreVersion,
composition: snapshot.healthScore.composition,
configured: snapshot.healthScore.configured,
scoreBasis: snapshot.healthScore.scoreBasis,
}
: null;
+1 -1
View File
@@ -87,7 +87,7 @@ vi.mock("@/lib/analytics/score/reader", () => ({
status: "insufficient" as const,
coverage,
provenance,
reason: "three_domains_required",
reason: "no_usable_data",
};
return {
composite,
+16
View File
@@ -85,6 +85,7 @@ import {
import type {
RestModeAnnotation,
ScoreBand,
ScoreBasis,
ScoreDeltaReason,
ScorePillarId,
} from "@/lib/analytics/score/types";
@@ -340,6 +341,20 @@ export interface DashboardSnapshotHealthScore {
* builder always sets it.
*/
configured?: boolean;
/**
* v1.38 — what the number rests on: how many distinct areas of health
* were counted, how many the method recommends, the resulting tier
* (`full` / `partial` / `minimal`) and whether any counted pillar was a
* physiological measurement. Since v1.38 a score exists at one or two
* areas as well as three, computed exactly the same way, so this block
* is the only thing that tells a narrow score from a broad one — a
* surface that renders the number should render its basis too.
* Counting `composition` is not a substitute: three of the seven
* pillars share the cardiometabolic area. Optional on the type
* (additive contract) so older cached snapshots stay valid; the live
* builder always sets it when a score exists.
*/
scoreBasis?: ScoreBasis;
deltaReason?: ScoreDeltaReason | null;
scoreVersion?: number;
bandSetter?: ScorePillarId | null;
@@ -803,6 +818,7 @@ async function buildExtras(
confidence: scoreResult.composite.confidence,
composition: scoreResult.composite.value.composition,
configured: scoreResult.composite.value.configured,
scoreBasis: scoreResult.composite.value.scoreBasis,
deltaReason: scoreResult.deltaReason,
scoreVersion: scoreResult.scoreVersion,
bandSetter: scoreResult.composite.value.bandSetter,
@@ -25,7 +25,7 @@
import { describe, expect, it } from "vitest";
import { computeComposite } from "@/lib/analytics/score/composite";
import { SCORE_MIN_ELIGIBLE_DOMAINS } from "@/lib/analytics/score/breadth";
import { SCORE_RECOMMENDED_DOMAINS } from "@/lib/analytics/score/breadth";
import {
healthScoreConfigFromSelection,
resolveHealthScoreConfig,
@@ -154,7 +154,7 @@ describe("Health Score coverage contract", () => {
expect(composite.coverage.requiredInputs).not.toBe(SCORE_PILLAR_IDS.length);
// The denominator is the breadth rule applied to the person's own set,
// so it can never exceed what they chose to count.
expect(composite.coverage.requiredInputs).toBe(SCORE_MIN_ELIGIBLE_DOMAINS);
expect(composite.coverage.requiredInputs).toBe(SCORE_RECOMMENDED_DOMAINS);
expect(composite.coverage.requiredInputs).toBeLessThanOrEqual(
selection.length,
);
+6
View File
@@ -26,6 +26,7 @@ import {
derivedCoverage,
derivedProvenance,
glucoseClinicalSchema,
healthScoreBasis,
} from "./insights/schemas";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
@@ -317,6 +318,11 @@ const compositeValue = z.object({
.describe(
"True when the account's own recipe narrows the composition below what its defaults would resolve to today. The configuration itself never rides this wire.",
),
scoreBasis: healthScoreBasis
.optional()
.describe(
"What the number rests on. Since v1.38 a score is produced from one or two areas of health as well as three, with identical arithmetic at each, so this block is the only thing on the wire that distinguishes them. Optional so a composite cached before v1.38 stays valid; the live scorer always sets it.",
),
noiseFloor: z.number(),
scoreVersion: z.number().int(),
});
+7 -1
View File
@@ -21,6 +21,7 @@ import {
recordRefusal,
stdResponses,
} from "./shared";
import { healthScoreBasis } from "./insights/schemas";
const priorityItemSchema = z
.object({
@@ -102,10 +103,15 @@ const dailyDigestResponse = z
.array(z.string())
.optional()
.describe("Registry-ordered pillar ids the value was composed of."),
scoreBasis: healthScoreBasis
.optional()
.describe(
"What the value rests on. Since v1.38 a score is produced from one or two areas of health as well as three, so this is what distinguishes a narrow score from a broad one. Optional so older cached digests without the field stay valid.",
),
})
.nullable()
.describe(
"Health score + band + week-over-week delta, plus whether the account authored the composition; null when none.",
"Health score + band + week-over-week delta, plus whether the account authored the composition and how broad the set behind it is; null when none.",
),
topSignal: z
.object({
+61 -2
View File
@@ -296,6 +296,51 @@ export const derivedProvenance = z
})
.meta({ id: "DerivedProvenance" });
/**
* v1.38 — what a Health Score rests on.
*
* Declared once and referenced from every surface that carries a score
* (the full report, the dashboard snapshot's flattened block, the daily
* digest), so the three cannot drift into describing the same object
* three different ways.
*
* OPTIONAL wherever it appears. It is a new field on shapes that already
* ship, and a cached snapshot or digest written before this release has
* no way to grow one; a required field would have made every one of them
* invalid against the published contract on the day it was published.
*/
export const healthScoreBasis = z
.object({
domains: z
.number()
.int()
.min(1)
.describe(
"Distinct areas of health counted. NOT the length of `composition` — three of the seven pillars share the cardiometabolic area, so a three-pillar score can rest on one area.",
),
recommended: z
.number()
.int()
.describe(
"How many distinct areas the method recommends. A recommendation, not a floor: a score below it is computed and served, it is just narrower.",
),
tier: z
.enum(["full", "partial", "minimal"])
.describe(
"`full` = at or above the recommended breadth, `partial` = two areas, `minimal` = one. The arithmetic is identical at every tier — the same mean, the same worst-pillar band drag, the same noise floor — so this is the only thing that distinguishes a narrow score from a broad one on the wire. Render it beside the number.",
),
physiological: z
.boolean()
.describe(
"Whether any counted pillar rests on a physiological measurement (blood pressure, glucose, sleep, body shape, cholesterol). False means the score is built from activity and/or wellbeing alone. A statement of scope, not a warning: the score still exists and is still honest about what produced it.",
),
})
.meta({
id: "HealthScoreBasis",
description:
"What the Health Score rests on, resolved server-side. Present on every score computed from at least one usable pillar; optional on every shape that carries it, so payloads cached before v1.38 stay valid.",
});
// v1.13.2 — per-derived-SCORE assessment text. Additive, non-breaking field
// on the derived response; the iOS field-name contract is LOCKED.
export const derivedAssessment = z
@@ -1227,6 +1272,11 @@ export const dashboardSnapshotResponse = z
.describe(
"True when the account's own recipe narrows the score's composition below what its defaults would resolve to today. Server-resolved, so a client never interprets a configuration blob: an account that kept every pillar reads false, and so does one whose disabled modules alone narrow the set. The configuration itself is never on this wire. Optional so older cached snapshots without the field stay valid.",
),
scoreBasis: healthScoreBasis
.optional()
.describe(
"What the number rests on. Since v1.38 a score is served from one or two areas of health as well as three, computed identically at each, so this is the only field that tells a narrow score from a broad one — render it beside the number. Optional so older cached snapshots without the field stay valid; the live builder always sets it.",
),
deltaReason: z
.enum([
"algorithm_changed",
@@ -1848,7 +1898,11 @@ const healthScoreDerivedInsufficient = z.object({
status: z.literal("insufficient"),
coverage: derivedCoverage,
provenance: derivedProvenance,
reason: z.string(),
reason: z
.string()
.describe(
"Why no score exists. `no_usable_data` — not one selected pillar has enough recent data to grade. Since v1.38 this is the only value the composite produces; the former `three_domains_required` / `measured_physiological_domain_required` refusals are gone, because a narrow set now scores and labels itself.",
),
});
const healthScoreComposite = z
@@ -1874,6 +1928,11 @@ const healthScoreComposite = z
.describe(
"True when the account's own recipe narrows the composition below what its defaults would resolve to today. The configuration blob itself is never on this wire.",
),
scoreBasis: healthScoreBasis
.optional()
.describe(
"What the number rests on — how many distinct areas of health were counted against how many the method recommends, and whether any of them was a physiological measurement. Optional so a composite cached before v1.38 stays valid; the live scorer always sets it.",
),
noiseFloor: z.number(),
scoreVersion: z.number().int(),
}),
@@ -1884,7 +1943,7 @@ const healthScoreComposite = z
healthScoreDerivedInsufficient,
])
.describe(
"Discriminated on `status`. The `insufficient` arm carries NO `value` and NO `confidence` key at all — they are absent, not null — which is how a composite reads before three distinct domains are eligible.",
"Discriminated on `status`. The `insufficient` arm carries NO `value` and NO `confidence` key at all — they are absent, not null — which is how a composite reads when NO pillar has usable data. Since v1.38 that is the only case: one or two eligible areas of health produce a score too, labelled by `value.scoreBasis`.",
);
const healthScorePillar = z.object({
+1 -1
View File
@@ -1173,7 +1173,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
// quietly omits the refusal a person actually hits.
"422": {
description:
"The write was refused and nothing was stored. `meta.errorCode` distinguishes the cases: `health_score_config.too_narrow` — the selection cannot produce a score (`meta.reason` is `measured_physiological_domain_required` or `three_domains_required`, and the `error` string is the sentence to show the person); `health_score_config.invalid` — the body failed validation, with the per-issue list under `details.issues`; `invalid_base_updated_at` — `baseUpdatedAt` was present but unparseable, `null` included, which is rejected rather than falling back to the unconditional write. A body that is not JSON at all returns the bare message `health-score-config.body.invalid_json` with no errorCode.",
"The write was refused and nothing was stored. `meta.errorCode` distinguishes the cases: `health_score_config.too_narrow` — the selection cannot produce a score (`meta.reason` is `no_pillars_selected`, and the `error` string is the sentence to show the person). Since v1.38 the empty selection is the ONLY one refused: two areas of health, or one, now save and produce a score that labels its own breadth, so a client must not pre-empt the write with a three-area rule of its own. `health_score_config.invalid` — the body failed validation, with the per-issue list under `details.issues`; `invalid_base_updated_at` — `baseUpdatedAt` was present but unparseable, `null` included, which is rejected rather than falling back to the unconditional write. A body that is not JSON at all returns the bare message `health-score-config.body.invalid_json` with no errorCode.",
content: { "application/json": { schema: errorEnvelope } },
},
},
@@ -78,7 +78,19 @@ interface AnalyticsEnvelope {
composite: {
status: "ok" | "insufficient";
reason?: string;
value?: { score: number; band: "green" | "yellow" | "red" };
value?: {
score: number;
band: "green" | "yellow" | "red";
// Not to be confused with a pillar's `scoreBasis`, which explains
// which axis set that pillar's number. This one says how much of
// the record the composite rests on.
scoreBasis?: {
domains: number;
recommended: number;
tier: "full" | "partial" | "minimal";
physiological: boolean;
};
};
};
pillars: PillarEnvelope[];
} | null;
@@ -348,11 +360,14 @@ describe("GET /api/analytics — Health Score (v1.34 reference composite)", () =
});
});
it("stays insufficient with only two eligible domains — the three-domain floor", async () => {
// The composite requires at least three eligible domains, including a
// measured physiological one, before it grades at all (CORE_MIN_ELIGIBLE
// _DOMAINS). Two graded pillars in two domains must not slip past that
// floor.
it("grades two eligible domains as a partial score over the wire", async () => {
// This case used to assert a refusal: two graded pillars in two areas
// fell under the three-domain floor and the whole composite went dark,
// which is what someone who keeps sleep and waist saw for as long as
// they kept only those two. The floor is now a recommendation, so the
// same fixture scores and says what it rests on. Asserted end to end
// rather than on the composite alone, because the basis has to survive
// the route's serialisation to be worth anything to a client.
const user = await seedSession("hs-two-domains");
const now = Date.now();
@@ -366,7 +381,13 @@ describe("GET /api/analytics — Health Score (v1.34 reference composite)", () =
expect(res.status).toBe(200);
const env = (await res.json()) as AnalyticsEnvelope;
const hs = env.data!.healthScore!;
expect(hs.composite.status).toBe("insufficient");
expect(hs.composite.status).toBe("ok");
expect(hs.composite.value!.scoreBasis).toEqual({
domains: 2,
recommended: 3,
tier: "partial",
physiological: true,
});
const byId = new Map(hs.pillars.map((p) => [p.id, p]));
expect(byId.get("SLEEP")!.result.status).toBe("ok");
expect(byId.get("ADIPOSITY")!.result.status).toBe("ok");