From 756c3fb8454d860d21a05cadb7b920d56a52f7c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 21:54:46 +0200 Subject: [PATCH] fix(coach): condense each series block in its own fields The condensing step read every block as a value series: daily rows under value, weekly rows under mean and count. Sleep nights carry minutes, so a condensed sleep summary ended on the last weekly mean weeks before the newest night; adherence rates fold by doses, not readings; glucose keeps a series per measurement context; workouts are a sessions list. Condensing now knows each block's shape: sleep reads minutes, adherence folds rate weighted by total doses, glucose condenses every context, and a workouts block keeps its newest sessions beside the whole-window rollup. --- .../__tests__/series-long-window.test.ts | 111 ++++++- src/lib/ai/coach/series-condense.ts | 286 ++++++++++++------ src/lib/ai/coach/snapshot.ts | 6 +- 3 files changed, 302 insertions(+), 101 deletions(-) diff --git a/src/lib/ai/coach/__tests__/series-long-window.test.ts b/src/lib/ai/coach/__tests__/series-long-window.test.ts index f05151f4d..a6e93f0ab 100644 --- a/src/lib/ai/coach/__tests__/series-long-window.test.ts +++ b/src/lib/ai/coach/__tests__/series-long-window.test.ts @@ -14,6 +14,7 @@ import type { CoachSnapshotResult } from "@/lib/ai/coach/snapshot"; import { KEEP_DAILY_ROWS, KEEP_MONTHLY_ROWS, + KEEP_SESSIONS, condenseSeriesBlock, isoWeekMonth, summarisePoints, @@ -118,14 +119,14 @@ describe("series-condense", () => { unit: "kg", timeline: { recent: recent(14), weekly: weeks(150) }, }; - expect(condenseSeriesBlock(block, 1)).toBe(true); + expect(condenseSeriesBlock(block, 1, "fatMass")).toBe(true); expect(block.timeline.recent).toHaveLength(KEEP_DAILY_ROWS); expect(block.timeline.recent.at(-1)?.value).toBe(43); - expect(condenseSeriesBlock(block, 2)).toBe(true); + expect(condenseSeriesBlock(block, 2, "fatMass")).toBe(true); expect(block.timeline).not.toHaveProperty("weekly"); const monthly = (block.timeline as { monthly?: unknown[] }).monthly; expect(monthly!.length).toBeGreaterThan(30); - expect(condenseSeriesBlock(block, 3)).toBe(true); + expect(condenseSeriesBlock(block, 3, "fatMass")).toBe(true); expect((block.timeline as { monthly?: unknown[] }).monthly).toHaveLength( KEEP_MONTHLY_ROWS, ); @@ -140,8 +141,8 @@ describe("series-condense", () => { }); expect((block as { condensed?: string[] }).condensed).toEqual([ `daily values: newest ${KEEP_DAILY_ROWS} days kept`, - "weekly means folded into monthly means", - `monthly means: newest ${KEEP_MONTHLY_ROWS} months kept`, + "weekly values folded into monthly values", + `monthly values: newest ${KEEP_MONTHLY_ROWS} months kept`, ]); }); @@ -153,7 +154,7 @@ describe("series-condense", () => { weeklyDia: [{ weekISO: "2026-W30", mean: 85, count: 4 }], }, }; - condenseSeriesBlock(block, 2); + condenseSeriesBlock(block, 2, "bloodPressure"); expect(block).toMatchObject({ summary: { sys: { first: 130, last: 121, change: -9 }, @@ -168,7 +169,7 @@ describe("series-condense", () => { it("leaves a block without a timeline alone", () => { const block = { aggregate: { mean: 1 } }; - expect(condenseSeriesBlock(block, 1)).toBe(false); + expect(condenseSeriesBlock(block, 1, "fatMass")).toBe(false); expect(block).toEqual({ aggregate: { mean: 1 } }); }); }); @@ -286,6 +287,102 @@ describe("the tool-mode rules", () => { ); }); +describe("series-condense reads each block in its own fields", () => { + it("sleep: nights carry minutes, so the summary ends on the latest night", () => { + const block = { + timeline: { + recent: Array.from({ length: 14 }, (_, i) => ({ + date: `2026-09-${String(i + 10).padStart(2, "0")}`, + weekday: "Mon", + minutes: 400 + i, + stages: { core: 200, deep: 80, rem: 90 }, + })), + weekly: weeks(6, 2026, () => 320).map((w, i) => ({ + ...w, + weekISO: `2026-W${30 + i}`, + })), + }, + }; + condenseSeriesBlock(block, 1, "sleep"); + expect(block).toMatchObject({ + summary: { to: "2026-09-23", last: 413 }, + }); + expect(block.timeline.recent).toHaveLength(KEEP_DAILY_ROWS); + expect(block.timeline.recent.at(-1)).toMatchObject({ minutes: 413 }); + condenseSeriesBlock(block, 2, "sleep"); + expect( + (block.timeline as unknown as { monthly: unknown[] }).monthly, + ).toEqual([ + { month: "2026-07", mean: 320, count: 14 }, + { month: "2026-08", mean: 320, count: 28 }, + ]); + }); + + it("adherence: rates fold weighted by doses, not by readings", () => { + const block = { + rate: 88, + timeline: { + recent: [ + { date: "2026-09-30", weekday: "Wed", rate: 1, taken: 2, total: 2 }, + { date: "2026-10-01", weekday: "Thu", rate: 0.5, taken: 1, total: 2 }, + ], + weekly: [ + { weekISO: "2026-W36", rate: 1, taken: 2, total: 2 }, + { weekISO: "2026-W37", rate: 0.5, taken: 7, total: 14 }, + ], + }, + }; + condenseSeriesBlock(block, 2, "compliance"); + expect(block).toMatchObject({ + summary: { to: "2026-10-01", last: 0.5 }, + timeline: { monthly: [{ month: "2026-09", rate: 0.56, total: 16 }] }, + }); + expect(block.timeline).not.toHaveProperty("weekly"); + }); + + it("glucose: each measurement context condenses on its own", () => { + const ctx = (base: number) => ({ + recent: recent(14, (i) => base + i), + weekly: weeks(30, 2026, () => base), + }); + const block = { + unit: "mg/dL", + panel: { tir: 0.8 }, + byContext: { FASTING: ctx(95), POST_MEAL: ctx(140) }, + }; + expect(condenseSeriesBlock(block, 1, "glucose")).toBe(true); + expect(condenseSeriesBlock(block, 2, "glucose")).toBe(true); + expect(block).toMatchObject({ + summary: { + FASTING: { last: 108, to: "2026-09-14" }, + POST_MEAL: { last: 153 }, + }, + }); + for (const c of Object.values(block.byContext)) { + expect(c.recent).toHaveLength(KEEP_DAILY_ROWS); + expect(c).not.toHaveProperty("weekly"); + expect((c as unknown as { monthly: unknown[] }).monthly.length).toBe(7); + } + }); + + it("workouts: keeps the newest sessions and the whole-window rollup", () => { + const block = { + recent: Array.from({ length: 15 }, (_, i) => ({ + date: `2026-09-${String(30 - i).padStart(2, "0")}`, + sport: "RUNNING", + durationMin: 30 + i, + })), + perSport: [{ sport: "RUNNING", count: 40, totalDurationMin: 1500 }], + totalInWindow: 40, + }; + expect(condenseSeriesBlock(block, 1, "workouts")).toBe(true); + expect(block.recent).toHaveLength(KEEP_SESSIONS); + expect(block.recent[0].date).toBe("2026-09-30"); + expect(block.perSport[0].count).toBe(40); + expect(condenseSeriesBlock(block, 2, "workouts")).toBe(false); + }); +}); + describe("the prompt-budget pass on a single-source read", () => { it("condenses the requested block last and never swaps it for a marker", async () => { const { degradeToBudget } = await vi.importActual< diff --git a/src/lib/ai/coach/series-condense.ts b/src/lib/ai/coach/series-condense.ts index 8dcc2d714..164a15b3f 100644 --- a/src/lib/ai/coach/series-condense.ts +++ b/src/lib/ai/coach/series-condense.ts @@ -44,18 +44,6 @@ export interface SeriesSummary { points: number; } -interface WeeklyBucket { - weekISO: string; - mean: number; - count: number; -} - -export interface MonthlyBucket { - month: string; - mean: number; - count: number; -} - type Rec = Record; function isRecord(value: unknown): value is Rec { @@ -66,19 +54,6 @@ function round(value: number): number { return Math.round(value * 100) / 100; } -function isWeekly(value: unknown): value is WeeklyBucket[] { - return ( - Array.isArray(value) && - value.every( - (b) => - isRecord(b) && - typeof b.weekISO === "string" && - typeof b.mean === "number" && - typeof b.count === "number", - ) - ); -} - /** ISO week `YYYY-Www` → the Monday that starts it, as `YYYY-MM-DD`. */ function isoWeekMonday(weekISO: string): string | null { const match = /^(\d{4})-W(\d{2})$/.exec(weekISO); @@ -144,64 +119,160 @@ export function isoWeekMonth(weekISO: string): string | null { return dateOnlyKey(thursday).slice(0, 7); } -/** Weekly means folded into count-weighted monthly means, in time order. */ +/** A monthly bucket: the month, the weighted value, the summed weight. */ +export type MonthlyBucket = { month: string } & Record; + +/** + * Weekly buckets folded into monthly ones, in time order. Each month's value + * is the weekly values weighted by `weightField` (readings for a mean, doses + * for an adherence rate), and the month carries the summed weight. + */ export function weeklyToMonthly( - weekly: ReadonlyArray, + weekly: ReadonlyArray, + valueField = "mean", + weightField = "count", ): MonthlyBucket[] { - const months = new Map(); + const months = new Map(); for (const bucket of weekly) { + if (!isRecord(bucket) || typeof bucket.weekISO !== "string") continue; + const value = bucket[valueField]; + const weight = bucket[weightField]; + if (typeof value !== "number" || typeof weight !== "number") continue; const month = isoWeekMonth(bucket.weekISO); - if (!month || bucket.count <= 0) continue; - const acc = months.get(month) ?? { sum: 0, count: 0 }; - acc.sum += bucket.mean * bucket.count; - acc.count += bucket.count; + if (!month || weight <= 0) continue; + const acc = months.get(month) ?? { sum: 0, weight: 0 }; + acc.sum += value * weight; + acc.weight += weight; months.set(month, acc); } + const places = valueField === "rate" ? 100 : 10; return Array.from(months.entries()) .sort(([a], [b]) => a.localeCompare(b)) - .map(([month, { sum, count }]) => ({ + .map(([month, { sum, weight }]) => ({ month, - mean: Math.round((sum / count) * 10) / 10, - count, + [valueField]: Math.round((sum / weight) * places) / places, + [weightField]: weight, })); } /** - * The value series a timeline carries: `value` over `weekly` for a single - * series, `sys`/`dia` over `weeklySys`/`weeklyDia` for blood pressure. + * One value series inside a block: where its rows live (`container`), which + * field of a daily row holds the value, and which weekly field holds the + * period value and its weight. The blocks name these differently: sleep + * nights carry `minutes`, adherence days and weeks a `rate` over `total` + * doses, blood pressure `sys`/`dia` beside separate weekly lists, glucose + * one series per measurement context. */ -const SERIES: ReadonlyArray<{ +interface Track { + /** The summary key; null for a block with a single series. */ name: string | null; + container: Rec; field: string; weekly: string; monthly: string; -}> = [ - { name: null, field: "value", weekly: "weekly", monthly: "monthly" }, - { name: "sys", field: "sys", weekly: "weeklySys", monthly: "monthlySys" }, - { name: "dia", field: "dia", weekly: "weeklyDia", monthly: "monthlyDia" }, -]; + weeklyValue: string; + weight: string; +} -function blockSummary(timeline: Rec): unknown { - const recent = Array.isArray(timeline.recent) ? timeline.recent : []; - const out: Rec = {}; - for (const s of SERIES) { - const points: Array<{ at: string; value: number }> = []; - const weekly = timeline[s.weekly]; - if (isWeekly(weekly)) { - for (const b of weekly) points.push({ at: b.weekISO, value: b.mean }); +/** Which shape a snapshot block has, by its key. */ +export type SeriesBlockKind = + "value" | "bloodPressure" | "sleep" | "compliance" | "glucose" | "workouts"; + +export function seriesBlockKind(key: string): SeriesBlockKind { + switch (key) { + case "bloodPressure": + case "sleep": + case "compliance": + case "glucose": + case "workouts": + return key; + default: + return "value"; + } +} + +function tracksOf(kind: SeriesBlockKind, block: Rec): Track[] { + const single = (container: unknown, field: string, name: string | null) => + isRecord(container) + ? [ + { + name, + container, + field, + weekly: "weekly", + monthly: "monthly", + weeklyValue: field === "rate" ? "rate" : "mean", + weight: field === "rate" ? "total" : "count", + }, + ] + : []; + switch (kind) { + case "value": + return single(block.timeline, "value", null); + case "sleep": + return single(block.timeline, "minutes", null); + case "compliance": + return single(block.timeline, "rate", null); + case "bloodPressure": + if (!isRecord(block.timeline)) return []; + return (["sys", "dia"] as const).map((name) => ({ + name, + container: block.timeline as Rec, + field: name, + weekly: name === "sys" ? "weeklySys" : "weeklyDia", + monthly: name === "sys" ? "monthlySys" : "monthlyDia", + weeklyValue: "mean", + weight: "count", + })); + case "glucose": { + const byContext = block.byContext; + if (!isRecord(byContext)) return []; + return Object.keys(byContext).flatMap((ctx) => + single(byContext[ctx], "value", ctx), + ); } + case "workouts": + return []; + } +} + +/** Whether `condenseSeriesBlock` knows how to condense this block. */ +export function isCondensable(key: string, block: unknown): boolean { + if (!isRecord(block)) return false; + const kind = seriesBlockKind(key); + if (kind === "workouts") return Array.isArray(block.recent); + return tracksOf(kind, block).length > 0; +} + +function trackSummary(track: Track): SeriesSummary | null { + const points: Array<{ at: string; value: number }> = []; + const weekly = track.container[track.weekly]; + if (Array.isArray(weekly)) { + for (const b of weekly) { + if (!isRecord(b) || typeof b.weekISO !== "string") continue; + const value = b[track.weeklyValue]; + if (typeof value === "number") points.push({ at: b.weekISO, value }); + } + } + const recent = track.container.recent; + if (Array.isArray(recent)) { for (const row of recent) { - if (isRecord(row) && typeof row.date === "string") { - const value = row[s.field]; - if (typeof value === "number") points.push({ at: row.date, value }); - } - } - const summary = summarisePoints(points); - if (summary) { - if (s.name === null) return summary; - out[s.name] = summary; + if (!isRecord(row) || typeof row.date !== "string") continue; + const value = row[track.field]; + if (typeof value === "number") points.push({ at: row.date, value }); } } + return summarisePoints(points); +} + +function blockSummary(tracks: ReadonlyArray): unknown { + const out: Rec = {}; + for (const track of tracks) { + const summary = trackSummary(track); + if (!summary) continue; + if (track.name === null) return summary; + out[track.name] = summary; + } return Object.keys(out).length > 0 ? out : undefined; } @@ -211,47 +282,80 @@ function note(block: Rec, text: string): void { block.condensed = list; } +/** Workout sessions a condensed block keeps (the list is newest first). */ +export const KEEP_SESSIONS = 5; + /** - * Take one condensing step on a block that carries a `timeline`. Returns - * whether anything changed; a block without a timeline is left alone and - * reports false. + * Take one condensing step on the block stored under `key`. Returns whether + * anything changed; a block of a shape this module does not know is left + * alone and reports false. */ -export function condenseSeriesBlock(block: unknown, step: 1 | 2 | 3): boolean { - if (!isRecord(block) || !isRecord(block.timeline)) return false; - const timeline = block.timeline; +export function condenseSeriesBlock( + block: unknown, + step: 1 | 2 | 3, + key: string, +): boolean { + if (!isRecord(block)) return false; + const kind = seriesBlockKind(key); + if (kind === "workouts") { + // The sessions list is newest first; the per-sport rollup and the window + // total already cover every session, so they stay whole. + if ( + step === 1 && + Array.isArray(block.recent) && + block.recent.length > KEEP_SESSIONS + ) { + block.recent = block.recent.slice(0, KEEP_SESSIONS); + note( + block, + `sessions: newest ${KEEP_SESSIONS} kept; perSport and totalInWindow cover the whole window`, + ); + return true; + } + return false; + } + const tracks = tracksOf(kind, block); + if (tracks.length === 0) return false; if (block.summary === undefined) { - const summary = blockSummary(timeline); + const summary = blockSummary(tracks); if (summary !== undefined) block.summary = summary; } + const containers = new Set(tracks.map((t) => t.container)); let changed = false; if (step === 1) { - if ( - Array.isArray(timeline.recent) && - timeline.recent.length > KEEP_DAILY_ROWS - ) { - timeline.recent = timeline.recent.slice(-KEEP_DAILY_ROWS); - note(block, `daily values: newest ${KEEP_DAILY_ROWS} days kept`); - changed = true; - } - } else if (step === 2) { - for (const s of SERIES) { - const weekly = timeline[s.weekly]; - if (!isWeekly(weekly)) continue; - timeline[s.monthly] = weeklyToMonthly(weekly); - delete timeline[s.weekly]; - note(block, "weekly means folded into monthly means"); - changed = true; - } - } else { - for (const s of SERIES) { - const monthly = timeline[s.monthly]; - if (Array.isArray(monthly) && monthly.length > KEEP_MONTHLY_ROWS) { - timeline[s.monthly] = monthly.slice(-KEEP_MONTHLY_ROWS); - note(block, `monthly means: newest ${KEEP_MONTHLY_ROWS} months kept`); + for (const container of containers) { + if ( + Array.isArray(container.recent) && + container.recent.length > KEEP_DAILY_ROWS + ) { + container.recent = container.recent.slice(-KEEP_DAILY_ROWS); + note(block, `daily values: newest ${KEEP_DAILY_ROWS} days kept`); changed = true; } } - const coarse = timeline.coarse; + } else if (step === 2) { + for (const track of tracks) { + const weekly = track.container[track.weekly]; + if (!Array.isArray(weekly)) continue; + track.container[track.monthly] = weeklyToMonthly( + weekly, + track.weeklyValue, + track.weight, + ); + delete track.container[track.weekly]; + note(block, "weekly values folded into monthly values"); + changed = true; + } + } else { + for (const track of tracks) { + const monthly = track.container[track.monthly]; + if (Array.isArray(monthly) && monthly.length > KEEP_MONTHLY_ROWS) { + track.container[track.monthly] = monthly.slice(-KEEP_MONTHLY_ROWS); + note(block, `monthly values: newest ${KEEP_MONTHLY_ROWS} months kept`); + changed = true; + } + } + const coarse = isRecord(block.timeline) ? block.timeline.coarse : undefined; if (isRecord(coarse) && ("monthly" in coarse || "anomalies" in coarse)) { delete coarse.monthly; delete coarse.anomalies; diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index a104ca00d..284ef092a 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -33,7 +33,7 @@ import { resolveSnapshotPrelude, } from "./snapshot-prelude"; import { annotateSnapshotFreshness } from "./snapshot-freshness"; -import { condenseSeriesBlock } from "./series-condense"; +import { condenseSeriesBlock, isCondensable } from "./series-condense"; import { buildGlp1SnapshotBlock } from "./glp1-snapshot"; import { buildDerivedSnapshotBlock } from "./derived-snapshot"; import { buildCorrelationsSnapshotBlock } from "./correlations-snapshot"; @@ -1314,10 +1314,10 @@ export function degradeToBudget( if (size() <= MAX_SNAPSHOT_CHARS) break; // A requested series is condensed step by step and keeps its numbers // (`series-condense.ts`); it is never swapped for the `omitted` marker. - if (requested.has(key) && asRecord(asRecord(snapshot[key])?.timeline)) { + if (requested.has(key) && isCondensable(key, snapshot[key])) { for (const step of [1, 2, 3] as const) { if (size() <= MAX_SNAPSHOT_CHARS) break; - if (condenseSeriesBlock(snapshot[key], step)) { + if (condenseSeriesBlock(snapshot[key], step, key)) { degraded.push({ key, cluster, pass: step }); } }