From f1ca0e28eb2073b9f0615edf9773ca0b936165e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 19:28:25 +0200 Subject: [PATCH] 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. --- messages/en.json | 4 +- .../migration.sql | 48 +++ prisma/schema.prisma | 49 +++ .../insights/__tests__/strain-score.test.ts | 230 ++++++++++++ src/lib/insights/strain-score.ts | 338 +++++++++++++++++- 5 files changed, 656 insertions(+), 13 deletions(-) create mode 100644 prisma/migrations/0109_v1103_strain_trimp_cache/migration.sql diff --git a/messages/en.json b/messages/en.json index 249114c6c..740befb9c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/prisma/migrations/0109_v1103_strain_trimp_cache/migration.sql b/prisma/migrations/0109_v1103_strain_trimp_cache/migration.sql new file mode 100644 index 000000000..e7ca80e23 --- /dev/null +++ b/prisma/migrations/0109_v1103_strain_trimp_cache/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f47c76dd0..14a888a8e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/lib/insights/__tests__/strain-score.test.ts b/src/lib/insights/__tests__/strain-score.test.ts index 4be24fe01..16e3a3546 100644 --- a/src/lib/insights/__tests__/strain-score.test.ts +++ b/src/lib/insights/__tests__/strain-score.test.ts @@ -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[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); + }); +}); diff --git a/src/lib/insights/strain-score.ts b/src/lib/insights/strain-score.ts index 3bc69f476..e924ad211 100644 --- a/src/lib/insights/strain-score.ts +++ b/src/lib/insights/strain-score.ts @@ -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 { 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 { + 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 { + 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 { 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 }; }