feat(brain): add Semantic Memory, Pattern Extractors, and Correlation Engine

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-13 22:07:46 +02:00
parent 41357b2541
commit 87a1dd6829
7 changed files with 670 additions and 6 deletions

View file

@ -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<string, unknown>) => boolean
): Promise<void> {
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<string, unknown>))
: 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<void> {
const since = daysAgoISO(LOOKBACK_DAYS);
const events = await queryEvents({ type: eventType, since, limit: 500 });
if (events.length < 10) return;
const hourCounts: Record<number, number> = {};
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<string, unknown>) => boolean
): Promise<void> {
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<string, unknown>))
: events;
if (filtered.length < 5) return;
// Count per day
const dayCounts: Record<string, number> = {};
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<void> {
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();
}

View file

@ -0,0 +1,3 @@
export { memoryStore } from './store';
export { extractAllPatterns } from './extractors';
export type { MemoryFact, MemoryCategory, Correlation } from './types';

View file

@ -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<MemoryFact> {
const now = new Date().toISOString();
const existing = await db
.table<MemoryFact>(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<void> {
const fact = await db
.table<MemoryFact>(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<number> {
const now = Date.now();
const all = await db.table<MemoryFact>(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<MemoryFact[]> {
const all = await db.table<MemoryFact>(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<MemoryFact[]> {
const all = await this.getFacts(minConfidence);
return all.filter((f) => f.category === category);
},
/** Delete a specific fact. */
async deleteFact(id: string): Promise<void> {
await db.table(TABLE).update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
};

View file

@ -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;
}

View file

@ -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');
}

View file

@ -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<string, { type: string; payload: Record<string, unknown> }[]>;
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<string, unknown> });
}
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<string, unknown>) => 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<string, unknown>) => 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<Correlation[]> {
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<string, number[]> = 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));
}

View file

@ -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';