mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
This commit is contained in:
1 parent
c574ef78fb
commit
d92be0d0bd
4 files changed
+109
-24
No files matched your search
@@ -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<ScorePillarId> = 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 };
|
||||
}
|
||||
@@ -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<ScorePillarId> = 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<ScoreBand, number> = {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ScorePillarId, ScorePillarResult> = {
|
||||
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),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<ScorePillarId, ScoreDomain> = {
|
||||
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";
|
||||
|
||||
|
||||
Reference in new issue
Block a user