diff --git a/messages/de.json b/messages/de.json index 8cd49fd84..d70772cab 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2271,7 +2271,7 @@ "insufficient": "{count} geeignete Bereiche. Der Score benötigt mindestens drei, darunter einen gemessenen physiologischen Bereich.", "anatomyToggle": "Wie dieser Score zustande kommt", "notScored": "Noch nicht bewertet ({count})", - "method": "Der Score ist der gleich gewichtete Mittelwert aller Säulen mit ausreichend aktuellen Daten, auf einer Skala von 0 bis 100. Jede Säule wird an einem veröffentlichten Referenzbereich gemessen, und die schwächste Säule kann den Gesamtbereich bestimmen. Eine Säule ohne ausreichende Daten bleibt außen vor und zählt nicht als null.", + "method": "Der Score ist der gleich gewichtete Mittelwert der Säulen, die für ihn zählen, auf einer Skala von 0 bis 100. Jede Säule wird an einem veröffentlichten Referenzbereich gemessen, und die schwächste Säule kann den Gesamtbereich bestimmen. Eine Säule ohne ausreichend aktuelle Daten bleibt außen vor und zählt nicht als null. Die Referenzbereiche stammen aus veröffentlichten Quellen; welche Säulen zählen, entscheidest du selbst, deshalb ist die zusammengefasste Zahl selbst kein klinischer Standard.", "bandSetter": "Gesamtstufe bestimmt durch {pillar}", "versionAndNoise": "Methodenversion {version}. Schwelle für den Wochenvergleich: {noise} Punkte.", "methodVersion": "Methodenversion {version}.", diff --git a/messages/en.json b/messages/en.json index 671707c0e..663caae50 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2271,7 +2271,7 @@ "insufficient": "{count} eligible pillars. The score needs at least three, including a measured physiological pillar.", "anatomyToggle": "How this score comes about", "notScored": "Not scored yet ({count})", - "method": "The score is the equal-weighted average of every pillar that has enough recent data, on a 0 to 100 scale. Each pillar is graded against a published reference band, and the weakest pillar can set the overall band. A pillar without enough data is left out rather than counted as a zero.", + "method": "The score is the equal-weighted average of the pillars that count toward it, on a 0 to 100 scale. Each pillar is graded against a published reference band, and the weakest pillar can set the overall band. A pillar without enough recent data is left out rather than counted as a zero. The reference bands come from published sources; which pillars count is yours to choose, so the combined number is not itself a clinical standard.", "bandSetter": "Overall band set by {pillar}", "versionAndNoise": "Method version {version}. Weekly comparison floor: {noise} points.", "methodVersion": "Method version {version}.", diff --git a/messages/es.json b/messages/es.json index 2cf66b4a7..6c74b5443 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2271,7 +2271,7 @@ "insufficient": "{count} pilares aptos. La puntuación necesita al menos tres, incluido un pilar fisiológico medido.", "anatomyToggle": "Cómo se forma esta puntuación", "notScored": "Aún sin puntuar ({count})", - "method": "La puntuación es la media con igual peso de todos los pilares que tienen suficientes datos recientes, en una escala de 0 a 100. Cada pilar se compara con un rango de referencia publicado, y el pilar más débil puede fijar el rango general. Un pilar sin datos suficientes queda fuera en lugar de contar como cero.", + "method": "La puntuación es la media con igual peso de los pilares que cuentan para ella, en una escala de 0 a 100. Cada pilar se compara con un rango de referencia publicado, y el pilar más débil puede fijar el rango general. Un pilar sin datos recientes suficientes queda fuera en lugar de contar como cero. Los rangos de referencia proceden de fuentes publicadas; qué pilares cuentan lo eliges tú, así que el número combinado no es en sí mismo un estándar clínico.", "bandSetter": "Categoría general determinada por {pillar}", "versionAndNoise": "Versión del método {version}. Umbral de comparación semanal: {noise} puntos.", "methodVersion": "Versión del método {version}.", diff --git a/messages/fr.json b/messages/fr.json index 045397178..61e459126 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2271,7 +2271,7 @@ "insufficient": "{count} piliers admissibles. Le score en requiert au moins trois, dont un pilier physiologique mesuré.", "anatomyToggle": "Comment ce score est établi", "notScored": "Pas encore évalué ({count})", - "method": "Le score est la moyenne à poids égal de tous les piliers disposant d'assez de données récentes, sur une échelle de 0 à 100. Chaque pilier est comparé à une plage de référence publiée, et le pilier le plus faible peut déterminer la plage globale. Un pilier sans données suffisantes est écarté plutôt que compté comme un zéro.", + "method": "Le score est la moyenne à poids égal des piliers qui comptent pour lui, sur une échelle de 0 à 100. Chaque pilier est comparé à une plage de référence publiée, et le pilier le plus faible peut déterminer la plage globale. Un pilier sans données récentes suffisantes est écarté plutôt que compté comme un zéro. Les plages de référence proviennent de sources publiées ; les piliers qui comptent sont ceux que vous choisissez, donc le nombre combiné n'est pas lui-même une norme clinique.", "bandSetter": "Catégorie globale fixée par {pillar}", "versionAndNoise": "Version de la méthode {version}. Seuil de comparaison hebdomadaire : {noise} points.", "methodVersion": "Version de la méthode {version}.", diff --git a/messages/it.json b/messages/it.json index 9cf858017..ba528d8ca 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2271,7 +2271,7 @@ "insufficient": "{count} pilastri idonei. Il punteggio ne richiede almeno tre, incluso un pilastro fisiologico misurato.", "anatomyToggle": "Come nasce questo punteggio", "notScored": "Non ancora valutato ({count})", - "method": "Il punteggio è la media a peso uguale di tutti i pilastri con dati recenti sufficienti, su una scala da 0 a 100. Ogni pilastro viene confrontato con un intervallo di riferimento pubblicato e il pilastro più debole può determinare la fascia complessiva. Un pilastro senza dati sufficienti resta escluso invece di contare come zero.", + "method": "Il punteggio è la media a peso uguale dei pilastri che contano per esso, su una scala da 0 a 100. Ogni pilastro viene confrontato con un intervallo di riferimento pubblicato e il pilastro più debole può determinare la fascia complessiva. Un pilastro senza dati recenti sufficienti resta escluso invece di contare come zero. Gli intervalli di riferimento provengono da fonti pubblicate; quali pilastri contano lo scegli tu, quindi il numero combinato non è di per sé uno standard clinico.", "bandSetter": "Fascia complessiva determinata da {pillar}", "versionAndNoise": "Versione del metodo {version}. Soglia del confronto settimanale: {noise} punti.", "methodVersion": "Versione del metodo {version}.", diff --git a/messages/pl.json b/messages/pl.json index 373259b63..bd8eda6a0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2271,7 +2271,7 @@ "insufficient": "{count} kwalifikujące się filary. Wynik wymaga co najmniej trzech, w tym zmierzonego filaru fizjologicznego.", "anatomyToggle": "Jak powstaje ten wynik", "notScored": "Jeszcze bez oceny ({count})", - "method": "Wynik to średnia o równych wagach ze wszystkich filarów, które mają wystarczająco dużo aktualnych danych, w skali od 0 do 100. Każdy filar jest porównywany z opublikowanym zakresem referencyjnym, a najsłabszy filar może wyznaczyć ogólny zakres. Filar bez wystarczających danych jest pomijany, a nie liczony jako zero.", + "method": "Wynik to średnia o równych wagach z filarów, które się do niego liczą, w skali od 0 do 100. Każdy filar jest porównywany z opublikowanym zakresem referencyjnym, a najsłabszy filar może wyznaczyć ogólny zakres. Filar bez wystarczających aktualnych danych jest pomijany, a nie liczony jako zero. Zakresy referencyjne pochodzą z opublikowanych źródeł; to ty decydujesz, które filary się liczą, więc łączna liczba sama w sobie nie jest standardem klinicznym.", "bandSetter": "Ogólny zakres wyznacza {pillar}", "versionAndNoise": "Wersja metody {version}. Próg porównania tygodniowego: {noise} pkt.", "methodVersion": "Wersja metody {version}.", diff --git a/src/components/insights/derived/__tests__/health-score-provenance-claim.test.ts b/src/components/insights/derived/__tests__/health-score-provenance-claim.test.ts new file mode 100644 index 000000000..cc9670d65 --- /dev/null +++ b/src/components/insights/derived/__tests__/health-score-provenance-claim.test.ts @@ -0,0 +1,104 @@ +/** + * v1.35.0 — the Health Score's composite-level provenance must not claim + * more than it does. + * + * Two sentences used to overreach, in different ways. + * + * The one people read, `insights.healthScore.method`, said the score was + * the average of "every pillar that has enough recent data". Once somebody + * can take a pillar out of their score, that is simply untrue for them, + * and it is untrue in the direction that matters: it tells them the number + * covers ground it does not. The replacement says the average is over the + * pillars that count, and that which pillars count is theirs to choose. + * This test pins the choice sentence in all six locales, because a + * translation that quietly drops it puts the old claim back for those + * readers only. + * + * The one nobody reads, `METRIC_PROVENANCE.HEALTH_SCORE.standard`, named + * the WHO HEARTS technical package flat, as though a standards body had + * signed off on whatever recipe the account happens to hold. HEARTS names + * the risk factors; it prescribes no average over them. The entry now says + * what it is cited for. It reaches no screen today (`ProvenanceExplainer` + * renders the method caption only), which is exactly why the method copy + * above carries the honesty and this half is a source-of-truth fix. + * + * The marker check is deliberately blunt, and blunt has a failure mode: a + * substring test can pass on prose that means nothing. So it is pinned in + * both directions here, against the wording this release replaced and + * against a rewrite that keeps the meaning, before it is trusted on the + * real bundles. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { METRIC_PROVENANCE } from "../standards"; + +const MESSAGES = join(__dirname, "../../../../../messages"); + +/** + * The phrase in each locale that says the selection belongs to the + * reader. Not a stylistic preference: it is the sentence that stops the + * paragraph from describing a fixed recipe. + */ +const SCOPE_MARKER: Record = { + en: "yours to choose", + de: "entscheidest du", + fr: "vous choisissez", + es: "lo eliges tú", + it: "lo scegli tu", + pl: "ty decydujesz", +}; + +/** The wording this release replaced, kept only to prove the check bites. */ +const PRE_V1350_EN = + "The score is the equal-weighted average of every pillar that has enough " + + "recent data, on a 0 to 100 scale."; + +/** An honest rewrite that keeps the meaning, to prove the check is quiet. */ +const INNOCENT_EN_REWRITE = + "Pillars are averaged with equal weight on a 0 to 100 scale, and which " + + "pillars count is yours to choose."; + +function methodCopy(locale: string): string { + const bundle = JSON.parse( + readFileSync(join(MESSAGES, `${locale}.json`), "utf8"), + ) as { + insights: { healthScore: { method: string } }; + }; + return bundle.insights.healthScore.method; +} + +function claimsPersonalScope(locale: string, text: string): boolean { + const marker = SCOPE_MARKER[locale]; + if (!marker) throw new Error(`no scope marker declared for ${locale}`); + return text.toLocaleLowerCase().includes(marker.toLocaleLowerCase()); +} + +describe("Health Score provenance claims", () => { + it("bites on the wording this release replaced", () => { + expect(claimsPersonalScope("en", PRE_V1350_EN)).toBe(false); + }); + + it("stays quiet on a rewrite that keeps the meaning", () => { + expect(claimsPersonalScope("en", INNOCENT_EN_REWRITE)).toBe(true); + }); + + it.each(Object.keys(SCOPE_MARKER))( + "says in %s that the pillars in scope are the reader's own", + (locale) => { + const copy = methodCopy(locale); + expect(copy.length).toBeGreaterThan(0); + expect(claimsPersonalScope(locale, copy)).toBe(true); + }, + ); + + it("does not cite a standard as describing the composite", () => { + const { name } = METRIC_PROVENANCE.HEALTH_SCORE.standard; + // The bare package title read as an endorsement of the account's own + // recipe. It is cited for the risk factors the pillars are drawn from, + // and the name has to say so. + expect(name).not.toBe("WHO HEARTS technical package"); + expect(name).toContain("risk-factor set"); + }); +}); diff --git a/src/components/insights/derived/standards.ts b/src/components/insights/derived/standards.ts index ef0480939..0ebc4f957 100644 --- a/src/components/insights/derived/standards.ts +++ b/src/components/insights/derived/standards.ts @@ -12,6 +12,16 @@ * * Client-safe: a string-keyed metadata map + i18n key names, no compute, no * server imports — a `"use client"` surface value-imports it freely. + * + * One thing to know before trusting `standard` to reach a reader: it does + * not, everywhere. `ProvenanceExplainer` accepts the prop and renders only + * the method caption (v1.22 folded the cited-standard line out along with + * the ⓘ popover it lived in), so a surface that goes through that component + * shows the method copy and nothing else. `InfoPopover` still renders the + * link, which is why the vitals dashboard does surface citations. The + * Health Score is on the first path, so its honesty about what the number + * covers has to live in `insights.healthScore.method` — the standard entry + * alone would say it to nobody. */ import type { ProvenanceStandard } from "./provenance-explainer"; import type { DerivedMetricId } from "@/lib/insights/derived/registry"; @@ -191,7 +201,17 @@ export const METRIC_PROVENANCE: Record = HEALTH_SCORE: { methodKey: "insights.healthScore.method", standard: { - name: "WHO HEARTS technical package", + // Cited for the risk-factor set the pillars are drawn from, and for + // nothing beyond it. HEARTS names the cardiometabolic factors worth + // following; it does not prescribe an equal-weighted average of + // them, and since v1.35.0 it could not, because which pillars enter + // that average is the account's own selection. Naming the package + // plainly, as this entry used to, reads as a standard endorsing + // whatever recipe the person happens to have chosen, which is a + // claim nobody can make. The pillar-level citations elsewhere in + // this map are unaffected: each of those really does describe the + // band its pillar is graded against. + name: "WHO HEARTS technical package (risk-factor set)", url: "https://www.who.int/publications/i/item/9789240001367", }, }, diff --git a/src/lib/insights/derived/__tests__/health-score-coverage-contract.test.ts b/src/lib/insights/derived/__tests__/health-score-coverage-contract.test.ts new file mode 100644 index 000000000..753ab0f48 --- /dev/null +++ b/src/lib/insights/derived/__tests__/health-score-coverage-contract.test.ts @@ -0,0 +1,211 @@ +/** + * v1.35.0 — what the Health Score's published coverage is allowed to claim. + * + * Two claims are pinned here, because both quietly became wrong the moment + * a person could choose which pillars count. + * + * 1. **The denominator.** A person who counts four pillars must not be + * measured against the eight-pillar catalogue. The registry's + * `HEALTH_SCORE` entry lists all eight and reads like the recipe, so + * the first test states plainly where the published denominator comes + * from instead: `computeComposite`, over the composition the resolver + * hands it. If someone ever wires the static list into the coverage + * model, this goes red. + * 2. **The missing list.** `coverage.missing` drives "track these to + * sharpen this". A pillar somebody deliberately took out of their + * score must never appear there, or the app asks for data in the voice + * of the person's own settings. The counter-test keeps that honest: a + * pillar they KEPT and have no data for still shows up, so the first + * assertion is not passing because the list is empty by construction. + * + * The composition comes from `resolveHealthScoreConfig` rather than a + * hand-written array, so the test exercises the same narrowing the reader + * does instead of asserting against a set it invented. + */ +import { describe, expect, it } from "vitest"; + +import { computeComposite } from "@/lib/analytics/score/composite"; +import { SCORE_MIN_ELIGIBLE_DOMAINS } from "@/lib/analytics/score/breadth"; +import { + healthScoreConfigFromSelection, + resolveHealthScoreConfig, +} from "@/lib/analytics/score/config"; +import { + SCORE_PILLAR_DOMAINS, + SCORE_PILLAR_IDS, + type PillarValue, + type ScorePillarId, + type ScorePillarResult, +} from "@/lib/analytics/score/types"; +import { buildInsufficient, buildOk, deriveCoverage } from "../coverage"; +import { getDerivedMetricMeta } from "../registry"; +import type { Derived } from "../types"; + +const NOW = new Date("2026-08-20T12:00:00.000Z"); + +/** A pillar with data, scored and eligible. */ +function scored(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 }; +} + +/** A pillar the account tracks nothing for. */ +function noData(id: ScorePillarId): ScorePillarResult { + const result: Derived = buildInsufficient({ + coverage: { + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: [id], + }, + provenance: { + inputs: [id], + source: "none", + windowDays: 0, + computedAt: NOW.toISOString(), + }, + reason: "notTracked", + }); + return { id, domain: SCORE_PILLAR_DOMAINS[id], result }; +} + +/** + * Every pillar in the catalogue, scored when it has data. The scorer + * always produces a row per pillar; the config decides which rows count, + * which is exactly the distinction these tests are about. + */ +function allPillars(withData: readonly ScorePillarId[]): ScorePillarResult[] { + const present = new Set(withData); + return SCORE_PILLAR_IDS.map((id) => + present.has(id) ? scored(id) : noData(id), + ); +} + +/** The reader's own narrowing: the stored recipe, resolved. */ +function compositionFor(selection: readonly ScorePillarId[]): ScorePillarId[] { + const blob = healthScoreConfigFromSelection({ + selection, + version: 1, + changedAt: NOW, + }); + return resolveHealthScoreConfig(blob).pillars; +} + +describe("Health Score coverage contract", () => { + it("does not measure a four-pillar account against the eight-pillar catalogue", () => { + const selection: ScorePillarId[] = [ + "BLOOD_PRESSURE", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + ]; + const availablePillars = compositionFor(selection); + expect(availablePillars).toEqual(selection); + + const composite = computeComposite({ + pillars: allPillars(selection), + availablePillars, + asOf: NOW, + }); + + expect(composite.status).toBe("ok"); + // The catalogue is eight wide and must not be the yardstick. + expect(SCORE_PILLAR_IDS.length).toBe(8); + 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).toBeLessThanOrEqual( + selection.length, + ); + expect(composite.coverage.presentInputs).toBe( + composite.coverage.requiredInputs, + ); + if (composite.status !== "ok") throw new Error("unreachable"); + // Fully covered means fully covered: four of four counted pillars have + // data, so nothing about the four they left out may pull this down. + expect(composite.confidence.score).toBe(100); + expect(composite.value.composition).toEqual(selection); + }); + + it("never asks for data from a pillar the person took out of the score", () => { + const selection: ScorePillarId[] = [ + "BLOOD_PRESSURE", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + ]; + const composite = computeComposite({ + pillars: allPillars(selection), + availablePillars: compositionFor(selection), + asOf: NOW, + }); + + expect(composite.coverage.missing).toEqual([]); + }); + + it("still asks for data from a pillar the person kept", () => { + // The same four pillars have data; the difference is that this account + // counts all eight, so the four blanks are genuinely missing coverage. + const withData: ScorePillarId[] = [ + "BLOOD_PRESSURE", + "ACTIVITY", + "SLEEP", + "ADIPOSITY", + ]; + const composite = computeComposite({ + pillars: allPillars(withData), + availablePillars: compositionFor(SCORE_PILLAR_IDS), + asOf: NOW, + }); + + expect(composite.coverage.missing).toContain("wellbeing"); + expect(composite.coverage.missing).toContain("fitness"); + }); + + it("keeps the registry's HEALTH_SCORE inputs equal to the score catalogue", () => { + const meta = getDerivedMetricMeta("HEALTH_SCORE"); + expect(meta).not.toBeNull(); + expect(meta?.inputs).toEqual([...SCORE_PILLAR_IDS]); + // Non-vacuity: the comparison is against the live catalogue, and it + // does distinguish. A list one pillar short is not equal. + expect(meta?.inputs).not.toEqual(SCORE_PILLAR_IDS.slice(0, -1)); + }); +}); diff --git a/src/lib/insights/derived/registry.ts b/src/lib/insights/derived/registry.ts index 8155ccb50..2a8ad9bab 100644 --- a/src/lib/insights/derived/registry.ts +++ b/src/lib/insights/derived/registry.ts @@ -342,6 +342,13 @@ const REGISTRY: Record = { id: "HEALTH_SCORE", displayName: "Cardiometabolic reference score", archetype: "composite", + // The eight ids below are the CATALOGUE of scorable pillars, and since + // v1.35.0 they are not the recipe. What counts toward an account's + // composite is the resolved per-user composition + // (`resolveHealthScoreConfig`) intersected with the modules that record + // data, so no static list here can say what any one person's score is + // made of. The list is kept in step with `SCORE_PILLAR_IDS` by + // `health-score-coverage-contract.test.ts`; edit both or neither. inputs: [ "BLOOD_PRESSURE", "GLYCAEMIA", @@ -353,6 +360,17 @@ const REGISTRY: Record = { "LIPIDS", ], minHistoryDays: 1, + // NOT the composite's floor, and not read by anything. The coverage and + // confidence this metric publishes are built in `computeComposite` + // (`src/lib/analytics/score/composite.ts`) over the resolved + // composition, never from this entry, which is why a person who counts + // four pillars is not measured against a denominator of eight. The real + // floor is the breadth rule in `src/lib/analytics/score/breadth.ts`: at + // least three distinct DOMAINS including one physiological pillar. Three + // of the eight ids above share the cardiometabolic domain, so "three + // inputs" and "enough breadth for a score" are different questions and + // this number answers neither. The field stays because every registry + // entry carries it; reading it as a threshold would be wrong. minInputs: 3, implemented: true, },