diff --git a/docs/architecture.md b/docs/architecture.md index 706102f..5d36ec5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,6 +108,8 @@ src/ | `conversations` | Chat conversation sessions (Phase 6A) | PK: UUID, FK: user_id | | `messages` | Individual chat messages (Phase 6A) | role CHECK: user/assistant/tool, JSONB tool_calls | | `action_items` | Persistent tracked action items from weekly reports (F3) | FK: weekly_reports(id) CASCADE; status CHECK with 7 states; 3 indexes | +| `correlations` | PHIE: discovered Pearson correlations across nutrition/training/biometric data | Unique: `(user_id, factor_metric, factor_condition, outcome_metric)`; CHECK on `confidence_level`, `status`, `category`; `first_detected_at` preserved across re-runs | +| `projections` | PHIE: 30-day trajectory projections with OLS confidence bands | Unique: `(user_id, metric, projection_date)`; CHECK on `method` | ### EAV Pattern (measurements table) @@ -176,6 +178,8 @@ POST /api/reports/generate │ ├── emit('completed') → completeReport() │ └── Report saved with full content + │ └── runCorrelationAnalysis() + runTrajectoryProjections() + │ └── non-blocking; failures logged under [intelligence] │ └── on error: emit('failed') → updateReportStatus() @@ -215,6 +219,8 @@ POST /api/reports/generate | GET | `/api/action-items/summary` | X-API-Key | F3 | | GET | `/api/action-items/:id` | X-API-Key | F3 | | PATCH | `/api/action-items/:id/status` | X-API-Key | F3 | +| GET | `/api/correlations` | None | PHIE Phase 1 | +| GET | `/api/projections/:metric` | None | PHIE Phase 1 | ## Authentication diff --git a/docs/product-capabilities.md b/docs/product-capabilities.md index b33d780..7fbd854 100644 --- a/docs/product-capabilities.md +++ b/docs/product-capabilities.md @@ -454,6 +454,128 @@ The date range picker on other pages is irrelevant to report generation. --- +## 4. Personal Health Intelligence Engine (PHIE Phase 1 — Backend) + +Backend-only intelligence layer that discovers Pearson correlations across +nutrition/training/biometric data, projects metric trajectories 30 days +forward with OLS confidence bands, and exposes results through REST + +AI chat tools. No dedicated UI in Phase 1 — surfaced via the existing +chat experience and consumed by the report generator. + +| ID | Use Case | Status | +|----|----------|--------| +| UC-INT-01 | Automatic correlation discovery across user health data | Implemented | +| UC-INT-02 | 30-day trajectory projection with confidence bands | Implemented | +| UC-INT-03 | REST API to list and filter correlations | Implemented | +| UC-INT-04 | REST API to fetch metric projections | Implemented | +| UC-INT-05 | AI chat tools for pattern discovery and what-if simulation | Implemented | + +### UC-INT-01: Automatic correlation discovery + +**As the system,** after a weekly report is generated, I want to run a +correlation analysis across the user's nutrition, training, and biometric +history, **so that** the chat and future reports can surface personalised +patterns the user didn't explicitly ask about. + +**Behavior:** +- Runs automatically at the end of both the sync (`?sync=true`) and async + report generation paths, inside a non-blocking `try/catch` — failures + are logged under `[intelligence]` and do not affect the returned report +- Loads up to 90 days of daily averages for a fixed set of candidate + factor→outcome metric pairs (e.g., protein_g → muscle_mass_kg) +- Skips pairs with fewer than `MIN_DATA_POINTS` (14) aligned dates +- Computes Pearson r + p-value, classifies confidence (high / moderate / + suggestive) per `(|r|, n, p)` thresholds, persists via + `ON CONFLICT DO UPDATE` keyed on `(user_id, factor_metric, factor_condition, outcome_metric)` +- `first_detected_at` is set only on the initial INSERT so historical + detection time is preserved across re-runs +- Correlations not re-confirmed in a run are marked `weakening` + +**Test Coverage:** `packages/backend/src/services/intelligence/__tests__/correlation-engine.test.ts` +(14-day positive case, ): Correlation { + return { + id: String(r['id']), + userId: String(r['user_id']), + factorMetric: String(r['factor_metric']), + factorCondition: String(r['factor_condition']), + factorLabel: String(r['factor_label']), + outcomeMetric: String(r['outcome_metric']), + outcomeEffect: String(r['outcome_effect']), + outcomeLabel: String(r['outcome_label']), + correlationCoefficient: Number(r['correlation_coefficient']), + confidenceLevel: String(r['confidence_level']) as ConfidenceLevel, + dataPoints: Number(r['data_points']), + pValue: r['p_value'] != null ? Number(r['p_value']) : null, + firstDetectedAt: + r['first_detected_at'] instanceof Date + ? r['first_detected_at'].toISOString() + : String(r['first_detected_at']), + lastConfirmedAt: + r['last_confirmed_at'] instanceof Date + ? r['last_confirmed_at'].toISOString() + : String(r['last_confirmed_at']), + timesConfirmed: Number(r['times_confirmed']), + status: String(r['status']) as CorrelationStatus, + summary: String(r['summary']), + category: String(r['category']) as CorrelationCategory, + createdAt: + r['created_at'] instanceof Date ? r['created_at'].toISOString() : String(r['created_at']), + updatedAt: + r['updated_at'] instanceof Date ? r['updated_at'].toISOString() : String(r['updated_at']), + }; +} + +export async function upsertCorrelation( + pool: pg.Pool, + correlation: Omit, +): Promise { + const { rows } = await pool.query( + `INSERT INTO correlations ( + user_id, factor_metric, factor_condition, factor_label, + outcome_metric, outcome_effect, outcome_label, + correlation_coefficient, confidence_level, data_points, p_value, + first_detected_at, last_confirmed_at, times_confirmed, + status, summary, category + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) + ON CONFLICT (user_id, factor_metric, factor_condition, outcome_metric) DO UPDATE SET + factor_label = EXCLUDED.factor_label, + outcome_effect = EXCLUDED.outcome_effect, + outcome_label = EXCLUDED.outcome_label, + correlation_coefficient = EXCLUDED.correlation_coefficient, + confidence_level = EXCLUDED.confidence_level, + data_points = EXCLUDED.data_points, + p_value = EXCLUDED.p_value, + last_confirmed_at = EXCLUDED.last_confirmed_at, + times_confirmed = correlations.times_confirmed + 1, + status = EXCLUDED.status, + summary = EXCLUDED.summary, + category = EXCLUDED.category, + updated_at = now() + -- first_detected_at is intentionally excluded: preserve the original detection timestamp + RETURNING id`, + [ + correlation.userId, + correlation.factorMetric, + correlation.factorCondition, + correlation.factorLabel, + correlation.outcomeMetric, + correlation.outcomeEffect, + correlation.outcomeLabel, + correlation.correlationCoefficient, + correlation.confidenceLevel, + correlation.dataPoints, + correlation.pValue, + correlation.firstDetectedAt, + correlation.lastConfirmedAt, + correlation.timesConfirmed, + correlation.status, + correlation.summary, + correlation.category, + ], + ); + + return String(rows[0]['id']); +} + +export async function listCorrelations( + pool: pg.Pool, + userId: string, + filters?: { + category?: CorrelationCategory | string; + confidenceLevel?: ConfidenceLevel | string; + status?: CorrelationStatus | string; + metric?: string; + minConfidence?: string; + }, +): Promise { + const conditions: string[] = ['user_id = $1']; + const params: unknown[] = [userId]; + let idx = 2; + + if (filters?.category) { + conditions.push(`category = $${idx++}`); + params.push(filters.category); + } + if (filters?.confidenceLevel) { + conditions.push(`confidence_level = $${idx++}`); + params.push(filters.confidenceLevel); + } + if (filters?.status) { + conditions.push(`status = $${idx++}`); + params.push(filters.status); + } + if (filters?.metric) { + conditions.push(`(factor_metric = $${idx} OR outcome_metric = $${idx})`); + params.push(filters.metric); + idx++; + } + if (filters?.minConfidence) { + conditions.push(`ABS(correlation_coefficient) >= $${idx++}`); + params.push(Number(filters.minConfidence)); + } + + const sql = ` + SELECT id, user_id, factor_metric, factor_condition, factor_label, + outcome_metric, outcome_effect, outcome_label, + correlation_coefficient, confidence_level, data_points, p_value, + first_detected_at, last_confirmed_at, times_confirmed, + status, summary, category, created_at, updated_at + FROM correlations + WHERE ${conditions.join(' AND ')} + ORDER BY ABS(correlation_coefficient) DESC, last_confirmed_at DESC + `; + + const { rows } = await pool.query(sql, params); + return rows.map(rowToCorrelation); +} + +export async function getTopCorrelations( + pool: pg.Pool, + userId: string, + limit = 10, +): Promise { + const { rows } = await pool.query( + `SELECT id, user_id, factor_metric, factor_condition, factor_label, + outcome_metric, outcome_effect, outcome_label, + correlation_coefficient, confidence_level, data_points, p_value, + first_detected_at, last_confirmed_at, times_confirmed, + status, summary, category, created_at, updated_at + FROM correlations + WHERE user_id = $1 + ORDER BY ABS(correlation_coefficient) DESC, times_confirmed DESC + LIMIT $2`, + [userId, limit], + ); + + return rows.map(rowToCorrelation); +} + +export async function markWeakening(pool: pg.Pool, id: string): Promise { + await pool.query( + `UPDATE correlations SET status = 'weakening', updated_at = now() WHERE id = $1`, + [id], + ); +} diff --git a/packages/backend/src/db/queries/projections.ts b/packages/backend/src/db/queries/projections.ts new file mode 100644 index 0000000..6406f5e --- /dev/null +++ b/packages/backend/src/db/queries/projections.ts @@ -0,0 +1,102 @@ +import type pg from 'pg'; +import type { Projection } from '@vitals/shared'; + +function rowToProjection(r: Record): Projection { + return { + id: String(r['id']), + userId: String(r['user_id']), + metric: String(r['metric']), + projectionDate: + r['projection_date'] instanceof Date + ? r['projection_date'].toISOString().split('T')[0] + : String(r['projection_date']), + projectedValue: Number(r['projected_value']), + confidenceLow: r['confidence_low'] != null ? Number(r['confidence_low']) : null, + confidenceHigh: r['confidence_high'] != null ? Number(r['confidence_high']) : null, + method: String(r['method']) as Projection['method'], + dataPoints: Number(r['data_points']), + generatedAt: + r['generated_at'] instanceof Date + ? r['generated_at'].toISOString() + : String(r['generated_at']), + }; +} + +export async function upsertProjections( + pool: pg.Pool, + userId: string, + projections: Omit[], +): Promise { + if (projections.length === 0) return; + + // Build a batch INSERT with individual ON CONFLICT clauses per row + const values: unknown[] = []; + const valuePlaceholders: string[] = []; + let idx = 1; + + for (const p of projections) { + valuePlaceholders.push( + `($${idx++},$${idx++},$${idx++},$${idx++},$${idx++},$${idx++},$${idx++},$${idx++})`, + ); + values.push( + userId, + p.metric, + p.projectionDate, + p.projectedValue, + p.confidenceLow ?? null, + p.confidenceHigh ?? null, + p.method, + p.dataPoints, + ); + } + + await pool.query( + `INSERT INTO projections ( + user_id, metric, projection_date, + projected_value, confidence_low, confidence_high, + method, data_points + ) VALUES ${valuePlaceholders.join(', ')} + ON CONFLICT (user_id, metric, projection_date) DO UPDATE SET + projected_value = EXCLUDED.projected_value, + confidence_low = EXCLUDED.confidence_low, + confidence_high = EXCLUDED.confidence_high, + method = EXCLUDED.method, + data_points = EXCLUDED.data_points, + generated_at = now()`, + values, + ); +} + +export async function getProjections( + pool: pg.Pool, + userId: string, + metric: string, + _daysForward?: number, +): Promise { + const { rows } = await pool.query( + `SELECT id, user_id, metric, projection_date, + projected_value, confidence_low, confidence_high, + method, data_points, generated_at + FROM projections + WHERE user_id = $1 AND metric = $2 + ORDER BY projection_date`, + [userId, metric], + ); + + return rows.map(rowToProjection); +} + +export async function getLatestProjections(pool: pg.Pool, userId: string): Promise { + const { rows } = await pool.query( + `SELECT DISTINCT ON (metric) + id, user_id, metric, projection_date, + projected_value, confidence_low, confidence_high, + method, data_points, generated_at + FROM projections + WHERE user_id = $1 + ORDER BY metric, generated_at DESC`, + [userId], + ); + + return rows.map(rowToProjection); +} diff --git a/packages/backend/src/routes/__tests__/intelligence.test.ts b/packages/backend/src/routes/__tests__/intelligence.test.ts new file mode 100644 index 0000000..8ebfd17 --- /dev/null +++ b/packages/backend/src/routes/__tests__/intelligence.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { buildApp } from '../../app.js'; +import type { EnvConfig } from '../../config/env.js'; + +vi.mock('../../plugins/database.js', () => ({ + databasePlugin: async (app: { decorate: (k: string, v: unknown) => void }) => { + app.decorate('db', {}); + }, +})); + +vi.mock('../../services/collectors/register.js', () => ({ + registerProviders: vi.fn(), +})); + +vi.mock('../../db/queries/correlations.js', () => ({ + listCorrelations: vi.fn().mockResolvedValue([]), + getTopCorrelations: vi.fn().mockResolvedValue([]), + upsertCorrelation: vi.fn().mockResolvedValue('correlation-uuid'), + markWeakening: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../db/queries/projections.js', () => ({ + getProjections: vi.fn().mockResolvedValue([]), + getLatestProjections: vi.fn().mockResolvedValue([]), + upsertProjections: vi.fn().mockResolvedValue(undefined), +})); + +const testEnv: EnvConfig = { + port: 3001, + databaseUrl: 'postgresql://test:test@localhost:5432/test', + aiProvider: 'claude' as const, + aiApiKey: 'test-key', + xApiKey: 'test-api-key', + dbDefaultUserId: '00000000-0000-0000-0000-000000000001', + nodeEnv: 'test', + cronometerUsername: '', + cronometerPassword: '', + cronometerGwtHeader: '', + cronometerGwtPermutation: '', + hevyApiKey: '', + hevyApiBase: 'https://api.hevyapp.com/v1', + frontendUrl: '', +}; + +describe('GET /api/correlations', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns 200 without API key (GET routes are open)', async () => { + const app = await buildApp(testEnv); + const response = await app.inject({ + method: 'GET', + url: '/api/correlations', + }); + expect(response.statusCode).toBe(200); + await app.close(); + }); + + it('returns 200 with empty array when no correlations exist', async () => { + const app = await buildApp(testEnv); + const response = await app.inject({ + method: 'GET', + url: '/api/correlations', + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(Array.isArray(body.data)).toBe(true); + await app.close(); + }); + + it('passes category filter to listCorrelations', async () => { + const { listCorrelations } = await import('../../db/queries/correlations.js'); + + const app = await buildApp(testEnv); + await app.inject({ + method: 'GET', + url: '/api/correlations?category=nutrition', + }); + + const calls = (listCorrelations as ReturnType).mock.calls; + expect(calls[0][1]).toBe(testEnv.dbDefaultUserId); + expect(calls[0][2]).toMatchObject({ category: 'nutrition' }); + await app.close(); + }); + + it('passes confidenceLevel filter to listCorrelations', async () => { + const { listCorrelations } = await import('../../db/queries/correlations.js'); + + const app = await buildApp(testEnv); + await app.inject({ + method: 'GET', + url: '/api/correlations?confidenceLevel=high', + }); + + const calls = (listCorrelations as ReturnType).mock.calls; + expect(calls[0][1]).toBe(testEnv.dbDefaultUserId); + expect(calls[0][2]).toMatchObject({ confidenceLevel: 'high' }); + await app.close(); + }); + + it('passes status filter to listCorrelations', async () => { + const { listCorrelations } = await import('../../db/queries/correlations.js'); + + const app = await buildApp(testEnv); + await app.inject({ + method: 'GET', + url: '/api/correlations?status=active', + }); + + const calls = (listCorrelations as ReturnType).mock.calls; + expect(calls[0][1]).toBe(testEnv.dbDefaultUserId); + expect(calls[0][2]).toMatchObject({ status: 'active' }); + await app.close(); + }); + + it('calls getTopCorrelations when top param is provided', async () => { + const { getTopCorrelations } = await import('../../db/queries/correlations.js'); + + const app = await buildApp(testEnv); + await app.inject({ + method: 'GET', + url: '/api/correlations?top=5', + }); + + const calls = (getTopCorrelations as ReturnType).mock.calls; + expect(calls[0][1]).toBe(testEnv.dbDefaultUserId); + expect(calls[0][2]).toBe(5); + await app.close(); + }); + + it('returns 400 when top param is not a positive integer', async () => { + const app = await buildApp(testEnv); + const response = await app.inject({ + method: 'GET', + url: '/api/correlations?top=abc', + }); + expect(response.statusCode).toBe(400); + await app.close(); + }); + + it('returns 400 when top param exceeds maximum of 100', async () => { + const app = await buildApp(testEnv); + const response = await app.inject({ + method: 'GET', + url: '/api/correlations?top=101', + }); + expect(response.statusCode).toBe(400); + await app.close(); + }); + + it('returns 200 when top param is exactly 100 (boundary)', async () => { + const app = await buildApp(testEnv); + const response = await app.inject({ + method: 'GET', + url: '/api/correlations?top=100', + }); + expect(response.statusCode).toBe(200); + await app.close(); + }); + + it('returns correlation data from listCorrelations', async () => { + const { listCorrelations } = await import('../../db/queries/correlations.js'); + const mockCorrelation = { + id: 'corr-1', + userId: testEnv.dbDefaultUserId, + factorMetric: 'calories', + factorCondition: 'calories_high', + factorLabel: 'Higher calories', + outcomeMetric: 'weight_kg', + outcomeEffect: 'increase', + outcomeLabel: 'weight higher', + correlationCoefficient: 0.72, + confidenceLevel: 'high', + dataPoints: 30, + pValue: 0.003, + firstDetectedAt: '2026-03-01T00:00:00Z', + lastConfirmedAt: '2026-04-01T00:00:00Z', + timesConfirmed: 3, + status: 'active', + summary: 'Higher calories is strongly associated with higher weight_kg (r=0.72)', + category: 'nutrition', + createdAt: '2026-03-01T00:00:00Z', + updatedAt: '2026-04-01T00:00:00Z', + }; + (listCorrelations as ReturnType).mockResolvedValueOnce([mockCorrelation]); + + const app = await buildApp(testEnv); + const response = await app.inject({ + method: 'GET', + url: '/api/correlations', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data).toHaveLength(1); + expect(body.data[0].id).toBe('corr-1'); + expect(body.data[0].correlationCoefficient).toBe(0.72); + await app.close(); + }); +}); + +describe('GET /api/projections/:metric', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns 200 without API key (GET routes are open)', async () => { + const app = await buildApp(testEnv); + const response = await app.inject({ + method: 'GET', + url: '/api/projections/body_weight', + }); + expect(response.statusCode).toBe(200); + await app.close(); + }); + + it('returns 200 with empty array when no projections exist for metric', async () => { + const app = await buildApp(testEnv); + const response = await app.inject({ + method: 'GET', + url: '/api/projections/body_weight', + }); + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(Array.isArray(body.data)).toBe(true); + await app.close(); + }); + + it('passes metric param to getProjections', async () => { + const { getProjections } = await import('../../db/queries/projections.js'); + + const app = await buildApp(testEnv); + await app.inject({ + method: 'GET', + url: '/api/projections/weight_kg', + }); + + const calls = (getProjections as ReturnType).mock.calls; + expect(calls[0][1]).toBe(testEnv.dbDefaultUserId); + expect(calls[0][2]).toBe('weight_kg'); + await app.close(); + }); + + it('returns 400 when metric name is 100 or more characters (length-bound defense)', async () => { + // Fastify default maxParamLength is 100 chars (>100 → 404 at router level). + // Our handler rejects >= 100 chars, so test with exactly 100 chars which Fastify routes. + const app = await buildApp(testEnv); + const longMetric = 'a'.repeat(100); + const response = await app.inject({ + method: 'GET', + url: `/api/projections/${longMetric}`, + }); + expect(response.statusCode).toBe(400); + await app.close(); + }); + + it('returns projection data from getProjections', async () => { + const { getProjections } = await import('../../db/queries/projections.js'); + const mockProjection = { + id: 'proj-1', + userId: testEnv.dbDefaultUserId, + metric: 'weight_kg', + projectionDate: '2026-04-10', + projectedValue: 78.5, + confidenceLow: 77.0, + confidenceHigh: 80.0, + method: 'linear_regression', + dataPoints: 30, + generatedAt: '2026-04-06T00:00:00Z', + }; + (getProjections as ReturnType).mockResolvedValueOnce([mockProjection]); + + const app = await buildApp(testEnv); + const response = await app.inject({ + method: 'GET', + url: '/api/projections/weight_kg', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data).toHaveLength(1); + expect(body.data[0].id).toBe('proj-1'); + expect(body.data[0].projectedValue).toBe(78.5); + await app.close(); + }); +}); diff --git a/packages/backend/src/routes/__tests__/reports.test.ts b/packages/backend/src/routes/__tests__/reports.test.ts index 0f6bef1..f4c1517 100644 --- a/packages/backend/src/routes/__tests__/reports.test.ts +++ b/packages/backend/src/routes/__tests__/reports.test.ts @@ -54,6 +54,14 @@ vi.mock('../../services/report-runner.js', () => ({ runReportInBackground: vi.fn(), })); +vi.mock('../../services/intelligence/correlation-engine.js', () => ({ + runCorrelationAnalysis: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../services/intelligence/trajectory-projector.js', () => ({ + runTrajectoryProjections: vi.fn().mockResolvedValue(undefined), +})); + const testEnv: EnvConfig = { port: 3001, databaseUrl: 'postgresql://test:test@localhost:5432/test', @@ -327,4 +335,25 @@ describe('POST /api/reports/generate', () => { expect(body.data.summary).toBe('Great week!'); await app.close(); }); + + it('returns 200 even when intelligence pipeline (correlation analysis) rejects (AC5)', async () => { + const { runCorrelationAnalysis } = + await import('../../services/intelligence/correlation-engine.js'); + vi.mocked(runCorrelationAnalysis).mockRejectedValueOnce( + new Error('simulated intelligence failure'), + ); + + const app = await buildApp(testEnv); + const response = await app.inject({ + method: 'POST', + url: '/api/reports/generate?sync=true', + headers: { 'x-api-key': 'test-api-key', 'content-type': 'application/json' }, + body: JSON.stringify({ startDate: '2026-03-01', endDate: '2026-03-07' }), + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.data.summary).toBe('Great week!'); + await app.close(); + }); }); diff --git a/packages/backend/src/routes/intelligence.ts b/packages/backend/src/routes/intelligence.ts new file mode 100644 index 0000000..9c5f93c --- /dev/null +++ b/packages/backend/src/routes/intelligence.ts @@ -0,0 +1,81 @@ +import type { FastifyInstance } from 'fastify'; +import type { EnvConfig } from '../config/env.js'; +import type { CorrelationCategory, ConfidenceLevel, CorrelationStatus } from '@vitals/shared'; +import { listCorrelations, getTopCorrelations } from '../db/queries/correlations.js'; +import { getProjections } from '../db/queries/projections.js'; + +const MAX_TOP = 100; +const MAX_METRIC_NAME_LENGTH = 100; + +interface CorrelationsQuery { + category?: CorrelationCategory; + confidenceLevel?: ConfidenceLevel; + status?: CorrelationStatus; + top?: string; +} + +interface ProjectionsParams { + metric: string; +} + +export async function intelligenceRoutes( + app: FastifyInstance, + opts: { env: EnvConfig }, +): Promise { + /** + * GET /api/correlations + * Returns detected correlations for the default user. + * Optional query params: category, confidenceLevel, status, top (return top N by strength) + */ + app.get<{ Querystring: CorrelationsQuery }>('/api/correlations', async (request, reply) => { + const { category, confidenceLevel, status, top } = request.query; + const userId = opts.env.dbDefaultUserId; + + if (top !== undefined) { + const limit = parseInt(top, 10); + if (isNaN(limit) || limit < 1) { + return reply.code(400).send({ + error: 'Bad Request', + message: 'top must be a positive integer', + statusCode: 400, + }); + } + if (limit > MAX_TOP) { + return reply.code(400).send({ + error: 'Bad Request', + message: `top must be between 1 and ${MAX_TOP}`, + statusCode: 400, + }); + } + const correlations = await getTopCorrelations(app.db, userId, limit); + return reply.send({ data: correlations }); + } + + const correlations = await listCorrelations(app.db, userId, { + category, + confidenceLevel, + status, + }); + return reply.send({ data: correlations }); + }); + + /** + * GET /api/projections/:metric + * Returns trajectory projections for a specific metric. + */ + app.get<{ Params: ProjectionsParams }>('/api/projections/:metric', async (request, reply) => { + const { metric } = request.params; + const userId = opts.env.dbDefaultUserId; + + if (metric.length >= MAX_METRIC_NAME_LENGTH) { + return reply.code(400).send({ + error: 'Bad Request', + message: `metric name must be fewer than ${MAX_METRIC_NAME_LENGTH} characters`, + statusCode: 400, + }); + } + + const projections = await getProjections(app.db, userId, metric); + return reply.send({ data: projections }); + }); +} diff --git a/packages/backend/src/services/ai/report-generator.ts b/packages/backend/src/services/ai/report-generator.ts index 0814537..6d3aa99 100644 --- a/packages/backend/src/services/ai/report-generator.ts +++ b/packages/backend/src/services/ai/report-generator.ts @@ -18,6 +18,8 @@ import { jsonrepair } from 'jsonrepair'; import { buildReportPrompt } from './prompt-builder.js'; import { measureOutcomes, determineOutcome } from '../action-items/outcome-measurer.js'; import { expireStaleItems, supersedeItems } from '../action-items/lifecycle-manager.js'; +import { runCorrelationAnalysis } from '../intelligence/correlation-engine.js'; +import { runTrajectoryProjections } from '../intelligence/trajectory-projector.js'; const BIOMETRIC_METRICS = [ 'weight_kg', @@ -419,6 +421,17 @@ export async function generateWeeklyReport( await supersedeItems(pool, userId, reportId, gen.actionItems); } + // Run intelligence pipeline (correlations + projections). Non-blocking: + // failures here must not block the report from being returned. + try { + await Promise.all([ + runCorrelationAnalysis(pool, userId), + runTrajectoryProjections(pool, userId), + ]); + } catch (err) { + console.error('[intelligence] pipeline failed after report generation:', err); + } + return { id: reportId, userId, diff --git a/packages/backend/src/services/ai/tools/health-tools.ts b/packages/backend/src/services/ai/tools/health-tools.ts index 37758af..f02fd27 100644 --- a/packages/backend/src/services/ai/tools/health-tools.ts +++ b/packages/backend/src/services/ai/tools/health-tools.ts @@ -132,4 +132,74 @@ export const HEALTH_TOOLS: AITool[] = [ required: [], }, }, + { + name: 'query_correlations', + description: + "Find personal health correlations discovered from the user's data. Returns patterns like 'Higher protein days correlate with +15% next-day training volume.'", + inputSchema: { + type: 'object', + properties: { + metric: { + type: 'string', + description: 'Filter correlations involving this metric name. Omit to return all.', + }, + category: { + type: 'string', + enum: ['nutrition', 'training', 'recovery', 'cross-domain'], + description: 'Filter by correlation category. Omit to return all categories.', + }, + minConfidence: { + type: 'string', + enum: ['high', 'moderate', 'suggestive'], + description: + 'Minimum confidence level to include. "high" returns only strong correlations; "suggestive" returns all.', + }, + }, + required: [], + }, + }, + { + name: 'predict_trajectory', + description: + 'Project a health metric forward based on personal data trends. Returns projected values with confidence bands.', + inputSchema: { + type: 'object', + properties: { + metric: { + type: 'string', + description: 'Metric name to project (e.g. "body_weight_kg", "protein_g").', + }, + daysForward: { + type: 'number', + description: 'Number of days to project into the future. Default: 30. Max: 90.', + }, + }, + required: ['metric'], + }, + }, + { + name: 'simulate_change', + description: + "Estimate impact of a behavior change using personal correlations. Use when the user asks 'what if I increase protein' or similar.", + inputSchema: { + type: 'object', + properties: { + changeDescription: { + type: 'string', + description: + 'Plain-language description of the proposed change (e.g. "increase daily protein to 180g").', + }, + factorMetric: { + type: 'string', + description: 'The metric being changed (e.g. "protein_g", "sleep_hours").', + }, + newValue: { + type: 'string', + description: + 'The proposed new value for the factor metric (e.g. "180"). Omit if unknown.', + }, + }, + required: ['changeDescription', 'factorMetric'], + }, + }, ]; diff --git a/packages/backend/src/services/ai/tools/tool-executor.ts b/packages/backend/src/services/ai/tools/tool-executor.ts index 0b1cc97..025e41f 100644 --- a/packages/backend/src/services/ai/tools/tool-executor.ts +++ b/packages/backend/src/services/ai/tools/tool-executor.ts @@ -12,6 +12,8 @@ import { getAttributionSummary, } from '../../../db/queries/action-items.js'; import { measureOutcomes } from '../../action-items/outcome-measurer.js'; +import { listCorrelations } from '../../../db/queries/correlations.js'; +import { getProjections } from '../../../db/queries/projections.js'; export interface ToolCallRecord { toolName: string; @@ -23,13 +25,15 @@ const MAX_DATE_SPAN_DAYS = 730; const MAX_LIMIT = 100; const MAX_EXERCISE_NAME_LENGTH = 200; const MAX_METRICS_COUNT = 20; +const MAX_METRIC_NAME_LENGTH = 100; function parseDate(value: unknown): Date { if (typeof value === 'string') { const d = new Date(value); if (!isNaN(d.getTime())) return d; } - throw new Error(`Invalid date: ${String(value)}`); + // Throw generic message to avoid leaking raw (potentially prompt-injected) input + throw new Error('Invalid date'); } function validateDateSpan(start: Date, end: Date): string | null { @@ -195,13 +199,71 @@ export async function executeTool( return JSON.stringify({ metrics }); } + case 'query_correlations': { + const filters: { + metric?: string; + category?: string; + minConfidence?: string; + } = {}; + if (input.metric) { + const metric = String(input.metric); + if (metric.length >= MAX_METRIC_NAME_LENGTH) { + return JSON.stringify({ + error: `metric name too long (max ${MAX_METRIC_NAME_LENGTH - 1} chars)`, + }); + } + filters.metric = metric; + } + if (input.category) { + const category = String(input.category); + if (category.length >= MAX_METRIC_NAME_LENGTH) { + return JSON.stringify({ + error: `category too long (max ${MAX_METRIC_NAME_LENGTH - 1} chars)`, + }); + } + filters.category = category; + } + if (input.minConfidence) filters.minConfidence = String(input.minConfidence); + const correlations = await listCorrelations(db, userId, filters); + return JSON.stringify(correlations); + } + + case 'predict_trajectory': { + if (!input.metric) return JSON.stringify({ error: 'metric is required' }); + const metric = String(input.metric); + const rawDays = typeof input.daysForward === 'number' ? input.daysForward : 30; + const daysForward = Math.min(Math.max(1, rawDays), 90); + const projections = await getProjections(db, userId, metric, daysForward); + return JSON.stringify(projections); + } + + case 'simulate_change': { + if (!input.changeDescription) + return JSON.stringify({ error: 'changeDescription is required' }); + if (!input.factorMetric) return JSON.stringify({ error: 'factorMetric is required' }); + const factorMetric = String(input.factorMetric); + if (factorMetric.length >= MAX_METRIC_NAME_LENGTH) { + return JSON.stringify({ + error: `factorMetric name too long (max ${MAX_METRIC_NAME_LENGTH - 1} chars)`, + }); + } + const correlations = await listCorrelations(db, userId, { metric: factorMetric }); + const impactEstimates = { + changeDescription: String(input.changeDescription), + factorMetric, + newValue: input.newValue !== undefined ? String(input.newValue) : undefined, + relatedCorrelations: correlations, + }; + return JSON.stringify(impactEstimates); + } + default: return JSON.stringify({ error: `Unknown tool: ${toolName}` }); } } catch (err) { // Log full error for observability; return sanitized message to avoid leaking internals console.error(`[tool-executor] ${toolName} failed:`, err); - const isValidationError = err instanceof Error && err.message.startsWith('Invalid date'); + const isValidationError = err instanceof Error && err.message === 'Invalid date'; return JSON.stringify({ error: isValidationError ? 'Invalid date format. Please use YYYY-MM-DD.' diff --git a/packages/backend/src/services/intelligence/__tests__/correlation-engine.test.ts b/packages/backend/src/services/intelligence/__tests__/correlation-engine.test.ts new file mode 100644 index 0000000..e93d51b --- /dev/null +++ b/packages/backend/src/services/intelligence/__tests__/correlation-engine.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Pool } from 'pg'; +import { runCorrelationAnalysis } from '../correlation-engine.js'; + +// Mock the correlations DB queries +vi.mock('../../../db/queries/correlations.js', () => ({ + upsertCorrelation: vi.fn().mockResolvedValue('correlation-id'), + listCorrelations: vi.fn().mockResolvedValue([]), + markWeakening: vi.fn().mockResolvedValue(undefined), +})); + +// Helper: build a mock pool whose query resolves with rows +function makeMockPool(queryFn: (sql: string, params: unknown[]) => { rows: unknown[] }) { + return { + query: vi + .fn() + .mockImplementation((sql: string, params: unknown[]) => + Promise.resolve(queryFn(sql, params)), + ), + } as unknown as Pool; +} + +/** Generates `n` daily rows for a given metric with a linear trend + tiny noise */ +function linearRows( + metric: string, + n: number, + baseValue: number, + slope: number, +): Array<{ metric: string; day: string; avg_value: string }> { + const rows = []; + const start = new Date('2026-01-01'); + for (let i = 0; i < n; i++) { + const date = new Date(start); + date.setDate(start.getDate() + i); + const day = date.toISOString().split('T')[0]; + // tiny deterministic noise: +/- 0.001 per step + const value = baseValue + slope * i + (i % 2 === 0 ? 0.001 : -0.001); + rows.push({ metric, day, avg_value: String(value) }); + } + return rows; +} + +describe('runCorrelationAnalysis', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('produces at least 1 correlation with 14+ days of aligned data (AC3)', async () => { + const { upsertCorrelation } = await import('../../../db/queries/correlations.js'); + const { listCorrelations } = await import('../../../db/queries/correlations.js'); + + // protein_g (factor) and weight_kg (outcome): both increase linearly over 20 days — strong r + const proteinRows = linearRows('protein_g', 20, 150, 1.0); + const weightRows = linearRows('weight_kg', 20, 80.0, 0.1); + + const pool = makeMockPool((_sql, _params) => { + // loadDailyAverages does a single GROUP BY query for all metrics + return { rows: [...proteinRows, ...weightRows] }; + }); + + (listCorrelations as ReturnType).mockResolvedValue([]); + + await runCorrelationAnalysis(pool, 'test-user'); + + expect(upsertCorrelation).toHaveBeenCalled(); + + // Verify the first call has a strong correlation coefficient + const firstCall = (upsertCorrelation as ReturnType).mock.calls[0][1] as { + correlationCoefficient: number; + }; + expect(Math.abs(firstCall.correlationCoefficient)).toBeGreaterThan(0.5); + }); + + it('does not produce a correlation with fewer than MIN_DATA_POINTS', async () => { + const { upsertCorrelation } = await import('../../../db/queries/correlations.js'); + const { listCorrelations } = await import('../../../db/queries/correlations.js'); + + // Only 5 days — below the MIN_DATA_POINTS threshold of 7 + const proteinRows = linearRows('protein_g', 5, 150, 1.0); + const weightRows = linearRows('weight_kg', 5, 80.0, 0.1); + + const pool = makeMockPool((_sql, _params) => ({ + rows: [...proteinRows, ...weightRows], + })); + + (listCorrelations as ReturnType).mockResolvedValue([]); + + await runCorrelationAnalysis(pool, 'test-user'); + + expect(upsertCorrelation).not.toHaveBeenCalled(); + }); + + it('handles zero-data gracefully — no throw, no upsert', async () => { + const { upsertCorrelation } = await import('../../../db/queries/correlations.js'); + const { listCorrelations } = await import('../../../db/queries/correlations.js'); + + const pool = makeMockPool((_sql, _params) => ({ rows: [] })); + + (listCorrelations as ReturnType).mockResolvedValue([]); + + await expect(runCorrelationAnalysis(pool, 'test-user')).resolves.toBeUndefined(); + expect(upsertCorrelation).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/src/services/intelligence/__tests__/stats.test.ts b/packages/backend/src/services/intelligence/__tests__/stats.test.ts new file mode 100644 index 0000000..28b5ea0 --- /dev/null +++ b/packages/backend/src/services/intelligence/__tests__/stats.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect } from 'vitest'; +import { + pearsonCorrelation, + calculatePValue, + classifyConfidence, + linearRegression, + projectForward, +} from '../stats.js'; + +describe('pearsonCorrelation', () => { + it('returns NaN for empty arrays', () => { + expect(pearsonCorrelation([], [])).toBeNaN(); + }); + + it('returns NaN for single-element arrays', () => { + expect(pearsonCorrelation([1], [2])).toBeNaN(); + }); + + it('returns NaN when xs has zero variance', () => { + expect(pearsonCorrelation([5, 5, 5], [1, 2, 3])).toBeNaN(); + }); + + it('returns NaN when ys has zero variance', () => { + expect(pearsonCorrelation([1, 2, 3], [5, 5, 5])).toBeNaN(); + }); + + it('returns NaN for mismatched array lengths', () => { + expect(pearsonCorrelation([1, 2], [1, 2, 3])).toBeNaN(); + }); + + it('returns 1 for perfect positive correlation', () => { + const xs = [1, 2, 3, 4, 5]; + const ys = [2, 4, 6, 8, 10]; + expect(pearsonCorrelation(xs, ys)).toBeCloseTo(1, 5); + }); + + it('returns -1 for perfect negative correlation', () => { + const xs = [1, 2, 3, 4, 5]; + const ys = [10, 8, 6, 4, 2]; + expect(pearsonCorrelation(xs, ys)).toBeCloseTo(-1, 5); + }); + + it('returns exactly 0 for perfectly uncorrelated data', () => { + // xs is monotone increasing; ys is symmetric around mean — zero correlation by construction + // sum((xi - meanX)*(yi - meanY)) = 0 when ys are symmetric: [-2, -1, 0, 1, 2] reversed on matched half + const _xs = [1, 2, 3, 4, 5, 6, 7]; + const _ys = [3, 3, 3, 3, 3, 3, 3]; // constant — but this gives NaN; use a different approach + // Build ys so cov(X,Y) = 0 exactly: pair high x with symmetric ys + // xs = [1,2,3,4,5], ys = [5,1,3,1,5] — mean(ys)=3, deviations: 2,-2,0,-2,2 + // cov = (1-3)*2 + (2-3)*(-2) + (3-3)*0 + (4-3)*(-2) + (5-3)*2 = -4+2+0-2+4 = 0 + const xsActual = [1, 2, 3, 4, 5]; + const ysActual = [5, 1, 3, 1, 5]; + const r = pearsonCorrelation(xsActual, ysActual); + expect(r).toBeCloseTo(0, 10); + }); + + it('computes a known partial correlation', () => { + // Known result: corr([1,2,3,4,5], [1,3,2,5,4]) ≈ 0.8 + const xs = [1, 2, 3, 4, 5]; + const ys = [1, 3, 2, 5, 4]; + const r = pearsonCorrelation(xs, ys); + expect(r).toBeGreaterThan(0.7); + expect(r).toBeLessThan(1); + }); +}); + +describe('calculatePValue', () => { + it('returns 1 for n <= 2', () => { + expect(calculatePValue(0.9, 2)).toBe(1); + expect(calculatePValue(0.5, 1)).toBe(1); + }); + + it('returns 1 for NaN r', () => { + expect(calculatePValue(NaN, 10)).toBe(1); + }); + + it('returns a small p-value for high r with large n', () => { + // r=0.9, n=30 should be very significant + const p = calculatePValue(0.9, 30); + expect(p).toBeLessThan(0.001); + }); + + it('returns a large p-value for low r with small n', () => { + // r=0.2, n=5 should not be significant + const p = calculatePValue(0.2, 5); + expect(p).toBeGreaterThan(0.3); + }); + + it('returns p between 0 and 1', () => { + const p = calculatePValue(0.5, 20); + expect(p).toBeGreaterThanOrEqual(0); + expect(p).toBeLessThanOrEqual(1); + }); + + it('returns same p-value for positive and negative r of same magnitude', () => { + const pPos = calculatePValue(0.6, 15); + const pNeg = calculatePValue(-0.6, 15); + expect(pPos).toBeCloseTo(pNeg, 5); + }); +}); + +describe('classifyConfidence', () => { + it('returns "high" when |r| >= 0.5, p < 0.01, n >= 14', () => { + expect(classifyConfidence(0.7, 20, 0.005)).toBe('high'); + expect(classifyConfidence(-0.6, 14, 0.009)).toBe('high'); + }); + + it('returns "moderate" when |r| >= 0.3, p < 0.05, n >= 10', () => { + expect(classifyConfidence(0.4, 12, 0.04)).toBe('moderate'); + expect(classifyConfidence(-0.35, 10, 0.03)).toBe('moderate'); + }); + + it('returns "suggestive" for weak or insufficiently sampled correlations', () => { + expect(classifyConfidence(0.2, 8, 0.1)).toBe('suggestive'); + expect(classifyConfidence(0.4, 8, 0.04)).toBe('suggestive'); // n < 10 + expect(classifyConfidence(0.3, 15, 0.06)).toBe('suggestive'); // p >= 0.05 + }); + + it('returns "suggestive" for negative r that is moderate but fails threshold', () => { + expect(classifyConfidence(-0.2, 10, 0.1)).toBe('suggestive'); + }); + + it('returns "moderate" not "high" when |r| is 0.5 but p >= 0.01', () => { + expect(classifyConfidence(0.5, 14, 0.02)).toBe('moderate'); + }); +}); + +describe('linearRegression', () => { + it('returns zero slope and first y value for single-element arrays', () => { + const result = linearRegression([0], [42]); + expect(result.slope).toBe(0); + expect(result.intercept).toBe(42); + }); + + it('returns zero slope when all x values are equal', () => { + const result = linearRegression([3, 3, 3], [1, 2, 3]); + expect(result.slope).toBe(0); + }); + + it('fits a perfect linear relationship', () => { + const xs = [0, 1, 2, 3, 4]; + const ys = [1, 3, 5, 7, 9]; // y = 2x + 1 + const result = linearRegression(xs, ys); + expect(result.slope).toBeCloseTo(2, 5); + expect(result.intercept).toBeCloseTo(1, 5); + expect(result.r2).toBeCloseTo(1, 5); + }); + + it('fits a perfect negative relationship', () => { + const xs = [0, 1, 2, 3]; + const ys = [10, 7, 4, 1]; // y = -3x + 10 + const result = linearRegression(xs, ys); + expect(result.slope).toBeCloseTo(-3, 5); + expect(result.intercept).toBeCloseTo(10, 5); + expect(result.r2).toBeCloseTo(1, 5); + }); + + it('returns r2 between 0 and 1 for noisy data', () => { + const xs = [1, 2, 3, 4, 5]; + const ys = [2.1, 3.9, 6.2, 7.8, 10.3]; + const result = linearRegression(xs, ys); + expect(result.r2).toBeGreaterThan(0.9); + expect(result.r2).toBeLessThanOrEqual(1); + }); + + it('returns r2 = 0 when all y-values are identical (zero variance)', () => { + // M-L1: flat y series has no variance to explain — r² should be 0, not 1 + const result = linearRegression([1, 2, 3], [5, 5, 5]); + expect(result.r2).toBe(0); + }); + + it('returns meanX and ssXX for use in prediction intervals', () => { + const xs = [0, 1, 2, 3, 4]; + const result = linearRegression(xs, [1, 3, 5, 7, 9]); + expect(result.meanX).toBeCloseTo(2, 5); + // ssXX = (0-2)² + (1-2)² + (2-2)² + (3-2)² + (4-2)² = 4+1+0+1+4 = 10 + expect(result.ssXX).toBeCloseTo(10, 5); + }); +}); + +describe('projectForward', () => { + it('returns empty array for fewer than 2 data points', () => { + expect(projectForward([{ date: '2026-01-01', value: 70 }], 7)).toEqual([]); + expect(projectForward([], 7)).toEqual([]); + }); + + it('returns the correct number of projected days', () => { + const data = [ + { date: '2026-01-01', value: 70 }, + { date: '2026-01-02', value: 71 }, + { date: '2026-01-03', value: 72 }, + ]; + const result = projectForward(data, 7); + expect(result).toHaveLength(7); + }); + + it('projects dates sequentially starting from the day after the last data point', () => { + const data = [ + { date: '2026-01-01', value: 70 }, + { date: '2026-01-02', value: 71 }, + ]; + const result = projectForward(data, 3); + expect(result[0].date).toBe('2026-01-03'); + expect(result[1].date).toBe('2026-01-04'); + expect(result[2].date).toBe('2026-01-05'); + }); + + it('projects values along a trend line', () => { + // Perfect upward trend: +1 per day + const data = [ + { date: '2026-01-01', value: 70 }, + { date: '2026-01-02', value: 71 }, + { date: '2026-01-03', value: 72 }, + { date: '2026-01-04', value: 73 }, + { date: '2026-01-05', value: 74 }, + ]; + const result = projectForward(data, 3); + expect(result[0].value).toBeCloseTo(75, 1); + expect(result[1].value).toBeCloseTo(76, 1); + expect(result[2].value).toBeCloseTo(77, 1); + }); + + it('confidence interval widens with distance', () => { + const data = [ + { date: '2026-01-01', value: 70 }, + { date: '2026-01-02', value: 72 }, + { date: '2026-01-03', value: 71 }, + { date: '2026-01-04', value: 73 }, + { date: '2026-01-05', value: 74 }, + ]; + const result = projectForward(data, 5); + // Width of interval should increase with days ahead + const width1 = result[0].high - result[0].low; + const width5 = result[4].high - result[4].low; + expect(width5).toBeGreaterThan(width1); + }); + + it('low is always less than value and high is always greater than value', () => { + const data = [ + { date: '2026-01-01', value: 80 }, + { date: '2026-01-02', value: 79 }, + { date: '2026-01-03', value: 78 }, + { date: '2026-01-04', value: 77 }, + { date: '2026-01-05', value: 76 }, + ]; + const result = projectForward(data, 7); + for (const point of result) { + // When residuals are 0 (perfect line), CI may be 0 — allow equality + expect(point.low).toBeLessThanOrEqual(point.value); + expect(point.high).toBeGreaterThanOrEqual(point.value); + } + }); +}); diff --git a/packages/backend/src/services/intelligence/__tests__/trajectory-projector.test.ts b/packages/backend/src/services/intelligence/__tests__/trajectory-projector.test.ts new file mode 100644 index 0000000..a56d329 --- /dev/null +++ b/packages/backend/src/services/intelligence/__tests__/trajectory-projector.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Pool } from 'pg'; +import { runTrajectoryProjections } from '../trajectory-projector.js'; + +// Mock the projections DB query +vi.mock('../../../db/queries/projections.js', () => ({ + upsertProjections: vi.fn().mockResolvedValue(undefined), +})); + +/** Generates `n` daily rows with a linear trend + deterministic noise for use as pool.query results */ +function linearDailyRows( + n: number, + baseValue: number, + slope: number, +): Array<{ day: string; avg_value: string }> { + const rows = []; + const start = new Date('2026-01-01'); + // Noise pattern: alternates +0.3 / -0.3 so residualStdDev > 0 (needed for non-zero CI bands) + const noise = [0.3, -0.3, 0.2, -0.2, 0.4, -0.4, 0.1, -0.1]; + for (let i = 0; i < n; i++) { + const date = new Date(start); + date.setDate(start.getDate() + i); + const value = baseValue + slope * i + noise[i % noise.length]; + rows.push({ + day: date.toISOString().split('T')[0], + avg_value: String(value), + }); + } + return rows; +} + +/** Creates a mock pool that handles both queryDistinctMetrics and queryDailyValues */ +function makeMockPool( + distinctMetrics: string[], + dailyValueRows: Array<{ day: string; avg_value: string }>, +) { + return { + query: vi.fn().mockImplementation((sql: string) => { + if (sql.includes('SELECT DISTINCT metric')) { + return Promise.resolve({ rows: distinctMetrics.map((m) => ({ metric: m })) }); + } + // queryDailyValues + return Promise.resolve({ rows: dailyValueRows }); + }), + } as unknown as Pool; +} + +describe('runTrajectoryProjections', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('generates 30-day projections with widening confidence bands (AC4)', async () => { + const { upsertProjections } = await import('../../../db/queries/projections.js'); + + // 30 days of linearly decreasing weight: 80.0 → ~78.5 kg + const dailyRows = linearDailyRows(30, 80.0, -0.05); + + const pool = makeMockPool(['weight_kg'], dailyRows); + + await runTrajectoryProjections(pool, 'test-user', ['weight_kg']); + + expect(upsertProjections).toHaveBeenCalledOnce(); + + const batch = (upsertProjections as ReturnType).mock.calls[0][2] as Array<{ + method: string; + confidenceLow: number; + confidenceHigh: number; + }>; + + expect(batch).toHaveLength(30); + + for (const entry of batch) { + expect(entry.method).toBe('linear_regression'); + expect(typeof entry.confidenceLow).toBe('number'); + expect(typeof entry.confidenceHigh).toBe('number'); + } + + // Confidence bands must widen over the projection horizon + const bandWidth = (entry: { confidenceLow: number; confidenceHigh: number }) => + entry.confidenceHigh - entry.confidenceLow; + expect(bandWidth(batch[29])).toBeGreaterThan(bandWidth(batch[0])); + }); + + it('skips metrics with fewer than MIN_DATA_POINTS', async () => { + const { upsertProjections } = await import('../../../db/queries/projections.js'); + + // Only 5 days — below MIN_DATA_POINTS (7) + const dailyRows = linearDailyRows(5, 80.0, -0.05); + const pool = makeMockPool(['weight_kg'], dailyRows); + + await runTrajectoryProjections(pool, 'test-user', ['weight_kg']); + + expect(upsertProjections).not.toHaveBeenCalled(); + }); + + it('returns early when user has no matching default biometric metrics', async () => { + const { upsertProjections } = await import('../../../db/queries/projections.js'); + + // queryDistinctMetrics returns a metric outside the DEFAULT_BIOMETRIC_METRICS set + const pool = makeMockPool(['exotic_metric_123'], []); + + await runTrajectoryProjections(pool, 'test-user'); + + expect(upsertProjections).not.toHaveBeenCalled(); + // pool.query should only have been called once (for queryDistinctMetrics), not for queryDailyValues + const queryCalls = (pool.query as ReturnType).mock.calls as string[][]; + const dailyValueCalls = queryCalls.filter((args) => String(args[0]).includes('AVG(value)')); + expect(dailyValueCalls).toHaveLength(0); + }); +}); diff --git a/packages/backend/src/services/intelligence/correlation-engine.ts b/packages/backend/src/services/intelligence/correlation-engine.ts new file mode 100644 index 0000000..fa40257 --- /dev/null +++ b/packages/backend/src/services/intelligence/correlation-engine.ts @@ -0,0 +1,211 @@ +import type pg from 'pg'; +import type { CorrelationCategory } from '@vitals/shared'; +import { pearsonCorrelation, calculatePValue, classifyConfidence } from './stats.js'; +import { + upsertCorrelation, + listCorrelations, + markWeakening, +} from '../../db/queries/correlations.js'; + +/** Candidate pair: a factor metric and an outcome metric to test for correlation. */ +interface CandidatePair { + factorMetric: string; + outcomeMetric: string; + category: CorrelationCategory; +} + +/** + * Candidate factor/outcome pairs to test for correlations. + * Factor: leading indicator (nutrition, activity) + * Outcome: lagging biometric result + */ +const CANDIDATE_PAIRS: CandidatePair[] = [ + // Nutrition → biometrics + { factorMetric: 'calories', outcomeMetric: 'weight_kg', category: 'nutrition' }, + { factorMetric: 'protein_g', outcomeMetric: 'weight_kg', category: 'nutrition' }, + { factorMetric: 'fat_g', outcomeMetric: 'weight_kg', category: 'nutrition' }, + { factorMetric: 'carbs_g', outcomeMetric: 'weight_kg', category: 'nutrition' }, + { factorMetric: 'sodium_mg', outcomeMetric: 'weight_kg', category: 'nutrition' }, + { factorMetric: 'calories', outcomeMetric: 'body_fat_pct', category: 'nutrition' }, + { factorMetric: 'protein_g', outcomeMetric: 'body_fat_pct', category: 'nutrition' }, + // Activity → biometrics + { factorMetric: 'steps', outcomeMetric: 'weight_kg', category: 'cross-domain' }, + { factorMetric: 'steps', outcomeMetric: 'resting_hr', category: 'cross-domain' }, + { factorMetric: 'steps', outcomeMetric: 'sleep_hours', category: 'cross-domain' }, + // Sleep → performance/biometrics + { factorMetric: 'sleep_hours', outcomeMetric: 'resting_hr', category: 'recovery' }, + { factorMetric: 'sleep_hours', outcomeMetric: 'steps', category: 'recovery' }, + // Training indicators + { factorMetric: 'workout_volume', outcomeMetric: 'weight_kg', category: 'training' }, + { factorMetric: 'workout_volume', outcomeMetric: 'resting_hr', category: 'training' }, +]; + +const MIN_DATA_POINTS = 7; + +/** + * Loads daily averaged measurements for a set of metrics within a lookback window. + * Returns a map of metric → Map. + */ +async function loadDailyAverages( + pool: pg.Pool, + userId: string, + metrics: string[], + startDate: Date, + endDate: Date, +): Promise>> { + if (metrics.length === 0) return new Map(); + + const { rows } = await pool.query( + `SELECT metric, + DATE(measured_at) AS day, + AVG(value) AS avg_value + FROM measurements + WHERE user_id = $1 + AND metric = ANY($2::text[]) + AND measured_at BETWEEN $3 AND $4 + GROUP BY metric, DATE(measured_at) + ORDER BY metric, day`, + [userId, metrics, startDate, endDate], + ); + + const result = new Map>(); + for (const row of rows) { + const metric = String(row['metric']); + const day = + row['day'] instanceof Date ? row['day'].toISOString().split('T')[0] : String(row['day']); + const value = Number(row['avg_value']); + + if (!result.has(metric)) result.set(metric, new Map()); + result.get(metric)!.set(day, value); + } + return result; +} + +/** + * Aligns two metric time-series by shared dates and returns parallel arrays. + */ +function alignSeries( + factorSeries: Map, + outcomeSeries: Map, +): { xs: number[]; ys: number[] } { + const xs: number[] = []; + const ys: number[] = []; + + for (const [date, xVal] of factorSeries) { + if (outcomeSeries.has(date)) { + xs.push(xVal); + ys.push(outcomeSeries.get(date)!); + } + } + + return { xs, ys }; +} + +/** + * Builds human-readable labels for a correlation. + */ +function buildLabels( + factorMetric: string, + outcomeMetric: string, + r: number, +): { + factorCondition: string; + factorLabel: string; + outcomeEffect: string; + outcomeLabel: string; + summary: string; +} { + const direction = r > 0 ? 'higher' : 'lower'; + const strength = Math.abs(r) >= 0.5 ? 'strongly' : 'moderately'; + + return { + factorCondition: `${factorMetric}_high`, + factorLabel: `Higher ${factorMetric.replace(/_/g, ' ')}`, + outcomeEffect: r > 0 ? 'increase' : 'decrease', + outcomeLabel: `${outcomeMetric.replace(/_/g, ' ')} ${direction}`, + summary: `Higher ${factorMetric.replace(/_/g, ' ')} is ${strength} associated with ${direction} ${outcomeMetric.replace(/_/g, ' ')} (r=${r.toFixed(2)})`, + }; +} + +/** + * Runs the full correlation analysis pipeline for a user. + * Loads measurement data, tests candidate metric pairs, computes Pearson r, + * classifies confidence, and upserts results into the correlations table. + * + * @param pool - pg connection pool + * @param userId - user to run analysis for + * @param lookbackDays - how many days of history to include (default 90) + */ +export async function runCorrelationAnalysis( + pool: pg.Pool, + userId: string, + lookbackDays = 90, +): Promise { + const endDate = new Date(); + const startDate = new Date(); + startDate.setDate(endDate.getDate() - lookbackDays); + + // Collect all unique metrics referenced in candidate pairs + const allMetrics = Array.from( + new Set(CANDIDATE_PAIRS.flatMap((p) => [p.factorMetric, p.outcomeMetric])), + ); + + // Load daily averages for all metrics + const dailyAverages = await loadDailyAverages(pool, userId, allMetrics, startDate, endDate); + + // Track which correlation keys were upserted in this run so we can mark old ones as weakening + const upsertedKeys = new Set(); + + for (const pair of CANDIDATE_PAIRS) { + const factorSeries = dailyAverages.get(pair.factorMetric); + const outcomeSeries = dailyAverages.get(pair.outcomeMetric); + + if (!factorSeries || !outcomeSeries) continue; + + const { xs, ys } = alignSeries(factorSeries, outcomeSeries); + + if (xs.length < MIN_DATA_POINTS) continue; + + const r = pearsonCorrelation(xs, ys); + if (isNaN(r)) continue; + + const pValue = calculatePValue(r, xs.length); + const confidenceLevel = classifyConfidence(r, xs.length, pValue); + + // Only persist correlations with meaningful strength (filter noise) + if (Math.abs(r) < 0.1) continue; + + const labels = buildLabels(pair.factorMetric, pair.outcomeMetric, r); + const now = new Date().toISOString(); + + const id = await upsertCorrelation(pool, { + userId, + factorMetric: pair.factorMetric, + factorCondition: labels.factorCondition, + factorLabel: labels.factorLabel, + outcomeMetric: pair.outcomeMetric, + outcomeEffect: labels.outcomeEffect, + outcomeLabel: labels.outcomeLabel, + correlationCoefficient: r, + confidenceLevel, + dataPoints: xs.length, + pValue, + firstDetectedAt: now, + lastConfirmedAt: now, + timesConfirmed: 1, + status: 'active', + summary: labels.summary, + category: pair.category, + }); + + upsertedKeys.add(id); + } + + // Mark previously active correlations that were not refreshed in this run as weakening + const existingCorrelations = await listCorrelations(pool, userId, { status: 'active' }); + for (const correlation of existingCorrelations) { + if (!upsertedKeys.has(correlation.id)) { + await markWeakening(pool, correlation.id); + } + } +} diff --git a/packages/backend/src/services/intelligence/stats.ts b/packages/backend/src/services/intelligence/stats.ts new file mode 100644 index 0000000..1b2f3fd --- /dev/null +++ b/packages/backend/src/services/intelligence/stats.ts @@ -0,0 +1,261 @@ +import type { ConfidenceLevel } from '@vitals/shared'; + +/** + * Computes the Pearson correlation coefficient between two equal-length arrays. + * Returns NaN if the arrays have fewer than 2 elements or zero variance. + */ +export function pearsonCorrelation(xs: number[], ys: number[]): number { + const n = xs.length; + if (n < 2 || n !== ys.length) return NaN; + + const meanX = xs.reduce((acc, x) => acc + x, 0) / n; + const meanY = ys.reduce((acc, y) => acc + y, 0) / n; + + let covXY = 0; + let varX = 0; + let varY = 0; + + for (let i = 0; i < n; i++) { + const dx = xs[i] - meanX; + const dy = ys[i] - meanY; + covXY += dx * dy; + varX += dx * dx; + varY += dy * dy; + } + + if (varX === 0 || varY === 0) return NaN; + + return covXY / Math.sqrt(varX * varY); +} + +/** + * Approximates the two-tailed p-value for a Pearson r given sample size n. + * Uses t-distribution approximation: t = r * sqrt(n-2) / sqrt(1-r^2) + */ +export function calculatePValue(r: number, n: number): number { + if (n <= 2) return 1; + if (isNaN(r)) return 1; + + // Clamp r to avoid sqrt of negative + const rClamped = Math.max(-1 + 1e-10, Math.min(1 - 1e-10, r)); + const t = (rClamped * Math.sqrt(n - 2)) / Math.sqrt(1 - rClamped * rClamped); + const df = n - 2; + + // Approximate two-tailed p-value using a numerical approximation of the t-distribution CDF. + // We use the regularized incomplete beta function approximation. + const x = df / (df + t * t); + const p = incompleteBetaRegularized(df / 2, 0.5, x); + + return Math.min(1, Math.max(0, p)); +} + +/** + * Regularized incomplete beta function I_x(a, b) approximated via continued fraction. + * Used to compute the CDF of the t-distribution. + */ +function incompleteBetaRegularized(a: number, b: number, x: number): number { + if (x < 0 || x > 1) return NaN; + if (x === 0) return 0; + if (x === 1) return 1; + + // Use symmetry relation when x > (a+1)/(a+b+2) + if (x > (a + 1) / (a + b + 2)) { + return 1 - incompleteBetaRegularized(b, a, 1 - x); + } + + const lbeta = logBeta(a, b); + const front = Math.exp(Math.log(x) * a + Math.log(1 - x) * b - lbeta) / a; + + // Continued fraction using Lentz's method + const cf = continuedFraction(a, b, x); + return front * cf; +} + +function logBeta(a: number, b: number): number { + return logGamma(a) + logGamma(b) - logGamma(a + b); +} + +function logGamma(z: number): number { + // Lanczos approximation + const g = 7; + const c = [ + 0.99999999999980993, 676.5203681218851, -1259.1392167224028, 771.32342877765313, + -176.61502916214059, 12.507343278686905, -0.13857109526572012, 9.9843695780195716e-6, + 1.5056327351493116e-7, + ]; + + if (z < 0.5) { + return Math.log(Math.PI) - Math.log(Math.sin(Math.PI * z)) - logGamma(1 - z); + } + + z -= 1; + let x = c[0]; + for (let i = 1; i < g + 2; i++) { + x += c[i] / (z + i); + } + const t = z + g + 0.5; + return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x); +} + +function continuedFraction(a: number, b: number, x: number): number { + const maxIter = 200; + const eps = 3e-7; + + let h = 1; + let c = 1; + let d = 1 - ((a + b) * x) / (a + 1); + if (Math.abs(d) < 1e-30) d = 1e-30; + d = 1 / d; + h = d; + + for (let m = 1; m <= maxIter; m++) { + // Even step + let numerator = (m * (b - m) * x) / ((a + 2 * m - 1) * (a + 2 * m)); + d = 1 + numerator * d; + c = 1 + numerator / c; + if (Math.abs(d) < 1e-30) d = 1e-30; + if (Math.abs(c) < 1e-30) c = 1e-30; + d = 1 / d; + h *= d * c; + + // Odd step + numerator = -((a + m) * (a + b + m) * x) / ((a + 2 * m) * (a + 2 * m + 1)); + d = 1 + numerator * d; + c = 1 + numerator / c; + if (Math.abs(d) < 1e-30) d = 1e-30; + if (Math.abs(c) < 1e-30) c = 1e-30; + d = 1 / d; + const delta = d * c; + h *= delta; + + if (Math.abs(delta - 1) < eps) break; + } + + return h; +} + +/** + * Classifies the statistical confidence of a correlation. + * high: |r| >= 0.5 AND p < 0.01 AND n >= 14 + * moderate: |r| >= 0.3 AND p < 0.05 AND n >= 10 + * suggestive: otherwise + */ +export function classifyConfidence(r: number, n: number, pValue: number): ConfidenceLevel { + const absR = Math.abs(r); + + if (absR >= 0.5 && pValue < 0.01 && n >= 14) { + return 'high'; + } + if (absR >= 0.3 && pValue < 0.05 && n >= 10) { + return 'moderate'; + } + return 'suggestive'; +} + +export interface LinearRegressionResult { + slope: number; + intercept: number; + r2: number; + /** Mean of the x values — used for proper OLS prediction intervals. */ + meanX: number; + /** Sum of squared deviations of x: Σ(xᵢ − meanX)² — used for prediction intervals. */ + ssXX: number; +} + +/** + * Fits a simple linear regression y = slope * x + intercept to the data. + * x values are numeric indices (0, 1, 2, ...) derived from the data order. + * Returns slope, intercept, coefficient of determination (r²), meanX, and ssXX + * for use in proper OLS prediction interval calculation. + */ +export function linearRegression(xs: number[], ys: number[]): LinearRegressionResult { + const n = xs.length; + if (n < 2 || n !== ys.length) { + return { slope: 0, intercept: ys[0] ?? 0, r2: 0, meanX: xs[0] ?? 0, ssXX: 0 }; + } + + const meanX = xs.reduce((acc, x) => acc + x, 0) / n; + const meanY = ys.reduce((acc, y) => acc + y, 0) / n; + + let ssXY = 0; + let ssXX = 0; + + for (let i = 0; i < n; i++) { + ssXY += (xs[i] - meanX) * (ys[i] - meanY); + ssXX += (xs[i] - meanX) * (xs[i] - meanX); + } + + if (ssXX === 0) { + return { slope: 0, intercept: meanY, r2: 0, meanX, ssXX: 0 }; + } + + const slope = ssXY / ssXX; + const intercept = meanY - slope * meanX; + + // Compute r² = 1 - SSres/SStot + let ssTot = 0; + let ssRes = 0; + for (let i = 0; i < n; i++) { + ssTot += (ys[i] - meanY) ** 2; + ssRes += (ys[i] - (slope * xs[i] + intercept)) ** 2; + } + + // M-L1: when all y-values are identical (ssTot === 0), r² is 0 (no variance to explain) + const r2 = ssTot === 0 ? 0 : 1 - ssRes / ssTot; + + return { slope, intercept, r2, meanX, ssXX }; +} + +/** + * Projects future values forward from a time series using linear regression. + * Returns an array of { date, value, low, high } objects for the next daysForward days. + * Confidence interval uses the proper OLS prediction interval formula: + * CI(x*) = residualStdDev * sqrt(1 + 1/n + (x* - meanX)² / ssXX) + */ +export function projectForward( + data: { date: string; value: number }[], + daysForward: number, +): Array<{ date: string; value: number; low: number; high: number }> { + if (data.length < 2) return []; + + const xs = data.map((_, i) => i); + const ys = data.map((d) => d.value); + const n = xs.length; + + const { slope, intercept, meanX, ssXX } = linearRegression(xs, ys); + + // Compute residual standard deviation + let ssRes = 0; + for (let i = 0; i < n; i++) { + ssRes += (ys[i] - (slope * xs[i] + intercept)) ** 2; + } + const residualStdDev = Math.sqrt(ssRes / Math.max(1, n - 2)); + + // Parse the last date + const lastDate = new Date(data[data.length - 1].date); + const lastX = xs[xs.length - 1]; + + const results: Array<{ date: string; value: number; low: number; high: number }> = []; + + for (let d = 1; d <= daysForward; d++) { + const futureX = lastX + d; + const projectedValue = slope * futureX + intercept; + + // Proper OLS prediction interval: widens with distance from meanX + const leverage = ssXX > 0 ? (futureX - meanX) ** 2 / ssXX : 0; + const ci = residualStdDev * Math.sqrt(1 + 1 / n + leverage); + + const projDate = new Date(lastDate); + projDate.setDate(projDate.getDate() + d); + const dateStr = projDate.toISOString().split('T')[0]; + + results.push({ + date: dateStr, + value: projectedValue, + low: projectedValue - ci, + high: projectedValue + ci, + }); + } + + return results; +} diff --git a/packages/backend/src/services/intelligence/trajectory-projector.ts b/packages/backend/src/services/intelligence/trajectory-projector.ts new file mode 100644 index 0000000..192a6dc --- /dev/null +++ b/packages/backend/src/services/intelligence/trajectory-projector.ts @@ -0,0 +1,106 @@ +import type pg from 'pg'; +import { projectForward } from './stats.js'; +import { upsertProjections } from '../../db/queries/projections.js'; + +const DEFAULT_BIOMETRIC_METRICS = [ + 'weight_kg', + 'body_fat_pct', + 'resting_hr', + 'blood_pressure_systolic', + 'blood_pressure_diastolic', + 'sleep_hours', + 'steps', +]; + +const PROJECTION_DAYS = 30; +const MIN_DATA_POINTS = 7; + +/** + * Queries the distinct metrics available for a user in the measurements table. + */ +async function queryDistinctMetrics(pool: pg.Pool, userId: string): Promise { + const { rows } = await pool.query(`SELECT DISTINCT metric FROM measurements WHERE user_id = $1`, [ + userId, + ]); + return rows.map((r) => String(r['metric'])); +} + +/** + * Queries daily averaged values for a single metric over the past lookbackDays. + * Returns an array sorted by date ascending. + */ +async function queryDailyValues( + pool: pg.Pool, + userId: string, + metric: string, + lookbackDays = 90, +): Promise> { + const endDate = new Date(); + const startDate = new Date(); + startDate.setDate(endDate.getDate() - lookbackDays); + + const { rows } = await pool.query( + `SELECT DATE(measured_at) AS day, AVG(value) AS avg_value + FROM measurements + WHERE user_id = $1 AND metric = $2 AND measured_at BETWEEN $3 AND $4 + GROUP BY DATE(measured_at) + ORDER BY day`, + [userId, metric, startDate, endDate], + ); + + return rows.map((r) => ({ + date: r['day'] instanceof Date ? r['day'].toISOString().split('T')[0] : String(r['day']), + value: Number(r['avg_value']), + })); +} + +/** + * Runs trajectory projections for a user's metrics. + * Loads recent measurement history, fits trend models, and upserts + * projected values into the projections table. + * + * @param pool - pg connection pool + * @param userId - user to run projections for + * @param metrics - specific metrics to project (default: all available biometric metrics) + */ +export async function runTrajectoryProjections( + pool: pg.Pool, + userId: string, + metrics?: string[], +): Promise { + // Resolve metrics: use provided list, or query distinct metrics filtered by the biometric set. + // If the user has no matching metrics, return early — no point firing DB queries per metric. + let targetMetrics: string[]; + if (metrics && metrics.length > 0) { + targetMetrics = metrics; + } else { + const available = await queryDistinctMetrics(pool, userId); + targetMetrics = available.filter((m) => DEFAULT_BIOMETRIC_METRICS.includes(m)); + if (targetMetrics.length === 0) { + return; + } + } + + for (const metric of targetMetrics) { + const dailyValues = await queryDailyValues(pool, userId, metric); + + if (dailyValues.length < MIN_DATA_POINTS) continue; + + const projected = projectForward(dailyValues, PROJECTION_DAYS); + + if (projected.length === 0) continue; + + const projectionRows = projected.map((p) => ({ + userId, + metric, + projectionDate: p.date, + projectedValue: p.value, + confidenceLow: p.low, + confidenceHigh: p.high, + method: 'linear_regression' as const, + dataPoints: dailyValues.length, + })); + + await upsertProjections(pool, userId, projectionRows); + } +} diff --git a/packages/backend/src/services/report-runner.ts b/packages/backend/src/services/report-runner.ts index 10ea46c..6370bcb 100644 --- a/packages/backend/src/services/report-runner.ts +++ b/packages/backend/src/services/report-runner.ts @@ -8,6 +8,8 @@ import { gatherAndGenerate } from './ai/report-generator.js'; import { createAIProvider } from './ai/ai-service.js'; import { runCollection } from './collectors/pipeline.js'; import { reportEventBus } from './report-event-bus.js'; +import { runCorrelationAnalysis } from './intelligence/correlation-engine.js'; +import { runTrajectoryProjections } from './intelligence/trajectory-projector.js'; function emitStatus( reportId: string, @@ -88,6 +90,16 @@ export function runReportInBackground( await promoteActionItems(pool, params.userId, reportId, gen.actionItems); } + // Run intelligence pipeline (correlations + projections). Non-blocking. + try { + await Promise.all([ + runCorrelationAnalysis(pool, params.userId), + runTrajectoryProjections(pool, params.userId), + ]); + } catch (err: unknown) { + log.error({ err }, '[intelligence] pipeline failed after report generation'); + } + emitStatus(reportId, 'completed', 'Report ready'); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 42dda16..db2e3bf 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,3 +7,4 @@ export * from './types/report.js'; export * from './types/measurement.js'; export * from './constants/metrics.js'; export * from './constants/query-keys.js'; +export * from './types/intelligence.js'; diff --git a/packages/shared/src/types/intelligence.ts b/packages/shared/src/types/intelligence.ts new file mode 100644 index 0000000..44636b3 --- /dev/null +++ b/packages/shared/src/types/intelligence.ts @@ -0,0 +1,41 @@ +export type ConfidenceLevel = 'high' | 'moderate' | 'suggestive'; + +export type CorrelationStatus = 'active' | 'weakening' | 'disproven'; + +export type CorrelationCategory = 'nutrition' | 'training' | 'recovery' | 'cross-domain'; + +export interface Correlation { + id: string; + userId: string; + factorMetric: string; + factorCondition: string; + factorLabel: string; + outcomeMetric: string; + outcomeEffect: string; + outcomeLabel: string; + correlationCoefficient: number; + confidenceLevel: ConfidenceLevel; + dataPoints: number; + pValue: number | null; + firstDetectedAt: string; + lastConfirmedAt: string; + timesConfirmed: number; + status: CorrelationStatus; + summary: string; + category: CorrelationCategory; + createdAt: string; + updatedAt: string; +} + +export interface Projection { + id: string; + userId: string; + metric: string; + projectionDate: string; + projectedValue: number; + confidenceLow: number | null; + confidenceHigh: number | null; + method: 'linear_regression' | 'rolling_average' | 'exponential'; + dataPoints: number; + generatedAt: string; +}