perf(insights): send snapshots as compact JSON and size the cap against it

The status cards, the briefing, the derived assessments and the Coach
snapshot drop the two-space indentation from the JSON they hand the model.
The content is unchanged (the hash gate hashes the object, so stored hashes
stay valid). MAX_SNAPSHOT_CHARS is now measured on the compact string: 14 000
for the status cards and the Coach, which every snapshot that fitted the old
24 000 pretty-printed characters still fits, so the model reads the same data
for fewer tokens.

Refs #1217
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 22:49:00 +02:00
1 parent ae8b614dd0
commit c2245d1d79
19 files changed
+226 -235

No files matched your search

+3 -1
View File
@@ -671,7 +671,9 @@ export const POST = apiHandler((request: NextRequest) =>
const compactFeatures = compactSections(
features as unknown as Record<string, unknown>,
);
const featuresJson = JSON.stringify(compactFeatures, null, 2);
// Compact on the wire: indentation is about 40% of a pretty-printed
// payload, and the model reads the structure without it (#1217).
const featuresJson = JSON.stringify(compactFeatures);
if (payloadDowngraded) {
annotate({
meta: {
+5 -194
View File
@@ -225,7 +225,10 @@ describe("buildCoachSnapshot", () => {
],
});
expect(out.snapshotJson).toBe(JSON.stringify(out.sections, null, 2));
// The prompt carries the snapshot compact (#1217): the same content,
// without the indentation.
expect(out.snapshotJson).toBe(JSON.stringify(out.sections));
expect(JSON.parse(out.snapshotJson)).toEqual(out.sections);
expect(out).toMatchInlineSnapshot(`
{
"provenance": {
@@ -468,199 +471,7 @@ describe("buildCoachSnapshot", () => {
"unit": "kg",
},
},
"snapshotJson": "{
"bloodPressure": {
"aggregate": {
"avgSys30": 125,
"avgDia30": 80,
"coverage": {
"count": 4
}
},
"timeline": {
"recent": [
{
"date": "2026-07-10",
"weekday": "Fri",
"sys": 128,
"dia": 82
}
],
"weeklySys": [
{
"weekISO": "2026-W27",
"mean": 122,
"count": 1
}
],
"weeklyDia": [
{
"weekISO": "2026-W27",
"mean": 78,
"count": 1
}
]
}
},
"weight": {
"unit": "kg",
"aggregate": {
"latest": 80.8,
"bmi": 24.7,
"coverage": {
"count": 2
},
"unit": "kg"
},
"timeline": {
"recent": [
{
"date": "2026-07-16",
"weekday": "Thu",
"value": 80.8
}
],
"weekly": [
{
"weekISO": "2026-W27",
"mean": 81.4,
"count": 1
}
]
}
},
"pulse": {
"aggregate": {
"avg7": 63,
"coverage": {
"count": 2
}
},
"timeline": {
"recent": [
{
"date": "2026-07-17",
"weekday": "Fri",
"value": 63
}
],
"weekly": [
{
"weekISO": "2026-W27",
"mean": 67,
"count": 1
}
]
}
},
"mood": {
"aggregate": {
"avg30": 4,
"coverage": {
"count": 2
}
},
"timeline": {
"recent": [
{
"date": "2026-07-18",
"weekday": "Sat",
"value": 5
}
],
"weekly": [
{
"weekISO": "2026-W27",
"mean": 3,
"count": 1
}
]
}
},
"bodyMassIndex": {
"unit": "kg/m²",
"timeline": {
"recent": [
{
"date": "2026-07-08",
"weekday": "Wed",
"value": 24.8
},
{
"date": "2026-07-15",
"weekday": "Wed",
"value": 24.6
}
],
"weekly": []
}
},
"steps": {
"unit": "steps",
"timeline": {
"recent": [
{
"date": "2026-07-18",
"weekday": "Sat",
"value": 8400
}
],
"weekly": [
{
"weekISO": "2026-W27",
"mean": 7200,
"count": 1
}
]
}
},
"skinTemperature": {
"unit": "°C",
"timeline": {
"recent": [
{
"date": "2026-07-19",
"weekday": "Sun",
"value": 33.5
}
],
"weekly": [
{
"weekISO": "2026-W27",
"mean": 33.2,
"count": 1
}
]
}
},
"anthropometrics": {
"heightCm": 181,
"ageYears": 41,
"gender": "MALE"
},
"dayStrain": {
"source": "WHOOP-native",
"scale": "0-21",
"latest": 13.9,
"recentMean": 12.6,
"days": 2,
"note": "Device-native day strain; prefer over derived.STRAIN_SCORE (computed 0-100 proxy)."
},
"scope": {
"window": "last30days",
"sources": [
"bp",
"weight",
"pulse",
"mood",
"steps",
"bmi",
"skin_temp",
"hrv"
],
"timelineRecentDays": 14
}
}",
"snapshotJson": "{"bloodPressure":{"aggregate":{"avgSys30":125,"avgDia30":80,"coverage":{"count":4}},"timeline":{"recent":[{"date":"2026-07-10","weekday":"Fri","sys":128,"dia":82}],"weeklySys":[{"weekISO":"2026-W27","mean":122,"count":1}],"weeklyDia":[{"weekISO":"2026-W27","mean":78,"count":1}]}},"weight":{"unit":"kg","aggregate":{"latest":80.8,"bmi":24.7,"coverage":{"count":2},"unit":"kg"},"timeline":{"recent":[{"date":"2026-07-16","weekday":"Thu","value":80.8}],"weekly":[{"weekISO":"2026-W27","mean":81.4,"count":1}]}},"pulse":{"aggregate":{"avg7":63,"coverage":{"count":2}},"timeline":{"recent":[{"date":"2026-07-17","weekday":"Fri","value":63}],"weekly":[{"weekISO":"2026-W27","mean":67,"count":1}]}},"mood":{"aggregate":{"avg30":4,"coverage":{"count":2}},"timeline":{"recent":[{"date":"2026-07-18","weekday":"Sat","value":5}],"weekly":[{"weekISO":"2026-W27","mean":3,"count":1}]}},"bodyMassIndex":{"unit":"kg/m²","timeline":{"recent":[{"date":"2026-07-08","weekday":"Wed","value":24.8},{"date":"2026-07-15","weekday":"Wed","value":24.6}],"weekly":[]}},"steps":{"unit":"steps","timeline":{"recent":[{"date":"2026-07-18","weekday":"Sat","value":8400}],"weekly":[{"weekISO":"2026-W27","mean":7200,"count":1}]}},"skinTemperature":{"unit":"°C","timeline":{"recent":[{"date":"2026-07-19","weekday":"Sun","value":33.5}],"weekly":[{"weekISO":"2026-W27","mean":33.2,"count":1}]}},"anthropometrics":{"heightCm":181,"ageYears":41,"gender":"MALE"},"dayStrain":{"source":"WHOOP-native","scale":"0-21","latest":13.9,"recentMean":12.6,"days":2,"note":"Device-native day strain; prefer over derived.STRAIN_SCORE (computed 0-100 proxy)."},"scope":{"window":"last30days","sources":["bp","weight","pulse","mood","steps","bmi","skin_temp","hrv"],"timelineRecentDays":14}}",
"units": {
"glucoseUnit": "mg/dL",
"system": "metric",
+12 -9
View File
@@ -36,16 +36,19 @@ import type { CoachScope, CoachScopeSource, CoachScopeWindow } from "./types";
export const DAILY_TIMELINE_DAYS = 14;
/**
* v1.7.0 — assembled-snapshot soft char cap. After the snapshot is
* built we measure `JSON.stringify(snapshot).length` as a ~4-chars-per-
* token proxy and, if it exceeds this cap, progressively degrade the
* lowest-priority clusters (drop `timeline.recent`, then collapse the
* weekly buckets) until it fits. ~24 000 chars ≈ ~6 000 tokens, which
* sits comfortably inside every provider's context alongside the system
* prompt + history window. The daily token ledger (`budget.ts`) stays
* the per-day cost backstop; this is the per-prompt shape backstop.
* v1.7.0 — assembled-snapshot soft char cap. After the snapshot is built we
* measure its compact JSON (`JSON.stringify(snapshot).length`, the form the
* prompt and every tool result ship) and, if it exceeds this cap,
* progressively degrade the lowest-priority clusters (drop
* `timeline.recent`, then collapse the weekly buckets) until it fits. The
* daily token ledger (`budget.ts`) stays the per-day cost backstop; this is
* the per-prompt shape backstop.
*
* v1.43 (#1217) — 24 000 pretty-printed characters became 14 000 compact
* ones. Compact JSON is ~0.42 to ~0.5 of the pretty form for these blocks, so
* everything that fitted before still fits: the same data, fewer tokens.
*/
export const MAX_SNAPSHOT_CHARS = 24_000;
export const MAX_SNAPSHOT_CHARS = 14_000;
/**
* v1.18.10 (P-2) — newest-first cap on the single multi-type measurement read
+4 -5
View File
@@ -1128,7 +1128,7 @@ async function buildCoachSnapshotImpl(
}
return {
snapshotJson: JSON.stringify(compactSnapshot, null, 2),
snapshotJson: JSON.stringify(compactSnapshot),
// v1.20.0 (F1) — the structured, post-degrade snapshot record keyed by
// domain block (`bloodPressure`, `glucose`, `labs`, …). The coach tool
// executor slices a single domain block out of this so a retrieval tool
@@ -1175,10 +1175,9 @@ export function degradeToBudget(
cluster: CoachDataCluster;
pass: number;
}> = [];
// Measure against the SAME pretty-printed form the prompt ships
// (`JSON.stringify(snapshot, null, 2)`), not the compact form —
// otherwise the cap under-counts by ~2× and the prompt overflows.
const size = () => JSON.stringify(snapshot, null, 2).length;
// Measure against the SAME compact form the prompt ships
// (`JSON.stringify(snapshot)`), so the cap counts what the model reads.
const size = () => JSON.stringify(snapshot).length;
if (size() <= MAX_SNAPSHOT_CHARS) return degraded;
// Build a degrade order: blocks grouped by cluster, lowest priority
+2 -2
View File
@@ -78,7 +78,7 @@ import type {
} from "./types";
/**
* Above this pretty-printed size the section goes to the full builder, whose
* Above this compact size the section goes to the full builder, whose
* budget pass may trim it (see the module comment). A 30-day section is a few
* thousand characters; a year of blood pressure with its coarse tail is the
* kind of section that crosses it.
@@ -240,7 +240,7 @@ export async function buildCoachSourceSnapshot(
const sections = compactSections(snapshot);
annotateSnapshotFreshness(sections);
const size = JSON.stringify(sections, null, 2).length;
const size = JSON.stringify(sections).length;
if (size > SOURCE_SNAPSHOT_FALLBACK_CHARS) {
annotate({
action: { name: "coach.source_snapshot.full" },
@@ -0,0 +1,165 @@
/**
* #1217 — the status snapshots travel as compact JSON, and the char cap was
* lowered with the switch so the model sees at least the same data for fewer
* tokens, never less.
*
* Two properties pin that:
* 1. compact JSON parses back to exactly the object the pretty form did, so
* dropping the indentation changes no content;
* 2. every snapshot (and every shed stage of one) that fitted the old cap,
* 24 000 pretty-printed characters, fits the new compact cap, so the
* shedding never starts earlier than it used to.
*/
import { describe, expect, it } from "vitest";
import {
MAX_SNAPSHOT_CHARS,
buildGradedSeriesFromPoints,
degradeStatusSnapshotToBudget,
} from "@/lib/insights/graded-series";
/** The cap before v1.43, measured on `JSON.stringify(snapshot, null, 2)`. */
const PRETTY_CAP_BEFORE = 24_000;
const NOW = new Date("2026-10-15T12:00:00Z");
function series(days: number, perDay: number, base: number, seed: number) {
const points: Array<{ measuredAt: Date; value: number }> = [];
let x = seed;
for (let d = 0; d < days; d++) {
for (let k = 0; k < perDay; k++) {
x = (x * 9301 + 49297) % 233280;
points.push({
measuredAt: new Date(NOW.getTime() - d * 86_400_000 - k * 3_600_000),
value: Math.round((base + (x / 233280) * base * 0.2) * 10) / 10,
});
}
}
return buildGradedSeriesFromPoints(points, NOW, "Europe/Berlin");
}
/** A general-status-shaped snapshot over `metrics` series of `days` days. */
function snapshot(metrics: number, days: number): Record<string, unknown> {
const measurementSeries: Record<string, unknown> = {};
for (let i = 0; i < metrics; i++) {
measurementSeries[`METRIC_${i}`] = {
unit: i % 2 === 0 ? "bpm" : "kg",
summary: { points: days * 2, mean: 61.2, min: 50, max: 80, latest: 63 },
series: series(days, 2, 50 + i * 10, i + 1),
};
}
return {
locale: "de",
generatedForDay: "2026-10-15",
interpretationHint:
"Use trend direction and deltas. Prioritize the newest data if trends conflict.",
dataCoverage: {
totalMeasurements: days * 2 * metrics,
totalSpanDays: days,
newestMeasurementDaysAgo: 0,
},
measurementSeries,
correlations: Array.from({ length: 40 }, (_, i) => ({
a: `METRIC_${i % metrics}`,
b: `METRIC_${(i + 1) % metrics}`,
r: Math.round(((i % 7) / 10 - 0.3) * 100) / 100,
n: 30 + i,
})),
};
}
const FIXTURES: Array<[number, number]> = [];
for (const metrics of [1, 2, 3, 4, 6, 8, 10, 16]) {
for (const days of [14, 30, 90, 200, 400, 1000]) {
FIXTURES.push([metrics, days]);
}
}
/** The shed stages `degradeStatusSnapshotToBudget` walks, in order. */
function stages(snap: Record<string, unknown>): Record<string, unknown>[] {
const out: Record<string, unknown>[] = [structuredClone(snap)];
const drop = (node: unknown, field: string): void => {
if (Array.isArray(node)) return node.forEach((n) => drop(n, field));
if (node && typeof node === "object") {
const obj = node as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (key === field && Array.isArray(obj[key])) delete obj[key];
else drop(obj[key], field);
}
}
};
const tails = (node: unknown): void => {
if (Array.isArray(node)) return node.forEach(tails);
if (node && typeof node === "object") {
const obj = node as Record<string, unknown>;
for (const key of Object.keys(obj)) {
const value = obj[key];
if (Array.isArray(value) && key !== "recent" && value.length > 30) {
obj[key] = value.slice(-30);
}
tails(obj[key]);
}
}
};
let current = structuredClone(snap);
drop(current, "yearly");
out.push(structuredClone(current));
drop(current, "weekly");
out.push(structuredClone(current));
tails(current);
out.push(structuredClone(current));
current = structuredClone(current);
drop(current, "recent");
out.push(current);
return out;
}
describe("compact status snapshots (#1217)", () => {
it.each(FIXTURES)(
"parses back to the same object (%i metrics, %i days)",
(metrics, days) => {
const snap = snapshot(metrics, days);
const compact = JSON.stringify(snap);
expect(JSON.parse(compact)).toEqual(
JSON.parse(JSON.stringify(snap, null, 2)),
);
expect(compact.length).toBeLessThan(JSON.stringify(snap, null, 2).length);
},
);
it("fits every stage that fitted the old pretty cap under the new compact cap", () => {
let checked = 0;
for (const [metrics, days] of FIXTURES) {
for (const stage of stages(snapshot(metrics, days))) {
if (JSON.stringify(stage, null, 2).length > PRETTY_CAP_BEFORE) continue;
checked += 1;
expect(JSON.stringify(stage).length).toBeLessThanOrEqual(
MAX_SNAPSHOT_CHARS,
);
}
}
// The property is only worth something if it saw real cases near the cap.
expect(checked).toBeGreaterThan(40);
});
it("sheds nothing from a snapshot that fitted before", () => {
let fitted = 0;
for (const [metrics, days] of FIXTURES) {
const snap = snapshot(metrics, days);
if (JSON.stringify(snap, null, 2).length > PRETTY_CAP_BEFORE) continue;
fitted += 1;
const before = structuredClone(snap);
expect(degradeStatusSnapshotToBudget(snap)).toEqual([]);
expect(snap).toEqual(before);
}
expect(fitted).toBeGreaterThan(5);
});
it("still sheds a snapshot far over the cap, coarsest-last", () => {
const snap = snapshot(16, 1000);
const size = JSON.stringify(snap).length;
const shed = degradeStatusSnapshotToBudget(snap);
expect(shed[0]).toBe("yearly");
expect(JSON.stringify(snap).length).toBeLessThan(size);
});
});
@@ -350,7 +350,7 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => {
expect(outcome).toEqual({ status: "generated", providerType: "openai" });
const prompt = JSON.stringify(runRawCompletionWithFallback.mock.calls[0]);
expect(prompt).toContain('\\"unit\\": \\"lb\\"');
expect(prompt).toContain('\\"unit\\":\\"lb\\"');
expect(prompt).toContain("179.5");
expect(prompt).not.toContain("81.4");
+1 -1
View File
@@ -315,7 +315,7 @@ export async function generateBiomarkerStatus(args: {
value: r.value,
})),
};
const snapshotJson = JSON.stringify(snapshot, null, 2);
const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
+1 -1
View File
@@ -642,7 +642,7 @@ export async function prepareBloodPressureStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
+1 -1
View File
@@ -335,7 +335,7 @@ export async function prepareBmiStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
+3 -1
View File
@@ -965,7 +965,9 @@ export async function generateComprehensiveInsight(
const compactFeatures = compactSections(
features as unknown as Record<string, unknown>,
);
const featuresJson = JSON.stringify(compactFeatures, null, 2);
// Compact on the wire: indentation is about 40% of a pretty-printed
// payload, and the model reads the structure without it (#1217).
const featuresJson = JSON.stringify(compactFeatures);
// v1.16.8 — content-hash gate. When the compacted feature snapshot is
// byte-equivalent (modulo clock-relative offsets, see snapshot-hash.ts)
@@ -133,11 +133,12 @@ function scoreUserPrompt(
/** v1.30.3 (QA F5) — the user's own IANA tz; was hardcoded "Europe/Berlin". */
tz: string,
): string {
const snapshot = JSON.stringify(
{ promptVersion: PROMPT_VERSION, generatedForDay: todayKey, band, signal },
null,
2,
);
const snapshot = JSON.stringify({
promptVersion: PROMPT_VERSION,
generatedForDay: todayKey,
band,
signal,
});
if (instructionLocale(locale) === "en") {
return `Date: ${todayKey} (${tz})
OPENER HINT: ${openerHint}
+1 -1
View File
@@ -419,7 +419,7 @@ export async function prepareGeneralStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
+16 -8
View File
@@ -682,17 +682,25 @@ export function convertGradedSeries(
}
/**
* Assembled-snapshot soft char cap, mirroring the Coach's
* `MAX_SNAPSHOT_CHARS` (~24 000 chars ≈ ~6 000 tokens against the
* pretty-printed form). The status snapshots had no cap at all; a
* many-metric `general-status` could balloon past 100 K tokens.
* Assembled-snapshot soft char cap, measured against the COMPACT JSON the
* prompt ships (`JSON.stringify(snapshot)`). The status snapshots had no cap
* at all once; a many-metric `general-status` could balloon past 100 K
* tokens.
*
* Until v1.43 the cap was 24 000 characters of pretty-printed JSON. The
* prompt is compact now, and a graded series loses about 58% of its
* characters to indentation (compact is ~0.42 of pretty across one to sixteen
* metrics and thirty to a thousand days), so 24 000 pretty characters are
* ~10 100 compact ones. 14 000 keeps every snapshot that fitted before
* fitting now, with room for the string-heavier blocks whose ratio is higher:
* the model sees at least the same data for fewer tokens, never less.
*/
export const MAX_SNAPSHOT_CHARS = 24_000;
export const MAX_SNAPSHOT_CHARS = 14_000;
/**
* Walk an arbitrary snapshot object and, while it exceeds
* `MAX_SNAPSHOT_CHARS` (measured against the pretty-printed form the
* prompt ships), shed the lowest-signal slices in order:
* `MAX_SNAPSHOT_CHARS` (measured against the compact form the prompt
* ships), shed the lowest-signal slices in order:
* 1. drop every graded `yearly` array,
* 2. drop every graded `weekly` array,
* 3. truncate any other array to its tail `arrayTailCap` entries
@@ -709,7 +717,7 @@ export function degradeStatusSnapshotToBudget(
arrayTailCap = 30,
): string[] {
const shed: string[] = [];
const size = () => JSON.stringify(snapshot, null, 2).length;
const size = () => JSON.stringify(snapshot).length;
if (size() <= MAX_SNAPSHOT_CHARS) return shed;
const walk = (
@@ -406,7 +406,7 @@ export async function prepareMedicationComplianceStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
+1 -1
View File
@@ -454,7 +454,7 @@ export async function generateMetricStatus(args: {
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
+1 -1
View File
@@ -665,7 +665,7 @@ export async function prepareMoodStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
+1 -1
View File
@@ -392,7 +392,7 @@ export async function preparePulseStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },
+1 -1
View File
@@ -608,7 +608,7 @@ export async function prepareWeightStatusForUser(
const shed = degradeStatusSnapshotToBudget(
snapshot as unknown as Record<string, unknown>,
);
const snapshotJson = JSON.stringify(snapshot, null, 2);
const snapshotJson = JSON.stringify(snapshot);
annotate({
action: { name: cacheAction },