diff --git a/src/lib/ai/coach/snapshot-freshness.ts b/src/lib/ai/coach/snapshot-freshness.ts index 670d365be..7f27fe8e7 100644 --- a/src/lib/ai/coach/snapshot-freshness.ts +++ b/src/lib/ai/coach/snapshot-freshness.ts @@ -16,6 +16,14 @@ * directly, so the answer arrives with the data instead of depending on the * reader working it out. * + * The same pass reconciles each block's coarse (MONTH / YEAR) timeline, served + * from the rollup tier, against the all-time extremes read live beside it. The + * read-swap falls back to live SQL only when a band is EMPTY, so a bucket whose + * rows were hard-deleted with no invalidation keeps being served as current — + * and the block then states two different things about the same record with + * nothing saying which is right. A bucket mean cannot lie outside its own rows' + * extremes, so when it does the band is dropped rather than narrated. + * * Pure and in-place — the snapshot is a plain record by the time it gets here. */ import { @@ -35,6 +43,12 @@ export interface SnapshotAsOf { * the day they were taken on, and must be stated with that day. */ currentForTodayClaims: boolean; + /** + * Set when the block's coarse (MONTH / YEAR) timeline was dropped because it + * could not be reconciled with the live record beside it. Present only in + * that case, so its absence is the normal state and costs no prompt budget. + */ + coarseHistoryWithheld?: true; } function readNewestDaysAgo(block: unknown): number | null { @@ -61,27 +75,117 @@ export function asOfFromDaysAgo(daysAgo: number): SnapshotAsOf { }; } +/** Coarse-band bucket rows are `[bucketStart, mean, min, max]`. */ +type CoarseBucket = [string, number, number, number]; + +/** Rounding headroom, so a float artefact is never read as a contradiction. */ +const RECONCILE_EPSILON = 1e-6; + +function readAllTimeExtremes( + block: unknown, +): { min: number; max: 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 min = (aggregate as { allTimeMin?: unknown }).allTimeMin; + const max = (aggregate as { allTimeMax?: unknown }).allTimeMax; + if (typeof min !== "number" || !Number.isFinite(min)) return null; + if (typeof max !== "number" || !Number.isFinite(max)) return null; + return { min, max }; +} + +function readCoarse(block: unknown): Record | null { + if (typeof block !== "object" || block === null) return null; + const timeline = (block as { timeline?: unknown }).timeline; + if (typeof timeline !== "object" || timeline === null) return null; + const coarse = (timeline as { coarse?: unknown }).coarse; + if (typeof coarse !== "object" || coarse === null) return null; + return coarse as Record; +} + +function bucketsOf(coarse: Record, band: string): number[] { + const rows = coarse[band]; + if (!Array.isArray(rows)) return []; + return (rows as CoarseBucket[]) + .map((row) => (Array.isArray(row) ? row[1] : Number.NaN)) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); +} + +/** + * Decide whether a block's coarse timeline can be reconciled with the live + * record it sits beside. + * + * The coarse MONTH / YEAR bands are served from the rollup tier; the aggregate's + * all-time extremes are read live. A bucket mean is an average of real rows, so + * it MUST lie within the all-time min/max of those same rows. When it does not, + * the rollup is describing readings the live read cannot see — the bucket + * outlived the rows it was folded from. + * + * This is an invariant, not a heuristic: no arrangement of existing rows can + * average to a value outside their own extremes. + */ +function coarseContradictsRecord(block: unknown): boolean { + const extremes = readAllTimeExtremes(block); + if (extremes === null) return false; + const coarse = readCoarse(block); + if (coarse === null) return false; + const means = [ + ...bucketsOf(coarse, "monthly"), + ...bucketsOf(coarse, "yearly"), + ]; + if (means.length === 0) return false; + return means.some( + (mean) => + mean < extremes.min - RECONCILE_EPSILON || + mean > extremes.max + RECONCILE_EPSILON, + ); +} + /** * 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. + * Also drops a coarse timeline that contradicts the live record beside it. The + * rollup tier's read-swap falls back to live SQL only when a band is EMPTY, so + * a bucket that is merely WRONG — the rows behind it deleted, and no + * invalidation fired — is served as though it were current. The block then + * carried two irreconcilable accounts of the same record (a coarse tail saying + * one thing, all-time extremes saying another) with nothing marking which to + * believe, under an `asOf` stamped from raw reading age that knows nothing + * about rollup age. Narrating a ghost is worse than narrating less, so the + * disputed tail goes and `coarseHistoryWithheld` says it went. + * + * `currentForTodayClaims` stays keyed to raw recency, which is what it means: + * the freshest READING is still as fresh as it was, and suppressing it would + * silence a true present-tense statement to punish a stale history band. + * + * Returns the metric keys stamped stale and the keys whose coarse tail was + * withheld, so the caller can annotate both: a briefing narrated off a week-old + * series is worth seeing in the wide event, and so is a rollup that has drifted + * away from the rows underneath it. */ -export function annotateSnapshotFreshness( - snapshot: Record, -): string[] { +export function annotateSnapshotFreshness(snapshot: Record): { + stale: string[]; + coarseWithheld: string[]; +} { const stale: string[] = []; + const coarseWithheld: string[] = []; for (const [key, block] of Object.entries(snapshot)) { const daysAgo = readNewestDaysAgo(block); if (daysAgo === null) continue; const asOf = asOfFromDaysAgo(daysAgo); + if (coarseContradictsRecord(block)) { + const timeline = (block as { timeline?: Record }) + .timeline; + if (timeline) delete timeline.coarse; + asOf.coarseHistoryWithheld = true; + coarseWithheld.push(key); + } (block as Record).asOf = asOf; if (!asOf.currentForTodayClaims) stale.push(key); } - return stale; + return { stale, coarseWithheld }; } export { TODAY_CLAIM_MAX_AGE_DAYS }; diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 7209f983b..3bbb92afc 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -1238,11 +1238,20 @@ async function buildCoachSnapshotImpl( // 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) { + const freshness = annotateSnapshotFreshness(compactSnapshot); + if (freshness.stale.length > 0) { annotate({ action: { name: "coach.snapshot.stale_blocks" }, - meta: { blocks: staleBlocks.sort() }, + meta: { blocks: freshness.stale.sort() }, + }); + } + // A rollup band that no longer reconciles with the rows underneath it is an + // operator-visible defect, not just a narration problem — the bucket is wrong + // on disk and stays wrong until something recomputes it. + if (freshness.coarseWithheld.length > 0) { + annotate({ + action: { name: "coach.snapshot.coarse_withheld" }, + meta: { blocks: freshness.coarseWithheld.sort() }, }); } diff --git a/src/lib/insights/__tests__/measurement-freshness.test.ts b/src/lib/insights/__tests__/measurement-freshness.test.ts index 4d77ad0d8..91e74c1ba 100644 --- a/src/lib/insights/__tests__/measurement-freshness.test.ts +++ b/src/lib/insights/__tests__/measurement-freshness.test.ts @@ -119,9 +119,11 @@ describe("the Coach snapshot stamp", () => { scope: { sources: ["pulse", "weight"] }, }; - const stale = annotateSnapshotFreshness(snapshot); + const { stale, coarseWithheld } = annotateSnapshotFreshness(snapshot); expect(stale).toEqual(["pulse"]); + // Neither block carries a coarse band, so nothing is disputed. + expect(coarseWithheld).toEqual([]); expect((snapshot.pulse as { asOf: unknown }).asOf).toEqual({ daysAgo: 5, isToday: false, @@ -135,4 +137,63 @@ describe("the Coach snapshot stamp", () => { expect(snapshot.memory).toEqual({ headline: "steady month" }); expect(snapshot.scope).toEqual({ sources: ["pulse", "weight"] }); }); + + /** + * A coarse bucket mean is an average of real rows, so it cannot sit outside + * those rows' own all-time extremes. When it does, the rollup band is + * describing readings the live read cannot see and must not be narrated. + */ + it("drops a coarse band whose bucket mean the live extremes cannot account for", () => { + const snapshot: Record = { + weight: { + aggregate: { + allTimeMin: 80, + allTimeMax: 80, + coverage: { count: 6, newestDaysAgo: 0 }, + }, + timeline: { + recent: [], + coarse: { monthly: [["2026-01-01", 95, 95, 95]], yearly: [] }, + }, + }, + }; + + const { coarseWithheld } = annotateSnapshotFreshness(snapshot); + + expect(coarseWithheld).toEqual(["weight"]); + const weight = snapshot.weight as { + timeline: { coarse?: unknown }; + asOf: { coarseHistoryWithheld?: true; currentForTodayClaims: boolean }; + }; + expect(weight.timeline.coarse).toBeUndefined(); + expect(weight.asOf.coarseHistoryWithheld).toBe(true); + // Raw recency is untouched — the surviving readings are still from today. + expect(weight.asOf.currentForTodayClaims).toBe(true); + }); + + it("keeps a coarse band that reconciles with the live extremes", () => { + const snapshot: Record = { + weight: { + aggregate: { + allTimeMin: 78, + allTimeMax: 96, + coverage: { count: 40, newestDaysAgo: 0 }, + }, + timeline: { + recent: [], + coarse: { monthly: [["2026-01-01", 95, 94, 96]], yearly: [] }, + }, + }, + }; + + const { coarseWithheld } = annotateSnapshotFreshness(snapshot); + + expect(coarseWithheld).toEqual([]); + const weight = snapshot.weight as { + timeline: { coarse?: unknown }; + asOf: { coarseHistoryWithheld?: true }; + }; + expect(weight.timeline.coarse).toBeDefined(); + expect(weight.asOf.coarseHistoryWithheld).toBeUndefined(); + }); }); diff --git a/tests/integration/coach-stale-rollup-tail.test.ts b/tests/integration/coach-stale-rollup-tail.test.ts index c49609ee1..60a579b4b 100644 --- a/tests/integration/coach-stale-rollup-tail.test.ts +++ b/tests/integration/coach-stale-rollup-tail.test.ts @@ -1,30 +1,36 @@ /** - * CHARACTERISATION — a rollup bucket outlives the rows it was folded from, and - * the Coach is shown the result without any marker saying so. + * REGRESSION — a rollup bucket must not narrate rows that no longer exist. * * The read-swap in `tiered-context.ts` falls back to live SQL only when a band - * is EMPTY (`if (buckets.length > 0) return buckets;`), so a bucket that is - * merely wrong is served as though it were current. The one staleness repair, - * `ensureUserRollupsFresh`, recomputes the DAY tier over the trailing 90 days - * only, and keys off `Measurement.updatedAt` — which a HARD delete does not - * bump. `src/lib/whoop/sync-body.ts` performs exactly such a hard - * `measurement.deleteMany` on a WEIGHT row with no rollup invalidation, so this - * reproduces that shape against a MONTH-band bucket. + * is EMPTY, so a bucket that is merely WRONG is served as though it were + * current. The one staleness repair, `ensureUserRollupsFresh`, recomputes the + * DAY tier over the trailing 90 days only, and keys off `Measurement.updatedAt` + * — which a HARD delete does not bump. `src/lib/whoop/sync-body.ts` performs + * exactly such a hard `measurement.deleteMany` on a WEIGHT row with no rollup + * invalidation, so this reproduces that shape against a MONTH-band bucket. * - * What this pins, in the snapshot the Coach narrates from: - * - `weight.timeline.coarse.monthly` still carries the deleted value; - * - `weight.aggregate.allTimeMin/Max` correctly carry only the surviving - * value, so the snapshot contradicts itself and nothing flags which half - * is right; - * - `weight.asOf.currentForTodayClaims` is true, because `asOf` is derived - * from the freshest RAW reading and knows nothing about rollup age. + * Before the fix the Coach snapshot carried two irreconcilable accounts of the + * same record with nothing marking which to believe: `coarse.monthly` reported + * the deleted value while `aggregate.allTimeMin/Max` beside it reported only + * the surviving one, under an `asOf` stamped current because it is derived from + * raw reading age and knows nothing about rollup age. * - * This asserts CURRENT behaviour. Closing the gap — comparing bucket - * `computedAt` against the type's in-window `MAX(measurement.updatedAt)`, or - * invalidating on the hard delete — will redden it, and the assertions should - * then be flipped to the honest expectation. - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; + * `annotateSnapshotFreshness` now reconciles the two where they are assembled. + * A bucket mean is an average of real rows, so it must lie within those rows' + * all-time extremes; a mean outside them proves the bucket outlived its rows. + * The disputed coarse tail is dropped and `asOf.coarseHistoryWithheld` records + * that it was — narrating a ghost is worse than narrating less. + * + * `currentForTodayClaims` deliberately stays true here. It means "the freshest + * READING is recent enough for present tense", and it is: the surviving rows + * are from today. Suppressing it would silence a true statement to punish a + * stale history band. + * + * The row on disk is still wrong. The deeper fix is to make the tier notice — + * either compare a bucket's `computedAt` against the type's in-window + * MAX(`updatedAt`), or invalidate on the hard-delete path — and that is a + * larger change than this honesty repair. + */ import { beforeEach, describe, expect, it, vi } from "vitest"; process.env.ENCRYPTION_KEY ??= "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -49,7 +55,7 @@ const LIVE_KG = 80; interface WeightBlock { aggregate: { allTimeMin: number; allTimeMax: number }; timeline: { coarse?: { monthly: Array<[string, number, number, number]> } }; - asOf: { currentForTodayClaims: boolean }; + asOf: { currentForTodayClaims: boolean; coarseHistoryWithheld?: true }; } describe("coach snapshot — a rollup bucket that outlived its rows", () => { @@ -57,7 +63,7 @@ describe("coach snapshot — a rollup bucket that outlived its rows", () => { await truncateAllTables(prisma); }); - it("serves the deleted value in the coarse tail while claiming the block is current", async () => { + it("withholds the coarse tail whose buckets the live record cannot account for", async () => { const user = await prisma.user.create({ data: { email: "stale-rollup@example.test", @@ -117,17 +123,18 @@ describe("coach snapshot — a rollup bucket that outlived its rows", () => { const weight = (snap.sections as Record) .weight as WeightBlock; - // The coarse tail the Coach narrates still reports the deleted value. - const monthly = weight.timeline.coarse?.monthly ?? []; - expect(monthly.length).toBeGreaterThan(0); - expect(monthly.every((row) => row[1] === GHOST_KG)).toBe(true); - expect(snap.snapshotJson).toContain(String(GHOST_KG)); - - // …while the all-time extremes, read live, know only the surviving value. + // The all-time extremes, read live, know only the surviving value. expect(weight.aggregate.allTimeMin).toBe(LIVE_KG); expect(weight.aggregate.allTimeMax).toBe(LIVE_KG); - // …and the block is stamped current, which licenses present-tense prose. + // The coarse tail could not be reconciled with them, so it is gone… + expect(weight.timeline.coarse).toBeUndefined(); + expect(weight.asOf.coarseHistoryWithheld).toBe(true); + + // …and the deleted value reaches no part of what the model is shown. + expect(snap.snapshotJson).not.toContain(String(GHOST_KG)); + + // The freshest reading is still from today, so present tense is still fair. expect(weight.asOf.currentForTodayClaims).toBe(true); }); });