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:
Marc-André Bombeck committed 2026-08-07 14:24:13 +02:00
1 parent 4563bce5b3
commit dfd9b4679b
8 files changed
+339 -2

No files matched your search

+87
View File
@@ -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 };
+14
View File
@@ -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