mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
86dea3e176
commit
999f2b6b08
3 files changed
+826
-1
No files matched your search
@@ -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(),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user