mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
perf(insights): fold correlation-discovery channels into the status input gate
The slow-moving status input fingerprint covered the card's own metric rows plus mood and height, but not the FDR discovery channels that also feed the prompt via getRelevantCorrelationsForMetric. A correlation newly surfacing off a behaviour channel (e.g. daylight -> next-day weight) left the card's own rows untouched, so the input gate re-stamped the stale assessment and the discovery never reached the prose. computeStatusInputFingerprint gains includeCorrelationChannels: it widens the same grouped probe by the discovery behaviour + outcome channels (deduped, mood excluded as it is mood-entry backed), so a channel change flips the hash. Wired into weight-status, the one caller that folds a relations block.
This commit is contained in:
1 parent
7540d0b508
commit
7f9aeba918
3 files changed
+115
-1
No files matched your search
@@ -457,6 +457,73 @@ describe("computeStatusInputFingerprint (v1.18.11 P6)", () => {
|
||||
expect(c).not.toBe(a);
|
||||
});
|
||||
|
||||
it("flips when a correlation-discovery channel moves (P6-tighten)", async () => {
|
||||
const t0 = new Date("2026-05-30T08:00:00.000Z");
|
||||
// The card's own metric (WEIGHT) is steady across both probes; only a
|
||||
// discovery BEHAVIOUR channel (steps) gains rows. Without the channel
|
||||
// coverage the gate would re-stamp the stale assessment and a newly
|
||||
// discoverable "steps → next-day weight" relation would never surface.
|
||||
const weightOnly = [
|
||||
{ type: "WEIGHT", _count: { _all: 10 }, _max: { measuredAt: t0 } },
|
||||
];
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValueOnce(
|
||||
weightOnly as never,
|
||||
);
|
||||
const before = await computeStatusInputFingerprint({
|
||||
userId: "u1",
|
||||
types: ["WEIGHT"],
|
||||
includeCorrelationChannels: true,
|
||||
});
|
||||
|
||||
// A new ACTIVITY_STEPS reading (a discovery channel, NOT the card's own
|
||||
// type) appears — the grouped probe now returns it.
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValueOnce([
|
||||
...weightOnly,
|
||||
{
|
||||
type: "ACTIVITY_STEPS",
|
||||
_count: { _all: 30 },
|
||||
_max: { measuredAt: t0 },
|
||||
},
|
||||
] as never);
|
||||
const after = await computeStatusInputFingerprint({
|
||||
userId: "u1",
|
||||
types: ["WEIGHT"],
|
||||
includeCorrelationChannels: true,
|
||||
});
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
|
||||
it("widens the grouped query by the discovery channels only when asked", async () => {
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
|
||||
await computeStatusInputFingerprint({
|
||||
userId: "u1",
|
||||
types: ["WEIGHT"],
|
||||
});
|
||||
const narrow = vi.mocked(prisma.measurement.groupBy).mock.calls[0][0] as {
|
||||
where: { type: { in: string[] } };
|
||||
};
|
||||
// No channel widening — exactly the card's own types.
|
||||
expect(narrow.where.type.in).toEqual(["WEIGHT"]);
|
||||
|
||||
vi.mocked(prisma.measurement.groupBy).mockClear();
|
||||
await computeStatusInputFingerprint({
|
||||
userId: "u1",
|
||||
types: ["WEIGHT"],
|
||||
includeCorrelationChannels: true,
|
||||
});
|
||||
const wide = vi.mocked(prisma.measurement.groupBy).mock.calls[0][0] as {
|
||||
where: { type: { in: string[] } };
|
||||
};
|
||||
// The card's own type is present exactly once (de-duped against the
|
||||
// discovery channel set, which also carries WEIGHT as an outcome).
|
||||
expect(wide.where.type.in.filter((t) => t === "WEIGHT")).toHaveLength(1);
|
||||
// A behaviour channel the card never reads on its own is now folded in.
|
||||
expect(wide.where.type.in).toContain("ACTIVITY_STEPS");
|
||||
expect(wide.where.type.in).toContain("SLEEP_DURATION");
|
||||
// MOOD is mood-entry backed, not a measurement type — never in the IN set.
|
||||
expect(wide.where.type.in).not.toContain("MOOD");
|
||||
});
|
||||
|
||||
it("folds mood and extra inputs into the hash when requested", async () => {
|
||||
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -9,8 +9,27 @@ import {
|
||||
type InsightStatusScope,
|
||||
} from "@/lib/jobs/insight-status-generate-shared";
|
||||
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
|
||||
import {
|
||||
DISCOVERY_BEHAVIOURS,
|
||||
DISCOVERY_OUTCOMES,
|
||||
} from "@/lib/insights/correlation-discovery";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* v1.18.11 (P6-tighten) — the measurement types the FDR correlation-discovery
|
||||
* matrix scans (`correlation-discovery.ts`). A status card whose prompt folds
|
||||
* the surviving cross-metric correlations (`getRelevantCorrelationsForMetric`)
|
||||
* can have a NEW correlation surface purely because one of these channels
|
||||
* gained paired data — with no change to the card's own metric rows. The input
|
||||
* gate must therefore fingerprint these channels too, or a freshly discovered
|
||||
* relation would be silently skipped for the day. `MOOD` is mood-entry backed
|
||||
* (folded separately via `includeMood`), so it is excluded from the
|
||||
* measurement-type set here.
|
||||
*/
|
||||
const CORRELATION_CHANNEL_TYPES: readonly MeasurementType[] = Array.from(
|
||||
new Set<string>([...DISCOVERY_BEHAVIOURS, ...DISCOVERY_OUTCOMES]),
|
||||
).filter((key) => key !== "MOOD") as MeasurementType[];
|
||||
|
||||
/**
|
||||
* Shared cache-read for the seven `*-status.ts` insight generators.
|
||||
*
|
||||
@@ -230,6 +249,20 @@ export async function computeStatusInputFingerprint(args: {
|
||||
* moved. Omit for metrics that don't read mood.
|
||||
*/
|
||||
includeMood?: boolean;
|
||||
/**
|
||||
* v1.18.11 (P6-tighten) — include the FDR correlation-discovery channels
|
||||
* (`CORRELATION_CHANNEL_TYPES`) in the fingerprint. A card that folds the
|
||||
* surviving cross-metric correlations (via `getRelevantCorrelationsForMetric`)
|
||||
* can surface a NEW relation purely because a discovery channel — steps,
|
||||
* sleep, HRV, glucose, daylight, … — gained paired data, with NO change to
|
||||
* the card's own metric rows. Without this the input gate would re-stamp the
|
||||
* stale assessment and the freshly discovered correlation would never reach
|
||||
* the prose. Cheap: it widens the SAME grouped query by the channel type set
|
||||
* (no extra round-trip). `includeMood` is honoured for the mood arm of the
|
||||
* discovery matrix as before. Set on any card whose prompt carries a
|
||||
* relations block (i.e. whose metric is a discovery channel).
|
||||
*/
|
||||
includeCorrelationChannels?: boolean;
|
||||
/**
|
||||
* v1.18.11 (P6) — extra non-measurement inputs the snapshot derives from
|
||||
* (e.g. BMI reads the profile `heightCm`). Folded into the hash so a
|
||||
@@ -237,12 +270,20 @@ export async function computeStatusInputFingerprint(args: {
|
||||
*/
|
||||
extra?: Record<string, string | number | null>;
|
||||
}): Promise<string> {
|
||||
// Widen the grouped query by the correlation-discovery channels when the
|
||||
// card folds a relations block, so a discovery-channel change flips the gate.
|
||||
// De-duplicate the union (a card's own type can also be a discovery channel)
|
||||
// so the `type IN (...)` list carries each type once.
|
||||
const groupTypes = args.includeCorrelationChannels
|
||||
? Array.from(new Set<string>([...args.types, ...CORRELATION_CHANNEL_TYPES]))
|
||||
: [...args.types];
|
||||
|
||||
const [grouped, mood] = await Promise.all([
|
||||
prisma.measurement.groupBy({
|
||||
by: ["type"],
|
||||
where: {
|
||||
userId: args.userId,
|
||||
type: { in: [...args.types] },
|
||||
type: { in: groupTypes as MeasurementType[] },
|
||||
deletedAt: null,
|
||||
},
|
||||
_count: { _all: true },
|
||||
|
||||
@@ -226,6 +226,12 @@ export async function prepareWeightStatusForUser(
|
||||
userId,
|
||||
types: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
|
||||
includeMood: true,
|
||||
// v1.18.11 (P6-tighten) — WEIGHT is an FDR discovery OUTCOME channel and
|
||||
// the prompt folds `getRelevantCorrelationsForMetric(userId, "WEIGHT")`, so
|
||||
// a relation surfacing off a behaviour channel (e.g. more daylight →
|
||||
// next-day weight) must flip the gate even when no weight row moved. Fold
|
||||
// the discovery channels + the prior-analysis anchor into the fingerprint.
|
||||
includeCorrelationChannels: true,
|
||||
});
|
||||
if (!force) {
|
||||
const unchangedInput = await gateUnchangedStatusInput({
|
||||
|
||||
Reference in new issue
Block a user