merge(RECON1): clinical-floors, coincident-tz, correlation quality, local drivers

This commit is contained in:
Marc-André Bombeck committed 2026-06-26 13:15:03 +02:00
commit f493ff58cb
16 files changed
+800 -43

No files matched your search

@@ -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();
});
});
+62
View File
@@ -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<CorrelationsSnapshotBlock | null> {
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,
};
}
+4 -1
View File
@@ -145,6 +145,7 @@ export async function buildDerivedSnapshotBlock(
userId: string,
profile: BaselineProfile,
now: Date,
tz?: string,
): Promise<DerivedSnapshotBlock | null> {
const block: Record<string, DerivedSnapshotEntry> = {};
@@ -152,9 +153,11 @@ export async function buildDerivedSnapshotBlock(
// off the one shared profile alongside the scores; fail-soft to null so a
// baseline hiccup never sinks the derived block. Only attached when it
// FIRED (≥2 vitals out of band today) — a quiet day adds no entry, keeping
// the snapshot noise-free.
// the snapshot noise-free. D2-8: pass the user's tz so the "today" grouping
// matches the user's calendar day, not UTC's.
const coincidentPromise = computeCoincidentDeviation(userId, profile, {
now,
...(tz ? { tz } : {}),
}).catch(() => null);
// The metrics are independent passthrough reads off the one shared profile —
+28 -1
View File
@@ -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";
@@ -1237,7 +1238,7 @@ async function buildCoachSnapshotImpl(
? null
: buildGlp1SnapshotBlock(userId, now);
const derivedBlockPromise = derivedActive
? buildDerivedSnapshotBlock(userId, derivedProfile, now)
? buildDerivedSnapshotBlock(userId, derivedProfile, now, userTz)
: null;
// v1.17.0 — WHOOP-native day strain (0–21), distinct from the COMPUTED
// STRAIN_SCORE (0–100) the derived block carries. Gated on the same
@@ -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) ──────────────
+4 -2
View File
@@ -157,8 +157,10 @@ export async function readCoachCorrelations(
select: { score: true, moodLoggedAt: true },
}),
// Coincident-deviation is its own derived metric — fail-soft to null so a
// baseline hiccup never sinks the whole correlations read.
computeCoincidentDeviation(userId, profile).catch(() => null),
// baseline hiccup never sinks the whole correlations read. D2-8: pass the
// user's tz so the "today" grouping matches the user's calendar day, not
// UTC's, before the fired flag is narrated as "out of band TODAY".
computeCoincidentDeviation(userId, profile, { tz }).catch(() => null),
]);
const byType = new Map<string, Array<{ value: number; at: Date }>>();