mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge work/score-coverage-contract
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user