From d92be0d0bd5f508e55fd409c3540c7da5d534980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 15:03:20 +0200 Subject: [PATCH] Lift the score breadth rule into one shared place The composite needs three distinct domains and one physiological pillar. Until now only the scorer could narrow the pillar set, so the rule lived inside it. A settings write can narrow it too, and a second copy of the rule would drift, so both layers now call the same function over the same pillar-to-domain map. --- src/lib/analytics/score/breadth.ts | 71 ++++++++++++++++++++++++++++ src/lib/analytics/score/composite.ts | 24 ++++------ src/lib/analytics/score/index.ts | 17 +++---- src/lib/analytics/score/types.ts | 21 ++++++++ 4 files changed, 109 insertions(+), 24 deletions(-) create mode 100644 src/lib/analytics/score/breadth.ts diff --git a/src/lib/analytics/score/breadth.ts b/src/lib/analytics/score/breadth.ts new file mode 100644 index 000000000..6ab9029ed --- /dev/null +++ b/src/lib/analytics/score/breadth.ts @@ -0,0 +1,71 @@ +/** + * v1.35.0 — the breadth rule, in one place. + * + * 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. + */ +import { + SCORE_PILLAR_DOMAINS, + type ScoreDomain, + type ScorePillarId, +} from "./types"; + +export const SCORE_MIN_ELIGIBLE_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. + */ +export const PHYSIOLOGICAL_PILLARS: ReadonlySet = new Set([ + "BLOOD_PRESSURE", + "GLYCAEMIA", + "SLEEP", + "ADIPOSITY", + "FITNESS", + "LIPIDS", +]); + +export type ScoreBreadthFailure = + "three_domains_required" | "measured_physiological_domain_required"; + +/** + * 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. + */ +export type ScoreBreadthVerdict = + | { ok: true; reason: null; domains: ScoreDomain[] } + | { ok: false; reason: ScoreBreadthFailure; domains: ScoreDomain[] }; + +/** + * 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. + */ +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) { + return { + ok: false, + reason: "measured_physiological_domain_required", + domains, + }; + } + if (domains.length < SCORE_MIN_ELIGIBLE_DOMAINS) { + return { ok: false, reason: "three_domains_required", domains }; + } + return { ok: true, reason: null, domains }; +} diff --git a/src/lib/analytics/score/composite.ts b/src/lib/analytics/score/composite.ts index 9ce934be4..c47bf3781 100644 --- a/src/lib/analytics/score/composite.ts +++ b/src/lib/analytics/score/composite.ts @@ -15,18 +15,14 @@ import { type ScorePillarResult, } from "./types"; import { mean, provenance, scoreBand } from "./shared"; +import { evaluateScoreBreadth, SCORE_MIN_ELIGIBLE_DOMAINS } from "./breadth"; export const SCORE_ALGORITHM_CHANGED_AT = "2026-07-28T00:00:00.000Z"; -export const SCORE_MIN_ELIGIBLE_DOMAINS = 3; -const PHYSIOLOGICAL_PILLARS: ReadonlySet = new Set([ - "BLOOD_PRESSURE", - "GLYCAEMIA", - "SLEEP", - "ADIPOSITY", - "FITNESS", - "LIPIDS", -]); +// 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 }; const BAND_RANK: Record = { green: 2, @@ -57,9 +53,7 @@ export function computeComposite( ); const composition = eligible.map((pillar) => pillar.id); const eligibleDomains = new Set(eligible.map((pillar) => pillar.domain)); - const hasPhysiological = composition.some((id) => - PHYSIOLOGICAL_PILLARS.has(id), - ); + const breadth = evaluateScoreBreadth(composition); const availableDomains = new Set( input.pillars .filter((pillar) => available.includes(pillar.id)) @@ -102,13 +96,11 @@ export function computeComposite( asOf: input.asOf, }); - if (eligibleDomains.size < SCORE_MIN_ELIGIBLE_DOMAINS || !hasPhysiological) { + if (!breadth.ok) { return buildInsufficient({ coverage, provenance: compositeProvenance, - reason: !hasPhysiological - ? "measured_physiological_domain_required" - : "three_domains_required", + reason: breadth.reason, }); } diff --git a/src/lib/analytics/score/index.ts b/src/lib/analytics/score/index.ts index 3d5f6ffb2..59ac59640 100644 --- a/src/lib/analytics/score/index.ts +++ b/src/lib/analytics/score/index.ts @@ -9,6 +9,7 @@ import { computeGlycaemiaPillar } from "./glycaemia"; import { computeLipidsPillar } from "./lipids"; import { computeSleepPillar } from "./sleep"; import { + SCORE_PILLAR_DOMAINS, SCORE_PILLAR_IDS, SCORE_VERSION, type CompositeValue, @@ -35,42 +36,42 @@ export function computeHealthScore( const byId: Record = { BLOOD_PRESSURE: { id: "BLOOD_PRESSURE", - domain: "cardiometabolic", + domain: SCORE_PILLAR_DOMAINS.BLOOD_PRESSURE, result: computeBloodPressurePillar(input.pillars.BLOOD_PRESSURE), }, GLYCAEMIA: { id: "GLYCAEMIA", - domain: "cardiometabolic", + domain: SCORE_PILLAR_DOMAINS.GLYCAEMIA, result: computeGlycaemiaPillar(input.pillars.GLYCAEMIA), }, ACTIVITY: { id: "ACTIVITY", - domain: "activity", + domain: SCORE_PILLAR_DOMAINS.ACTIVITY, result: computeActivityPillar(input.pillars.ACTIVITY), }, SLEEP: { id: "SLEEP", - domain: "sleep", + domain: SCORE_PILLAR_DOMAINS.SLEEP, result: computeSleepPillar(input.pillars.SLEEP), }, ADIPOSITY: { id: "ADIPOSITY", - domain: "adiposity", + domain: SCORE_PILLAR_DOMAINS.ADIPOSITY, result: computeAdiposityPillar(input.pillars.ADIPOSITY), }, WELLBEING: { id: "WELLBEING", - domain: "wellbeing", + domain: SCORE_PILLAR_DOMAINS.WELLBEING, result: computeWellbeingPillar(input.pillars.WELLBEING), }, FITNESS: { id: "FITNESS", - domain: "fitness", + domain: SCORE_PILLAR_DOMAINS.FITNESS, result: computeFitnessPillar(input.pillars.FITNESS), }, LIPIDS: { id: "LIPIDS", - domain: "cardiometabolic", + domain: SCORE_PILLAR_DOMAINS.LIPIDS, result: computeLipidsPillar(input.pillars.LIPIDS), }, }; diff --git a/src/lib/analytics/score/types.ts b/src/lib/analytics/score/types.ts index 086749b95..83ee83941 100644 --- a/src/lib/analytics/score/types.ts +++ b/src/lib/analytics/score/types.ts @@ -29,6 +29,27 @@ export type ScoreDomain = | "wellbeing" | "fitness"; +/** + * 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 eight pillars share the cardiometabolic + * domain, which is why a selection of five pillars can still fail. + * + * 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 + * into disagreeing about what counts as a distinct area. + */ +export const SCORE_PILLAR_DOMAINS: Record = { + BLOOD_PRESSURE: "cardiometabolic", + GLYCAEMIA: "cardiometabolic", + ACTIVITY: "activity", + SLEEP: "sleep", + ADIPOSITY: "adiposity", + WELLBEING: "wellbeing", + FITNESS: "fitness", + LIPIDS: "cardiometabolic", +}; + export type PillarReferenceKind = "clinical-threshold" | "population-percentile" | "guideline-band";