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";