Add the health-score composition resolver

Reads the stored deselection list into the pillar set the scorer and
the settings surface both work from. An absent list means the person
never chose and keeps counting everything; a present list is an
authored recipe and is never widened. Ids outside the catalogue are
dropped on read rather than failing the whole blob, so one unknown
string cannot discard a recipe someone wrote.
This commit is contained in:
Marc-André Bombeck committed 2026-07-31 15:06:55 +02:00
1 parent d92be0d0bd
commit 4e649de4f7
3 files changed
+523

No files matched your search

@@ -0,0 +1,171 @@
/**
* v1.35.0 — the breadth rule, and the proof that both layers apply 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.
*/
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 { computeComposite } from "../composite";
import {
SCORE_PILLAR_DOMAINS,
SCORE_PILLAR_IDS,
type PillarValue,
type ScorePillarId,
type ScorePillarResult,
} from "../types";
const NOW = new Date("2026-08-20T12:00:00.000Z");
function pillar(id: ScorePillarId, score = 80): ScorePillarResult {
const { coverage, confidence } = deriveCoverage({
requiredInputs: 1,
presentInputs: 1,
historyDays: 28,
missing: [],
fullHistoryDays: 28,
});
const value: PillarValue = {
score,
observed: {
label: `${score}`,
value: score,
unit: "score",
asOf: NOW.toISOString(),
sources: ["MANUAL"],
},
reference: {
kind: "guideline-band",
low: 0,
high: 100,
label: "test reference",
source: "Test 2026",
},
noiseFloor: 1,
deltaEligible: true,
deltaIdentity: id,
};
const result: Derived<PillarValue> = buildOk({
value,
coverage,
confidence,
provenance: {
inputs: [id],
source: "live",
windowDays: 28,
computedAt: NOW.toISOString(),
},
});
return { id, domain: SCORE_PILLAR_DOMAINS[id], result };
}
/** The sets the rule has an opinion about, each with the expected verdict. */
const CASES: Array<{
what: string;
ids: ScorePillarId[];
reason: string | null;
}> = [
{
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",
},
{
what: "the whole cardiometabolic triple, which is one domain",
ids: ["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"],
reason: "three_domains_required",
},
{
what: "two domains with a physiological pillar",
ids: ["BLOOD_PRESSURE", "ACTIVITY"],
reason: "three_domains_required",
},
{
what: "three domains including a physiological pillar",
ids: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"],
reason: null,
},
{
what: "three domains where two pillars share one",
ids: ["BLOOD_PRESSURE", "LIPIDS", "ACTIVITY", "SLEEP"],
reason: null,
},
{ what: "every pillar", ids: [...SCORE_PILLAR_IDS], reason: null },
];
describe("evaluateScoreBreadth", () => {
for (const { what, ids, reason } of CASES) {
it(`${reason ? "refuses" : "accepts"} ${what}`, () => {
const verdict = evaluateScoreBreadth(ids);
expect(verdict.ok).toBe(reason === null);
expect(verdict.reason).toBe(reason);
});
}
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("reports the distinct domains the set spans", () => {
expect(
evaluateScoreBreadth(["BLOOD_PRESSURE", "GLYCAEMIA", "LIPIDS"]).domains,
).toEqual(["cardiometabolic"]);
});
});
describe("the scorer refuses exactly what the write refuses", () => {
for (const { what, ids, reason } of CASES) {
it(`agrees on ${what}`, () => {
const composite = computeComposite({
pillars: ids.map((id) => pillar(id)),
availablePillars: ids,
asOf: NOW,
});
const verdict = evaluateScoreBreadth(ids);
expect(composite.status).toBe(verdict.ok ? "ok" : "insufficient");
if (composite.status === "insufficient") {
expect(composite.reason).toBe(verdict.reason);
}
});
}
});
describe("the pillar-to-domain map", () => {
it("covers every pillar in the registry", () => {
for (const id of SCORE_PILLAR_IDS) {
expect(SCORE_PILLAR_DOMAINS[id]).toBeTruthy();
}
});
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.
expect(SCORE_PILLAR_DOMAINS.BLOOD_PRESSURE).toBe("cardiometabolic");
expect(SCORE_PILLAR_DOMAINS.GLYCAEMIA).toBe("cardiometabolic");
expect(SCORE_PILLAR_DOMAINS.LIPIDS).toBe("cardiometabolic");
});
});
@@ -0,0 +1,191 @@
/**
* v1.35.0 — the per-user Health Score composition resolver.
*
* The failure this file exists to catch is silent by nature: a resolver
* that folds "never chose" into "chose nothing", or that quietly widens
* an authored recipe, changes people's numbers without anyone seeing a
* stack trace. Every assertion below is about a distinction staying a
* distinction.
*/
import { describe, expect, it } from "vitest";
import {
DEFAULT_HEALTH_SCORE_CONFIG,
healthScoreConfigFromSelection,
resolveHealthScoreConfig,
} from "../config";
import { SCORE_PILLAR_IDS, type ScorePillarId } from "../types";
const ALL: ScorePillarId[] = [...SCORE_PILLAR_IDS];
describe("resolveHealthScoreConfig — never chose", () => {
it("treats a NULL row as counting every pillar, with no selection", () => {
const resolved = resolveHealthScoreConfig(null);
expect(resolved.pillars).toEqual(ALL);
expect(resolved.excludedPillars).toEqual([]);
expect(resolved.hasSelection).toBe(false);
expect(resolved.version).toBe(0);
expect(resolved.changedAt).toBeNull();
});
it("treats an empty object and a blob without the key the same way", () => {
for (const raw of [{}, { version: 4, changedAt: "2026-07-31" }]) {
const resolved = resolveHealthScoreConfig(raw);
expect(resolved.hasSelection).toBe(false);
expect(resolved.pillars).toEqual(ALL);
expect(resolved.version).toBe(0);
}
});
it("falls back to counting everything when the blob no longer parses", () => {
for (const raw of [
{ excludedPillars: "SLEEP" },
{ excludedPillars: [17] },
"not an object",
42,
]) {
const resolved = resolveHealthScoreConfig(raw);
expect(resolved.pillars).toEqual(ALL);
expect(resolved.hasSelection).toBe(false);
}
});
it("hands back a copy, so a caller cannot mutate the shared defaults", () => {
const resolved = resolveHealthScoreConfig(null);
resolved.pillars.pop();
expect(DEFAULT_HEALTH_SCORE_CONFIG.pillars).toEqual(ALL);
expect(resolveHealthScoreConfig(null).pillars).toEqual(ALL);
});
});
describe("resolveHealthScoreConfig — never chose is not chose nothing", () => {
it("keeps an empty selection empty instead of reading it as no choice", () => {
const choseNothing = resolveHealthScoreConfig({
excludedPillars: ALL,
version: 2,
changedAt: "2026-07-31T09:00:00.000Z",
});
expect(choseNothing.pillars).toEqual([]);
expect(choseNothing.hasSelection).toBe(true);
expect(choseNothing.version).toBe(2);
const neverChose = resolveHealthScoreConfig(null);
expect(neverChose.pillars).not.toEqual(choseNothing.pillars);
expect(neverChose.hasSelection).not.toBe(choseNothing.hasSelection);
});
it("counts a kept-everything selection as an authored choice", () => {
const kept = resolveHealthScoreConfig({
excludedPillars: [],
version: 1,
changedAt: "2026-07-31T09:00:00.000Z",
});
// Same composition as an account that never chose, different state:
// this person opened the surface and kept every pillar.
expect(kept.pillars).toEqual(ALL);
expect(kept.hasSelection).toBe(true);
expect(kept.version).toBe(1);
expect(resolveHealthScoreConfig(null).hasSelection).toBe(false);
});
});
describe("resolveHealthScoreConfig — the catalogue wins", () => {
it("drops ids the build does not know", () => {
const resolved = resolveHealthScoreConfig({
excludedPillars: ["SLEEP", "MOON_PHASE", "LIPIDS"],
version: 1,
});
expect(resolved.excludedPillars).toEqual(["SLEEP", "LIPIDS"]);
expect(resolved.pillars).not.toContain("SLEEP");
expect(resolved.pillars).not.toContain("LIPIDS");
expect(resolved.pillars).toContain("BLOOD_PRESSURE");
});
it("de-duplicates and restores registry order", () => {
const resolved = resolveHealthScoreConfig({
excludedPillars: ["LIPIDS", "SLEEP", "LIPIDS"],
version: 1,
});
expect(resolved.excludedPillars).toEqual(["SLEEP", "LIPIDS"]);
expect(resolved.pillars).toEqual(
ALL.filter((id) => id !== "SLEEP" && id !== "LIPIDS"),
);
});
it("partitions the catalogue exactly: counted plus excluded is everything", () => {
const resolved = resolveHealthScoreConfig({
excludedPillars: ["WELLBEING"],
version: 1,
});
expect([...resolved.pillars, ...resolved.excludedPillars].sort()).toEqual(
[...ALL].sort(),
);
});
});
/**
* The reconciliation the deselection list buys: a pillar that did not
* exist when the person chose is absent from their stored list, so it
* counts. The blob below is one written in a smaller world, naming only
* ids that existed then. Every later pillar must arrive counted, and no
* choice the person actually made may be undone in the process.
*/
describe("resolveHealthScoreConfig — a pillar shipped later", () => {
const STORED_IN_A_SMALLER_WORLD = {
excludedPillars: ["SLEEP"],
version: 3,
changedAt: "2026-01-04T08:00:00.000Z",
};
it("counts every pillar the person never had the chance to remove", () => {
const resolved = resolveHealthScoreConfig(STORED_IN_A_SMALLER_WORLD);
for (const id of ALL) {
if (id === "SLEEP") continue;
expect(resolved.pillars).toContain(id);
}
});
it("still honours the one removal the person did make", () => {
const resolved = resolveHealthScoreConfig(STORED_IN_A_SMALLER_WORLD);
expect(resolved.pillars).not.toContain("SLEEP");
expect(resolved.excludedPillars).toEqual(["SLEEP"]);
expect(resolved.version).toBe(3);
expect(resolved.changedAt).toBe("2026-01-04T08:00:00.000Z");
});
});
describe("healthScoreConfigFromSelection", () => {
it("stores the complement of the selection and round-trips it", () => {
const selection: ScorePillarId[] = [
"BLOOD_PRESSURE",
"ACTIVITY",
"SLEEP",
"ADIPOSITY",
];
const blob = healthScoreConfigFromSelection({
selection,
version: 5,
changedAt: new Date("2026-07-31T10:15:00.000Z"),
});
expect(blob.excludedPillars).toEqual(
ALL.filter((id) => !selection.includes(id)),
);
expect(blob.version).toBe(5);
expect(blob.changedAt).toBe("2026-07-31T10:15:00.000Z");
const resolved = resolveHealthScoreConfig(blob);
expect(resolved.pillars).toEqual(selection);
expect(resolved.hasSelection).toBe(true);
expect(resolved.version).toBe(5);
});
it("stores every pillar as excluded when the selection is empty", () => {
const blob = healthScoreConfigFromSelection({
selection: [],
version: 1,
changedAt: new Date("2026-07-31T10:15:00.000Z"),
});
expect(blob.excludedPillars).toEqual(ALL);
expect(resolveHealthScoreConfig(blob).pillars).toEqual([]);
});
});
+161
View File
@@ -0,0 +1,161 @@
/**
* v1.35.0 — per-user Health Score composition: the stored shape, the
* schema that guards it, and the resolver every reader goes through.
*
* Three things this file is careful about, each because getting it
* wrong is silent rather than loud.
*
* 1. **"Never chose" is not "chose nothing."** `excludedPillars` is
* `.optional()` and deliberately not `.default([])`. An absent key
* means the person never opened the surface, and such an account may
* still inherit a change of defaults later without its number
* jumping. A present list, even a long one, is an authored recipe
* and is never widened behind the person's back. Collapse the two
* and every never-configured account drifts the day defaults move.
*
* 2. **The stored list is a deselection list.** It records what the
* person took out, not what they left in. A pillar shipped after
* they chose is simply not in the list, so it counts, which is the
* "everything eligible" starting point applied to an existing
* account with no backfill. Had the column stored the positive
* selection, a newly shipped pillar would be indistinguishable from
* one the person deliberately removed, and the resolver would have
* to guess. This is the same posture as `modulePreferencesJson`.
*
* 3. **The catalogue wins on read.** Ids outside `SCORE_PILLAR_IDS` are
* dropped rather than trusted, so a retired pillar, a hand-edited
* blob, or a row written by a newer build can never put an unknown
* id in front of the scorer.
*
* The write route (`PATCH /api/auth/me/health-score-config`) speaks the
* positive selection, because that is what a person sees and what the
* score's published composition means. The inversion happens once, at
* the boundary, in `healthScoreConfigFromSelection`.
*/
import { z } from "zod";
import { SCORE_PILLAR_IDS, type ScorePillarId } from "./types";
/** Ordered, de-duplicated, unknown ids dropped. Registry order is the score's own. */
export function orderedUniquePillars(ids: readonly unknown[]): ScorePillarId[] {
const present = new Set(ids);
return SCORE_PILLAR_IDS.filter((id) => present.has(id));
}
export const scorePillarIdSchema = z.enum(SCORE_PILLAR_IDS);
/**
* The persisted blob.
*
* Deliberately not `.strict()`, and deliberately loose about the ids:
* a row written by a newer build, or carrying a pillar this build has
* retired, must degrade to what this build understands rather than
* failing the parse. A failed parse reads as "never chose", which would
* silently discard an authored recipe over one unrecognised string. The
* ids are filtered against the catalogue on the way out instead. The
* REQUEST body is the strict end: an unknown id there is a 422, because
* a person's client has no business sending one.
*/
export const healthScoreConfigSchema = z.object({
excludedPillars: z.array(z.string().max(64)).max(64).optional(),
/** Per-user recipe version. Increments on every accepted write. */
version: z.number().int().min(1).optional(),
/** When the recipe last changed, ISO 8601. */
changedAt: z.string().max(40).optional(),
});
export type HealthScoreConfig = z.infer<typeof healthScoreConfigSchema>;
/**
* What an account that never chose gets: every pillar counts, no
* recipe version, no change date. Read-time reconciliation is against
* this, so a pillar added to `SCORE_PILLAR_IDS` later appears here and
* in every resolved config without a backfill.
*/
export const DEFAULT_HEALTH_SCORE_CONFIG: Readonly<ResolvedHealthScoreConfig> =
Object.freeze({
pillars: Object.freeze([...SCORE_PILLAR_IDS]) as ScorePillarId[],
excludedPillars: Object.freeze([] as ScorePillarId[]) as ScorePillarId[],
hasSelection: false,
version: 0,
changedAt: null,
});
/** A fresh, caller-owned copy of the defaults. */
function defaults(): ResolvedHealthScoreConfig {
return {
...DEFAULT_HEALTH_SCORE_CONFIG,
pillars: [...DEFAULT_HEALTH_SCORE_CONFIG.pillars],
excludedPillars: [],
};
}
export interface ResolvedHealthScoreConfig {
/**
* Registry-ordered pillars the person counts toward the score. This
* is the composition the scorer starts from; data availability
* narrows it further, and that narrowing is not a choice.
*/
pillars: ScorePillarId[];
/** Registry-ordered pillars the person took out. */
excludedPillars: ScorePillarId[];
/**
* True once a selection has been written. It says the person chose,
* not that their choice differs from the default: an account that
* opened the surface and kept every pillar has a selection.
*/
hasSelection: boolean;
/** Per-user recipe version. 0 while no selection has been written. */
version: number;
/** When the recipe last changed. Null while no selection exists. */
changedAt: string | null;
}
/**
* Resolve a stored `healthScoreConfigJson` blob into the composition
* the scorer and the settings surface both read.
*
* A null row, a non-object, or a shape that no longer parses all
* resolve to the defaults, which is the honest fallback: the account
* counts everything, exactly as it did before the column existed.
*/
export function resolveHealthScoreConfig(
raw: unknown,
): ResolvedHealthScoreConfig {
if (raw == null || typeof raw !== "object") return defaults();
const parsed = healthScoreConfigSchema.safeParse(raw);
if (!parsed.success) return defaults();
if (parsed.data.excludedPillars === undefined) return defaults();
const excludedPillars = orderedUniquePillars(parsed.data.excludedPillars);
const excluded = new Set(excludedPillars);
return {
pillars: SCORE_PILLAR_IDS.filter((id) => !excluded.has(id)),
excludedPillars,
hasSelection: true,
version: parsed.data.version ?? 1,
changedAt: parsed.data.changedAt ?? null,
};
}
/**
* Turn the positive selection a person made into the blob the column
* stores. Unknown ids are dropped on the way in, so the stored
* deselection list only ever names pillars this build knows.
*
* `version` is supplied by the caller, which reads the previous version
* off the row and increments it: the recipe version has to be monotonic
* per account for a score record to say which recipe produced it.
*/
export function healthScoreConfigFromSelection(input: {
selection: readonly ScorePillarId[];
version: number;
changedAt: Date;
}): HealthScoreConfig {
const selected = new Set(orderedUniquePillars(input.selection));
return {
excludedPillars: SCORE_PILLAR_IDS.filter((id) => !selected.has(id)),
version: input.version,
changedAt: input.changedAt.toISOString(),
};
}