mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(insights): stop making claims about today out of older readings
The hero line said a pulse was well above its 30-day average today. Nothing had measured a pulse for five days. The day-signals card flagged the same reading, and the baseline-drift card said the value was above the usual range, present tense, on a day that held no reading at all. One class, and it sits in the data preparation rather than in any of the sentences. Each surface was handed "the freshest stored value" with no notion of when it was taken, so each of them assumed now. The age was reachable — the aggregates carry `newestDaysAgo` inside their coverage object — but it read as a statistics footnote rather than as the fact that governs the tense of every sentence about the metric, and none of the three consulted it. A reading is material for a claim about today when it comes from today or from yesterday. One day rather than zero: a vital taken at 23:40 and read at 00:10 is the same reading it was half an hour earlier. Two is not, because by then a whole day has passed with nothing in it and the sentence would be describing a gap. The rule now lives beside the data. The vital deviations carry the age of the reading they were computed from, and the two deterministic consumers apply it: the day-signals flag counts only current vitals toward its count, and the baseline-drift card surfaces only current deviations. Stale vitals stay in the list with their age, so the anatomy view still shows them and nothing silently disappears; the dated level shifts were never today claims and are untouched. For the narrated surfaces the same fact is stamped onto every metric block of the Coach snapshot before anything reads it, so the age travels with the numbers into the hero, the briefing, the Coach reply and the retrieval tools instead of each of them guessing. A block narrated off a stale series also lands in the wide event, which is where it should have been visible all along.
This commit is contained in:
1 parent
4563bce5b3
commit
dfd9b4679b
8 files changed
+339
-2
No files matched your search
@@ -37,6 +37,7 @@ function deviation(
|
||||
high: 62,
|
||||
outside,
|
||||
direction: outside ? "above" : "in",
|
||||
daysAgo: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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, unknown>,
|
||||
): 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<string, unknown>).asOf = asOf;
|
||||
if (!asOf.currentForTodayClaims) stale.push(key);
|
||||
}
|
||||
return stale;
|
||||
}
|
||||
|
||||
export { TODAY_CLAIM_MAX_AGE_DAYS };
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
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"] });
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user