mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Three distinct areas of health stops being the price of admission and becomes what it always described: a recommendation. `evaluateScoreBreadth` returns a tier instead of a pass/fail — `full` at three areas or more, `partial` at two, `minimal` at one — and refuses only when not one pillar came back usable, with the reason `no_usable_data`. The arithmetic does not change with the tier. Same mean, same worst-pillar band drag, same noise floor. A one-area score of 82 is an honest 82 that rests on one area, and discounting it for the pillars somebody does not track would have been a new invented target, which is the thing this score spent two releases removing. The physiological requirement becomes a labelling fact rather than a veto. An account whose only readable pillar is activity or wellbeing gets its number with `physiological: false` on the basis block; the old refusal told that person nothing they could not already see. `SCORE_MIN_ELIGIBLE_DOMAINS` is now `SCORE_RECOMMENDED_DOMAINS` and still feeds `deriveCoverage`, so the coverage fraction and the confidence blend keep telling the truth with no new machinery: a one-area score lands at low confidence through the path that was already there. The fraction used to be a floor every scored account had cleared; it is a real moving number now. `SCORE_VERSION` 3 -> 4 and `SCORE_ALGORITHM_CHANGED_AT` moves to the release date. Both are method-identity duties: deltas suppress as `algorithm_changed` across the straddle window, the notice key rotates so each account is told once, and stored days stamp 4. No v3 row is rewritten and no v3 number changes meaning. The settings write follows the same rule rather than a stricter one. It refused two-area and non-physiological selections on the ground that they could not produce a score; they can, so refusing the write would be the two layers disagreeing about the one question they share. The empty selection is still refused, under a single reason `no_pillars_selected`, and the seven message catalogues trade the two old refusal sentences for one. Also fixes the stale `./series.ts` reference in the config header — that module was deleted as dead in v1.35; the columns the sentence describes are still written by `./record.ts`.
124 lines
4.2 KiB
TypeScript
124 lines
4.2 KiB
TypeScript
/**
|
|
* v1.38 — the breadth rule, in one place, and no longer a floor.
|
|
*
|
|
* Until now a composite Health Score existed only when the pillars behind
|
|
* it spanned at least three distinct domains and included at least one
|
|
* physiological measure. Everything below that line got "not enough data
|
|
* yet" — including accounts that had a perfectly readable standing in one
|
|
* or two areas. The refusal told those people nothing they could not
|
|
* already see, and it hid a number they had earned.
|
|
*
|
|
* So three domains stops being the price of admission and becomes the
|
|
* recommendation it always described: the score is computed the same way
|
|
* at every breadth, and the verdict says how broad it is. `full` is the
|
|
* old rule, `partial` is two domains, `minimal` is one. Refusal survives
|
|
* for the one case where there is genuinely nothing to average — no ok
|
|
* pillar at all.
|
|
*
|
|
* The physiological question moves with it. It is now a labelling fact
|
|
* rather than a veto: an account whose only ok pillar is activity or
|
|
* wellbeing gets a `minimal` score that says on its face what it rests
|
|
* on, which is more than the old refusal ever said. The arithmetic does
|
|
* not change with the tier — inventing a discount for the pillars
|
|
* somebody does not track would be exactly the made-up target this score
|
|
* spent two releases removing.
|
|
*
|
|
* Both layers still call this function. The settings write refuses an
|
|
* empty selection, the scorer refuses an empty ok set, and neither
|
|
* restates the rule.
|
|
*/
|
|
import {
|
|
SCORE_PILLAR_DOMAINS,
|
|
type ScoreDomain,
|
|
type ScorePillarId,
|
|
} from "./types";
|
|
|
|
/**
|
|
* The breadth the score recommends, not the breadth it requires.
|
|
*
|
|
* Still the denominator `deriveCoverage` counts distinct domains
|
|
* against, which is what makes a partial score read as partial without
|
|
* any new machinery: one domain of three lands at low confidence through
|
|
* the blend that was already there.
|
|
*/
|
|
export const SCORE_RECOMMENDED_DOMAINS = 3;
|
|
|
|
/**
|
|
* Pillars that rest on a physiological measurement. `ACTIVITY` and
|
|
* `WELLBEING` are the only two that do not. This no longer decides
|
|
* whether a score exists; it decides what the score says about itself.
|
|
*/
|
|
const PHYSIOLOGICAL_PILLARS: ReadonlySet<ScorePillarId> = new Set([
|
|
"BLOOD_PRESSURE",
|
|
"GLYCAEMIA",
|
|
"SLEEP",
|
|
"ADIPOSITY",
|
|
"LIPIDS",
|
|
]);
|
|
|
|
/**
|
|
* How broad the set behind a score is. Ordered by breadth, and named
|
|
* rather than numbered so a surface can speak about it without
|
|
* re-deriving the thresholds.
|
|
*/
|
|
export type ScoreBreadthTier = "full" | "partial" | "minimal";
|
|
|
|
/**
|
|
* The one thing that still refuses. A set with no ok pillar has nothing
|
|
* to average, and a mean over nothing is not a low score, it is an
|
|
* absent one.
|
|
*/
|
|
export type ScoreBreadthFailure = "no_pillars_selected";
|
|
|
|
/**
|
|
* 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 a tier.
|
|
* `domains` carries the distinct domains the set spans; `physiological`
|
|
* carries whether any of them rests on a measurement, for the label.
|
|
*/
|
|
export type ScoreBreadthVerdict =
|
|
| {
|
|
ok: true;
|
|
reason: null;
|
|
tier: ScoreBreadthTier;
|
|
domains: ScoreDomain[];
|
|
physiological: boolean;
|
|
}
|
|
| {
|
|
ok: false;
|
|
reason: ScoreBreadthFailure;
|
|
tier: null;
|
|
domains: ScoreDomain[];
|
|
physiological: boolean;
|
|
};
|
|
|
|
/**
|
|
* Judge a pillar set against the breadth rule.
|
|
*
|
|
* The tier is a function of DOMAINS, not pillars, which is why blood
|
|
* pressure, glucose and cholesterol together are `minimal`: three
|
|
* pillars, one area of health.
|
|
*/
|
|
export function evaluateScoreBreadth(
|
|
ids: readonly ScorePillarId[],
|
|
): ScoreBreadthVerdict {
|
|
const domains = [...new Set(ids.map((id) => SCORE_PILLAR_DOMAINS[id]))];
|
|
const physiological = ids.some((id) => PHYSIOLOGICAL_PILLARS.has(id));
|
|
if (domains.length === 0) {
|
|
return {
|
|
ok: false,
|
|
reason: "no_pillars_selected",
|
|
tier: null,
|
|
domains,
|
|
physiological,
|
|
};
|
|
}
|
|
const tier: ScoreBreadthTier =
|
|
domains.length >= SCORE_RECOMMENDED_DOMAINS
|
|
? "full"
|
|
: domains.length === 2
|
|
? "partial"
|
|
: "minimal";
|
|
return { ok: true, reason: null, tier, domains, physiological };
|
|
}
|