feat(insights): persist correlation patterns

This commit is contained in:
Marc-André Bombeck committed 2026-07-28 14:01:11 +02:00
1 parent 8ece6f39cc
commit 666a85d311
53 files changed
+2129 -55

No files matched your search

+6 -5
View File
@@ -7,9 +7,9 @@
* so the wire contract stays single-source. The response shapes are declared
* here.
*
* Custom metrics are a SEPARATE generic store from the closed measurement
* system: not synced, not in FHIR, not in AI insights — log + chart only. All
* fields are plaintext.
* Custom metrics are a separate generic store from the closed measurement
* system. A definition can explicitly opt into the bounded daily correlation
* behaviour-channel scan. All fields are plaintext.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
@@ -26,13 +26,13 @@ import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
createCustomMetricSchema.meta({
id: "CreateCustomMetricRequest",
description:
"Define a user-scoped custom metric ONCE: free-text `name` + `unit`, an optional target window (`targetLow` / `targetHigh`; when both present `targetLow` must not exceed `targetHigh`), optional display `decimals`, and an optional `description`. The name is unique per user. Logging a value later just picks this metric — its unit is snapshotted onto the value at write time. Isolated from the closed measurement system: not synced, not in FHIR, not in insights.",
"Define a user-scoped custom metric ONCE: free-text `name` + `unit`, an optional target window (`targetLow` / `targetHigh`; when both present `targetLow` must not exceed `targetHigh`), optional display `decimals`, an optional `description`, and explicit `correlationEnabled` opt-in. The name is unique per user. Logging a value later snapshots its unit.",
});
updateCustomMetricSchema.meta({
id: "UpdateCustomMetricRequest",
description:
"Partial edit of a custom metric. An omitted key leaves the column untouched; an explicit `null` on a target bound / `decimals` / `description` clears it. A rename that collides with another of the caller's live metrics is rejected 409.",
"Partial edit of a custom metric. An omitted key leaves the column untouched; an explicit `null` on a target bound / `decimals` / `description` clears it. `correlationEnabled` explicitly controls use as a bounded behaviour channel. A conflicting rename is rejected 409.",
});
createCustomMetricEntrySchema.meta({
@@ -64,6 +64,7 @@ const customMetricRow = z
targetHigh: z.number().nullable(),
decimals: z.number().nullable(),
description: z.string().nullable(),
correlationEnabled: z.boolean(),
latest: latestValue,
entryCount: z.number(),
createdAt: z.string(),
+58
View File
@@ -24,6 +24,9 @@ import {
derivedBatchQuery,
derivedBatchResponse,
correlationDiscoveryResponse,
correlationPatternListResponse,
updateCorrelationPatternRequest,
updateCorrelationPatternResponse,
glp1PlateauResponse,
insightStatusQuery,
insightStatusResponse,
@@ -511,4 +514,59 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/insights/patterns": {
get: {
tags: ["Insights"],
summary: "List current correlation patterns",
description:
"Returns the authenticated account's currently accepted persisted correlation identities, evidence, and dismissal timestamps.",
responses: {
"200": {
description: "Current persisted correlation patterns.",
content: {
"application/json": {
schema: dataEnvelope(
correlationPatternListResponse,
"CorrelationPatternListResponseEnvelope",
),
},
},
},
...stdResponses,
},
},
},
"/api/insights/patterns/{id}": {
patch: {
tags: ["Insights"],
summary: "Update correlation pattern dismissal",
description:
"Dismisses or restores one current account-owned pattern. A dismissal remains effective until the accepted evidence changes materially.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: {
"application/json": { schema: updateCorrelationPatternRequest },
},
},
responses: {
"200": {
description: "Updated dismissal decision.",
content: {
"application/json": {
schema: dataEnvelope(
updateCorrelationPatternResponse,
"UpdateCorrelationPatternResponseEnvelope",
),
},
},
},
"404": {
description: "No current pattern with that id for this account.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
};
@@ -281,6 +281,14 @@ export const discoveredCorrelation = z
.describe(
"Outcome channel (lag target), e.g. SLEEP_DURATION, HEART_RATE_VARIABILITY.",
),
behaviourLabel: z
.string()
.optional()
.describe("Display label for a dynamic behaviour channel."),
outcomeLabel: z
.string()
.optional()
.describe("Display label for a dynamic outcome channel."),
n: z
.number()
.int()
@@ -308,9 +316,57 @@ export const discoveredCorrelation = z
.describe(
"v1.22 — true for an emerging recent-window pair: fewer days, hedged as provisional rather than established.",
),
patternId: z
.string()
.optional()
.describe("Account-scoped persisted identity for dismissal."),
canonicalKey: z
.string()
.optional()
.describe("Stable factor + outcome + lag identity."),
dismissed: z
.boolean()
.optional()
.describe("Server decision to suppress unchanged evidence."),
})
.meta({ id: "DiscoveredCorrelation" });
export const correlationPattern = z
.object({
id: z.string(),
canonicalKey: z.string(),
family: z.string(),
factorKey: z.string(),
outcomeKey: z.string(),
lagDays: z.number().int(),
sampleSize: z.number().int(),
effectSize: z.number(),
pValue: z.number(),
qValue: z.number().nullable(),
evidenceHash: z.string(),
lastComputedAt: z.string(),
dismissedAt: z.string().nullable(),
})
.meta({ id: "CorrelationPattern" });
export const correlationPatternListResponse = z
.object({ patterns: z.array(correlationPattern) })
.meta({ id: "CorrelationPatternListResponse" });
export const updateCorrelationPatternRequest = z
.strictObject({ dismissed: z.boolean() })
.meta({ id: "UpdateCorrelationPatternRequest" });
export const updateCorrelationPatternResponse = z
.object({
id: z.string(),
canonicalKey: z.string(),
dismissed: z.boolean(),
dismissedAt: z.string().nullable(),
evidenceHash: z.string(),
})
.meta({ id: "UpdateCorrelationPatternResponse" });
// v1.22 — one labs ↔ outcome association (point-vs-window over sparse draws).
export const discoveredLabCorrelation = z
.object({