Files
HealthLog/src/lib/analytics/score/composite.ts
T
Marc-André Bombeck 956746fe7d Keep one sanitiser for pillar ids
The scorer and the stored composition both had to answer what a valid
pillar list looks like, and both had their own copy of the answer. One
function next to the catalogue now, used by both.
2026-07-31 15:37:04 +02:00

268 lines
7.7 KiB
TypeScript

import {
buildInsufficient,
buildOk,
deriveCoverage,
} from "@/lib/insights/derived/coverage";
import type { Derived } from "@/lib/insights/derived/types";
import {
orderedUniquePillars,
SCORE_VERSION,
type CompositeValue,
type HealthScoreReport,
type ScoreBand,
type ScorePillarId,
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";
// 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,
yellow: 1,
red: 0,
};
export interface CompositeInput {
pillars: ScorePillarResult[];
availablePillars: ScorePillarId[];
asOf: Date;
}
export function computeComposite(
input: CompositeInput,
): Derived<CompositeValue> {
const available = orderedUniquePillars(input.availablePillars);
const byId = new Map(input.pillars.map((pillar) => [pillar.id, pillar]));
const eligible = available
.map((id) => byId.get(id))
.filter(
(pillar): pillar is ScorePillarResult => pillar?.result.status === "ok",
);
const composition = eligible.map((pillar) => pillar.id);
const eligibleDomains = new Set(eligible.map((pillar) => pillar.domain));
const breadth = evaluateScoreBreadth(composition);
const availableDomains = new Set(
input.pillars
.filter((pillar) => available.includes(pillar.id))
.map((pillar) => pillar.domain),
);
const missing = [...availableDomains].filter(
(domain) => !eligibleDomains.has(domain),
);
const historyDays = eligible.reduce(
(minimum, pillar) =>
pillar.result.status === "ok"
? Math.min(minimum, pillar.result.coverage.historyDays)
: minimum,
Number.POSITIVE_INFINITY,
);
const { coverage, confidence } = deriveCoverage({
requiredInputs: SCORE_MIN_ELIGIBLE_DOMAINS,
presentInputs: eligibleDomains.size,
historyDays: Number.isFinite(historyDays) ? historyDays : 0,
missing,
fullHistoryDays: 28,
});
const compositeProvenance = provenance({
inputs: composition,
source:
eligible.length > 0 &&
eligible.every(
(pillar) =>
pillar.result.provenance.source !== "live" &&
pillar.result.provenance.source !== "none",
)
? "DAY"
: eligible.length > 0
? "live"
: "none",
windowDays: Math.max(
0,
...eligible.map((pillar) => pillar.result.provenance.windowDays),
),
asOf: input.asOf,
});
if (!breadth.ok) {
return buildInsufficient({
coverage,
provenance: compositeProvenance,
reason: breadth.reason,
});
}
const values = eligible.map((pillar) => {
if (pillar.result.status !== "ok") {
throw new Error("Eligible score pillar lost its narrowed state");
}
return pillar.result.value;
});
const score = Math.round(mean(values.map((value) => value.score)));
const meanBand = scoreBand(score);
const bandSetterIndex = values.reduce(
(worstIndex, value, index) =>
BAND_RANK[scoreBand(value.score)] <
BAND_RANK[scoreBand(values[worstIndex].score)]
? index
: worstIndex,
0,
);
const bandSetterValue = values[bandSetterIndex];
const worstBand = scoreBand(bandSetterValue.score);
const band =
BAND_RANK[worstBand] < BAND_RANK[meanBand] ? worstBand : meanBand;
const deltaValues = values.filter((value) => value.deltaEligible);
const noiseFloor =
deltaValues.length === 0
? 0
: Math.ceil(
Math.sqrt(
deltaValues.reduce((sum, value) => sum + value.noiseFloor ** 2, 0),
) / deltaValues.length,
);
return buildOk({
value: {
score,
band,
bandSetter:
BAND_RANK[worstBand] < BAND_RANK[meanBand]
? composition[bandSetterIndex]
: null,
composition,
noiseFloor,
scoreVersion: SCORE_VERSION,
},
coverage,
confidence,
provenance: compositeProvenance,
});
}
function sameComposition(
left: Derived<CompositeValue>,
right: Derived<CompositeValue>,
): boolean {
return (
left.status === "ok" &&
right.status === "ok" &&
left.value.composition.length === right.value.composition.length &&
left.value.composition.every(
(pillar, index) => pillar === right.value.composition[index],
)
);
}
function comparableDynamicPillars(
report: Derived<CompositeValue>,
other: Derived<CompositeValue>,
pillars: ScorePillarResult[],
otherPillars: ScorePillarResult[],
): {
current: number;
previous: number;
noiseFloor: number;
} | null {
if (report.status !== "ok" || other.status !== "ok") return null;
const otherById = new Map(otherPillars.map((pillar) => [pillar.id, pillar]));
const pairs = pillars.flatMap((pillar) => {
const previous = otherById.get(pillar.id);
if (
!report.value.composition.includes(pillar.id) ||
!other.value.composition.includes(pillar.id) ||
pillar.result.status !== "ok" ||
previous?.result.status !== "ok" ||
!pillar.result.value.deltaEligible ||
!previous.result.value.deltaEligible ||
pillar.result.value.deltaIdentity !== previous.result.value.deltaIdentity
) {
return [];
}
return [
{
current: pillar.result.value.score,
previous: previous.result.value.score,
noiseFloor: Math.max(
pillar.result.value.noiseFloor,
previous.result.value.noiseFloor,
),
},
];
});
if (pairs.length === 0) return null;
return {
current: mean(pairs.map((pair) => pair.current)),
previous: mean(pairs.map((pair) => pair.previous)),
noiseFloor: Math.ceil(
Math.sqrt(pairs.reduce((sum, pair) => sum + pair.noiseFloor ** 2, 0)) /
pairs.length,
),
};
}
export function attachScoreDelta(
current: Derived<CompositeValue>,
previous: Derived<CompositeValue>,
previousPrevious: Derived<CompositeValue>,
asOf: Date,
currentPillars: ScorePillarResult[] = [],
previousPillars: ScorePillarResult[] = [],
): Pick<HealthScoreReport, "delta" | "deltaReason"> {
if (current.status !== "ok") {
return { delta: null, deltaReason: "no_current_score" };
}
if (previous.status !== "ok") {
return { delta: null, deltaReason: "no_previous_window" };
}
const previousAt = new Date(previous.provenance.computedAt);
const boundary = new Date(SCORE_ALGORITHM_CHANGED_AT);
if (
current.value.scoreVersion !== previous.value.scoreVersion ||
(asOf >= boundary && previousAt < boundary)
) {
return { delta: null, deltaReason: "algorithm_changed" };
}
if (!sameComposition(current, previous)) {
return { delta: null, deltaReason: "composition_changed" };
}
if (
previousPrevious.status !== "ok" ||
!sameComposition(previous, previousPrevious)
) {
return { delta: null, deltaReason: "first_eligibility_window" };
}
const comparable =
currentPillars.length > 0 || previousPillars.length > 0
? comparableDynamicPillars(
current,
previous,
currentPillars,
previousPillars,
)
: null;
if (
(currentPillars.length > 0 || previousPillars.length > 0) &&
!comparable
) {
return { delta: null, deltaReason: "composition_changed" };
}
const delta = Math.round(
(comparable?.current ?? current.value.score) -
(comparable?.previous ?? previous.value.score),
);
if (Math.abs(delta) < (comparable?.noiseFloor ?? current.value.noiseFloor)) {
return { delta: null, deltaReason: "below_noise_floor" };
}
return { delta, deltaReason: null };
}