diff --git a/src/lib/analytics/score/__tests__/breadth.test.ts b/src/lib/analytics/score/__tests__/breadth.test.ts new file mode 100644 index 000000000..27a3272a6 --- /dev/null +++ b/src/lib/analytics/score/__tests__/breadth.test.ts @@ -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 = 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"); + }); +}); diff --git a/src/lib/analytics/score/__tests__/config.test.ts b/src/lib/analytics/score/__tests__/config.test.ts new file mode 100644 index 000000000..b85d72744 --- /dev/null +++ b/src/lib/analytics/score/__tests__/config.test.ts @@ -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([]); + }); +}); diff --git a/src/lib/analytics/score/config.ts b/src/lib/analytics/score/config.ts new file mode 100644 index 000000000..8842b08ee --- /dev/null +++ b/src/lib/analytics/score/config.ts @@ -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; + +/** + * 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 = + 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(), + }; +}