feat(insights): period-narrative context assembler

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 01:05:06 +02:00
1 parent 86dea3e176
commit 999f2b6b08
3 files changed
+826 -1

No files matched your search

+2 -1
View File
@@ -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"]
}
}
@@ -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<string, DailySeriesPoint[]>,
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<typeof assemblePeriodNarrativeContext>,
): 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<string, DailySeriesPoint[]>();
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<string, DailySeriesPoint[]>();
// 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<string, DailySeriesPoint[]>();
// 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<string, DailySeriesPoint[]>();
// 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<string, DailySeriesPoint[]>();
// 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<string, DailySeriesPoint[]>();
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<string, DailySeriesPoint[]>();
// 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<string, DailySeriesPoint[]>();
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<string, DailySeriesPoint[]>();
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<string, DailySeriesPoint[]>();
// 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<string, DailySeriesPoint[]>();
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<string, DailySeriesPoint[]>();
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");
});
});
@@ -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<string, { sum: number; count: number }>();
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<string, DailySeriesPoint[]>;
/** 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<string, DailySeriesPoint[]>,
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<string, { low: number; high: number }>();
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<PeriodNarrativeResult> {
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<string>([
...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<string, Array<{ value: number; at: Date }>>();
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<string, DailySeriesPoint[]>();
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(),
});
}