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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-05 21:54:46 +02:00
1 parent 73ce721ff2
commit 756c3fb845
3 files changed
+302 -101

No files matched your search

@@ -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<
+195 -91
View File
@@ -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<string, unknown>;
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<string, number | string>;
/**
* 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<WeeklyBucket>,
weekly: ReadonlyArray<unknown>,
valueField = "mean",
weightField = "count",
): MonthlyBucket[] {
const months = new Map<string, { sum: number; count: number }>();
const months = new Map<string, { sum: number; weight: number }>();
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<Track>): 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;
+3 -3
View File
@@ -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 });
}
}