From 87a1dd68294824dd569f68fc49e5d69a66e0863f Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 13 Apr 2026 22:07:46 +0200 Subject: [PATCH] feat(brain): add Semantic Memory, Pattern Extractors, and Correlation Engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 7 (final) of the Companion Brain architecture. Semantic Memory (companion/memory/): - MemoryFact model with confidence lifecycle (0.3 initial, +0.15 confirm, -0.15 contradict, weekly decay after 30 days, delete below 0.1) - Store with recordFact (upsert by factKey), contradictFact, applyDecay - 3 pattern extractors: day-of-week (recurring days), time-of-day (peak 4h window), frequency (daily average) — all rule-based, no LLM - Runs across all 5 pilot modules (11 extraction rules total) Correlation Engine (data/projections/correlations.ts): - Pearson correlation between 7 daily metrics across 4 modules - Metrics: tasks completed, water ml, coffee count, calories, meals, calendar events, places visited - Only returns cross-module correlations with |r| >= 0.3 and >= 14 days - Natural language sentence generation for each correlation Context Document updated: - Now accepts optional memory facts + correlations - Appends "Bekannte Muster" section (top 6 high-confidence facts) - Appends "Zusammenhaenge" section (top 3 correlations with r-value) This completes all 7 phases of the Companion Brain architecture. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/companion/memory/extractors.ts | 219 +++++++++++++++++ .../web/src/lib/companion/memory/index.ts | 3 + .../web/src/lib/companion/memory/store.ts | 148 ++++++++++++ .../web/src/lib/companion/memory/types.ts | 48 ++++ .../lib/data/projections/context-document.ts | 37 ++- .../src/lib/data/projections/correlations.ts | 220 ++++++++++++++++++ .../web/src/lib/data/projections/index.ts | 1 + 7 files changed, 670 insertions(+), 6 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/companion/memory/extractors.ts create mode 100644 apps/mana/apps/web/src/lib/companion/memory/index.ts create mode 100644 apps/mana/apps/web/src/lib/companion/memory/store.ts create mode 100644 apps/mana/apps/web/src/lib/companion/memory/types.ts create mode 100644 apps/mana/apps/web/src/lib/data/projections/correlations.ts diff --git a/apps/mana/apps/web/src/lib/companion/memory/extractors.ts b/apps/mana/apps/web/src/lib/companion/memory/extractors.ts new file mode 100644 index 000000000..04afaf1f7 --- /dev/null +++ b/apps/mana/apps/web/src/lib/companion/memory/extractors.ts @@ -0,0 +1,219 @@ +/** + * Pattern Extractors — Analyze event history to discover user patterns. + * + * Each extractor scans the event store for a specific type of pattern + * and records/confirms facts in the memory store. Run periodically + * (e.g. daily) or after a batch of events. + * + * All extractors are rule-based (no LLM). They look for: + * - Recurring day-of-week patterns (e.g. "trains Mon/Wed/Fri") + * - Time-of-day preferences (e.g. "completes tasks mostly before noon") + * - Sequences (e.g. "always logs coffee before first event") + * - Frequency patterns (e.g. "drinks 3 coffees per day on average") + */ + +import { queryEvents } from '$lib/data/events/event-store'; +import { memoryStore } from './store'; + +const DAY_NAMES = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; +const LOOKBACK_DAYS = 30; + +function daysAgoISO(n: number): string { + const d = new Date(); + d.setDate(d.getDate() - n); + return d.toISOString(); +} + +function dayOfWeek(timestamp: string): number { + return new Date(timestamp).getDay(); +} + +function hourOfDay(timestamp: string): number { + return new Date(timestamp).getHours(); +} + +interface DayCount { + [day: number]: number; +} + +// ── Recurring Day Pattern ─────────────────────────── + +/** + * Detects which days of the week an event type occurs most. + * E.g. "DrinkLogged(coffee) happens mostly on Mon/Tue/Wed/Thu/Fri" → work days. + */ +async function extractDayOfWeekPattern( + eventType: string, + label: string, + module: string, + filterFn?: (payload: Record) => boolean +): Promise { + const since = daysAgoISO(LOOKBACK_DAYS); + const events = await queryEvents({ type: eventType, since, limit: 500 }); + const filtered = filterFn + ? events.filter((e) => filterFn(e.payload as Record)) + : events; + + if (filtered.length < 7) return; // Not enough data + + const dayCounts: DayCount = {}; + for (const e of filtered) { + const day = dayOfWeek(e.meta.timestamp); + dayCounts[day] = (dayCounts[day] ?? 0) + 1; + } + + // Find days that have significantly more events than average + const total = filtered.length; + const avgPerDay = total / 7; + const activeDays = Object.entries(dayCounts) + .filter(([, count]) => count > avgPerDay * 1.3) + .map(([day]) => Number(day)) + .sort(); + + if (activeDays.length === 0 || activeDays.length === 7) return; // No pattern or every day + + const dayLabels = activeDays.map((d) => DAY_NAMES[d]).join('/'); + const factKey = `pattern:${module}:day_of_week:${eventType}`; + + await memoryStore.recordFact({ + factKey, + category: 'pattern', + content: `${label} typischerweise an ${dayLabels}`, + sourceModules: [module], + }); +} + +// ── Time of Day Preference ────────────────────────── + +/** + * Detects preferred time windows for an event type. + * E.g. "Tasks mostly completed between 9-12" → morning productivity. + */ +async function extractTimePreference( + eventType: string, + label: string, + module: string +): Promise { + const since = daysAgoISO(LOOKBACK_DAYS); + const events = await queryEvents({ type: eventType, since, limit: 500 }); + + if (events.length < 10) return; + + const hourCounts: Record = {}; + for (const e of events) { + const h = hourOfDay(e.meta.timestamp); + hourCounts[h] = (hourCounts[h] ?? 0) + 1; + } + + // Find the peak 4-hour window + let bestStart = 0; + let bestCount = 0; + for (let start = 5; start <= 20; start++) { + let count = 0; + for (let h = start; h < start + 4; h++) { + count += hourCounts[h] ?? 0; + } + if (count > bestCount) { + bestCount = count; + bestStart = start; + } + } + + const peakPercent = Math.round((bestCount / events.length) * 100); + if (peakPercent < 40) return; // No clear peak + + const timeLabel = bestStart < 12 ? 'morgens' : bestStart < 17 ? 'nachmittags' : 'abends'; + const factKey = `preference:${module}:time_of_day:${eventType}`; + + await memoryStore.recordFact({ + factKey, + category: 'preference', + content: `${label} hauptsaechlich ${timeLabel} (${bestStart}:00-${bestStart + 4}:00, ${peakPercent}% aller Eintraege)`, + sourceModules: [module], + }); +} + +// ── Frequency Pattern ─────────────────────────────── + +/** + * Detects average daily frequency of an event type. + * E.g. "3 Kaffee pro Tag im Durchschnitt" + */ +async function extractFrequencyPattern( + eventType: string, + label: string, + module: string, + filterFn?: (payload: Record) => boolean +): Promise { + const since = daysAgoISO(LOOKBACK_DAYS); + const events = await queryEvents({ type: eventType, since, limit: 1000 }); + const filtered = filterFn + ? events.filter((e) => filterFn(e.payload as Record)) + : events; + + if (filtered.length < 5) return; + + // Count per day + const dayCounts: Record = {}; + for (const e of filtered) { + const date = e.meta.timestamp.split('T')[0]; + dayCounts[date] = (dayCounts[date] ?? 0) + 1; + } + + const activeDays = Object.keys(dayCounts).length; + if (activeDays < 3) return; + + const avg = Math.round((filtered.length / activeDays) * 10) / 10; + const factKey = `pattern:${module}:frequency:${eventType}`; + + await memoryStore.recordFact({ + factKey, + category: 'pattern', + content: `Durchschnittlich ${avg} ${label} pro Tag (letzte ${activeDays} aktive Tage)`, + sourceModules: [module], + }); +} + +// ── Run All Extractors ────────────────────────────── + +/** + * Run all pattern extractors. Call once daily (e.g. from a scheduled rule + * or on app startup after data has loaded). + */ +export async function extractAllPatterns(): Promise { + await Promise.all([ + // Todo patterns + extractDayOfWeekPattern('TaskCompleted', 'Tasks erledigt', 'todo'), + extractTimePreference('TaskCompleted', 'Tasks erledigt', 'todo'), + extractTimePreference('TaskCreated', 'Tasks erstellt', 'todo'), + + // Drink patterns + extractDayOfWeekPattern( + 'DrinkLogged', + 'Kaffee getrunken', + 'drink', + (p) => p.drinkType === 'coffee' + ), + extractFrequencyPattern('DrinkLogged', 'Kaffee', 'drink', (p) => p.drinkType === 'coffee'), + extractFrequencyPattern( + 'DrinkLogged', + 'Wasser-Eintraege', + 'drink', + (p) => p.drinkType === 'water' + ), + extractTimePreference('DrinkLogged', 'Getraenke geloggt', 'drink'), + + // Calendar patterns + extractDayOfWeekPattern('CalendarEventCreated', 'Termine erstellt', 'calendar'), + + // Nutriphi patterns + extractTimePreference('MealLogged', 'Mahlzeiten geloggt', 'nutriphi'), + extractFrequencyPattern('MealLogged', 'Mahlzeiten', 'nutriphi'), + + // Places patterns + extractDayOfWeekPattern('PlaceVisited', 'Orte besucht', 'places'), + ]); + + // Apply decay to old facts + await memoryStore.applyDecay(); +} diff --git a/apps/mana/apps/web/src/lib/companion/memory/index.ts b/apps/mana/apps/web/src/lib/companion/memory/index.ts new file mode 100644 index 000000000..3e278c4b4 --- /dev/null +++ b/apps/mana/apps/web/src/lib/companion/memory/index.ts @@ -0,0 +1,3 @@ +export { memoryStore } from './store'; +export { extractAllPatterns } from './extractors'; +export type { MemoryFact, MemoryCategory, Correlation } from './types'; diff --git a/apps/mana/apps/web/src/lib/companion/memory/store.ts b/apps/mana/apps/web/src/lib/companion/memory/store.ts new file mode 100644 index 000000000..150ad430a --- /dev/null +++ b/apps/mana/apps/web/src/lib/companion/memory/store.ts @@ -0,0 +1,148 @@ +/** + * Memory Store — CRUD + confidence lifecycle for semantic memory facts. + */ + +import { db } from '$lib/data/database'; +import type { MemoryFact, MemoryCategory } from './types'; + +const TABLE = '_memory'; + +const INITIAL_CONFIDENCE = 0.3; +const CONFIRM_BOOST = 0.15; +const CONTRADICT_PENALTY = 0.15; +const DECAY_PER_WEEK = 0.05; +const MIN_CONFIDENCE = 0.1; +const MAX_CONFIDENCE = 0.95; + +function clamp(v: number, min: number, max: number): number { + return Math.min(max, Math.max(min, v)); +} + +export const memoryStore = { + /** + * Record or confirm a fact. If a fact with the same factKey exists, + * its confidence is boosted. Otherwise a new fact is created. + */ + async recordFact(input: { + factKey: string; + category: MemoryCategory; + content: string; + sourceModules: string[]; + }): Promise { + const now = new Date().toISOString(); + const existing = await db + .table(TABLE) + .where('id') + .above('') + .filter((f) => f.factKey === input.factKey && !f.deletedAt) + .first(); + + if (existing) { + const newConf = clamp(existing.confidence + CONFIRM_BOOST, 0, MAX_CONFIDENCE); + const modules = [...new Set([...existing.sourceModules, ...input.sourceModules])]; + await db.table(TABLE).update(existing.id, { + confidence: newConf, + confirmations: existing.confirmations + 1, + lastConfirmed: now, + sourceModules: modules, + content: input.content, // Update with latest wording + updatedAt: now, + }); + return { + ...existing, + confidence: newConf, + confirmations: existing.confirmations + 1, + lastConfirmed: now, + }; + } + + const fact: MemoryFact = { + id: crypto.randomUUID(), + category: input.category, + content: input.content, + confidence: INITIAL_CONFIDENCE, + confirmations: 1, + contradictions: 0, + sourceModules: input.sourceModules, + factKey: input.factKey, + firstSeen: now, + lastConfirmed: now, + createdAt: now, + updatedAt: now, + }; + await db.table(TABLE).add(fact); + return fact; + }, + + /** Record a contradiction — lowers confidence. */ + async contradictFact(factKey: string): Promise { + const fact = await db + .table(TABLE) + .where('id') + .above('') + .filter((f) => f.factKey === factKey && !f.deletedAt) + .first(); + if (!fact) return; + + const newConf = clamp(fact.confidence - CONTRADICT_PENALTY, 0, MAX_CONFIDENCE); + await db.table(TABLE).update(fact.id, { + confidence: newConf, + contradictions: fact.contradictions + 1, + updatedAt: new Date().toISOString(), + }); + }, + + /** Apply time decay to all facts. Call periodically (e.g. daily). */ + async applyDecay(): Promise { + const now = Date.now(); + const all = await db.table(TABLE).toArray(); + let cleaned = 0; + + for (const fact of all) { + if (fact.deletedAt) continue; + const lastMs = new Date(fact.lastConfirmed).getTime(); + const weeksSince = (now - lastMs) / (7 * 86400000); + if (weeksSince < 4) continue; // No decay within first month + + const decay = Math.floor(weeksSince - 4) * DECAY_PER_WEEK; + const newConf = clamp(fact.confidence - decay, 0, MAX_CONFIDENCE); + + if (newConf < MIN_CONFIDENCE) { + await db.table(TABLE).update(fact.id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + cleaned++; + } else if (newConf !== fact.confidence) { + await db.table(TABLE).update(fact.id, { + confidence: newConf, + updatedAt: new Date().toISOString(), + }); + } + } + + return cleaned; + }, + + /** Get all active facts above a confidence threshold. */ + async getFacts(minConfidence = 0.3): Promise { + const all = await db.table(TABLE).toArray(); + return all + .filter((f) => !f.deletedAt && f.confidence >= minConfidence) + .sort((a, b) => b.confidence - a.confidence); + }, + + /** Get facts for a specific category. */ + async getFactsByCategory(category: MemoryCategory, minConfidence = 0.3): Promise { + const all = await this.getFacts(minConfidence); + return all.filter((f) => f.category === category); + }, + + /** Delete a specific fact. */ + async deleteFact(id: string): Promise { + await db.table(TABLE).update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/companion/memory/types.ts b/apps/mana/apps/web/src/lib/companion/memory/types.ts new file mode 100644 index 000000000..d7cffbdf1 --- /dev/null +++ b/apps/mana/apps/web/src/lib/companion/memory/types.ts @@ -0,0 +1,48 @@ +/** + * Semantic Memory types — Extracted user knowledge that persists across sessions. + * + * Memory facts represent patterns, preferences, and context inferred from + * the event stream. They are more durable and compact than raw events — + * "User trains Mon/Wed/Fri evenings" is one fact vs. hundreds of events. + * + * Confidence lifecycle: + * New fact → 0.3 + * Confirmed again → +0.15 (cap 0.95) + * Contradicted → -0.15 + * Not seen 30 days → -0.05/week + * Below 0.1 → deleted + */ + +export interface MemoryFact { + id: string; + category: MemoryCategory; + /** Human-readable description of the fact */ + content: string; + /** 0.0 to 1.0 — rises with confirmations, decays with time/contradictions */ + confidence: number; + confirmations: number; + contradictions: number; + /** Which modules contributed to this fact */ + sourceModules: string[]; + /** Machine-readable key for deduplication (e.g. 'pattern:drink:morning_coffee') */ + factKey: string; + firstSeen: string; + lastConfirmed: string; + createdAt: string; + updatedAt: string; + deletedAt?: string; +} + +export type MemoryCategory = 'pattern' | 'preference' | 'context'; + +/** Correlation between two daily metrics */ +export interface Correlation { + id: string; + factorA: { module: string; metric: string; label: string }; + factorB: { module: string; metric: string; label: string }; + coefficient: number; + sampleSize: number; + direction: 'positive' | 'negative'; + sentence: string; + computedAt: string; +} diff --git a/apps/mana/apps/web/src/lib/data/projections/context-document.ts b/apps/mana/apps/web/src/lib/data/projections/context-document.ts index 48df36b40..7e4b7e425 100644 --- a/apps/mana/apps/web/src/lib/data/projections/context-document.ts +++ b/apps/mana/apps/web/src/lib/data/projections/context-document.ts @@ -2,11 +2,12 @@ * Context Document Generator — Produces a ~500 token text snapshot * of the user's current state for use as an LLM system prompt. * - * Combines DaySnapshot + Streaks into a structured markdown string - * that any LLM tier (local Gemma or cloud) can reason over. + * Combines DaySnapshot + Streaks + Memory + Correlations into a + * structured markdown string that any LLM tier can reason over. */ import type { DaySnapshot, StreakInfo } from './types'; +import type { MemoryFact, Correlation } from '$lib/companion/memory/types'; function formatTime(iso: string): string { try { @@ -19,11 +20,18 @@ function formatTime(iso: string): string { /** * Generate a concise user context document. * - * @param day - Today's snapshot - * @param streaks - Current streak info - * @returns Markdown string (~300-500 tokens) + * @param day - Today's snapshot + * @param streaks - Current streak info + * @param memory - Extracted user patterns (optional) + * @param correlations - Cross-module correlations (optional) + * @returns Markdown string (~300-600 tokens) */ -export function generateContextDocument(day: DaySnapshot, streaks: StreakInfo[]): string { +export function generateContextDocument( + day: DaySnapshot, + streaks: StreakInfo[], + memory: MemoryFact[] = [], + correlations: Correlation[] = [] +): string { const lines: string[] = []; lines.push(`## Nutzer-Kontext (${day.date})\n`); @@ -103,5 +111,22 @@ export function generateContextDocument(day: DaySnapshot, streaks: StreakInfo[]) } } + // ── Memory (Patterns & Preferences) ──────────────── + const highConfMemory = memory.filter((m) => m.confidence >= 0.5); + if (highConfMemory.length > 0) { + lines.push('\n### Bekannte Muster'); + for (const m of highConfMemory.slice(0, 6)) { + lines.push(`- ${m.content}`); + } + } + + // ── Correlations ──────────────────────────────────── + if (correlations.length > 0) { + lines.push('\n### Zusammenhaenge'); + for (const c of correlations.slice(0, 3)) { + lines.push(`- ${c.sentence} (r=${c.coefficient})`); + } + } + return lines.join('\n'); } diff --git a/apps/mana/apps/web/src/lib/data/projections/correlations.ts b/apps/mana/apps/web/src/lib/data/projections/correlations.ts new file mode 100644 index 000000000..629715cd4 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/projections/correlations.ts @@ -0,0 +1,220 @@ +/** + * Correlation Engine — Finds statistical relationships between daily + * metrics from different modules. + * + * Computes Pearson correlation coefficients between pairs of daily + * aggregates (e.g. "water intake" vs "tasks completed"). Only + * correlations with |r| >= 0.3 and enough data points (>= 14 days) + * are returned. + * + * All computation is local — no server, no LLM. + */ + +import { queryEvents } from '$lib/data/events/event-store'; +import type { Correlation } from '$lib/companion/memory/types'; + +const MIN_DAYS = 14; +const MIN_ABS_R = 0.3; +const LOOKBACK_DAYS = 60; + +// ── Metric Definitions ────────────────────────────── + +interface MetricDef { + id: string; + module: string; + label: string; + /** Extract a daily value from events for a given date */ + extract: (dayEvents: DayEventMap) => number; +} + +type DayEventMap = Map }[]>; + +function buildDayEventMap( + events: { type: string; payload: unknown; meta: { timestamp: string } }[] +): DayEventMap { + const map: DayEventMap = new Map(); + for (const e of events) { + const date = e.meta.timestamp.split('T')[0]; + if (!map.has(date)) map.set(date, []); + map.get(date)!.push({ type: e.type, payload: e.payload as Record }); + } + return map; +} + +const METRICS: MetricDef[] = [ + { + id: 'todo:completed', + module: 'todo', + label: 'Tasks erledigt', + extract: (days) => countByType(days, 'TaskCompleted'), + }, + { + id: 'drink:water_ml', + module: 'drink', + label: 'Wasser (ml)', + extract: (days) => + sumByTypeField(days, 'DrinkLogged', 'quantityMl', (p) => p.drinkType === 'water'), + }, + { + id: 'drink:coffee_count', + module: 'drink', + label: 'Kaffee (Tassen)', + extract: (days) => countByType(days, 'DrinkLogged', (p) => p.drinkType === 'coffee'), + }, + { + id: 'nutriphi:calories', + module: 'nutriphi', + label: 'Kalorien', + extract: (days) => sumByTypeField(days, 'MealLogged', 'calories'), + }, + { + id: 'nutriphi:meals', + module: 'nutriphi', + label: 'Mahlzeiten', + extract: (days) => countByType(days, 'MealLogged'), + }, + { + id: 'calendar:events', + module: 'calendar', + label: 'Termine', + extract: (days) => countByType(days, 'CalendarEventCreated'), + }, + { + id: 'places:visits', + module: 'places', + label: 'Orte besucht', + extract: (days) => countByType(days, 'PlaceVisited'), + }, +]; + +function countByType( + days: DayEventMap, + eventType: string, + filter?: (payload: Record) => boolean +): number { + let count = 0; + for (const [, events] of days) { + for (const e of events) { + if (e.type === eventType && (!filter || filter(e.payload))) count++; + } + } + return count; +} + +function sumByTypeField( + days: DayEventMap, + eventType: string, + field: string, + filter?: (payload: Record) => boolean +): number { + let sum = 0; + for (const [, events] of days) { + for (const e of events) { + if (e.type === eventType && (!filter || filter(e.payload))) { + const val = e.payload[field]; + if (typeof val === 'number') sum += val; + } + } + } + return sum; +} + +// ── Pearson Correlation ───────────────────────────── + +function pearson(xs: number[], ys: number[]): number { + const n = xs.length; + if (n < 3) return 0; + + let sumX = 0, + sumY = 0, + sumXY = 0, + sumX2 = 0, + sumY2 = 0; + for (let i = 0; i < n; i++) { + sumX += xs[i]; + sumY += ys[i]; + sumXY += xs[i] * ys[i]; + sumX2 += xs[i] * xs[i]; + sumY2 += ys[i] * ys[i]; + } + + const denom = Math.sqrt((n * sumX2 - sumX * sumX) * (n * sumY2 - sumY * sumY)); + if (denom === 0) return 0; + + return (n * sumXY - sumX * sumY) / denom; +} + +// ── Sentence Generation ───────────────────────────── + +function generateSentence(labelA: string, labelB: string, r: number): string { + const direction = r > 0 ? 'mehr' : 'weniger'; + const strength = Math.abs(r) > 0.6 ? 'deutlich' : 'etwas'; + return `An Tagen mit mehr ${labelA} hast du ${strength} ${direction} ${labelB}`; +} + +// ── Main ──────────────────────────────────────────── + +/** + * Compute correlations between all metric pairs. + * Returns only significant correlations (|r| >= 0.3, >= 14 days). + */ +export async function computeCorrelations(): Promise { + const since = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString(); + const allEvents = await queryEvents({ since, limit: 5000 }); + + if (allEvents.length < 20) return []; + + const dayMap = buildDayEventMap(allEvents); + const dates = [...dayMap.keys()].sort(); + + if (dates.length < MIN_DAYS) return []; + + // Build daily metric vectors + const metricVectors: Map = new Map(); + for (const metric of METRICS) { + const values: number[] = []; + for (const date of dates) { + const singleDayMap: DayEventMap = new Map([[date, dayMap.get(date) ?? []]]); + values.push(metric.extract(singleDayMap)); + } + // Skip metrics that are all zeros + if (values.some((v) => v > 0)) { + metricVectors.set(metric.id, values); + } + } + + // Compute pairwise correlations + const correlations: Correlation[] = []; + const metricIds = [...metricVectors.keys()]; + + for (let i = 0; i < metricIds.length; i++) { + for (let j = i + 1; j < metricIds.length; j++) { + const idA = metricIds[i]; + const idB = metricIds[j]; + const metricA = METRICS.find((m) => m.id === idA)!; + const metricB = METRICS.find((m) => m.id === idB)!; + + // Skip same-module correlations (trivially correlated) + if (metricA.module === metricB.module) continue; + + const xs = metricVectors.get(idA)!; + const ys = metricVectors.get(idB)!; + const r = pearson(xs, ys); + + if (Math.abs(r) >= MIN_ABS_R) { + correlations.push({ + id: `corr:${idA}:${idB}`, + factorA: { module: metricA.module, metric: idA, label: metricA.label }, + factorB: { module: metricB.module, metric: idB, label: metricB.label }, + coefficient: Math.round(r * 100) / 100, + sampleSize: dates.length, + direction: r > 0 ? 'positive' : 'negative', + sentence: generateSentence(metricA.label, metricB.label, r), + computedAt: new Date().toISOString(), + }); + } + } + } + + return correlations.sort((a, b) => Math.abs(b.coefficient) - Math.abs(a.coefficient)); +} diff --git a/apps/mana/apps/web/src/lib/data/projections/index.ts b/apps/mana/apps/web/src/lib/data/projections/index.ts index 95d2f408b..1e3a4466f 100644 --- a/apps/mana/apps/web/src/lib/data/projections/index.ts +++ b/apps/mana/apps/web/src/lib/data/projections/index.ts @@ -1,4 +1,5 @@ export { useDaySnapshot } from './day-snapshot'; export { useStreaks } from './streaks'; +export { computeCorrelations } from './correlations'; export { generateContextDocument } from './context-document'; export type { DaySnapshot, StreakInfo, TaskSummary, EventSummary } from './types';