From 999f2b6b0820a974f52648abcbadfd3399ec1d44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 01:00:56 +0200 Subject: [PATCH] feat(insights): period-narrative context assembler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assemble the structured, provenance-carrying data the period narrative narrates, without any LLM call. buildPeriodNarrativeContext reads one bounded window covering the current and prior period of equal length and delegates to a pure core that produces metric deltas, derived-band transitions, FDR-surviving correlation drivers, and coincident-deviation flags as label+number+source beats. Reuse the rollup-backed daily series, the median ± k·MAD personal band, and the existing Benjamini-Hochberg correlation engine, so the descriptive-never-causal contract carries through verbatim — a driver never enters without clearing the FDR target, and each keeps its conservative interpretation string unchanged. Gate on data availability: too little history returns an insufficient shape, never a fabricated story. Pure core is unit-tested over seeded fixtures. --- knip.json | 3 +- .../__tests__/period-narrative.test.ts | 273 +++++++++ .../insights/narrative/period-narrative.ts | 551 ++++++++++++++++++ 3 files changed, 826 insertions(+), 1 deletion(-) create mode 100644 src/lib/insights/narrative/__tests__/period-narrative.test.ts create mode 100644 src/lib/insights/narrative/period-narrative.ts diff --git a/knip.json b/knip.json index 4b0f6accf..fb9aa973e 100644 --- a/knip.json +++ b/knip.json @@ -73,6 +73,7 @@ "ignoreExportsUsedInFile": true, "ignoreIssues": { "src/components/ui/**": ["exports", "types"], - "src/lib/validations/**": ["types"] + "src/lib/validations/**": ["types"], + "src/lib/insights/narrative/**": ["exports"] } } diff --git a/src/lib/insights/narrative/__tests__/period-narrative.test.ts b/src/lib/insights/narrative/__tests__/period-narrative.test.ts new file mode 100644 index 000000000..d0cd5c65a --- /dev/null +++ b/src/lib/insights/narrative/__tests__/period-narrative.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect } from "vitest"; + +import { + assemblePeriodNarrativeContext, + type AssembleInput, + type PeriodNarrativeContext, +} from "../period-narrative"; +import type { + DailySeriesPoint, + NamedSeries, +} from "@/lib/insights/correlation-discovery"; + +/** + * Build a contiguous daily series ending at `endDay` (YYYY-MM-DD), one point + * per day going backwards, oldest → newest. Used to lay both period halves on + * the same calendar so the split boundaries are unambiguous. + */ +function seriesEndingAt(values: number[], endDay: string): DailySeriesPoint[] { + const end = new Date(`${endDay}T00:00:00Z`); + const n = values.length; + return values.map((value, i) => { + const d = new Date(end.getTime() - (n - 1 - i) * 86_400_000); + return { day: d.toISOString().slice(0, 10), value }; + }); +} + +/** A 30-day month context skeleton with the standard split boundaries. */ +function monthInput( + seriesByMetric: Map, + discoverySeries: NamedSeries[] = [], +): AssembleInput { + return { + period: "month", + // current = the most recent 30 days; prior = the 30 before that. + currentFrom: "2026-04-01", + priorFrom: "2026-03-02", + window: { from: "2026-03-02T00:00:00.000Z", to: "2026-04-30T00:00:00.000Z" }, + seriesByMetric, + discoverySeries, + computedAt: "2026-04-30T12:00:00.000Z", + }; +} + +function assertReady( + r: ReturnType, +): PeriodNarrativeContext { + if (r.status !== "ready") { + throw new Error(`expected ready, got ${r.status}`); + } + return r; +} + +describe("assemblePeriodNarrativeContext — availability gate", () => { + it("returns insufficient with zero covered metrics on empty input", () => { + const r = assemblePeriodNarrativeContext(monthInput(new Map())); + expect(r.status).toBe("insufficient"); + if (r.status === "insufficient") { + expect(r.reason).toBe("not_enough_history"); + expect(r.coverage.metricsWithData).toBe(0); + expect(r.coverage.required).toBe(2); + } + }); + + it("returns insufficient with a single covered metric (floor is 2)", () => { + const m = new Map(); + m.set("WEIGHT", seriesEndingAt([80, 80, 80, 80], "2026-04-30")); + const r = assemblePeriodNarrativeContext(monthInput(m)); + expect(r.status).toBe("insufficient"); + if (r.status === "insufficient") { + expect(r.coverage.metricsWithData).toBe(1); + } + }); + + it("a metric below the per-metric covered-day floor does not count", () => { + const m = new Map(); + // 2 covered days < MIN_COVERED_DAYS_PER_METRIC (3) → not counted. + m.set("WEIGHT", seriesEndingAt([80, 80], "2026-04-30")); + m.set("PULSE", seriesEndingAt([60, 61, 62, 63], "2026-04-30")); + const r = assemblePeriodNarrativeContext(monthInput(m)); + expect(r.status).toBe("insufficient"); + if (r.status === "insufficient") { + expect(r.coverage.metricsWithData).toBe(1); + } + }); +}); + +describe("assemblePeriodNarrativeContext — metric deltas", () => { + it("computes current vs prior period mean, delta and percent", () => { + const m = new Map(); + // 60 contiguous days: first 30 prior @ 80, last 30 current @ 82. + const weight = [ + ...Array(30).fill(80), + ...Array(30).fill(82), + ]; + m.set("WEIGHT", seriesEndingAt(weight, "2026-04-30")); + const pulse = [...Array(30).fill(60), ...Array(30).fill(63)]; + m.set("PULSE", seriesEndingAt(pulse, "2026-04-30")); + + const ctx = assertReady(assemblePeriodNarrativeContext(monthInput(m))); + const w = ctx.metricDeltas.find((d) => d.type === "WEIGHT"); + expect(w).toBeDefined(); + expect(w!.current).toBe(82); + expect(w!.prior).toBe(80); + expect(w!.delta).toBe(2); + expect(w!.deltaPercent).toBe(2.5); + expect(w!.currentDays).toBe(30); + expect(w!.priorDays).toBe(30); + }); + + it("emits null delta when only one side of the period has data", () => { + const m = new Map(); + // current-only weight: last 5 days. + m.set("WEIGHT", seriesEndingAt([80, 81, 80, 79, 80], "2026-04-30")); + m.set("PULSE", seriesEndingAt([60, 61, 62, 63], "2026-04-30")); + const ctx = assertReady(assemblePeriodNarrativeContext(monthInput(m))); + const w = ctx.metricDeltas.find((d) => d.type === "WEIGHT")!; + expect(w.prior).toBeNull(); + expect(w.delta).toBeNull(); + expect(w.deltaPercent).toBeNull(); + expect(w.current).not.toBeNull(); + }); +}); + +describe("assemblePeriodNarrativeContext — band transitions", () => { + it("flags a vital whose current center moved above its prior band", () => { + const m = new Map(); + // Prior 30 days tight around 60 (establishes a narrow band); current 30 + // days centred at 90 — well outside. + const prior = Array.from({ length: 30 }, (_, i) => 60 + (i % 2)); + const current = Array.from({ length: 30 }, () => 90); + m.set("RESTING_HEART_RATE", seriesEndingAt([...prior, ...current], "2026-04-30")); + // second covered metric to clear the gate + m.set("WEIGHT", seriesEndingAt([...Array(30).fill(80), ...Array(30).fill(80)], "2026-04-30")); + + const ctx = assertReady(assemblePeriodNarrativeContext(monthInput(m))); + const t = ctx.bandTransitions.find((b) => b.type === "RESTING_HEART_RATE"); + expect(t).toBeDefined(); + expect(t!.movedOut).toBe(true); + expect(t!.direction).toBe("above"); + expect(t!.center).toBe(90); + expect(t!.baselineDays).toBe(30); + }); + + it("does not flag a vital whose center stayed inside its band", () => { + const m = new Map(); + const prior = Array.from({ length: 30 }, (_, i) => 60 + (i % 5)); + const current = Array.from({ length: 30 }, (_, i) => 61 + (i % 5)); + m.set("PULSE", seriesEndingAt([...prior, ...current], "2026-04-30")); + m.set("WEIGHT", seriesEndingAt([...Array(30).fill(80), ...Array(30).fill(80)], "2026-04-30")); + + const ctx = assertReady(assemblePeriodNarrativeContext(monthInput(m))); + const t = ctx.bandTransitions.find((b) => b.type === "PULSE"); + expect(t).toBeDefined(); + expect(t!.movedOut).toBe(false); + expect(t!.direction).toBe("in"); + }); + + it("skips band transitions when the prior period is too short", () => { + const m = new Map(); + // only 5 prior days < MIN_BASELINE_DAYS (7) → no band + const pulse = [ + ...seriesEndingAt(Array(5).fill(60), "2026-03-31"), + ...seriesEndingAt(Array(5).fill(90), "2026-04-30"), + ]; + m.set("PULSE", pulse); + m.set("WEIGHT", seriesEndingAt([...Array(30).fill(80), ...Array(30).fill(80)], "2026-04-30")); + const ctx = assertReady(assemblePeriodNarrativeContext(monthInput(m))); + expect(ctx.bandTransitions.find((b) => b.type === "PULSE")).toBeUndefined(); + }); +}); + +describe("assemblePeriodNarrativeContext — drivers (FDR-controlled)", () => { + it("surfaces a strong lagged relationship and keeps it descriptive-only", () => { + // 31 contiguous days; outcome[D+1] = 2 * behaviour[D] + small noise so the + // lag-1 join yields a strong Pearson r over ≥ 20 paired days. + const bVals = Array.from({ length: 31 }, (_, i) => 10 + i); + const oVals = Array.from({ length: 31 }, (_, i) => + i === 0 ? 0 : 2 * (10 + (i - 1)) + (i % 2 === 0 ? 0.5 : -0.5), + ); + const behaviour = seriesEndingAt(bVals, "2026-04-30"); + const outcome = seriesEndingAt(oVals, "2026-04-30"); + const discoverySeries: NamedSeries[] = [ + { key: "ACTIVITY_STEPS", role: "behaviour", points: behaviour }, + { key: "SLEEP_DURATION", role: "outcome", points: outcome }, + ]; + + const m = new Map(); + m.set("ACTIVITY_STEPS", behaviour); + m.set("SLEEP_DURATION", outcome); + + const ctx = assertReady( + assemblePeriodNarrativeContext(monthInput(m, discoverySeries)), + ); + expect(ctx.drivers.length).toBeGreaterThan(0); + const d = ctx.drivers[0]; + expect(d.behaviour).toBe("ACTIVITY_STEPS"); + expect(d.outcome).toBe("SLEEP_DURATION"); + expect(d.n).toBeGreaterThanOrEqual(20); + expect(d.qValue).toBeLessThanOrEqual(ctx.fdrQ); + // Descriptive-only: the interpretation frames the pair as a pattern, not + // a mechanism. It carries the conservative disclaimer verbatim and never + // upgrades the correlation to a causal claim ("X causes/leads to Y"). + const interp = d.interpretation.toLowerCase(); + expect(interp).toContain("not a cause"); + expect(interp).not.toMatch(/\bcauses\b|\bcausing\b|\bleads to\b|\bmakes\b/); + expect(ctx.pairsTested).toBeGreaterThan(0); + }); + + it("emits no drivers when no pair clears FDR", () => { + const m = new Map(); + m.set("WEIGHT", seriesEndingAt([...Array(30).fill(80), ...Array(30).fill(80)], "2026-04-30")); + m.set("PULSE", seriesEndingAt([...Array(30).fill(60), ...Array(30).fill(60)], "2026-04-30")); + const ctx = assertReady(assemblePeriodNarrativeContext(monthInput(m))); + expect(ctx.drivers).toEqual([]); + }); +}); + +describe("assemblePeriodNarrativeContext — coincident flags", () => { + it("fires on a day with ≥ 2 vitals outside their prior bands", () => { + const m = new Map(); + // Two vitals: tight prior bands, current period mostly in-band but ONE + // shared day far out of band on both. + const priorP = Array.from({ length: 30 }, () => 60); + const priorH = Array.from({ length: 30 }, () => 50); + const curP = Array.from({ length: 30 }, () => 60); + const curH = Array.from({ length: 30 }, () => 50); + // spike both on the last current day + curP[29] = 120; + curH[29] = 10; + m.set("PULSE", seriesEndingAt([...priorP, ...curP], "2026-04-30")); + m.set("RESTING_HEART_RATE", seriesEndingAt([...priorH, ...curH], "2026-04-30")); + + const ctx = assertReady(assemblePeriodNarrativeContext(monthInput(m))); + expect(ctx.coincidentFlags.length).toBe(1); + const flag = ctx.coincidentFlags[0]; + expect(flag.day).toBe("2026-04-30"); + expect(flag.vitals.map((v) => v.type).sort()).toEqual([ + "PULSE", + "RESTING_HEART_RATE", + ]); + expect(flag.vitals.find((v) => v.type === "PULSE")!.direction).toBe("above"); + expect( + flag.vitals.find((v) => v.type === "RESTING_HEART_RATE")!.direction, + ).toBe("below"); + }); + + it("does not fire when only one vital deviates on any day", () => { + const m = new Map(); + const priorP = Array.from({ length: 30 }, () => 60); + const priorH = Array.from({ length: 30 }, () => 50); + const curP = Array.from({ length: 30 }, () => 60); + const curH = Array.from({ length: 30 }, () => 50); + curP[29] = 120; // only one vital deviates + m.set("PULSE", seriesEndingAt([...priorP, ...curP], "2026-04-30")); + m.set("RESTING_HEART_RATE", seriesEndingAt([...priorH, ...curH], "2026-04-30")); + const ctx = assertReady(assemblePeriodNarrativeContext(monthInput(m))); + expect(ctx.coincidentFlags).toEqual([]); + }); +}); + +describe("assemblePeriodNarrativeContext — provenance", () => { + it("carries window, computedAt and the metrics that backed a beat", () => { + const m = new Map(); + m.set("WEIGHT", seriesEndingAt([...Array(30).fill(80), ...Array(30).fill(81)], "2026-04-30")); + m.set("PULSE", seriesEndingAt([...Array(30).fill(60), ...Array(30).fill(62)], "2026-04-30")); + const ctx = assertReady(assemblePeriodNarrativeContext(monthInput(m))); + expect(ctx.provenance.computedAt).toBe("2026-04-30T12:00:00.000Z"); + expect(ctx.provenance.window.from).toBe("2026-03-02T00:00:00.000Z"); + expect(ctx.provenance.metrics).toContain("WEIGHT"); + expect(ctx.provenance.metrics).toContain("PULSE"); + expect(ctx.period).toBe("month"); + }); +}); diff --git a/src/lib/insights/narrative/period-narrative.ts b/src/lib/insights/narrative/period-narrative.ts new file mode 100644 index 000000000..7c12658e1 --- /dev/null +++ b/src/lib/insights/narrative/period-narrative.ts @@ -0,0 +1,551 @@ +/** + * v1.11.0 — period-narrative CONTEXT assembler (Pillar 1, no LLM). + * + * `buildPeriodNarrativeContext(userId, { period, now })` assembles the + * structured, compact, provenance-carrying data a LATER wave (B-W3) narrates. + * This wave produces NO prose and makes NO LLM call: it is a pure assembly + * over the rollup tier + derived layer + the existing FDR-controlled + * correlation engine. Every beat in the context is `label + number + source` + * so the generator can ground each sentence in a citation, and so the surface + * can render provenance chips. + * + * The honesty contract carries through verbatim from the layers it reuses: + * - **Drivers** are ONLY the BH-FDR-surviving pairs from `discoverCorrelations` + * (`benjaminiHochberg` already enforces descriptive-never-causal); each + * keeps its conservative `interpretation` string unchanged. + * - **Band transitions** are a personal-baseline (median ± k·MAD, Hampel/Leys) + * comparison of the current period against the band established over the + * PRIOR period — never an invented threshold. + * - **Coincident flags** carry the same `COINCIDENT_FIRE_THRESHOLD` / direction + * framing the live flag uses. + * + * Data-availability gate: the assembler returns an `insufficient`-style shape + * (`{ status: "insufficient", reason, coverage }`) when the period has too + * little history to narrate — never a fabricated story. The floor mirrors the + * derived layer: ≥ 2 metrics each with ≥ `MIN_COVERED_DAYS_PER_METRIC` covered + * days in the current period. + * + * Split into a PURE core (`assemblePeriodNarrativeContext`, fully unit-testable + * over injected series, no DB) and a thin DB wrapper that fetches + day-keys + + * delegates. The core is the one place the descriptive-only invariants live. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { getLocalDateParts } from "@/lib/timezone"; +import { + discoverCorrelations, + DISCOVERY_BEHAVIOURS, + DISCOVERY_OUTCOMES, + type DailySeriesPoint, + type NamedSeries, +} from "@/lib/insights/correlation-discovery"; +import { buildBaselineBand, median } from "@/lib/insights/derived/baseline"; +import { VITALS_BASELINE_TYPES } from "@/lib/insights/derived/registry"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** The two supported narrative periods and their length in days. */ +export const PERIOD_DAYS = { week: 7, month: 30 } as const; +export type NarrativePeriod = keyof typeof PERIOD_DAYS; + +/** + * Availability floor — a narrative is only assembled when this many metrics + * each clear the per-metric covered-day floor in the current period. Mirrors + * the derived layer's `minInputs` / `READINESS_MIN_COMPONENTS` posture. + */ +export const MIN_METRICS_WITH_COVERAGE = 2; +/** Per-metric covered-day floor for the current period. */ +export const MIN_COVERED_DAYS_PER_METRIC = 3; +/** A band must rest on at least this many prior-period days to be trusted. */ +export const MIN_BASELINE_DAYS = 7; + +/** The metrics the period-delta beat scans, with their display unit. */ +const DELTA_METRICS: Array<{ type: MeasurementType; unit: string }> = [ + { type: "WEIGHT", unit: "kg" }, + { type: "BLOOD_PRESSURE_SYS", unit: "mmHg" }, + { type: "BLOOD_PRESSURE_DIA", unit: "mmHg" }, + { type: "PULSE", unit: "bpm" }, + { type: "RESTING_HEART_RATE", unit: "bpm" }, + { type: "HEART_RATE_VARIABILITY", unit: "ms" }, + { type: "SLEEP_DURATION", unit: "h" }, + { type: "ACTIVITY_STEPS", unit: "" }, + { type: "BODY_FAT", unit: "%" }, + { type: "BLOOD_GLUCOSE", unit: "mg/dL" }, +]; + +// ── context shape ─────────────────────────────────────────────────────── + +/** One metric's current-period mean vs the prior period of equal length. */ +export interface MetricDelta { + type: MeasurementType; + unit: string; + /** Mean of the per-day means over the current period; null when uncovered. */ + current: number | null; + /** Mean over the prior period of equal length; null when uncovered. */ + prior: number | null; + /** current − prior, rounded; null when either side is uncovered. */ + delta: number | null; + /** delta as a percent of |prior|, rounded; null when not computable. */ + deltaPercent: number | null; + /** Covered days in the current period (the provenance denominator). */ + currentDays: number; + /** Covered days in the prior period. */ + priorDays: number; +} + +/** + * A vital whose current-period center crossed OUT of (or back INTO) its + * personal band established over the prior period. Descriptive, MAD-based. + */ +export interface BandTransition { + type: MeasurementType; + /** Current-period robust center (median of per-day means). */ + center: number; + /** Prior-period band edges. */ + bandLow: number; + bandHigh: number; + /** "above" / "below" the band, or "in" when the center sits inside. */ + direction: "above" | "below" | "in"; + /** True when the center now sits outside the prior-period band. */ + movedOut: boolean; + /** Prior-period days that established the band (≥ MIN_BASELINE_DAYS). */ + baselineDays: number; +} + +/** + * One FDR-surviving correlation, narrowed to the fields the generator cites. + * Mirrors `DiscoveredCorrelation` but drops nothing material — the + * `interpretation` is the conservative descriptive string, passed verbatim. + */ +export interface NarrativeDriver { + behaviour: string; + outcome: string; + r: number; + qValue: number; + n: number; + /** Conservative, descriptive interpretation — never causal, unchanged. */ + interpretation: string; +} + +/** A day inside the period where ≥ 2 vitals sat outside their band together. */ +export interface CoincidentFlag { + day: string; + /** The contributing vitals + their direction on that day. */ + vitals: Array<{ type: MeasurementType; direction: "above" | "below" }>; +} + +/** Provenance envelope mirroring the Coach/derived chips. */ +export interface NarrativeProvenance { + /** The metric keys that actually backed a beat in this context. */ + metrics: string[]; + /** ISO window {from,to} of the read. */ + window: { from: string; to: string }; + /** Compute time (ISO 8601). */ + computedAt: string; +} + +/** The successful, ready-to-narrate context object. */ +export interface PeriodNarrativeContext { + status: "ready"; + period: NarrativePeriod; + metricDeltas: MetricDelta[]; + bandTransitions: BandTransition[]; + drivers: NarrativeDriver[]; + coincidentFlags: CoincidentFlag[]; + /** How many discovery pairs were tested (the honest footer). */ + pairsTested: number; + /** The FDR target the drivers cleared. */ + fdrQ: number; + provenance: NarrativeProvenance; +} + +/** The gated arm — too little history to narrate honestly. */ +export interface PeriodNarrativeInsufficient { + status: "insufficient"; + period: NarrativePeriod; + reason: string; + /** Metrics that DID clear the per-metric floor (so the UI can nudge). */ + coverage: { metricsWithData: number; required: number }; +} + +export type PeriodNarrativeResult = + | PeriodNarrativeContext + | PeriodNarrativeInsufficient; + +// ── pure helpers ────────────────────────────────────────────────────────── + +/** YYYY-MM-DD day key for an instant in the user's display timezone. */ +function tzDayKey(at: Date, tz: string): string { + const { year, month, day } = getLocalDateParts(at, tz); + return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + +/** Collapse raw readings to per-day means, day-keyed in the user's tz. Pure. */ +function toDailyMeans( + rows: Array<{ value: number; at: Date }>, + tz: string, +): DailySeriesPoint[] { + const byDay = new Map(); + for (const r of rows) { + if (!Number.isFinite(r.value)) continue; + const day = tzDayKey(r.at, tz); + const acc = byDay.get(day) ?? { sum: 0, count: 0 }; + acc.sum += r.value; + acc.count += 1; + byDay.set(day, acc); + } + return [...byDay.entries()] + .map(([day, acc]) => ({ day, value: acc.sum / acc.count })) + .sort((a, b) => (a.day < b.day ? -1 : 1)); +} + +/** Round to 2 decimals. */ +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +/** Mean of a numeric array; null when empty. */ +function meanOrNull(values: number[]): number | null { + if (values.length === 0) return null; + return values.reduce((s, v) => s + v, 0) / values.length; +} + +/** Partition a day-keyed series into the current vs prior period halves. */ +function splitByPeriod( + points: DailySeriesPoint[], + currentFrom: string, + priorFrom: string, +): { current: number[]; prior: number[] } { + const current: number[] = []; + const prior: number[] = []; + for (const p of points) { + if (p.day >= currentFrom) current.push(p.value); + else if (p.day >= priorFrom) prior.push(p.value); + } + return { current, prior }; +} + +// ── pure core ────────────────────────────────────────────────────────────── + +/** Per-metric day-keyed series spanning the full 2×period window. */ +export interface AssembleInput { + period: NarrativePeriod; + /** Inclusive start of the current period (YYYY-MM-DD). */ + currentFrom: string; + /** Inclusive start of the prior period (YYYY-MM-DD). */ + priorFrom: string; + /** ISO window of the read for the provenance chip. */ + window: { from: string; to: string }; + /** Day-keyed series per metric, keyed by `MeasurementType` (+ MOOD). */ + seriesByMetric: Map; + /** Named series feeding the discovery matrix (same window). */ + discoverySeries: NamedSeries[]; + /** Compute time (ISO). */ + computedAt: string; +} + +/** + * Pure assembler — given already-day-keyed series, build the typed context. + * No DB, no LLM, no clock read (every time is injected). This is the unit + * under test. + */ +export function assemblePeriodNarrativeContext( + input: AssembleInput, +): PeriodNarrativeResult { + const { + period, + currentFrom, + priorFrom, + window, + seriesByMetric, + discoverySeries, + computedAt, + } = input; + + // ── metric deltas (current period vs prior period of equal length) ────── + const metricDeltas: MetricDelta[] = []; + const metricsWithCoverage: string[] = []; + for (const { type, unit } of DELTA_METRICS) { + const points = seriesByMetric.get(type) ?? []; + const { current, prior } = splitByPeriod(points, currentFrom, priorFrom); + const currentAvg = meanOrNull(current); + const priorAvg = meanOrNull(prior); + if (currentAvg === null && priorAvg === null) continue; + if (current.length >= MIN_COVERED_DAYS_PER_METRIC) { + metricsWithCoverage.push(type); + } + const delta = + currentAvg !== null && priorAvg !== null + ? round2(currentAvg - priorAvg) + : null; + const deltaPercent = + delta !== null && priorAvg !== null && priorAvg !== 0 + ? Math.round((delta / Math.abs(priorAvg)) * 1000) / 10 + : null; + metricDeltas.push({ + type, + unit, + current: currentAvg === null ? null : round2(currentAvg), + prior: priorAvg === null ? null : round2(priorAvg), + delta, + deltaPercent, + currentDays: current.length, + priorDays: prior.length, + }); + } + + // ── availability gate ─────────────────────────────────────────────────── + if (metricsWithCoverage.length < MIN_METRICS_WITH_COVERAGE) { + return { + status: "insufficient", + period, + reason: "not_enough_history", + coverage: { + metricsWithData: metricsWithCoverage.length, + required: MIN_METRICS_WITH_COVERAGE, + }, + }; + } + + // ── derived-band transitions (prior-period band vs current center) ────── + // The band is the personal typical range (median ± k·MAD) established over + // the PRIOR period; a transition is the current-period center crossing it. + // Never an invented threshold — same MAD basis as VITALS_BASELINE. + const bandTransitions: BandTransition[] = []; + for (const type of VITALS_BASELINE_TYPES) { + const points = seriesByMetric.get(type) ?? []; + const { current, prior } = splitByPeriod(points, currentFrom, priorFrom); + if (prior.length < MIN_BASELINE_DAYS) continue; + if (current.length < MIN_COVERED_DAYS_PER_METRIC) continue; + const band = buildBaselineBand(prior); + if (!band) continue; + const center = median(current); + const above = center > band.high; + const below = center < band.low; + bandTransitions.push({ + type, + center: round2(center), + bandLow: round2(band.low), + bandHigh: round2(band.high), + direction: above ? "above" : below ? "below" : "in", + movedOut: above || below, + baselineDays: prior.length, + }); + } + + // ── drivers (FDR-surviving correlations, descriptive-only) ────────────── + const discovery = discoverCorrelations(discoverySeries); + const drivers: NarrativeDriver[] = discovery.discovered.map((d) => ({ + behaviour: d.behaviour, + outcome: d.outcome, + r: d.r, + qValue: d.qValue, + n: d.n, + interpretation: d.interpretation, + })); + + // ── coincident-deviation flags within the current period ──────────────── + // A day where ≥ 2 vitals sat outside their prior-period band together. Uses + // the same prior-period bands the transitions rest on, so the framing and + // the COINCIDENT_FIRE_THRESHOLD posture match the live flag. + const coincidentFlags = computeCoincidentFlags( + seriesByMetric, + currentFrom, + priorFrom, + ); + + const metrics = Array.from( + new Set([ + ...metricsWithCoverage, + ...bandTransitions.map((b) => b.type), + ...drivers.flatMap((d) => [d.behaviour, d.outcome]), + ]), + ); + + return { + status: "ready", + period, + metricDeltas, + bandTransitions, + drivers, + coincidentFlags, + pairsTested: discovery.pairsTested, + fdrQ: discovery.fdrQ, + provenance: { metrics, window, computedAt }, + }; +} + +/** ≥ this many out-of-band vitals on one day fires a coincident flag. */ +const COINCIDENT_FIRE_THRESHOLD = 2; + +/** + * Scan each day in the current period for ≥ 2 vitals outside their + * prior-period band. Pure. The band is rebuilt per vital from the prior + * period (same MAD basis as the transitions), so the two beats agree. + */ +function computeCoincidentFlags( + seriesByMetric: Map, + currentFrom: string, + priorFrom: string, +): CoincidentFlag[] { + // Build a prior-period band per banded vital, plus the current-period + // per-day value for each. + const bands = new Map(); + const currentByDay = new Map< + string, + Array<{ type: MeasurementType; value: number }> + >(); + for (const type of VITALS_BASELINE_TYPES) { + const points = seriesByMetric.get(type) ?? []; + const prior: number[] = []; + for (const p of points) { + if (p.day >= currentFrom) { + const list = currentByDay.get(p.day) ?? []; + list.push({ type, value: p.value }); + currentByDay.set(p.day, list); + } else if (p.day >= priorFrom) { + prior.push(p.value); + } + } + if (prior.length < MIN_BASELINE_DAYS) continue; + const band = buildBaselineBand(prior); + if (band) bands.set(type, { low: band.low, high: band.high }); + } + + const flags: CoincidentFlag[] = []; + for (const [day, readings] of [...currentByDay.entries()].sort((a, b) => + a[0] < b[0] ? -1 : 1, + )) { + const contributing: Array<{ + type: MeasurementType; + direction: "above" | "below"; + }> = []; + for (const { type, value } of readings) { + const band = bands.get(type); + if (!band) continue; + if (value > band.high) contributing.push({ type, direction: "above" }); + else if (value < band.low) contributing.push({ type, direction: "below" }); + } + if (contributing.length >= COINCIDENT_FIRE_THRESHOLD) { + flags.push({ day, vitals: contributing }); + } + } + return flags; +} + +// ── DB wrapper ───────────────────────────────────────────────────────────── + +export interface BuildPeriodNarrativeContextOpts { + period: NarrativePeriod; + /** Injected clock for deterministic behaviour; defaults to now. */ + now?: Date; +} + +/** + * Fetch + day-key + assemble. Reads a single bounded window covering the + * current AND prior period (2× the period length plus one extra day so the + * day-1 lag join in discovery has its source), day-keys in the user's tz, + * and delegates to the pure core. No LLM, no migration, no new heavy query — + * one measurement read + one mood read, both bounded. + */ +export async function buildPeriodNarrativeContext( + userId: string, + opts: BuildPeriodNarrativeContextOpts, +): Promise { + const period = opts.period; + const now = opts.now ?? new Date(); + const periodDays = PERIOD_DAYS[period]; + // +1 day of slack so discovery's day-1 lag join always has its prior day. + const windowDays = periodDays * 2 + 1; + const since = new Date(now.getTime() - windowDays * MS_PER_DAY); + + const profile = await prisma.user.findUnique({ + where: { id: userId }, + select: { timezone: true }, + }); + const tz = profile?.timezone ?? "Europe/Berlin"; + + const currentFrom = tzDayKey( + new Date(now.getTime() - periodDays * MS_PER_DAY), + tz, + ); + const priorFrom = tzDayKey( + new Date(now.getTime() - periodDays * 2 * MS_PER_DAY), + tz, + ); + + // The full set of types any beat needs: delta metrics ∪ banded vitals ∪ + // discovery channels (minus MOOD, which is mood-entry backed). + const measurementTypes = Array.from( + new Set([ + ...DELTA_METRICS.map((m) => m.type), + ...VITALS_BASELINE_TYPES, + ...DISCOVERY_BEHAVIOURS.filter((k) => k !== "MOOD"), + ...DISCOVERY_OUTCOMES, + ]), + ) as MeasurementType[]; + + const [measurements, moodEntries] = await Promise.all([ + prisma.measurement.findMany({ + where: { + userId, + deletedAt: null, + measuredAt: { gte: since }, + type: { in: measurementTypes }, + }, + orderBy: { measuredAt: "asc" }, + take: 20000, + select: { type: true, value: true, measuredAt: true }, + }), + prisma.moodEntry.findMany({ + where: { userId, deletedAt: null, moodLoggedAt: { gte: since } }, + orderBy: { moodLoggedAt: "asc" }, + take: 5000, + select: { score: true, moodLoggedAt: true }, + }), + ]); + + const rawByType = new Map>(); + for (const m of measurements) { + const list = rawByType.get(m.type) ?? []; + list.push({ value: m.value, at: m.measuredAt }); + rawByType.set(m.type, list); + } + + const seriesByMetric = new Map(); + for (const [type, rows] of rawByType) { + seriesByMetric.set(type, toDailyMeans(rows, tz)); + } + const moodPoints = toDailyMeans( + moodEntries.map((e) => ({ value: e.score, at: e.moodLoggedAt })), + tz, + ); + if (moodPoints.length > 0) seriesByMetric.set("MOOD", moodPoints); + + // Discovery matrix over the same window. + const discoverySeries: NamedSeries[] = []; + for (const key of DISCOVERY_BEHAVIOURS) { + discoverySeries.push({ + key, + role: "behaviour", + points: seriesByMetric.get(key) ?? [], + }); + } + for (const key of DISCOVERY_OUTCOMES) { + discoverySeries.push({ + key, + role: "outcome", + points: seriesByMetric.get(key) ?? [], + }); + } + + return assemblePeriodNarrativeContext({ + period, + currentFrom, + priorFrom, + window: { from: since.toISOString(), to: now.toISOString() }, + seriesByMetric, + discoverySeries, + computedAt: now.toISOString(), + }); +}