diff --git a/src/lib/ai/coach/__tests__/correlations-snapshot.test.ts b/src/lib/ai/coach/__tests__/correlations-snapshot.test.ts new file mode 100644 index 000000000..ef0ae83f2 --- /dev/null +++ b/src/lib/ai/coach/__tests__/correlations-snapshot.test.ts @@ -0,0 +1,94 @@ +/** + * RECON1 (D5-5) — the no-tools snapshot driver block surfaces the SAME gated, + * ranked drivers the get_correlations tool serves, bounded to the top N, and + * attaches nothing when no driver survives the quality gates. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const readCoachCorrelations = vi.fn(); +vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({ + readCoachCorrelations: (userId: string) => readCoachCorrelations(userId), +})); + +import { + buildCorrelationsSnapshotBlock, + SNAPSHOT_DRIVER_CAP, +} from "@/lib/ai/coach/correlations-snapshot"; +import type { CoachCorrelationDriver } from "@/lib/ai/coach/tools/correlations-read"; + +function driver( + behaviour: string, + outcome: string, + r: number, +): CoachCorrelationDriver { + return { + behaviour, + outcome, + direction: r >= 0 ? "higher" : "lower", + lagDays: 1, + n: 42, + r, + note: `Higher ${behaviour} tends to go with ${outcome} — not a cause.`, + }; +} + +describe("buildCorrelationsSnapshotBlock (D5-5)", () => { + beforeEach(() => { + readCoachCorrelations.mockReset(); + }); + + it("carries driver pairs when the engine surfaces them", async () => { + readCoachCorrelations.mockResolvedValue({ + present: true, + drivers: [ + driver("time in daylight", "sleep duration", 0.41), + driver("mood", "resting heart rate", -0.33), + ], + pairsTested: 16, + windowDays: 180, + }); + const block = await buildCorrelationsSnapshotBlock("user-1"); + expect(block).not.toBeNull(); + expect(block!.drivers).toHaveLength(2); + expect(block!.drivers[0].behaviour).toBe("time in daylight"); + expect(block!.pairsTested).toBe(16); + expect(block!.windowDays).toBe(180); + }); + + it("bounds the block to the top N ranked drivers", async () => { + const many = Array.from({ length: SNAPSHOT_DRIVER_CAP + 4 }, (_, i) => + driver(`behaviour ${i}`, "sleep duration", 0.5 - i * 0.02), + ); + readCoachCorrelations.mockResolvedValue({ + present: true, + drivers: many, + pairsTested: 30, + windowDays: 180, + }); + const block = await buildCorrelationsSnapshotBlock("user-1"); + expect(block!.drivers).toHaveLength(SNAPSHOT_DRIVER_CAP); + // Order preserved from the already-ranked tool output (highest effect first). + expect(block!.drivers[0].behaviour).toBe("behaviour 0"); + }); + + it("attaches nothing when no driver survives the quality gates", async () => { + readCoachCorrelations.mockResolvedValue({ + present: false, + reason: "no_significant_pattern", + }); + expect(await buildCorrelationsSnapshotBlock("user-1")).toBeNull(); + }); + + it("attaches nothing when present but the driver list is empty (coincident-only)", async () => { + // The reader can be present purely for the coincident flag with zero + // discovered drivers — the snapshot floor adds the driver layer only. + readCoachCorrelations.mockResolvedValue({ + present: true, + drivers: [], + coincident: { fired: true, contributing: [], day: "2026-06-26" }, + pairsTested: 12, + windowDays: 180, + }); + expect(await buildCorrelationsSnapshotBlock("user-1")).toBeNull(); + }); +}); diff --git a/src/lib/ai/coach/correlations-snapshot.ts b/src/lib/ai/coach/correlations-snapshot.ts new file mode 100644 index 000000000..8f80545b7 --- /dev/null +++ b/src/lib/ai/coach/correlations-snapshot.ts @@ -0,0 +1,62 @@ +/** + * RECON1 (D5-5) — discovered-driver block for the no-tools Coach snapshot. + * + * The FDR-controlled cross-metric driver pairs were reachable ONLY via the + * `get_correlations` tool, so a local / no-tools provider (Ollama) got the + * coincident-deviation flag but ZERO discovered drivers — the differentiating + * cross-metric layer silently degraded, and system-prompt rule 14's fallback + * clause ("any correlation/driver field the SNAPSHOT carries") pointed at a + * field that did not exist on the no-tools path. + * + * This block closes that gap by attaching a BOUNDED slice of the SAME gated, + * ranked output the tool serves — `readCoachCorrelations` runs the identical + * discovery scan + quality gates (effect-size floor, family-tautology + * exclusion, sample-size shrinkage, confidence tiering), so a local user sees + * exactly the quality-filtered drivers a cloud user's tool would. We keep only + * the top few by ranked effect so the no-tools prompt stays compact. + * + * Server-only — `readCoachCorrelations` reads `@/lib/db`. Fail-soft: a null + * return (no surviving driver / read hiccup) attaches nothing. + */ +import { + readCoachCorrelations, + type CoachCorrelationDriver, +} from "./tools/correlations-read"; + +/** How many ranked drivers reach the no-tools snapshot floor. */ +export const SNAPSHOT_DRIVER_CAP = 3; + +/** One compact driver line for the snapshot — descriptive, never causal. */ +export interface CorrelationsSnapshotBlock { + /** Top discovered drivers by ranked effect, capped + descriptive. */ + drivers: CoachCorrelationDriver[]; + /** How many behaviour×outcome pairs were tested (honest footer). */ + pairsTested: number; + /** Trailing-day window the discovery scanned. */ + windowDays: number; +} + +/** + * Build the bounded discovered-driver block for the no-tools snapshot floor, or + * `null` when no driver survives the quality gates (or the read fails). The + * coincident flag is intentionally NOT duplicated here — the derived block + * already carries it; this block adds only the discovered-driver layer the + * no-tools path was missing. + */ +export async function buildCorrelationsSnapshotBlock( + userId: string, +): Promise { + const result = await readCoachCorrelations(userId); + if (!result.present || !result.drivers || result.drivers.length === 0) { + return null; + } + // `result.drivers` arrives already ranked by the discovery engine (shrunk + // effect magnitude, q as tie-break) and already gated (below-floor pairs + // dropped, tautologies excluded) — so the top-N slice is the highest-signal + // quality-filtered drivers, identical to what the tool path would surface. + return { + drivers: result.drivers.slice(0, SNAPSHOT_DRIVER_CAP), + pairsTested: result.pairsTested ?? 0, + windowDays: result.windowDays ?? 0, + }; +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 69050bdd2..ff9e8f1e7 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -46,6 +46,7 @@ import { annotate } from "@/lib/logging/context"; import { memoizePerRequest } from "@/lib/request-cache"; import { buildGlp1SnapshotBlock } from "./glp1-snapshot"; import { buildDerivedSnapshotBlock } from "./derived-snapshot"; +import { buildCorrelationsSnapshotBlock } from "./correlations-snapshot"; import { buildCoachMemoryBlock } from "./memory-snapshot"; import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot"; import { buildCycleSnapshotBlock } from "./cycle-snapshot"; @@ -1259,6 +1260,16 @@ async function buildCoachSnapshotImpl( const trajectoryBlockPromise = derivedActive ? buildTrajectorySnapshotBlock(userId, derivedProfile, now) : null; + // RECON1 (D5-5) — discovered cross-metric driver pairs for the no-tools + // snapshot floor. Reuses the SAME gated/ranked output the get_correlations + // tool serves (effect-size floor + family-tautology exclusion + shrinkage + + // confidence tiering already applied inside the discovery engine), so a + // local/no-tools provider reaches parity on the flagship cross-metric layer + // instead of getting only the coincident flag. Gated on `derivedActive` (it + // is the recovery / cross-metric layer) and fail-soft to null. + const correlationsBlockPromise = derivedActive + ? buildCorrelationsSnapshotBlock(userId) + : null; const memoryBlockPromise = buildCoachMemoryBlock( userId, derivedProfile, @@ -1305,6 +1316,7 @@ async function buildCoachSnapshotImpl( derivedBlock, dayStrainRows, trajectoryBlock, + correlationsBlock, memoryBlock, cycleBlock, illnessBlock, @@ -1320,6 +1332,7 @@ async function buildCoachSnapshotImpl( derivedBlockPromise, dayStrainRowsPromise, trajectoryBlockPromise, + correlationsBlockPromise, memoryBlockPromise, cycleBlockPromise, illnessBlockPromise, @@ -2269,6 +2282,20 @@ async function buildCoachSnapshotImpl( snapshot.trajectory = trajectoryBlock; registerBlock("trajectory", "skin_temp"); } + + // ── RECON1 (D5-5) — discovered cross-metric drivers ────────────────── + // The bounded top-N driver pairs (post quality-gate, post-rank) from the + // SAME engine the get_correlations tool reads, attached so the no-tools / + // local-provider path narrates the cross-metric layer it was missing — + // closing the parity gap with the tool path and making system-prompt rule + // 14's "any driver field the SNAPSHOT carries" fallback real. Descriptive, + // never causal. Registered against the lowest-priority `skin_temp` + // (environment) cluster so the budget degrader sheds it before any + // clinical block under prompt-budget pressure. + if (correlationsBlock) { + snapshot.correlations = correlationsBlock; + registerBlock("correlations", "skin_temp"); + } } // ── v1.11.0 W5a — rolling-profile memory (Pillar P2 2a) ──────────────