feat(insights): anchor STRAIN to the user's own training load

Map day-total Banister TRIMP to 0-100 against a personal reference -- the
EWMA-smoothed 75th percentile of the user's own training-day TRIMP over a
42-day chronic window -- instead of the fixed population anchor that pinned a
deconditioned or chronic-condition user near 0 regardless of their effort.
Below a 7-training-day cold-start floor the engine falls back to the
population anchor, recorded as anchor: population with lower confidence; at
or above it the personal anchor takes over. Always writes a score row -- no
new null gate. The anchor is derived from the TRIMP input, never the 0-100
output, so there is no circular feedback; rest-day zeros stay out of the
training-day distribution.

Add a server-internal per-(user, day) strain_trimp_cache (day-total TRIMP +
running EWMA reference + anchor) so the chronic window reads cheap cached
values instead of re-integrating 42 days of HR series each night. The cache
is not a Measurement -- it stays out of every user-facing surface, the
doctor PDF, and the FHIR bundle by construction. No backfill: the nightly
idempotent recompute populates the cache forward and self-heals, falling
back to the population anchor until enough personal history accrues.

Keep the Banister/Morton/Tanaka citations and the doctor-PDF exclusion; the
honesty copy now states the scale is relative to the user's typical effort,
with a general reference during cold start.
This commit is contained in:
Marc-André Bombeck committed 2026-06-03 19:31:16 +02:00
1 parent 059ba571e3
commit f1ca0e28eb
5 files changed
+656 -13

No files matched your search

