diff --git a/src/components/insights/__tests__/coincident-deviation-card.test.tsx b/src/components/insights/__tests__/coincident-deviation-card.test.tsx index 17ed6ec87..7e123c875 100644 --- a/src/components/insights/__tests__/coincident-deviation-card.test.tsx +++ b/src/components/insights/__tests__/coincident-deviation-card.test.tsx @@ -37,6 +37,7 @@ function deviation( high: 62, outside, direction: outside ? "above" : "in", + daysAgo: 0, }; } diff --git a/src/lib/ai/coach/snapshot-freshness.ts b/src/lib/ai/coach/snapshot-freshness.ts new file mode 100644 index 000000000..670d365be --- /dev/null +++ b/src/lib/ai/coach/snapshot-freshness.ts @@ -0,0 +1,87 @@ +/** + * Stamp every metric block in the Coach snapshot with the age of its freshest + * reading. + * + * The narrated surfaces — the dashboard hero line, the daily briefing, the + * Coach reply — are written from this snapshot. Each metric block carried its + * aggregates and a dated timeline, and nothing else: no statement of when the + * series actually ends. Reading it, the newest row looks like now, and the + * prose came out as "your pulse is well above your 30-day average today" on a + * day whose pulse had not been measured for the better part of a week. + * + * The age was already in the block, buried one level down in the coverage + * object as `newestDaysAgo`, where it read as a statistics footnote rather + * than as the fact that governs the tense of every sentence about the metric. + * This lifts it to the block's own surface and states the consequence + * directly, so the answer arrives with the data instead of depending on the + * reader working it out. + * + * Pure and in-place — the snapshot is a plain record by the time it gets here. + */ +import { + TODAY_CLAIM_MAX_AGE_DAYS, + isCurrentForTodayClaim, +} from "@/lib/insights/measurement-freshness"; + +/** When a metric block's series actually ends, and what may be said about it. */ +export interface SnapshotAsOf { + /** Whole days between the freshest reading and today. `0` is today. */ + daysAgo: number; + /** True when the freshest reading is from today. */ + isToday: boolean; + /** + * False when the freshest reading is too old to back a present-tense + * sentence. The block's numbers are still true — they are just true about + * the day they were taken on, and must be stated with that day. + */ + currentForTodayClaims: boolean; +} + +function readNewestDaysAgo(block: unknown): number | null { + if (typeof block !== "object" || block === null) return null; + const aggregate = (block as { aggregate?: unknown }).aggregate; + if (typeof aggregate !== "object" || aggregate === null) return null; + const coverage = (aggregate as { coverage?: unknown }).coverage; + if (typeof coverage !== "object" || coverage === null) return null; + const newestDaysAgo = (coverage as { newestDaysAgo?: unknown }).newestDaysAgo; + return typeof newestDaysAgo === "number" && Number.isFinite(newestDaysAgo) + ? newestDaysAgo + : null; +} + +/** + * Build the `asOf` stamp for one reading age. Exported so the rule is testable + * without assembling a snapshot. + */ +export function asOfFromDaysAgo(daysAgo: number): SnapshotAsOf { + return { + daysAgo, + isToday: daysAgo === 0, + currentForTodayClaims: isCurrentForTodayClaim(daysAgo), + }; +} + +/** + * Attach `asOf` to every snapshot block whose aggregate reports a freshest + * reading. Blocks with no coverage (narrative memory, plans, the reference + * grounding table) are untouched — they carry no measurement to date. + * + * Returns the metric keys that were stamped stale, so the caller can annotate + * them: a briefing narrated off a week-old series is worth seeing in the wide + * event, not just in the reader's confusion. + */ +export function annotateSnapshotFreshness( + snapshot: Record, +): string[] { + const stale: string[] = []; + for (const [key, block] of Object.entries(snapshot)) { + const daysAgo = readNewestDaysAgo(block); + if (daysAgo === null) continue; + const asOf = asOfFromDaysAgo(daysAgo); + (block as Record).asOf = asOf; + if (!asOf.currentForTodayClaims) stale.push(key); + } + return stale; +} + +export { TODAY_CLAIM_MAX_AGE_DAYS }; diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index ca34a5df1..1b4473dfc 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -29,6 +29,7 @@ import type { SleepStageRow } from "@/lib/analytics/sleep-night"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { annotate } from "@/lib/logging/context"; import { memoizePerRequest } from "@/lib/request-cache"; +import { annotateSnapshotFreshness } from "./snapshot-freshness"; import { buildGlp1SnapshotBlock } from "./glp1-snapshot"; import { buildDerivedSnapshotBlock } from "./derived-snapshot"; import { buildCorrelationsSnapshotBlock } from "./correlations-snapshot"; @@ -1209,6 +1210,19 @@ async function buildCoachSnapshotImpl( // side of the prompt. const compactSnapshot = compactSections(snapshot); + // Date the end of every metric series before anything narrates it. Without + // this each block reads as if its newest number were taken just now, and the + // hero line said "today" about a metric last measured five days earlier. The + // stamp sits on the block itself so the age travels with the numbers into + // every surface the snapshot feeds — hero, briefing, Coach reply, tools. + const staleBlocks = annotateSnapshotFreshness(compactSnapshot); + if (staleBlocks.length > 0) { + annotate({ + action: { name: "coach.snapshot.stale_blocks" }, + meta: { blocks: staleBlocks.sort() }, + }); + } + // v1.7.0 — assembled-snapshot soft cap. Enabling every cluster at a // long window can balloon the prompt; degrade progressively by // reverse cluster priority until the serialised size fits diff --git a/src/lib/insights/__tests__/health-status.test.ts b/src/lib/insights/__tests__/health-status.test.ts index f0750a2bc..a932d476b 100644 --- a/src/lib/insights/__tests__/health-status.test.ts +++ b/src/lib/insights/__tests__/health-status.test.ts @@ -7,6 +7,7 @@ import type { ChangepointSignal } from "@/lib/insights/derived/changepoint"; function vital( outside: boolean, direction: VitalDeviation["direction"], + daysAgo = 0, ): VitalDeviation { return { type: "RESTING_HEART_RATE", @@ -16,6 +17,7 @@ function vital( high: 65, outside, direction, + daysAgo, }; } diff --git a/src/lib/insights/__tests__/measurement-freshness.test.ts b/src/lib/insights/__tests__/measurement-freshness.test.ts new file mode 100644 index 000000000..4d77ad0d8 --- /dev/null +++ b/src/lib/insights/__tests__/measurement-freshness.test.ts @@ -0,0 +1,138 @@ +/** + * A reading from five days ago must not produce a sentence about today. + * + * Two surfaces said it out loud — the day-signals card ("one of your vitals is + * outside its usual range today") and the baseline-drift card ("your pulse is + * above your usual range") — and the narrated hero line said it in prose. One + * class: the freshest stored reading reached each of them with no notion of + * when it was taken, so every one of them assumed now. + * + * These cover the shared rule and the two deterministic consumers. The + * narrated surfaces are covered by the snapshot stamp beside them. + */ +import { describe, expect, it } from "vitest"; + +import { + TODAY_CLAIM_MAX_AGE_DAYS, + dayKeyAgeInDays, + isCurrentForTodayClaim, +} from "../measurement-freshness"; +import { summariseHealthStatus } from "../health-status"; +import { classifyDeviation } from "../derived/coincident-deviation"; +import { + annotateSnapshotFreshness, + asOfFromDaysAgo, +} from "@/lib/ai/coach/snapshot-freshness"; + +describe("the today-claim window", () => { + it("admits today and yesterday and refuses anything older", () => { + expect(isCurrentForTodayClaim(0)).toBe(true); + expect(isCurrentForTodayClaim(TODAY_CLAIM_MAX_AGE_DAYS)).toBe(true); + expect(isCurrentForTodayClaim(TODAY_CLAIM_MAX_AGE_DAYS + 1)).toBe(false); + expect(isCurrentForTodayClaim(5)).toBe(false); + }); + + it("treats an unknown age as not current", () => { + expect(isCurrentForTodayClaim(null)).toBe(false); + expect(isCurrentForTodayClaim(undefined)).toBe(false); + expect(isCurrentForTodayClaim(Number.NaN)).toBe(false); + expect(isCurrentForTodayClaim(Number.POSITIVE_INFINITY)).toBe(false); + expect(isCurrentForTodayClaim(-1)).toBe(false); + }); + + it("counts whole calendar days between two local day keys", () => { + expect(dayKeyAgeInDays("2026-08-07", "2026-08-07")).toBe(0); + expect(dayKeyAgeInDays("2026-08-06", "2026-08-07")).toBe(1); + expect(dayKeyAgeInDays("2026-08-02", "2026-08-07")).toBe(5); + // Across a month end and across a DST change in the reader's zone. + expect(dayKeyAgeInDays("2026-07-31", "2026-08-07")).toBe(7); + expect(dayKeyAgeInDays("2026-10-24", "2026-10-26")).toBe(2); + expect(dayKeyAgeInDays("not-a-day", "2026-08-07")).toBeNull(); + }); +}); + +describe("the baseline-drift card's deviations", () => { + /** A pulse well above its band, taken `daysAgo` days ago. */ + const raisedPulse = (daysAgo: number) => + classifyDeviation("PULSE", 96, 58, 74, 66, daysAgo); + + it("carries a deviation measured today", () => { + const summary = summariseHealthStatus([raisedPulse(0)], []); + + expect(summary.present).toBe(true); + expect(summary.deviations.map((d) => d.type)).toEqual(["PULSE"]); + }); + + it("says nothing about a vital last measured five days ago", () => { + const summary = summariseHealthStatus([raisedPulse(5)], []); + + expect(summary.deviations).toEqual([]); + expect(summary.present).toBe(false); + }); + + it("keeps a dated level shift, which was never a today claim", () => { + const summary = summariseHealthStatus( + [raisedPulse(5)], + [ + { + metric: "RESTING_HEART_RATE", + breakDate: "2026-07-20", + beforeMean: 54, + afterMean: 61, + direction: "up", + magnitude: 2.1, + }, + ], + ); + + expect(summary.deviations).toEqual([]); + expect(summary.shifts).toHaveLength(1); + expect(summary.present).toBe(true); + }); +}); + +describe("the Coach snapshot stamp", () => { + it("states the age and the consequence on the block itself", () => { + expect(asOfFromDaysAgo(0)).toEqual({ + daysAgo: 0, + isToday: true, + currentForTodayClaims: true, + }); + expect(asOfFromDaysAgo(5)).toEqual({ + daysAgo: 5, + isToday: false, + currentForTodayClaims: false, + }); + }); + + it("stamps every metric block and names the stale ones", () => { + const snapshot: Record = { + pulse: { + aggregate: { avg30: 66, coverage: { count: 40, newestDaysAgo: 5 } }, + timeline: { recent: [] }, + }, + weight: { + aggregate: { latest: 81.4, coverage: { count: 30, newestDaysAgo: 0 } }, + }, + // No measurement to date — untouched. + memory: { headline: "steady month" }, + scope: { sources: ["pulse", "weight"] }, + }; + + const stale = annotateSnapshotFreshness(snapshot); + + expect(stale).toEqual(["pulse"]); + expect((snapshot.pulse as { asOf: unknown }).asOf).toEqual({ + daysAgo: 5, + isToday: false, + currentForTodayClaims: false, + }); + expect((snapshot.weight as { asOf: unknown }).asOf).toEqual({ + daysAgo: 0, + isToday: true, + currentForTodayClaims: true, + }); + expect(snapshot.memory).toEqual({ headline: "steady month" }); + expect(snapshot.scope).toEqual({ sources: ["pulse", "weight"] }); + }); +}); diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts index 6217971ab..152c4401f 100644 --- a/src/lib/insights/derived/coincident-deviation.ts +++ b/src/lib/insights/derived/coincident-deviation.ts @@ -41,6 +41,10 @@ import { VITALS_BASELINE_TYPES } from "./registry"; import type { Derived, DerivedProvenanceSource } from "./types"; import { resolveRestMode } from "@/lib/illness/rest-mode"; import { isPlausibleMetricValue } from "@/lib/measurements/value-domain"; +import { + dayKeyAgeInDays, + isCurrentForTodayClaim, +} from "@/lib/insights/measurement-freshness"; import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -70,6 +74,16 @@ export interface VitalDeviation { outside: boolean; /** "above" / "below" the band, or "in" when inside. */ direction: "above" | "below" | "in"; + /** + * Whole days between the reading this standing was computed from and the + * caller's own local today. `0` is today, `1` yesterday. + * + * The value used to be presented with no age at all, so a vital last taken + * five days ago was narrated as "outside its range today" on a day that + * carried no reading. Consumers making a present-tense claim check this; + * `contributing` below already does. + */ + daysAgo: number; } export interface CoincidentDeviationValue { @@ -102,6 +116,7 @@ export function classifyDeviation( low: number, high: number, center: number, + daysAgo: number = 0, ): VitalDeviation { const above = value > high; const below = value < low; @@ -113,6 +128,7 @@ export function classifyDeviation( high, outside: above || below, direction: above ? "above" : below ? "below" : "in", + daysAgo, }; } @@ -210,6 +226,9 @@ export async function computeCoincidentDeviation( let latestDay = ""; let anyDaySource = false; let maxHistoryDays = 0; + // The reader's own calendar day — the anchor every vital's age is measured + // against, so "today" means the day the reader is having. + const todayKey = userDayKey(now, tz); // Fan the baseline engine across the supported vitals. Each baseline read // shares the one coverage probe (no per-vital re-probe). @@ -235,6 +254,7 @@ export async function computeCoincidentDeviation( baseline.value.low, baseline.value.high, baseline.value.center, + dayKeyAgeInDays(latest.day, todayKey) ?? Number.POSITIVE_INFINITY, ), ); } @@ -263,7 +283,16 @@ export async function computeCoincidentDeviation( }); } - const contributing = vitals.filter((v) => v.outside); + // Everything this flag says is about TODAY — the card is literally called + // "signals of the day" and every line it renders is present tense. A vital + // whose freshest reading is older than that cannot contribute to it: the + // reading describes the day it was taken on, and saying otherwise tells the + // reader something about a day on which nothing was measured. Stale vitals + // stay in `vitals` with their age, so the anatomy view still lists them and + // nothing silently disappears. + const contributing = vitals.filter( + (v) => v.outside && isCurrentForTodayClaim(v.daysAgo), + ); const fired = contributing.length >= COINCIDENT_FIRE_THRESHOLD; // v1.18.1 P4 — when the flag fired, reframe it as illness-explained if an diff --git a/src/lib/insights/health-status.ts b/src/lib/insights/health-status.ts index 419f7dbc1..a1dc31092 100644 --- a/src/lib/insights/health-status.ts +++ b/src/lib/insights/health-status.ts @@ -17,6 +17,7 @@ import type { MeasurementType } from "@/generated/prisma/client"; import type { VitalDeviation } from "@/lib/insights/derived/coincident-deviation"; import type { ChangepointSignal } from "@/lib/insights/derived/changepoint"; +import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness"; /** One vital sitting outside its personal band today. */ export interface HealthStatusDeviation { @@ -58,7 +59,15 @@ export function summariseHealthStatus( shifts: readonly ChangepointSignal[], ): HealthStatusSummary { const deviations: HealthStatusDeviation[] = vitals - .filter((v) => v.outside && v.direction !== "in") + // Present tense, so present readings only. "Your pulse is above your usual + // range" describes now; a reading from last week describes last week, and + // the card carried no date to say which. A vital whose freshest reading is + // outside the claim window is left out rather than dressed as current — + // the changepoint shifts below are dated by construction and unaffected. + .filter( + (v) => + v.outside && v.direction !== "in" && isCurrentForTodayClaim(v.daysAgo), + ) .map((v) => ({ type: v.type, value: v.value, diff --git a/src/lib/insights/measurement-freshness.ts b/src/lib/insights/measurement-freshness.ts new file mode 100644 index 000000000..54068b1ba --- /dev/null +++ b/src/lib/insights/measurement-freshness.ts @@ -0,0 +1,57 @@ +/** + * How old a reading may be before "today" stops being true about it. + * + * Several surfaces make present-tense statements out of the freshest stored + * reading: the day-signals card ("one of your vitals is outside its usual + * range today"), the baseline-drift card ("your pulse is above your usual + * range"), and the narrated hero line on the dashboard. Every one of them was + * handed "the latest value" with no notion of when it was taken, so a reading + * from last week was narrated as this morning's — the reader is told something + * about a day on which nothing was measured at all. + * + * The answer belongs beside the data, not beside each sentence. A reading is + * material for a claim about today when it comes from today or from yesterday; + * anything older is history, and history is stated with its date. + * + * The window is deliberately one day rather than zero. A vital taken at 23:40 + * and read at 00:10 is the same reading it was thirty minutes earlier, and a + * morning weigh-in narrated that evening is still current. Two days is not: + * by then a day has passed with no reading in it, and the sentence would be + * describing a gap. + */ + +/** Whole days of age a reading may carry and still support a present claim. */ +export const TODAY_CLAIM_MAX_AGE_DAYS = 1; + +/** + * Whether a reading of this age may back a present-tense statement. + * + * An absent, negative or non-finite age answers `false`. A missing age is not + * a fresh one, and it is exactly the case that produced the wrong sentence: + * nothing knew how old the value was, so everything assumed it was new. + */ +export function isCurrentForTodayClaim( + daysAgo: number | null | undefined, +): boolean { + return ( + typeof daysAgo === "number" && + Number.isFinite(daysAgo) && + daysAgo >= 0 && + daysAgo <= TODAY_CLAIM_MAX_AGE_DAYS + ); +} + +/** + * Whole days between two `YYYY-MM-DD` local-day keys (`then` before `today`). + * + * Both keys are already resolved in the reader's own timezone by the caller, + * so the arithmetic is plain calendar subtraction — parsing them as UTC + * midnights keeps it free of any second zone conversion. A malformed key + * answers `null` rather than a fabricated distance. + */ +export function dayKeyAgeInDays(then: string, today: string): number | null { + const a = Date.parse(`${then}T00:00:00.000Z`); + const b = Date.parse(`${today}T00:00:00.000Z`); + if (!Number.isFinite(a) || !Number.isFinite(b)) return null; + return Math.round((b - a) / 86_400_000); +}