Merge work/score-coverage-contract

This commit is contained in:
Marc-André Bombeck committed 2026-07-31 20:31:01 +02:00
commit f42d423edd
10 files changed
+360 -7

No files matched your search

@@ -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<string, string> = {
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");
});
});
+21 -1
View File
@@ -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<DerivedMetricId, MetricProvenanceMeta> =
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",
},
},
@@ -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<PillarValue> = 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<PillarValue> = 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));
});
});
+18
View File
@@ -342,6 +342,13 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
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<DerivedMetricId, DerivedMetricMeta> = {
"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,
},