+2 -2
View File
@@ -2618,9 +2618,9 @@
"description": "A nightly stress proxy from the shape of your intra-day HRV."
},
"STRAIN_SCORE": {
"method": "A nightly 0–100 strain proxy from your heart-rate-weighted activity load (a TRIMP-style training-impulse model). Higher means a harder day.",
"method": "A nightly 0–100 strain proxy from your heart-rate-weighted activity load (a TRIMP-style training-impulse model). Higher means a harder day. The scale is anchored to your own recent training-day load — relative to your typical effort — so a genuinely hard day for you reads high. While you are still building history it uses a general reference instead.",
"caveat": "A descriptive activity-load proxy, not a clinical or training-grade load measure.",
"description": "A nightly activity-load proxy from your heart-rate-weighted effort."
"description": "A nightly activity-load proxy from your heart-rate-weighted effort, relative to your typical effort."
},
"WRIST_TEMPERATURE_BASELINE": {
"method": "Your typical overnight wrist temperature is the median of your nightly readings over the window, ± a robust spread (median absolute deviation, scaled). It tracks YOUR readings — last night is shown as a deviation from your own baseline, not a population norm.",
@@ -0,0 +1,48 @@
-- v1.10.3 — Strain personal-anchor cache.
--
-- A server-internal per-(user, day) cache that lets the nightly Strain engine
-- anchor the 0–100 map to the user's OWN recent training-day load (an
-- EWMA-smoothed P75 of day-total TRIMP over a 42-day chronic window) instead
-- of the fixed population reference. Persisting the day-total TRIMP + the
-- running EWMA reference means the chronic window reads cheap cached values
-- rather than re-integrating 42 days of HR series every night.
--
-- Purely additive: one new table, no enum changes, no backfill. The nightly
-- idempotent recompute populates the cache forward; until a user accrues
-- enough personal training history the engine falls back to the population
-- anchor, so behaviour matches today until the cache fills in — a clean,
-- self-healing rollout.
--
-- NOT a Measurement: never read through the derived registry, never on a
-- user-facing surface, never in the doctor PDF or FHIR bundle, never ingested
-- from a client. A dedicated table keeps it server-internal and out of every
-- MeasurementType completeness wall.
--
-- Reversibility: forward-only; dropping the table loses only the cache, and
-- the nightly job rebuilds it (falling back to the population anchor in the
-- interim).
CREATE TABLE IF NOT EXISTS "strain_trimp_cache" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"day" TEXT NOT NULL,
"day_trimp" DOUBLE PRECISION NOT NULL,
"ref_personal" DOUBLE PRECISION NOT NULL,
"anchor" TEXT NOT NULL,
"training_days" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "strain_trimp_cache_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "strain_trimp_cache_user_id_day_key"
ON "strain_trimp_cache" ("user_id", "day");
CREATE INDEX IF NOT EXISTS "strain_trimp_cache_user_id_day_idx"
ON "strain_trimp_cache" ("user_id", "day" DESC);
ALTER TABLE "strain_trimp_cache"
ADD CONSTRAINT "strain_trimp_cache_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
+49
View File
@@ -404,6 +404,11 @@ model User {
// retires rows older than 90 days (see `handlePushAttemptCleanup` in
// `reminder-worker.ts`).
pushAttempts PushAttempt[]
// v1.10.3 — per-(user, day) Strain personal-anchor cache (day-total
// TRIMP + running EWMA reference) so the nightly Strain engine anchors
// the 0–100 map to the user's own training load without re-integrating
// the 42-day chronic window of HR series each night.
strainTrimpCache StrainTrimpCache[]
// v1.7.0 profile — optional patient-identity fields surfaced on the
// health-record export cover (PDF) and the FHIR `Patient` resource.
@@ -933,6 +938,50 @@ model WorkoutSamples {
@@map("workout_samples")
}
// ─── Strain personal anchor cache (v1.10.3) ─────────────────
//
// Server-internal per-(user, day) cache that lets the nightly Strain
// engine anchor the 0–100 map to the USER'S OWN recent training-day load
// instead of a fixed population reference. Each row records the scored
// day's day-total Banister TRIMP plus the running EWMA-smoothed personal
// reference and which anchor produced that night's score (personal once
// the cold-start floor is met, population before). Persisting day-total
// TRIMP means the 42-day chronic window reads cheap cached values instead
// of re-integrating 42 days of HR series every night.
//
// NOT a Measurement: it is never read through the derived registry, never
// surfaced on any user-facing surface, never in the doctor PDF or FHIR
// bundle, and never ingested from a client. Reusing the Measurement enum
// would have pulled an internal cache type through every MeasurementType
// completeness wall (chart tokens, categories, PR direction); a dedicated
// table keeps it cleanly server-internal. Idempotent on `(userId, day)` so
// a re-fired nightly tick overwrites the day's cache row in place.
model StrainTrimpCache {
id String @id @default(cuid())
userId String @map("user_id")
/// The scored UTC calendar day (`YYYY-MM-DD`), matching the score row's day key.
day String
/// Day-total Banister TRIMP for the day (0 when no usable HR series).
dayTrimp Float @map("day_trimp")
/// EWMA-smoothed personal reference TRIMP in effect for this day's score.
refPersonal Float @map("ref_personal")
/// Which anchor produced this day's score: `personal` once enough personal
/// training history accrued, `population` during cold start.
anchor String
/// Distinct training days (TRIMP > 0) seen in the chronic window — drives
/// the cold-start gate + the confidence label.
trainingDays Int @map("training_days")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, day])
@@index([userId, day(sort: Desc)])
@@map("strain_trimp_cache")
}
// ─── Personal records (v1.4.25 W8d — schema only) ──────────
//
// MAX vs MIN direction is stored on the row so the future detection
@@ -17,8 +17,15 @@ import {
tanakaHrMax,
banisterTrimp,
saturateToScore,
percentile,
resolvePersonalReference,
persistStrainScore,
STRAIN_SCORE_EXTERNAL_ID_PREFIX,
STRAIN_TRIMP_REFERENCE,
STRAIN_MIN_TRAINING_DAYS,
STRAIN_PERSONAL_REF_FLOOR,
STRAIN_PERSONAL_REF_PERCENTILE,
STRAIN_EWMA_ALPHA,
} from "../strain-score";
const NOW = new Date("2026-06-02T08:30:00Z");
@@ -118,6 +125,13 @@ function makePrisma(opts: {
rhr?: number | null;
gender?: string;
dateOfBirth?: Date | null;
/** Cached training-day TRIMP history rows (newest day first). */
cacheRows?: Array<{
day: string;
dayTrimp: number;
refPersonal: number;
trainingDays?: number;
}>;
}) {
const energyRows = (opts.energy ?? []).map((value, i) => ({
value,
@@ -136,6 +150,15 @@ function makePrisma(opts: {
: new Date("1986-01-01T00:00:00Z"),
gender: opts.gender ?? "MALE",
});
const cacheFindMany = vi.fn().mockResolvedValue(
(opts.cacheRows ?? []).map((r) => ({
// A cached row with TRIMP > 0 was a training day; default its window
// training-day count to 1 so it reads as a warmed EWMA unless overridden.
trainingDays: r.dayTrimp > 0 ? 1 : 0,
...r,
})),
);
const cacheUpsert = vi.fn().mockResolvedValue({});
return {
prisma: {
measurement: {
@@ -145,8 +168,11 @@ function makePrisma(opts: {
},
workout: { findMany: workoutFindMany },
user: { findUnique },
strainTrimpCache: { findMany: cacheFindMany, upsert: cacheUpsert },
} as unknown as Parameters<typeof persistStrainScore>[0],
upsert,
cacheFindMany,
cacheUpsert,
};
}
@@ -258,3 +284,207 @@ describe("persistStrainScore", () => {
expect(secondKey).toEqual(firstKey);
});
});
// ─── v1.10.3 personal-relative anchor ─────────────────────────
describe("percentile", () => {
it("returns 0 for an empty sample and the single value for one", () => {
expect(percentile([], 75)).toBe(0);
expect(percentile([42], 75)).toBe(42);
});
it("interpolates between order statistics", () => {
// P50 of {10,20,30,40} → rank 1.5 → 20 + 0.5·(30−20) = 25.
expect(percentile([40, 10, 30, 20], 50)).toBeCloseTo(25, 6);
// P75 of {10,20,30,40} → rank 2.25 → 30 + 0.25·(40−30) = 32.5.
expect(percentile([10, 20, 30, 40], 75)).toBeCloseTo(32.5, 6);
});
});
describe("resolvePersonalReference", () => {
it("falls back to the population anchor below the cold-start floor", () => {
// Only a handful of training days — under STRAIN_MIN_TRAINING_DAYS.
const trimps = Array.from(
{ length: STRAIN_MIN_TRAINING_DAYS - 1 },
() => 25,
);
const r = resolvePersonalReference({
trainingDayTrimps: trimps,
priorRefPersonal: null,
});
expect(r.anchor).toBe("population");
expect(r.reference).toBe(STRAIN_TRIMP_REFERENCE);
expect(r.trainingDays).toBe(STRAIN_MIN_TRAINING_DAYS - 1);
// The EWMA still warms up so it is ready the night the user qualifies.
expect(r.refPersonalToPersist).not.toBeNull();
});
it("uses the personal P75 anchor once the floor is met (seed run)", () => {
// Eight identical-ish training days at ~25 TRIMP — a deconditioned user.
const trimps = [20, 22, 24, 25, 25, 26, 28, 30];
const r = resolvePersonalReference({
trainingDayTrimps: trimps,
priorRefPersonal: null,
});
expect(r.anchor).toBe("personal");
expect(r.trainingDays).toBe(8);
// Seed run (no prior EWMA) → reference is the window P75 of the trimps.
const expectedP75 = percentile(trimps, STRAIN_PERSONAL_REF_PERCENTILE);
expect(r.reference).toBeCloseTo(expectedP75, 6);
// The deconditioned user's hard day (~30) now scores meaningfully high
// instead of being pinned near 0 against the population 150.
expect(saturateToScore(30, r.reference)).toBeGreaterThan(60);
expect(saturateToScore(30, STRAIN_TRIMP_REFERENCE)).toBeLessThan(25);
});
it("EWMA-blends this window's P75 with the prior reference", () => {
const trimps = [40, 45, 50, 55, 60, 65, 70, 80];
const prior = 100;
const r = resolvePersonalReference({
trainingDayTrimps: trimps,
priorRefPersonal: prior,
});
const windowP75 = percentile(trimps, STRAIN_PERSONAL_REF_PERCENTILE);
const expected =
STRAIN_EWMA_ALPHA * windowP75 + (1 - STRAIN_EWMA_ALPHA) * prior;
expect(r.reference).toBeCloseTo(expected, 6);
// Smoothing keeps the reference between the prior and the window value.
expect(r.reference).toBeLessThan(prior);
expect(r.reference).toBeGreaterThan(windowP75);
});
it("floors the personal reference so trivial days do not all score 100", () => {
// All training days are very light — P75 would sit below the floor.
const trimps = [2, 3, 3, 4, 4, 5, 5, 6];
const r = resolvePersonalReference({
trainingDayTrimps: trimps,
priorRefPersonal: null,
});
expect(r.anchor).toBe("personal");
expect(r.reference).toBe(STRAIN_PERSONAL_REF_FLOOR);
});
it("ignores rest-day zeros — distribution is training-days only", () => {
// Zeros must never enter the percentile (they would crush the anchor).
const withZeros = [0, 0, 0, 20, 22, 24, 25, 26, 28, 30];
const r = resolvePersonalReference({
trainingDayTrimps: withZeros,
priorRefPersonal: null,
});
expect(r.trainingDays).toBe(7); // only the seven > 0 days count
expect(r.reference).toBeCloseTo(
percentile([20, 22, 24, 25, 26, 28, 30], STRAIN_PERSONAL_REF_PERCENTILE),
6,
);
});
});
describe("persistStrainScore — personal anchor + cache", () => {
const hardSeries = {
samples: [
{ t: "2026-06-01T08:00:00.000Z", hr: 150 },
{ t: "2026-06-01T08:30:00.000Z", hr: 155 },
{ t: "2026-06-01T09:00:00.000Z", hr: 150 },
],
};
it("cold start (empty cache) scores against the population anchor + writes the cache row", async () => {
const { prisma, cacheUpsert } = makePrisma({
rhr: 50,
workouts: [{ id: "w1", samples: hardSeries }],
cacheRows: [], // no personal history yet
});
const result = await persistStrainScore(prisma, "user-1", NOW);
expect(result.outcome).toBe("stored");
expect(result.anchor).toBe("population");
// The cache row is always written so the distribution fills forward.
expect(cacheUpsert).toHaveBeenCalledTimes(1);
const cacheArg = cacheUpsert.mock.calls[0][0];
expect(cacheArg.where.userId_day).toEqual({
userId: "user-1",
day: "2026-06-01",
});
expect(cacheArg.create.dayTrimp).toBeGreaterThan(0);
expect(cacheArg.create.anchor).toBe("population");
});
it("uses the personal anchor once the cache holds enough training days", async () => {
// Seven prior training days of light load (~25 TRIMP) → with today's
// session that is ≥ the cold-start floor, so the personal anchor fires.
const cacheRows = Array.from({ length: 7 }, (_, i) => ({
day: `2026-05-${String(25 - i).padStart(2, "0")}`,
dayTrimp: 25,
refPersonal: 25,
}));
const { prisma, cacheUpsert } = makePrisma({
rhr: 50,
workouts: [{ id: "w1", samples: hardSeries }],
cacheRows,
});
const result = await persistStrainScore(prisma, "user-1", NOW);
expect(result.outcome).toBe("stored");
expect(result.anchor).toBe("personal");
const cacheArg = cacheUpsert.mock.calls[0][0];
expect(cacheArg.create.anchor).toBe("personal");
// refPersonal is the EWMA-blended personal reference, not the population 150.
expect(cacheArg.create.refPersonal).toBeLessThan(STRAIN_TRIMP_REFERENCE);
});
it("self-heals with no backfill — a re-run reads the prior EWMA, not its own write", async () => {
// A prior cache row strictly before the scored day carries the prior EWMA.
const cacheRows = [
{ day: "2026-05-31", dayTrimp: 40, refPersonal: 60 },
...Array.from({ length: 7 }, (_, i) => ({
day: `2026-05-${String(24 - i).padStart(2, "0")}`,
dayTrimp: 40,
refPersonal: 60,
})),
];
const { prisma, cacheFindMany, cacheUpsert } = makePrisma({
rhr: 50,
workouts: [{ id: "w1", samples: hardSeries }],
cacheRows,
});
await persistStrainScore(prisma, "user-1", NOW);
// The history read excludes the scored day itself (lt: dayKey) so a
// re-run never blends against its own write — the nightly idempotent
// recompute converges instead of compounding.
const findArg = cacheFindMany.mock.calls[0][0];
expect(findArg.where.day.lt).toBe("2026-06-01");
expect(cacheUpsert).toHaveBeenCalledTimes(1);
});
it("does not adopt a pure energy-only seed row as the EWMA prior", async () => {
// Seven prior training days (warm the personal anchor) plus an
// energy-only placeholder row (trainingDays: 0, refPersonal: population)
// that must NOT be blended in as the prior — it would stall activation.
const cacheRows = [
// newest is the energy-only placeholder.
{ day: "2026-05-31", dayTrimp: 0, refPersonal: STRAIN_TRIMP_REFERENCE },
...Array.from({ length: 7 }, (_, i) => ({
day: `2026-05-${String(24 - i).padStart(2, "0")}`,
dayTrimp: 25,
refPersonal: 25,
})),
];
const { prisma, cacheUpsert } = makePrisma({
rhr: 50,
workouts: [{ id: "w1", samples: hardSeries }],
cacheRows,
});
await persistStrainScore(prisma, "user-1", NOW);
const cacheArg = cacheUpsert.mock.calls[0][0];
expect(cacheArg.create.anchor).toBe("personal");
// The blended reference rides the warmed ~25-TRIMP prior, NOT the
// population 150 carried by the energy-only placeholder row.
expect(cacheArg.create.refPersonal).toBeLessThan(80);
});
});
+327 -11
View File
@@ -23,12 +23,26 @@
* Tanaka, Monahan & Seals 2001, J. Am. Coll. Cardiol. 37(1):153–156.
*
* The per-workout TRIMP values are summed across the scored day to a
* day-total TRIMP, then mapped to the 0–100 scale by a saturating curve
* anchored at a reference daily load (`STRAIN_TRIMP_REFERENCE` ≈ a hard
* hour). Days with NO usable HR series but WITH active energy fall back to
* an active-energy-only proxy so a "logged but no series" workout still
* registers some strain (clearly the weaker signal — the provenance records
* which path produced the score).
* day-total TRIMP, then mapped to the 0–100 scale by a saturating curve.
*
* v1.10.3 — PERSONAL-RELATIVE ANCHOR. The 0–100 map is anchored to the
* USER'S OWN recent training-day load, not a fixed population reference: the
* reference that maps to score ≈ 63 is the EWMA-smoothed 75th percentile of
* the user's own training-day (TRIMP > 0) day-total TRIMP over a 42-day
* chronic window (the acute-vs-chronic framing Garmin Training Load uses,
* the personal-zone idea WHOOP uses). This makes the score meaningful for a
* deconditioned or chronic-condition user, who would otherwise be pinned near
* 0 against the population anchor regardless of how hard *they* worked. Below
* a 7-training-day cold-start floor the engine falls back to the fixed
* population anchor (`STRAIN_TRIMP_REFERENCE` ≈ a hard hour), recorded as
* `anchor: "population"`. The anchor is derived from the TRIMP INPUT, never
* from the 0–100 OUTPUT, so there is no circular feedback.
*
* Days with NO usable HR series but WITH active energy fall back to an
* active-energy-only proxy so a "logged but no series" workout still
* registers some strain (clearly the weaker signal, and with no personal
* intensity distribution it stays on the population anchor — the provenance
* records which path + which anchor produced the score).
*
* Honest confidence: the score is only stored when the day carried at least
* one workout HR series with usable samples OR a non-trivial active-energy
@@ -76,6 +90,59 @@ export const STRAIN_TRIMP_REFERENCE = 150;
*/
export const STRAIN_ACTIVE_ENERGY_REFERENCE = 600;
/**
* v1.10.3 — personal-relative anchor.
*
* The fixed `STRAIN_TRIMP_REFERENCE` is a POPULATION anchor: for a fit user a
* hard hour ≈ 150 TRIMP → score ≈ 63, but for a deconditioned / chronic user
* whose hardest realistic effort is, say, 25 TRIMP the score saturates near 15
* — they gave *their* maximum and the headline reads 15. STRESS + RECOVERY are
* already personal-relative; this brings STRAIN into line by anchoring the
* 0–100 map to the USER'S OWN recent training-day load.
*
* The anchor is the EWMA-smoothed 75th percentile of the user's own
* training-day (TRIMP > 0) day-total TRIMP over a 42-day chronic window — the
* acute-vs-chronic framing Garmin Training Load uses, the personal-zone idea
* WHOOP uses, gated like Oura's "a few days before it appears". We anchor on
* the TRIMP INPUT, never on the 0–100 OUTPUT, so there is no circularity.
*/
/** Chronic window (days) the personal training-day distribution is read over. */
export const STRAIN_CHRONIC_WINDOW_DAYS = 42;
/** Percentile of the user's own training-day TRIMP that maps to score ≈ 63. */
export const STRAIN_PERSONAL_REF_PERCENTILE = 75;
/**
* EWMA effective span (Nₑ) for smoothing the personal reference across nights,
* α = 2/(Nₑ+1) ≈ 0.13. Mirrors Garmin's chronic moving average — the anchor
* tracks fitness changes over weeks without day-to-day jitter.
*/
export const STRAIN_EWMA_N = 14;
/** EWMA smoothing factor derived from {@link STRAIN_EWMA_N}. */
export const STRAIN_EWMA_ALPHA = 2 / (STRAIN_EWMA_N + 1);
/**
* Cold-start floor: distinct training days (TRIMP > 0) the user must have in
* the chronic window before the personal anchor activates. Below it the engine
* falls back to the population anchor, labelled lower-confidence. Counting
* TRAINING days (not calendar days) is deliberate — the anchor describes how
* hard the user trains *when they train*, so a twice-a-week trainer needs
* ~3.5 weeks to qualify, which is the correct semantics.
*/
export const STRAIN_MIN_TRAINING_DAYS = 7;
/**
* Floor on the personal reference (TRIMP). Guards against a near-zero anchor
* (a user whose training days are all very light) inflating every trivial day
* toward 100.
*/
export const STRAIN_PERSONAL_REF_FLOOR = 10;
/** Which anchor produced a day's score. */
export type StrainAnchor = "personal" | "population";
/**
* The UTC calendar day a Strain run scores — the PREVIOUS day relative to
* `now`. The cron fires in the small hours; scoring the just-ended day is what
@@ -161,6 +228,105 @@ export function saturateToScore(load: number, reference: number): number {
return Math.max(0, Math.min(100, Math.round(score)));
}
/**
* The `p`-th percentile (0–100) of a numeric sample via linear interpolation
* between order statistics. Returns 0 for an empty sample. Pure — exported for
* unit testing.
*/
export function percentile(values: readonly number[], p: number): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
if (sorted.length === 1) return sorted[0];
const rank = (Math.min(100, Math.max(0, p)) / 100) * (sorted.length - 1);
const lo = Math.floor(rank);
const hi = Math.ceil(rank);
if (lo === hi) return sorted[lo];
const frac = rank - lo;
return sorted[lo] + (sorted[hi] - sorted[lo]) * frac;
}
/** The training-day TRIMP history backing the personal anchor. */
export interface StrainAnchorHistory {
/** Day-total TRIMP for each TRAINING day (TRIMP > 0) in the chronic window. */
trainingDayTrimps: readonly number[];
/** The EWMA reference persisted by the previous night's run, or null on first run. */
priorRefPersonal: number | null;
}
/** The resolved reference + the provenance the score row's cache should record. */
export interface ResolvedStrainReference {
/** The reference to feed `saturateToScore` for the TRIMP path. */
reference: number;
/** Which anchor was used. */
anchor: StrainAnchor;
/** Distinct training days seen in the chronic window. */
trainingDays: number;
/**
* The EWMA-smoothed personal reference to persist for the next night. Always
* the personal reference even when the population anchor was used this night
* (so the EWMA keeps warming up toward activation); null only when there is
* no training-day history at all.
*/
refPersonalToPersist: number | null;
}
/**
* Resolve the day's strain reference from the user's own training-day TRIMP
* history. Below the cold-start floor → the fixed population anchor (labelled
* `population`); at or above it → the EWMA-smoothed P75 of the user's own
* training-day TRIMP (labelled `personal`), floored at
* {@link STRAIN_PERSONAL_REF_FLOOR}.
*
* The EWMA blends this window's P75 with the prior night's reference
* (`ref = α·P75 + (1−α)·prev`); on the first night with any history it seeds
* from the window P75. Anchors on the TRIMP INPUT, never the score OUTPUT, so
* there is no circular feedback. Pure — no Prisma. Exported for unit testing.
*/
export function resolvePersonalReference(
history: StrainAnchorHistory,
populationReference: number = STRAIN_TRIMP_REFERENCE,
): ResolvedStrainReference {
const trimps = history.trainingDayTrimps.filter((t) => t > 0);
const trainingDays = trimps.length;
// The EWMA reference warms up regardless of the cold-start gate so it is
// ready the night the user crosses the floor.
let refPersonalToPersist: number | null = null;
if (trainingDays > 0) {
const windowP75 = Math.max(
STRAIN_PERSONAL_REF_FLOOR,
percentile(trimps, STRAIN_PERSONAL_REF_PERCENTILE),
);
refPersonalToPersist =
history.priorRefPersonal != null
? STRAIN_EWMA_ALPHA * windowP75 +
(1 - STRAIN_EWMA_ALPHA) * history.priorRefPersonal
: windowP75;
// Keep the persisted EWMA above the floor too (a long stretch of light
// days should never drag the anchor below it).
refPersonalToPersist = Math.max(
STRAIN_PERSONAL_REF_FLOOR,
refPersonalToPersist,
);
}
if (trainingDays < STRAIN_MIN_TRAINING_DAYS) {
return {
reference: populationReference,
anchor: "population",
trainingDays,
refPersonalToPersist,
};
}
return {
reference: refPersonalToPersist as number,
anchor: "personal",
trainingDays,
refPersonalToPersist,
};
}
export interface StrainComputeResult {
/** The 0..100 score to persist, or null when the inputs gate. */
score: number | null;
@@ -176,6 +342,17 @@ export interface StrainComputeResult {
dayActiveEnergy: number;
/** Workouts with a usable HR series on the day. */
workoutsWithSeries: number;
/**
* v1.10.3 — which anchor the score was mapped against. `personal` once the
* user has ≥ `STRAIN_MIN_TRAINING_DAYS` training days of history (TRIMP
* path only); `population` during cold start and always for the
* active-energy fallback (which has no personal intensity distribution).
*/
anchor: StrainAnchor;
/** Distinct training days (TRIMP > 0) in the chronic window. */
trainingDays: number;
/** EWMA personal reference to persist for the next night, or null. */
refPersonalToPersist: number | null;
}
export interface StrainProfile {
@@ -196,6 +373,10 @@ export async function computeStrainScore(
profile: StrainProfile,
hrRest: number | null,
now: Date,
anchorHistory: StrainAnchorHistory = {
trainingDayTrimps: [],
priorRefPersonal: null,
},
): Promise<StrainComputeResult> {
const dayKey = strainDayKey(now);
const dayStart = new Date(`${dayKey}T00:00:00.000Z`);
@@ -240,19 +421,37 @@ export async function computeStrainScore(
}
}
// v1.10.3 — resolve the personal-vs-population reference from the user's
// own training-day TRIMP history. Below the cold-start floor (or with no
// history) this returns the population anchor labelled `population`. The
// scored day's own TRIMP is included in the distribution it is judged
// against — the anchor is "your typical hard day", today included.
const ref = resolvePersonalReference({
trainingDayTrimps:
dayTrimp > 0
? [...anchorHistory.trainingDayTrimps, dayTrimp]
: anchorHistory.trainingDayTrimps,
priorRefPersonal: anchorHistory.priorRefPersonal,
});
if (dayTrimp > 0) {
return {
score: saturateToScore(dayTrimp, STRAIN_TRIMP_REFERENCE),
score: saturateToScore(dayTrimp, ref.reference),
reason: "trimp",
dayTrimp,
dayActiveEnergy,
workoutsWithSeries,
anchor: ref.anchor,
trainingDays: ref.trainingDays,
refPersonalToPersist: ref.refPersonalToPersist,
};
}
// No usable HR series. Fall back to the active-energy-only proxy when
// there is a non-trivial active-energy total. A profile with no HRmax /
// resting HR can still get this fallback because it needs neither.
// resting HR can still get this fallback because it needs neither. The
// fallback has no personal intensity distribution, so it stays on the
// population anchor (honestly labelled `population`).
if (dayActiveEnergy > 0) {
return {
score: saturateToScore(dayActiveEnergy, STRAIN_ACTIVE_ENERGY_REFERENCE),
@@ -260,6 +459,9 @@ export async function computeStrainScore(
dayTrimp: 0,
dayActiveEnergy,
workoutsWithSeries: 0,
anchor: "population",
trainingDays: ref.trainingDays,
refPersonalToPersist: ref.refPersonalToPersist,
};
}
@@ -274,6 +476,9 @@ export async function computeStrainScore(
dayTrimp: 0,
dayActiveEnergy,
workoutsWithSeries: 0,
anchor: "population",
trainingDays: ref.trainingDays,
refPersonalToPersist: ref.refPersonalToPersist,
};
}
@@ -311,10 +516,101 @@ export async function loadStrainInputs(
return { profile, hrRest: rhr?.value ?? null };
}
/**
* v1.10.3 — read the trailing training-day TRIMP distribution + the prior
* EWMA reference from the server-internal `strainTrimpCache`. Reads cheap
* cached day-total TRIMP instead of re-integrating 42 days of HR series. The
* cache populates itself forward (no backfill); a user with no cache rows yet
* returns an empty history → the cold-start population anchor.
*
* `priorRefPersonal` = the most recent cached row's `refPersonal` STRICTLY
* before the scored day, so a re-run for the same day (idempotent recompute)
* blends against the prior night, never against its own previous write.
*/
export async function loadStrainAnchorHistory(
prisma: PrismaClient,
userId: string,
now: Date,
): Promise<StrainAnchorHistory> {
const dayKey = strainDayKey(now);
const windowStart = new Date(
new Date(`${dayKey}T00:00:00.000Z`).getTime() -
STRAIN_CHRONIC_WINDOW_DAYS * MS_PER_DAY,
)
.toISOString()
.slice(0, 10);
// The chronic window of cached rows, excluding the scored day itself (its
// TRIMP is added at compute time so the distribution always reflects the
// freshly-computed day).
const rows = await prisma.strainTrimpCache.findMany({
where: { userId, day: { gte: windowStart, lt: dayKey } },
select: {
day: true,
dayTrimp: true,
refPersonal: true,
trainingDays: true,
},
orderBy: { day: "desc" },
});
const trainingDayTrimps = rows
.map((r) => r.dayTrimp)
.filter((t) => t > 0);
// The prior EWMA = the most recent cached row that actually carried personal
// training history (`trainingDays > 0`). A row written on a pure rest /
// energy-only day stores the population seed only to keep the column
// non-null; adopting it would pollute the EWMA and stall personal
// activation, so it is skipped — the seed stays unset (null) until a real
// training day has warmed the reference.
const warmedRow = rows.find((r) => r.trainingDays > 0);
const priorRefPersonal = warmedRow ? warmedRow.refPersonal : null;
return { trainingDayTrimps, priorRefPersonal };
}
/**
* v1.10.3 — upsert the scored day's Strain anchor cache row (day-total TRIMP +
* the EWMA reference + the anchor used + the training-day count). Idempotent on
* `(userId, day)` so a re-fired nightly tick overwrites in place. Server-only.
*/
export async function upsertStrainTrimpCache(
prisma: PrismaClient,
args: {
userId: string;
now: Date;
dayTrimp: number;
refPersonal: number;
anchor: StrainAnchor;
trainingDays: number;
},
): Promise<void> {
const day = strainDayKey(args.now);
await prisma.strainTrimpCache.upsert({
where: { userId_day: { userId: args.userId, day } },
create: {
userId: args.userId,
day,
dayTrimp: args.dayTrimp,
refPersonal: args.refPersonal,
anchor: args.anchor,
trainingDays: args.trainingDays,
},
update: {
dayTrimp: args.dayTrimp,
refPersonal: args.refPersonal,
anchor: args.anchor,
trainingDays: args.trainingDays,
},
});
}
export interface PersistStrainResult {
outcome: "stored" | "insufficient";
score: number | null;
reason: StrainComputeResult["reason"];
/** v1.10.3 — which anchor produced the score (drives the honesty label). */
anchor: StrainAnchor;
}
/**
@@ -328,16 +624,20 @@ export async function persistStrainScore(
now: Date,
): Promise<PersistStrainResult> {
const { profile, hrRest } = await loadStrainInputs(prisma, userId, now);
const { score, reason } = await computeStrainScore(
const anchorHistory = await loadStrainAnchorHistory(prisma, userId, now);
const result = await computeStrainScore(
prisma,
userId,
profile,
hrRest,
now,
anchorHistory,
);
const { score, reason, dayTrimp, anchor, trainingDays, refPersonalToPersist } =
result;
if (score === null) {
return { outcome: "insufficient", score: null, reason };
return { outcome: "insufficient", score: null, reason, anchor };
}
await upsertScoreRow(prisma, {
@@ -348,5 +648,21 @@ export async function persistStrainScore(
now,
});
return { outcome: "stored", score, reason };
// Persist the day's TRIMP + the EWMA reference so the next night's chronic
// window reads cheap cached values. Only the TRIMP path contributes a
// training day; the active-energy fallback (dayTrimp === 0) still writes a
// row so the cache reflects a non-training day, but it never counts toward
// the personal distribution. `refPersonalToPersist` is null only when there
// is no training history at all — fall back to the population reference for
// the stored EWMA seed so the column stays non-null.
await upsertStrainTrimpCache(prisma, {
userId,
now,
dayTrimp,
refPersonal: refPersonalToPersist ?? STRAIN_TRIMP_REFERENCE,
anchor,
trainingDays,
});
return { outcome: "stored", score, reason, anchor };
}