@timmeck/brain-core 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +18 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -1
- package/dist/research/adaptive-strategy.d.ts +56 -0
- package/dist/research/adaptive-strategy.js +236 -0
- package/dist/research/adaptive-strategy.js.map +1 -0
- package/dist/research/agenda-engine.d.ts +46 -0
- package/dist/research/agenda-engine.js +264 -0
- package/dist/research/agenda-engine.js.map +1 -0
- package/dist/research/anomaly-detective.d.ts +62 -0
- package/dist/research/anomaly-detective.js +318 -0
- package/dist/research/anomaly-detective.js.map +1 -0
- package/dist/research/counterfactual-engine.d.ts +63 -0
- package/dist/research/counterfactual-engine.js +263 -0
- package/dist/research/counterfactual-engine.js.map +1 -0
- package/dist/research/cross-domain-engine.d.ts +52 -0
- package/dist/research/cross-domain-engine.js +283 -0
- package/dist/research/cross-domain-engine.js.map +1 -0
- package/dist/research/experiment-engine.d.ts +77 -0
- package/dist/research/experiment-engine.js +328 -0
- package/dist/research/experiment-engine.js.map +1 -0
- package/dist/research/journal.d.ts +62 -0
- package/dist/research/journal.js +262 -0
- package/dist/research/journal.js.map +1 -0
- package/dist/research/knowledge-distiller.d.ts +95 -0
- package/dist/research/knowledge-distiller.js +426 -0
- package/dist/research/knowledge-distiller.js.map +1 -0
- package/dist/research/self-observer.d.ts +55 -0
- package/dist/research/self-observer.js +268 -0
- package/dist/research/self-observer.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { getLogger } from '../utils/logger.js';
|
|
2
|
+
// ── Migration ───────────────────────────────────────────
|
|
3
|
+
export function runAgendaMigration(db) {
|
|
4
|
+
db.exec(`
|
|
5
|
+
CREATE TABLE IF NOT EXISTS research_agenda (
|
|
6
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
7
|
+
priority REAL NOT NULL,
|
|
8
|
+
question TEXT NOT NULL,
|
|
9
|
+
type TEXT NOT NULL,
|
|
10
|
+
estimated_cycles INTEGER NOT NULL,
|
|
11
|
+
expected_impact TEXT NOT NULL,
|
|
12
|
+
prerequisites TEXT NOT NULL DEFAULT '[]',
|
|
13
|
+
auto_executable INTEGER NOT NULL DEFAULT 0,
|
|
14
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
15
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
16
|
+
completed_at TEXT
|
|
17
|
+
);
|
|
18
|
+
CREATE INDEX IF NOT EXISTS idx_agenda_status ON research_agenda(status);
|
|
19
|
+
CREATE INDEX IF NOT EXISTS idx_agenda_priority ON research_agenda(priority DESC);
|
|
20
|
+
`);
|
|
21
|
+
}
|
|
22
|
+
// ── Engine ──────────────────────────────────────────────
|
|
23
|
+
export class ResearchAgendaEngine {
|
|
24
|
+
db;
|
|
25
|
+
config;
|
|
26
|
+
log = getLogger();
|
|
27
|
+
constructor(db, config) {
|
|
28
|
+
this.db = db;
|
|
29
|
+
this.config = {
|
|
30
|
+
brainName: config.brainName,
|
|
31
|
+
maxItems: config.maxItems ?? 50,
|
|
32
|
+
};
|
|
33
|
+
runAgendaMigration(db);
|
|
34
|
+
}
|
|
35
|
+
/** Generate research agenda from current state of knowledge. */
|
|
36
|
+
generate() {
|
|
37
|
+
const items = [];
|
|
38
|
+
// 1. Unconfirmed hypotheses → need testing
|
|
39
|
+
items.push(...this.findOpenHypotheses());
|
|
40
|
+
// 2. Knowledge gaps → areas with little data
|
|
41
|
+
items.push(...this.findKnowledgeGaps());
|
|
42
|
+
// 3. Unexplored correlations
|
|
43
|
+
items.push(...this.findUnexploredCorrelations());
|
|
44
|
+
// 4. Anomalies needing investigation
|
|
45
|
+
items.push(...this.findOpenAnomalies());
|
|
46
|
+
// 5. Parameter optimization opportunities
|
|
47
|
+
items.push(...this.findParameterSweeps());
|
|
48
|
+
// Deduplicate and persist
|
|
49
|
+
for (const item of items) {
|
|
50
|
+
this.upsertItem(item);
|
|
51
|
+
}
|
|
52
|
+
// Cleanup old completed/dismissed items
|
|
53
|
+
this.db.prepare(`
|
|
54
|
+
DELETE FROM research_agenda WHERE status IN ('completed', 'dismissed')
|
|
55
|
+
AND id NOT IN (SELECT id FROM research_agenda ORDER BY completed_at DESC LIMIT 20)
|
|
56
|
+
`).run();
|
|
57
|
+
return this.getAgenda();
|
|
58
|
+
}
|
|
59
|
+
/** Get the prioritized research agenda. */
|
|
60
|
+
getAgenda(limit = 20) {
|
|
61
|
+
return this.db.prepare(`
|
|
62
|
+
SELECT * FROM research_agenda WHERE status IN ('open', 'in_progress')
|
|
63
|
+
ORDER BY priority DESC LIMIT ?
|
|
64
|
+
`).all(limit).map(r => this.rowToItem(r));
|
|
65
|
+
}
|
|
66
|
+
/** Get the single most important next research question. */
|
|
67
|
+
getNext() {
|
|
68
|
+
const row = this.db.prepare(`
|
|
69
|
+
SELECT * FROM research_agenda WHERE status = 'open'
|
|
70
|
+
ORDER BY priority DESC LIMIT 1
|
|
71
|
+
`).get();
|
|
72
|
+
return row ? this.rowToItem(row) : null;
|
|
73
|
+
}
|
|
74
|
+
/** Reprioritize an agenda item. */
|
|
75
|
+
setPriority(id, priority) {
|
|
76
|
+
const result = this.db.prepare(`
|
|
77
|
+
UPDATE research_agenda SET priority = ? WHERE id = ?
|
|
78
|
+
`).run(Math.max(0, Math.min(1, priority)), id);
|
|
79
|
+
return result.changes > 0;
|
|
80
|
+
}
|
|
81
|
+
/** Add a user-defined research question. */
|
|
82
|
+
ask(question, type = 'hypothesis_test') {
|
|
83
|
+
const result = this.db.prepare(`
|
|
84
|
+
INSERT INTO research_agenda (priority, question, type, estimated_cycles, expected_impact, auto_executable, status)
|
|
85
|
+
VALUES (?, ?, ?, ?, ?, ?, 'open')
|
|
86
|
+
`).run(0.9, question, type, 5, 'User-requested investigation', 0);
|
|
87
|
+
return this.getById(Number(result.lastInsertRowid));
|
|
88
|
+
}
|
|
89
|
+
/** Mark an item as completed or dismissed. */
|
|
90
|
+
resolve(id, status) {
|
|
91
|
+
const result = this.db.prepare(`
|
|
92
|
+
UPDATE research_agenda SET status = ?, completed_at = datetime('now') WHERE id = ?
|
|
93
|
+
`).run(status, id);
|
|
94
|
+
return result.changes > 0;
|
|
95
|
+
}
|
|
96
|
+
getById(id) {
|
|
97
|
+
const row = this.db.prepare(`SELECT * FROM research_agenda WHERE id = ?`).get(id);
|
|
98
|
+
return row ? this.rowToItem(row) : null;
|
|
99
|
+
}
|
|
100
|
+
findOpenHypotheses() {
|
|
101
|
+
const items = [];
|
|
102
|
+
try {
|
|
103
|
+
const pending = this.db.prepare(`
|
|
104
|
+
SELECT COUNT(*) as c FROM hypotheses WHERE status IN ('proposed', 'testing')
|
|
105
|
+
`).get();
|
|
106
|
+
if (pending.c > 0) {
|
|
107
|
+
items.push({
|
|
108
|
+
priority: 0.7,
|
|
109
|
+
question: `${pending.c} hypotheses pending testing. Run hypothesis tests to confirm or reject.`,
|
|
110
|
+
type: 'hypothesis_test',
|
|
111
|
+
estimated_cycles: Math.ceil(pending.c / 3),
|
|
112
|
+
expected_impact: 'Validate or reject pending theories about system behavior.',
|
|
113
|
+
prerequisites: [],
|
|
114
|
+
auto_executable: true,
|
|
115
|
+
status: 'open',
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch { /* table might not exist */ }
|
|
120
|
+
return items;
|
|
121
|
+
}
|
|
122
|
+
findKnowledgeGaps() {
|
|
123
|
+
const items = [];
|
|
124
|
+
try {
|
|
125
|
+
// Find event types with few observations
|
|
126
|
+
const sparse = this.db.prepare(`
|
|
127
|
+
SELECT type, COUNT(*) as c FROM causal_events
|
|
128
|
+
GROUP BY type HAVING c < 10
|
|
129
|
+
ORDER BY c ASC LIMIT 5
|
|
130
|
+
`).all();
|
|
131
|
+
for (const s of sparse) {
|
|
132
|
+
items.push({
|
|
133
|
+
priority: 0.5 + (1 - s.c / 10) * 0.3,
|
|
134
|
+
question: `Only ${s.c} observations of "${s.type}". Need more data for reliable causal analysis.`,
|
|
135
|
+
type: 'knowledge_gap',
|
|
136
|
+
estimated_cycles: 10,
|
|
137
|
+
expected_impact: `More data about "${s.type}" enables better causal inference.`,
|
|
138
|
+
prerequisites: [],
|
|
139
|
+
auto_executable: false,
|
|
140
|
+
status: 'open',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch { /* table might not exist */ }
|
|
145
|
+
return items;
|
|
146
|
+
}
|
|
147
|
+
findUnexploredCorrelations() {
|
|
148
|
+
const items = [];
|
|
149
|
+
try {
|
|
150
|
+
const eventTypes = this.db.prepare(`
|
|
151
|
+
SELECT DISTINCT type FROM causal_events ORDER BY type
|
|
152
|
+
`).all();
|
|
153
|
+
const existingEdges = new Set();
|
|
154
|
+
try {
|
|
155
|
+
const edges = this.db.prepare(`SELECT cause, effect FROM causal_edges`).all();
|
|
156
|
+
for (const e of edges)
|
|
157
|
+
existingEdges.add(`${e.cause}→${e.effect}`);
|
|
158
|
+
}
|
|
159
|
+
catch { /* ignore */ }
|
|
160
|
+
// Find pairs not yet analyzed
|
|
161
|
+
let unexplored = 0;
|
|
162
|
+
for (let i = 0; i < eventTypes.length && unexplored < 3; i++) {
|
|
163
|
+
for (let j = i + 1; j < eventTypes.length; j++) {
|
|
164
|
+
const key = `${eventTypes[i].type}→${eventTypes[j].type}`;
|
|
165
|
+
const keyRev = `${eventTypes[j].type}→${eventTypes[i].type}`;
|
|
166
|
+
if (!existingEdges.has(key) && !existingEdges.has(keyRev)) {
|
|
167
|
+
unexplored++;
|
|
168
|
+
if (unexplored <= 3) {
|
|
169
|
+
items.push({
|
|
170
|
+
priority: 0.4,
|
|
171
|
+
question: `Is there a causal relationship between "${eventTypes[i].type}" and "${eventTypes[j].type}"?`,
|
|
172
|
+
type: 'correlation_search',
|
|
173
|
+
estimated_cycles: 1,
|
|
174
|
+
expected_impact: 'Discover hidden relationships between event types.',
|
|
175
|
+
prerequisites: [],
|
|
176
|
+
auto_executable: true,
|
|
177
|
+
status: 'open',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch { /* table might not exist */ }
|
|
185
|
+
return items;
|
|
186
|
+
}
|
|
187
|
+
findOpenAnomalies() {
|
|
188
|
+
const items = [];
|
|
189
|
+
try {
|
|
190
|
+
const anomalies = this.db.prepare(`
|
|
191
|
+
SELECT type, title FROM self_insights
|
|
192
|
+
WHERE type = 'anomaly' AND timestamp > ?
|
|
193
|
+
ORDER BY timestamp DESC LIMIT 3
|
|
194
|
+
`).all(Date.now() - 86_400_000 * 7);
|
|
195
|
+
for (const a of anomalies) {
|
|
196
|
+
items.push({
|
|
197
|
+
priority: 0.8,
|
|
198
|
+
question: `Investigate anomaly: ${a.title}`,
|
|
199
|
+
type: 'anomaly_investigation',
|
|
200
|
+
estimated_cycles: 3,
|
|
201
|
+
expected_impact: 'Understanding anomalies prevents degradation.',
|
|
202
|
+
prerequisites: [],
|
|
203
|
+
auto_executable: false,
|
|
204
|
+
status: 'open',
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch { /* table might not exist */ }
|
|
209
|
+
return items;
|
|
210
|
+
}
|
|
211
|
+
findParameterSweeps() {
|
|
212
|
+
const items = [];
|
|
213
|
+
try {
|
|
214
|
+
const underOptimized = this.db.prepare(`
|
|
215
|
+
SELECT strategy, parameter, value, min_value, max_value
|
|
216
|
+
FROM strategy_parameters
|
|
217
|
+
WHERE parameter NOT IN (
|
|
218
|
+
SELECT DISTINCT parameter FROM strategy_adaptations WHERE timestamp > ?
|
|
219
|
+
)
|
|
220
|
+
`).all(Date.now() - 86_400_000 * 14);
|
|
221
|
+
if (underOptimized.length > 0) {
|
|
222
|
+
items.push({
|
|
223
|
+
priority: 0.5,
|
|
224
|
+
question: `${underOptimized.length} parameters haven't been optimized in 2+ weeks. Run parameter sweep.`,
|
|
225
|
+
type: 'parameter_sweep',
|
|
226
|
+
estimated_cycles: underOptimized.length * 2,
|
|
227
|
+
expected_impact: 'Stale parameters may be suboptimal. Re-optimization could improve performance.',
|
|
228
|
+
prerequisites: [],
|
|
229
|
+
auto_executable: true,
|
|
230
|
+
status: 'open',
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch { /* table might not exist */ }
|
|
235
|
+
return items;
|
|
236
|
+
}
|
|
237
|
+
upsertItem(item) {
|
|
238
|
+
// Check for similar existing items
|
|
239
|
+
const existing = this.db.prepare(`
|
|
240
|
+
SELECT id FROM research_agenda WHERE question = ? AND status = 'open' LIMIT 1
|
|
241
|
+
`).get(item.question);
|
|
242
|
+
if (existing)
|
|
243
|
+
return;
|
|
244
|
+
this.db.prepare(`
|
|
245
|
+
INSERT INTO research_agenda (priority, question, type, estimated_cycles, expected_impact, prerequisites, auto_executable, status)
|
|
246
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
247
|
+
`).run(item.priority, item.question, item.type, item.estimated_cycles, item.expected_impact, JSON.stringify(item.prerequisites), item.auto_executable ? 1 : 0, item.status);
|
|
248
|
+
}
|
|
249
|
+
rowToItem(row) {
|
|
250
|
+
return {
|
|
251
|
+
id: row.id,
|
|
252
|
+
priority: row.priority,
|
|
253
|
+
question: row.question,
|
|
254
|
+
type: row.type,
|
|
255
|
+
estimated_cycles: row.estimated_cycles,
|
|
256
|
+
expected_impact: row.expected_impact,
|
|
257
|
+
prerequisites: JSON.parse(row.prerequisites || '[]'),
|
|
258
|
+
auto_executable: row.auto_executable === 1,
|
|
259
|
+
status: row.status,
|
|
260
|
+
created_at: row.created_at,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
//# sourceMappingURL=agenda-engine.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"agenda-engine.js","sourceRoot":"","sources":["../../src/research/agenda-engine.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
export type AnomalyType = 'statistical' | 'behavioral' | 'causal' | 'cross_domain' | 'drift';
|
|
3
|
+
export type AnomalySeverity = 'low' | 'medium' | 'high' | 'critical';
|
|
4
|
+
export interface Anomaly {
|
|
5
|
+
id?: number;
|
|
6
|
+
timestamp: number;
|
|
7
|
+
type: AnomalyType;
|
|
8
|
+
severity: AnomalySeverity;
|
|
9
|
+
title: string;
|
|
10
|
+
description: string;
|
|
11
|
+
metric: string;
|
|
12
|
+
expected_value: number;
|
|
13
|
+
actual_value: number;
|
|
14
|
+
deviation: number;
|
|
15
|
+
evidence: Record<string, unknown>;
|
|
16
|
+
resolved: boolean;
|
|
17
|
+
resolution?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface DriftReport {
|
|
20
|
+
metric: string;
|
|
21
|
+
direction: 'increasing' | 'decreasing' | 'stable';
|
|
22
|
+
rate_per_day: number;
|
|
23
|
+
cumulative_change: number;
|
|
24
|
+
period_days: number;
|
|
25
|
+
significant: boolean;
|
|
26
|
+
description: string;
|
|
27
|
+
}
|
|
28
|
+
export interface AnomalyDetectiveConfig {
|
|
29
|
+
brainName: string;
|
|
30
|
+
/** Z-score threshold for statistical anomalies. Default: 2.0 */
|
|
31
|
+
zThreshold?: number;
|
|
32
|
+
/** EWMA smoothing factor (0-1). Default: 0.3 */
|
|
33
|
+
ewmaAlpha?: number;
|
|
34
|
+
/** Minimum data points for drift detection. Default: 7 */
|
|
35
|
+
minDriftPoints?: number;
|
|
36
|
+
}
|
|
37
|
+
export declare function runAnomalyDetectiveMigration(db: Database.Database): void;
|
|
38
|
+
export declare class AnomalyDetective {
|
|
39
|
+
private db;
|
|
40
|
+
private config;
|
|
41
|
+
private log;
|
|
42
|
+
constructor(db: Database.Database, config: AnomalyDetectiveConfig);
|
|
43
|
+
/** Record a metric value for anomaly tracking. */
|
|
44
|
+
recordMetric(metric: string, value: number): void;
|
|
45
|
+
/** Check all tracked metrics for anomalies. */
|
|
46
|
+
detect(): Anomaly[];
|
|
47
|
+
/** Get current anomalies. */
|
|
48
|
+
getAnomalies(type?: AnomalyType, limit?: number): Anomaly[];
|
|
49
|
+
/** Investigate a specific anomaly — provide context and potential causes. */
|
|
50
|
+
investigate(anomalyId: number): Record<string, unknown> | null;
|
|
51
|
+
/** Get anomaly history (including resolved). */
|
|
52
|
+
getHistory(limit?: number): Anomaly[];
|
|
53
|
+
/** Get drift reports for all tracked metrics. */
|
|
54
|
+
getDriftReport(): DriftReport[];
|
|
55
|
+
/** Resolve an anomaly. */
|
|
56
|
+
resolve(anomalyId: number, resolution: string): boolean;
|
|
57
|
+
private detectStatisticalAnomaly;
|
|
58
|
+
private detectDrift;
|
|
59
|
+
private computeDriftReport;
|
|
60
|
+
private persistAnomaly;
|
|
61
|
+
private rowToAnomaly;
|
|
62
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { getLogger } from '../utils/logger.js';
|
|
2
|
+
// ── Migration ───────────────────────────────────────────
|
|
3
|
+
export function runAnomalyDetectiveMigration(db) {
|
|
4
|
+
db.exec(`
|
|
5
|
+
CREATE TABLE IF NOT EXISTS anomalies (
|
|
6
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
7
|
+
timestamp INTEGER NOT NULL,
|
|
8
|
+
type TEXT NOT NULL,
|
|
9
|
+
severity TEXT NOT NULL,
|
|
10
|
+
title TEXT NOT NULL,
|
|
11
|
+
description TEXT NOT NULL,
|
|
12
|
+
metric TEXT NOT NULL,
|
|
13
|
+
expected_value REAL NOT NULL,
|
|
14
|
+
actual_value REAL NOT NULL,
|
|
15
|
+
deviation REAL NOT NULL,
|
|
16
|
+
evidence TEXT NOT NULL,
|
|
17
|
+
resolved INTEGER DEFAULT 0,
|
|
18
|
+
resolution TEXT,
|
|
19
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
20
|
+
);
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_anomalies_type ON anomalies(type);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS idx_anomalies_ts ON anomalies(timestamp);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_anomalies_resolved ON anomalies(resolved);
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS metric_history (
|
|
26
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
27
|
+
metric TEXT NOT NULL,
|
|
28
|
+
value REAL NOT NULL,
|
|
29
|
+
timestamp INTEGER NOT NULL,
|
|
30
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
31
|
+
);
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_metric_hist ON metric_history(metric, timestamp);
|
|
33
|
+
`);
|
|
34
|
+
}
|
|
35
|
+
// ── Engine ──────────────────────────────────────────────
|
|
36
|
+
export class AnomalyDetective {
|
|
37
|
+
db;
|
|
38
|
+
config;
|
|
39
|
+
log = getLogger();
|
|
40
|
+
constructor(db, config) {
|
|
41
|
+
this.db = db;
|
|
42
|
+
this.config = {
|
|
43
|
+
brainName: config.brainName,
|
|
44
|
+
zThreshold: config.zThreshold ?? 2.0,
|
|
45
|
+
ewmaAlpha: config.ewmaAlpha ?? 0.3,
|
|
46
|
+
minDriftPoints: config.minDriftPoints ?? 7,
|
|
47
|
+
};
|
|
48
|
+
runAnomalyDetectiveMigration(db);
|
|
49
|
+
}
|
|
50
|
+
/** Record a metric value for anomaly tracking. */
|
|
51
|
+
recordMetric(metric, value) {
|
|
52
|
+
this.db.prepare(`
|
|
53
|
+
INSERT INTO metric_history (metric, value, timestamp)
|
|
54
|
+
VALUES (?, ?, ?)
|
|
55
|
+
`).run(metric, value, Date.now());
|
|
56
|
+
}
|
|
57
|
+
/** Check all tracked metrics for anomalies. */
|
|
58
|
+
detect() {
|
|
59
|
+
const anomalies = [];
|
|
60
|
+
const metrics = this.db.prepare(`
|
|
61
|
+
SELECT DISTINCT metric FROM metric_history
|
|
62
|
+
`).all();
|
|
63
|
+
for (const { metric } of metrics) {
|
|
64
|
+
const values = this.db.prepare(`
|
|
65
|
+
SELECT value, timestamp FROM metric_history
|
|
66
|
+
WHERE metric = ? ORDER BY timestamp ASC
|
|
67
|
+
`).all(metric);
|
|
68
|
+
if (values.length < 3)
|
|
69
|
+
continue;
|
|
70
|
+
// Statistical anomaly detection (Z-score)
|
|
71
|
+
const statistical = this.detectStatisticalAnomaly(metric, values);
|
|
72
|
+
if (statistical)
|
|
73
|
+
anomalies.push(statistical);
|
|
74
|
+
// Drift detection (EWMA)
|
|
75
|
+
const drift = this.detectDrift(metric, values);
|
|
76
|
+
if (drift)
|
|
77
|
+
anomalies.push(drift);
|
|
78
|
+
}
|
|
79
|
+
// Persist new anomalies
|
|
80
|
+
for (const a of anomalies) {
|
|
81
|
+
this.persistAnomaly(a);
|
|
82
|
+
}
|
|
83
|
+
return anomalies;
|
|
84
|
+
}
|
|
85
|
+
/** Get current anomalies. */
|
|
86
|
+
getAnomalies(type, limit = 20) {
|
|
87
|
+
let sql = `SELECT * FROM anomalies`;
|
|
88
|
+
const params = [];
|
|
89
|
+
if (type) {
|
|
90
|
+
sql += ` WHERE type = ?`;
|
|
91
|
+
params.push(type);
|
|
92
|
+
}
|
|
93
|
+
sql += ` ORDER BY timestamp DESC LIMIT ?`;
|
|
94
|
+
params.push(limit);
|
|
95
|
+
return this.db.prepare(sql).all(...params).map(r => this.rowToAnomaly(r));
|
|
96
|
+
}
|
|
97
|
+
/** Investigate a specific anomaly — provide context and potential causes. */
|
|
98
|
+
investigate(anomalyId) {
|
|
99
|
+
const anomaly = this.db.prepare(`SELECT * FROM anomalies WHERE id = ?`).get(anomalyId);
|
|
100
|
+
if (!anomaly)
|
|
101
|
+
return null;
|
|
102
|
+
const a = this.rowToAnomaly(anomaly);
|
|
103
|
+
// Get metric history around the anomaly
|
|
104
|
+
const window = 86_400_000; // 24h
|
|
105
|
+
const history = this.db.prepare(`
|
|
106
|
+
SELECT value, timestamp FROM metric_history
|
|
107
|
+
WHERE metric = ? AND timestamp BETWEEN ? AND ?
|
|
108
|
+
ORDER BY timestamp
|
|
109
|
+
`).all(a.metric, a.timestamp - window, a.timestamp + window);
|
|
110
|
+
// Look for related events in causal_events (if available)
|
|
111
|
+
let relatedEvents = [];
|
|
112
|
+
try {
|
|
113
|
+
relatedEvents = this.db.prepare(`
|
|
114
|
+
SELECT type, source, timestamp FROM causal_events
|
|
115
|
+
WHERE timestamp BETWEEN ? AND ?
|
|
116
|
+
ORDER BY timestamp DESC LIMIT 20
|
|
117
|
+
`).all(a.timestamp - 3_600_000, a.timestamp);
|
|
118
|
+
}
|
|
119
|
+
catch { /* table might not exist */ }
|
|
120
|
+
return {
|
|
121
|
+
anomaly: a,
|
|
122
|
+
metric_history: history,
|
|
123
|
+
related_events: relatedEvents,
|
|
124
|
+
context: {
|
|
125
|
+
metric_count: history.length,
|
|
126
|
+
avg_value: history.length > 0 ? history.reduce((s, h) => s + h.value, 0) / history.length : 0,
|
|
127
|
+
max_value: history.length > 0 ? Math.max(...history.map(h => h.value)) : 0,
|
|
128
|
+
min_value: history.length > 0 ? Math.min(...history.map(h => h.value)) : 0,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** Get anomaly history (including resolved). */
|
|
133
|
+
getHistory(limit = 50) {
|
|
134
|
+
return this.db.prepare(`
|
|
135
|
+
SELECT * FROM anomalies ORDER BY timestamp DESC LIMIT ?
|
|
136
|
+
`).all(limit).map(r => this.rowToAnomaly(r));
|
|
137
|
+
}
|
|
138
|
+
/** Get drift reports for all tracked metrics. */
|
|
139
|
+
getDriftReport() {
|
|
140
|
+
const reports = [];
|
|
141
|
+
const metrics = this.db.prepare(`
|
|
142
|
+
SELECT DISTINCT metric FROM metric_history
|
|
143
|
+
`).all();
|
|
144
|
+
for (const { metric } of metrics) {
|
|
145
|
+
const values = this.db.prepare(`
|
|
146
|
+
SELECT value, timestamp FROM metric_history
|
|
147
|
+
WHERE metric = ? ORDER BY timestamp ASC
|
|
148
|
+
`).all(metric);
|
|
149
|
+
if (values.length < this.config.minDriftPoints)
|
|
150
|
+
continue;
|
|
151
|
+
const report = this.computeDriftReport(metric, values);
|
|
152
|
+
if (report)
|
|
153
|
+
reports.push(report);
|
|
154
|
+
}
|
|
155
|
+
return reports.sort((a, b) => Math.abs(b.rate_per_day) - Math.abs(a.rate_per_day));
|
|
156
|
+
}
|
|
157
|
+
/** Resolve an anomaly. */
|
|
158
|
+
resolve(anomalyId, resolution) {
|
|
159
|
+
const result = this.db.prepare(`
|
|
160
|
+
UPDATE anomalies SET resolved = 1, resolution = ? WHERE id = ?
|
|
161
|
+
`).run(resolution, anomalyId);
|
|
162
|
+
return result.changes > 0;
|
|
163
|
+
}
|
|
164
|
+
detectStatisticalAnomaly(metric, values) {
|
|
165
|
+
if (values.length < 5)
|
|
166
|
+
return null;
|
|
167
|
+
const nums = values.map(v => v.value);
|
|
168
|
+
const m = mean(nums);
|
|
169
|
+
const s = stddev(nums);
|
|
170
|
+
if (s === 0)
|
|
171
|
+
return null;
|
|
172
|
+
// Check latest value
|
|
173
|
+
const latest = values[values.length - 1];
|
|
174
|
+
const z = Math.abs(latest.value - m) / s;
|
|
175
|
+
if (z < this.config.zThreshold)
|
|
176
|
+
return null;
|
|
177
|
+
const severity = z > 4 ? 'critical' :
|
|
178
|
+
z > 3 ? 'high' :
|
|
179
|
+
z > 2.5 ? 'medium' : 'low';
|
|
180
|
+
return {
|
|
181
|
+
timestamp: latest.timestamp,
|
|
182
|
+
type: 'statistical',
|
|
183
|
+
severity,
|
|
184
|
+
title: `${metric} deviated ${z.toFixed(1)}σ from mean`,
|
|
185
|
+
description: `${metric} = ${latest.value.toFixed(3)} (mean: ${m.toFixed(3)}, σ: ${s.toFixed(3)}, z-score: ${z.toFixed(2)})`,
|
|
186
|
+
metric,
|
|
187
|
+
expected_value: m,
|
|
188
|
+
actual_value: latest.value,
|
|
189
|
+
deviation: z,
|
|
190
|
+
evidence: { mean: m, stddev: s, z_score: z, n: values.length },
|
|
191
|
+
resolved: false,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
detectDrift(metric, values) {
|
|
195
|
+
if (values.length < this.config.minDriftPoints)
|
|
196
|
+
return null;
|
|
197
|
+
// EWMA on the values
|
|
198
|
+
const alpha = this.config.ewmaAlpha;
|
|
199
|
+
let ewma = values[0].value;
|
|
200
|
+
const ewmaValues = [ewma];
|
|
201
|
+
for (let i = 1; i < values.length; i++) {
|
|
202
|
+
ewma = alpha * values[i].value + (1 - alpha) * ewma;
|
|
203
|
+
ewmaValues.push(ewma);
|
|
204
|
+
}
|
|
205
|
+
// Check if EWMA trend is consistently increasing or decreasing
|
|
206
|
+
const recentWindow = Math.min(values.length, 10);
|
|
207
|
+
const recentEwma = ewmaValues.slice(-recentWindow);
|
|
208
|
+
let increasing = 0, decreasing = 0;
|
|
209
|
+
for (let i = 1; i < recentEwma.length; i++) {
|
|
210
|
+
if (recentEwma[i] > recentEwma[i - 1])
|
|
211
|
+
increasing++;
|
|
212
|
+
else if (recentEwma[i] < recentEwma[i - 1])
|
|
213
|
+
decreasing++;
|
|
214
|
+
}
|
|
215
|
+
const total = recentEwma.length - 1;
|
|
216
|
+
if (total < 3)
|
|
217
|
+
return null;
|
|
218
|
+
const driftRatio = Math.max(increasing, decreasing) / total;
|
|
219
|
+
if (driftRatio < 0.7)
|
|
220
|
+
return null; // Not a consistent trend
|
|
221
|
+
const direction = increasing > decreasing ? 'increasing' : 'decreasing';
|
|
222
|
+
const firstValue = recentEwma[0];
|
|
223
|
+
const lastValue = recentEwma[recentEwma.length - 1];
|
|
224
|
+
const change = lastValue - firstValue;
|
|
225
|
+
const pctChange = firstValue !== 0 ? (change / Math.abs(firstValue)) * 100 : 0;
|
|
226
|
+
if (Math.abs(pctChange) < 5)
|
|
227
|
+
return null; // Less than 5% drift — ignore
|
|
228
|
+
const severity = Math.abs(pctChange) > 30 ? 'high' :
|
|
229
|
+
Math.abs(pctChange) > 15 ? 'medium' : 'low';
|
|
230
|
+
return {
|
|
231
|
+
timestamp: Date.now(),
|
|
232
|
+
type: 'drift',
|
|
233
|
+
severity,
|
|
234
|
+
title: `${metric} is ${direction} (${pctChange > 0 ? '+' : ''}${pctChange.toFixed(1)}% drift)`,
|
|
235
|
+
description: `${metric} has been consistently ${direction} over the last ${recentWindow} measurements. EWMA: ${firstValue.toFixed(3)} → ${lastValue.toFixed(3)}.`,
|
|
236
|
+
metric,
|
|
237
|
+
expected_value: firstValue,
|
|
238
|
+
actual_value: lastValue,
|
|
239
|
+
deviation: Math.abs(pctChange),
|
|
240
|
+
evidence: { direction, change, pct_change: pctChange, window: recentWindow, drift_ratio: driftRatio },
|
|
241
|
+
resolved: false,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
computeDriftReport(metric, values) {
|
|
245
|
+
if (values.length < 2)
|
|
246
|
+
return null;
|
|
247
|
+
const first = values[0];
|
|
248
|
+
const last = values[values.length - 1];
|
|
249
|
+
const periodMs = last.timestamp - first.timestamp;
|
|
250
|
+
const periodDays = periodMs / 86_400_000;
|
|
251
|
+
if (periodDays < 1)
|
|
252
|
+
return null;
|
|
253
|
+
const change = last.value - first.value;
|
|
254
|
+
const ratePerDay = change / periodDays;
|
|
255
|
+
// Simple linear regression significance
|
|
256
|
+
const xMean = values.reduce((s, v) => s + v.timestamp, 0) / values.length;
|
|
257
|
+
const yMean = values.reduce((s, v) => s + v.value, 0) / values.length;
|
|
258
|
+
let ssXY = 0, ssXX = 0, ssYY = 0;
|
|
259
|
+
for (const v of values) {
|
|
260
|
+
ssXY += (v.timestamp - xMean) * (v.value - yMean);
|
|
261
|
+
ssXX += (v.timestamp - xMean) ** 2;
|
|
262
|
+
ssYY += (v.value - yMean) ** 2;
|
|
263
|
+
}
|
|
264
|
+
const r = ssXX > 0 && ssYY > 0 ? ssXY / Math.sqrt(ssXX * ssYY) : 0;
|
|
265
|
+
const significant = Math.abs(r) > 0.5 && values.length >= 5;
|
|
266
|
+
const direction = Math.abs(ratePerDay) < 0.001 ? 'stable' :
|
|
267
|
+
ratePerDay > 0 ? 'increasing' : 'decreasing';
|
|
268
|
+
return {
|
|
269
|
+
metric,
|
|
270
|
+
direction,
|
|
271
|
+
rate_per_day: ratePerDay,
|
|
272
|
+
cumulative_change: change,
|
|
273
|
+
period_days: periodDays,
|
|
274
|
+
significant,
|
|
275
|
+
description: `${metric}: ${direction} at ${ratePerDay.toFixed(4)}/day over ${periodDays.toFixed(1)} days (r=${r.toFixed(2)})`,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
persistAnomaly(a) {
|
|
279
|
+
// Avoid duplicates: same metric + type within 1 hour
|
|
280
|
+
const existing = this.db.prepare(`
|
|
281
|
+
SELECT id FROM anomalies WHERE metric = ? AND type = ? AND timestamp > ? LIMIT 1
|
|
282
|
+
`).get(a.metric, a.type, Date.now() - 3_600_000);
|
|
283
|
+
if (existing)
|
|
284
|
+
return;
|
|
285
|
+
this.db.prepare(`
|
|
286
|
+
INSERT INTO anomalies (timestamp, type, severity, title, description, metric,
|
|
287
|
+
expected_value, actual_value, deviation, evidence)
|
|
288
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
289
|
+
`).run(a.timestamp, a.type, a.severity, a.title, a.description, a.metric, a.expected_value, a.actual_value, a.deviation, JSON.stringify(a.evidence));
|
|
290
|
+
}
|
|
291
|
+
rowToAnomaly(row) {
|
|
292
|
+
return {
|
|
293
|
+
id: row.id,
|
|
294
|
+
timestamp: row.timestamp,
|
|
295
|
+
type: row.type,
|
|
296
|
+
severity: row.severity,
|
|
297
|
+
title: row.title,
|
|
298
|
+
description: row.description,
|
|
299
|
+
metric: row.metric,
|
|
300
|
+
expected_value: row.expected_value,
|
|
301
|
+
actual_value: row.actual_value,
|
|
302
|
+
deviation: row.deviation,
|
|
303
|
+
evidence: JSON.parse(row.evidence),
|
|
304
|
+
resolved: row.resolved === 1,
|
|
305
|
+
resolution: row.resolution,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function mean(arr) {
|
|
310
|
+
return arr.length === 0 ? 0 : arr.reduce((a, b) => a + b, 0) / arr.length;
|
|
311
|
+
}
|
|
312
|
+
function stddev(arr) {
|
|
313
|
+
if (arr.length < 2)
|
|
314
|
+
return 0;
|
|
315
|
+
const m = mean(arr);
|
|
316
|
+
return Math.sqrt(arr.reduce((s, x) => s + (x - m) ** 2, 0) / (arr.length - 1));
|
|
317
|
+
}
|
|
318
|
+
//# sourceMappingURL=anomaly-detective.js.map
|