ruvnet-kb-first 5.0.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/LICENSE +21 -0
- package/README.md +674 -0
- package/SKILL.md +740 -0
- package/bin/kb-first.js +123 -0
- package/install/init-project.sh +435 -0
- package/install/install-global.sh +257 -0
- package/install/kb-first-autodetect.sh +108 -0
- package/install/kb-first-command.md +80 -0
- package/install/kb-first-skill.md +262 -0
- package/package.json +87 -0
- package/phases/00-assessment.md +529 -0
- package/phases/01-storage.md +194 -0
- package/phases/01.5-hooks-setup.md +521 -0
- package/phases/02-kb-creation.md +413 -0
- package/phases/03-persistence.md +125 -0
- package/phases/04-visualization.md +170 -0
- package/phases/05-integration.md +114 -0
- package/phases/06-scaffold.md +130 -0
- package/phases/07-build.md +493 -0
- package/phases/08-verification.md +597 -0
- package/phases/09-security.md +512 -0
- package/phases/10-documentation.md +613 -0
- package/phases/11-deployment.md +670 -0
- package/phases/testing.md +713 -0
- package/scripts/1.5-hooks-verify.sh +252 -0
- package/scripts/8.1-code-scan.sh +58 -0
- package/scripts/8.2-import-check.sh +42 -0
- package/scripts/8.3-source-returns.sh +52 -0
- package/scripts/8.4-startup-verify.sh +65 -0
- package/scripts/8.5-fallback-check.sh +63 -0
- package/scripts/8.6-attribution.sh +56 -0
- package/scripts/8.7-confidence.sh +56 -0
- package/scripts/8.8-gap-logging.sh +70 -0
- package/scripts/9-security-audit.sh +202 -0
- package/scripts/init-project.sh +395 -0
- package/scripts/verify-enforcement.sh +167 -0
- package/src/commands/hooks.js +361 -0
- package/src/commands/init.js +315 -0
- package/src/commands/phase.js +372 -0
- package/src/commands/score.js +380 -0
- package/src/commands/status.js +193 -0
- package/src/commands/verify.js +286 -0
- package/src/index.js +56 -0
- package/src/mcp-server.js +412 -0
- package/templates/attention-router.ts +534 -0
- package/templates/code-analysis.ts +683 -0
- package/templates/federated-kb-learner.ts +649 -0
- package/templates/gnn-engine.ts +1091 -0
- package/templates/intentions.md +277 -0
- package/templates/kb-client.ts +905 -0
- package/templates/schema.sql +303 -0
- package/templates/sona-config.ts +312 -0
|
@@ -0,0 +1,1091 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KB-First v3.0 - GNN Engine Template
|
|
3
|
+
*
|
|
4
|
+
* Graph Neural Network for modeling decision webs and relationship propagation.
|
|
5
|
+
* Use this for applications where changing one thing affects many others.
|
|
6
|
+
*
|
|
7
|
+
* NEW IN v3.0:
|
|
8
|
+
* - Graph clustering with Louvain community detection
|
|
9
|
+
* - MinCut boundary finding
|
|
10
|
+
* - Spectral clustering integration
|
|
11
|
+
* - RuVector graph algorithm integration
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Pool } from 'pg';
|
|
15
|
+
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// TYPES
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
export interface GraphNode {
|
|
21
|
+
id: string;
|
|
22
|
+
type: 'decision' | 'outcome' | 'constraint' | 'entity';
|
|
23
|
+
label: string;
|
|
24
|
+
currentValue?: any;
|
|
25
|
+
possibleValues?: any[];
|
|
26
|
+
embedding?: number[];
|
|
27
|
+
metadata?: Record<string, any>;
|
|
28
|
+
clusterId?: number; // NEW: Cluster membership
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface GraphEdge {
|
|
32
|
+
id: string;
|
|
33
|
+
from: string;
|
|
34
|
+
to: string;
|
|
35
|
+
relationship: 'affects' | 'requires' | 'conflicts' | 'enables' | 'depends_on';
|
|
36
|
+
weight: number; // 0-1, strength of influence
|
|
37
|
+
direction: 'unidirectional' | 'bidirectional';
|
|
38
|
+
transform?: string; // Name of transform function
|
|
39
|
+
metadata?: Record<string, any>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Graph {
|
|
43
|
+
nodes: GraphNode[];
|
|
44
|
+
edges: GraphEdge[];
|
|
45
|
+
metadata?: Record<string, any>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PropagationResult {
|
|
49
|
+
changedNode: string;
|
|
50
|
+
originalValue: any;
|
|
51
|
+
newValue: any;
|
|
52
|
+
depth: number;
|
|
53
|
+
path: string[];
|
|
54
|
+
confidence: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SimulationResult {
|
|
58
|
+
initialChange: { node: string; from: any; to: any };
|
|
59
|
+
directEffects: PropagationResult[];
|
|
60
|
+
cascadingEffects: PropagationResult[];
|
|
61
|
+
equilibrium: Record<string, any>;
|
|
62
|
+
confidence: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// NEW: Clustering types
|
|
66
|
+
export interface Cluster {
|
|
67
|
+
id: number;
|
|
68
|
+
nodes: string[];
|
|
69
|
+
centroid?: number[];
|
|
70
|
+
size: number;
|
|
71
|
+
density: number;
|
|
72
|
+
label?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ClusteringResult {
|
|
76
|
+
clusters: Cluster[];
|
|
77
|
+
modularity: number;
|
|
78
|
+
numClusters: number;
|
|
79
|
+
algorithm: string;
|
|
80
|
+
isolatedNodes: string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface BoundaryResult {
|
|
84
|
+
boundaries: Array<{
|
|
85
|
+
from: string;
|
|
86
|
+
to: string;
|
|
87
|
+
cutWeight: number;
|
|
88
|
+
crossCluster: boolean;
|
|
89
|
+
}>;
|
|
90
|
+
totalCutWeight: number;
|
|
91
|
+
numClusters: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface GNNConfig {
|
|
95
|
+
hiddenDim: number;
|
|
96
|
+
numLayers: number;
|
|
97
|
+
aggregation: 'sum' | 'mean' | 'max' | 'attention';
|
|
98
|
+
dropout?: number;
|
|
99
|
+
learningRate?: number;
|
|
100
|
+
// NEW: Clustering config
|
|
101
|
+
defaultClusterAlgorithm?: 'louvain' | 'spectral' | 'mincut' | 'label_propagation';
|
|
102
|
+
clusterResolution?: number; // For Louvain (higher = more clusters)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// =============================================================================
|
|
106
|
+
// GNN ENGINE
|
|
107
|
+
// =============================================================================
|
|
108
|
+
|
|
109
|
+
export class GNNEngine {
|
|
110
|
+
private pool: Pool;
|
|
111
|
+
private config: GNNConfig;
|
|
112
|
+
private transforms: Map<string, (value: any, weight: number) => any>;
|
|
113
|
+
|
|
114
|
+
// RuVector graph integration (lazy loaded)
|
|
115
|
+
private ruvectorGraphs: any = null;
|
|
116
|
+
|
|
117
|
+
constructor(config: GNNConfig, databaseUrl?: string) {
|
|
118
|
+
this.config = {
|
|
119
|
+
hiddenDim: config.hiddenDim || 128,
|
|
120
|
+
numLayers: config.numLayers || 3,
|
|
121
|
+
aggregation: config.aggregation || 'attention',
|
|
122
|
+
dropout: config.dropout || 0.1,
|
|
123
|
+
learningRate: config.learningRate || 0.001,
|
|
124
|
+
defaultClusterAlgorithm: config.defaultClusterAlgorithm || 'louvain',
|
|
125
|
+
clusterResolution: config.clusterResolution || 1.0
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
if (databaseUrl) {
|
|
129
|
+
this.pool = new Pool({ connectionString: databaseUrl });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this.transforms = new Map();
|
|
133
|
+
this.registerDefaultTransforms();
|
|
134
|
+
|
|
135
|
+
// Initialize ruvector graphs
|
|
136
|
+
this.initRuvectorGraphs();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Initialize RuVector graph algorithms
|
|
141
|
+
*/
|
|
142
|
+
private async initRuvectorGraphs(): Promise<boolean> {
|
|
143
|
+
if (this.ruvectorGraphs !== null) return true;
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const ruvector = await import('ruvector');
|
|
147
|
+
if (ruvector.graphs || ruvector.graphClusters) {
|
|
148
|
+
this.ruvectorGraphs = ruvector;
|
|
149
|
+
console.log('[GNN-ENGINE] RuVector graph algorithms initialized');
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
} catch (e) {
|
|
154
|
+
console.warn('[GNN-ENGINE] RuVector graphs not available, using fallback algorithms');
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// GRAPH MANAGEMENT
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Build a graph from node and edge definitions
|
|
165
|
+
*/
|
|
166
|
+
buildGraph(nodes: GraphNode[], edges: GraphEdge[]): Graph {
|
|
167
|
+
// Validate edges reference existing nodes
|
|
168
|
+
const nodeIds = new Set(nodes.map(n => n.id));
|
|
169
|
+
for (const edge of edges) {
|
|
170
|
+
if (!nodeIds.has(edge.from)) {
|
|
171
|
+
throw new Error(`Edge references unknown node: ${edge.from}`);
|
|
172
|
+
}
|
|
173
|
+
if (!nodeIds.has(edge.to)) {
|
|
174
|
+
throw new Error(`Edge references unknown node: ${edge.to}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { nodes, edges };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Load graph from database
|
|
183
|
+
*/
|
|
184
|
+
async loadGraph(namespace: string): Promise<Graph> {
|
|
185
|
+
const client = await this.pool.connect();
|
|
186
|
+
try {
|
|
187
|
+
const nodesResult = await client.query(`
|
|
188
|
+
SELECT id, type, label, current_value, possible_values, embedding, metadata, cluster_id
|
|
189
|
+
FROM gnn_nodes WHERE namespace = $1
|
|
190
|
+
`, [namespace]);
|
|
191
|
+
|
|
192
|
+
const edgesResult = await client.query(`
|
|
193
|
+
SELECT id, from_node, to_node, relationship, weight, direction, transform, metadata
|
|
194
|
+
FROM gnn_edges WHERE namespace = $1
|
|
195
|
+
`, [namespace]);
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
nodes: nodesResult.rows.map(r => ({
|
|
199
|
+
id: r.id,
|
|
200
|
+
type: r.type,
|
|
201
|
+
label: r.label,
|
|
202
|
+
currentValue: r.current_value,
|
|
203
|
+
possibleValues: r.possible_values,
|
|
204
|
+
embedding: r.embedding,
|
|
205
|
+
metadata: r.metadata,
|
|
206
|
+
clusterId: r.cluster_id
|
|
207
|
+
})),
|
|
208
|
+
edges: edgesResult.rows.map(r => ({
|
|
209
|
+
id: r.id,
|
|
210
|
+
from: r.from_node,
|
|
211
|
+
to: r.to_node,
|
|
212
|
+
relationship: r.relationship,
|
|
213
|
+
weight: r.weight,
|
|
214
|
+
direction: r.direction,
|
|
215
|
+
transform: r.transform,
|
|
216
|
+
metadata: r.metadata
|
|
217
|
+
}))
|
|
218
|
+
};
|
|
219
|
+
} finally {
|
|
220
|
+
client.release();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Save graph to database
|
|
226
|
+
*/
|
|
227
|
+
async saveGraph(graph: Graph, namespace: string): Promise<void> {
|
|
228
|
+
const client = await this.pool.connect();
|
|
229
|
+
try {
|
|
230
|
+
await client.query('BEGIN');
|
|
231
|
+
|
|
232
|
+
// Clear existing
|
|
233
|
+
await client.query('DELETE FROM gnn_edges WHERE namespace = $1', [namespace]);
|
|
234
|
+
await client.query('DELETE FROM gnn_nodes WHERE namespace = $1', [namespace]);
|
|
235
|
+
|
|
236
|
+
// Insert nodes
|
|
237
|
+
for (const node of graph.nodes) {
|
|
238
|
+
await client.query(`
|
|
239
|
+
INSERT INTO gnn_nodes (id, namespace, type, label, current_value, possible_values, embedding, metadata, cluster_id)
|
|
240
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
241
|
+
`, [node.id, namespace, node.type, node.label, node.currentValue,
|
|
242
|
+
node.possibleValues, node.embedding, node.metadata || {}, node.clusterId]);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Insert edges
|
|
246
|
+
for (const edge of graph.edges) {
|
|
247
|
+
await client.query(`
|
|
248
|
+
INSERT INTO gnn_edges (id, namespace, from_node, to_node, relationship, weight, direction, transform, metadata)
|
|
249
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
250
|
+
`, [edge.id, namespace, edge.from, edge.to, edge.relationship,
|
|
251
|
+
edge.weight, edge.direction, edge.transform, edge.metadata || {}]);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
await client.query('COMMIT');
|
|
255
|
+
} catch (e) {
|
|
256
|
+
await client.query('ROLLBACK');
|
|
257
|
+
throw e;
|
|
258
|
+
} finally {
|
|
259
|
+
client.release();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ===========================================================================
|
|
264
|
+
// GRAPH CLUSTERING (NEW in v3.0)
|
|
265
|
+
// ===========================================================================
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Detect communities in the graph using Louvain algorithm
|
|
269
|
+
* Uses RuVector's graph algorithms when available
|
|
270
|
+
*/
|
|
271
|
+
async detectCommunities(
|
|
272
|
+
graph: Graph,
|
|
273
|
+
options: {
|
|
274
|
+
algorithm?: 'louvain' | 'spectral' | 'label_propagation';
|
|
275
|
+
resolution?: number;
|
|
276
|
+
minClusterSize?: number;
|
|
277
|
+
} = {}
|
|
278
|
+
): Promise<ClusteringResult> {
|
|
279
|
+
const {
|
|
280
|
+
algorithm = this.config.defaultClusterAlgorithm || 'louvain',
|
|
281
|
+
resolution = this.config.clusterResolution || 1.0,
|
|
282
|
+
minClusterSize = 2
|
|
283
|
+
} = options;
|
|
284
|
+
|
|
285
|
+
await this.initRuvectorGraphs();
|
|
286
|
+
|
|
287
|
+
// Try RuVector's graph clustering first
|
|
288
|
+
if (this.ruvectorGraphs?.graphClusters) {
|
|
289
|
+
try {
|
|
290
|
+
const vectors = this.graphToVectors(graph);
|
|
291
|
+
const result = await this.ruvectorGraphs.graphClusters(vectors, {
|
|
292
|
+
algorithm,
|
|
293
|
+
resolution
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
return this.convertRuvectorResult(result, graph);
|
|
297
|
+
} catch (e) {
|
|
298
|
+
console.warn('[GNN-ENGINE] RuVector clustering failed, using fallback');
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Fallback to built-in Louvain
|
|
303
|
+
return this.louvainClustering(graph, resolution, minClusterSize);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Built-in Louvain community detection
|
|
308
|
+
*/
|
|
309
|
+
private louvainClustering(
|
|
310
|
+
graph: Graph,
|
|
311
|
+
resolution: number,
|
|
312
|
+
minClusterSize: number
|
|
313
|
+
): ClusteringResult {
|
|
314
|
+
// Initialize each node in its own cluster
|
|
315
|
+
const nodeToCluster = new Map<string, number>();
|
|
316
|
+
graph.nodes.forEach((node, i) => nodeToCluster.set(node.id, i));
|
|
317
|
+
|
|
318
|
+
// Build adjacency list with weights
|
|
319
|
+
const adjacency = new Map<string, Map<string, number>>();
|
|
320
|
+
graph.nodes.forEach(n => adjacency.set(n.id, new Map()));
|
|
321
|
+
|
|
322
|
+
graph.edges.forEach(edge => {
|
|
323
|
+
adjacency.get(edge.from)!.set(edge.to, edge.weight);
|
|
324
|
+
if (edge.direction === 'bidirectional') {
|
|
325
|
+
adjacency.get(edge.to)!.set(edge.from, edge.weight);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// Calculate total edge weight
|
|
330
|
+
let totalWeight = 0;
|
|
331
|
+
graph.edges.forEach(e => {
|
|
332
|
+
totalWeight += e.weight;
|
|
333
|
+
if (e.direction === 'bidirectional') totalWeight += e.weight;
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// Iterative optimization
|
|
337
|
+
let improved = true;
|
|
338
|
+
let iterations = 0;
|
|
339
|
+
const maxIterations = 100;
|
|
340
|
+
|
|
341
|
+
while (improved && iterations < maxIterations) {
|
|
342
|
+
improved = false;
|
|
343
|
+
iterations++;
|
|
344
|
+
|
|
345
|
+
for (const node of graph.nodes) {
|
|
346
|
+
const currentCluster = nodeToCluster.get(node.id)!;
|
|
347
|
+
|
|
348
|
+
// Find neighboring clusters
|
|
349
|
+
const neighborClusters = new Set<number>();
|
|
350
|
+
adjacency.get(node.id)!.forEach((_, neighbor) => {
|
|
351
|
+
neighborClusters.add(nodeToCluster.get(neighbor)!);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
let bestCluster = currentCluster;
|
|
355
|
+
let bestGain = 0;
|
|
356
|
+
|
|
357
|
+
// Try moving to each neighbor cluster
|
|
358
|
+
for (const targetCluster of neighborClusters) {
|
|
359
|
+
if (targetCluster === currentCluster) continue;
|
|
360
|
+
|
|
361
|
+
const gain = this.modularityGain(
|
|
362
|
+
node.id, currentCluster, targetCluster,
|
|
363
|
+
nodeToCluster, adjacency, totalWeight, resolution
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
if (gain > bestGain) {
|
|
367
|
+
bestGain = gain;
|
|
368
|
+
bestCluster = targetCluster;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Move to best cluster if improvement found
|
|
373
|
+
if (bestCluster !== currentCluster && bestGain > 0) {
|
|
374
|
+
nodeToCluster.set(node.id, bestCluster);
|
|
375
|
+
improved = true;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Build cluster result
|
|
381
|
+
const clusterNodes = new Map<number, string[]>();
|
|
382
|
+
nodeToCluster.forEach((cluster, node) => {
|
|
383
|
+
if (!clusterNodes.has(cluster)) {
|
|
384
|
+
clusterNodes.set(cluster, []);
|
|
385
|
+
}
|
|
386
|
+
clusterNodes.get(cluster)!.push(node);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Renumber clusters and filter by size
|
|
390
|
+
const clusters: Cluster[] = [];
|
|
391
|
+
const isolatedNodes: string[] = [];
|
|
392
|
+
let clusterIndex = 0;
|
|
393
|
+
|
|
394
|
+
clusterNodes.forEach((nodes, _) => {
|
|
395
|
+
if (nodes.length >= minClusterSize) {
|
|
396
|
+
clusters.push({
|
|
397
|
+
id: clusterIndex,
|
|
398
|
+
nodes,
|
|
399
|
+
size: nodes.length,
|
|
400
|
+
density: this.calculateClusterDensity(nodes, adjacency)
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// Update node cluster IDs
|
|
404
|
+
nodes.forEach(nodeId => {
|
|
405
|
+
const gNode = graph.nodes.find(n => n.id === nodeId);
|
|
406
|
+
if (gNode) gNode.clusterId = clusterIndex;
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
clusterIndex++;
|
|
410
|
+
} else {
|
|
411
|
+
isolatedNodes.push(...nodes);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const modularity = this.calculateModularity(
|
|
416
|
+
nodeToCluster, adjacency, totalWeight, resolution
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
console.log(`[GNN-ENGINE] Louvain clustering: ${clusters.length} clusters, modularity: ${modularity.toFixed(3)}`);
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
clusters,
|
|
423
|
+
modularity,
|
|
424
|
+
numClusters: clusters.length,
|
|
425
|
+
algorithm: 'louvain',
|
|
426
|
+
isolatedNodes
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Calculate modularity gain from moving a node
|
|
432
|
+
*/
|
|
433
|
+
private modularityGain(
|
|
434
|
+
nodeId: string,
|
|
435
|
+
fromCluster: number,
|
|
436
|
+
toCluster: number,
|
|
437
|
+
nodeToCluster: Map<string, number>,
|
|
438
|
+
adjacency: Map<string, Map<string, number>>,
|
|
439
|
+
totalWeight: number,
|
|
440
|
+
resolution: number
|
|
441
|
+
): number {
|
|
442
|
+
const neighbors = adjacency.get(nodeId)!;
|
|
443
|
+
let sumIn = 0, sumOut = 0;
|
|
444
|
+
let ki = 0;
|
|
445
|
+
|
|
446
|
+
neighbors.forEach((weight, neighbor) => {
|
|
447
|
+
ki += weight;
|
|
448
|
+
const neighborCluster = nodeToCluster.get(neighbor)!;
|
|
449
|
+
if (neighborCluster === toCluster) sumIn += weight;
|
|
450
|
+
if (neighborCluster === fromCluster) sumOut += weight;
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// Simplified modularity gain formula
|
|
454
|
+
const m = totalWeight;
|
|
455
|
+
const gain = (sumIn - sumOut) / m - resolution * ki * ki / (m * m);
|
|
456
|
+
|
|
457
|
+
return gain;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Calculate overall modularity
|
|
462
|
+
*/
|
|
463
|
+
private calculateModularity(
|
|
464
|
+
nodeToCluster: Map<string, number>,
|
|
465
|
+
adjacency: Map<string, Map<string, number>>,
|
|
466
|
+
totalWeight: number,
|
|
467
|
+
resolution: number
|
|
468
|
+
): number {
|
|
469
|
+
if (totalWeight === 0) return 0;
|
|
470
|
+
|
|
471
|
+
let Q = 0;
|
|
472
|
+
const m = totalWeight;
|
|
473
|
+
|
|
474
|
+
nodeToCluster.forEach((ci, i) => {
|
|
475
|
+
const ki = [...(adjacency.get(i)?.values() || [])].reduce((a, b) => a + b, 0);
|
|
476
|
+
|
|
477
|
+
adjacency.get(i)?.forEach((Aij, j) => {
|
|
478
|
+
const cj = nodeToCluster.get(j)!;
|
|
479
|
+
if (ci === cj) {
|
|
480
|
+
const kj = [...(adjacency.get(j)?.values() || [])].reduce((a, b) => a + b, 0);
|
|
481
|
+
Q += Aij - resolution * ki * kj / (2 * m);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
return Q / (2 * m);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Calculate cluster density
|
|
491
|
+
*/
|
|
492
|
+
private calculateClusterDensity(
|
|
493
|
+
nodes: string[],
|
|
494
|
+
adjacency: Map<string, Map<string, number>>
|
|
495
|
+
): number {
|
|
496
|
+
if (nodes.length < 2) return 0;
|
|
497
|
+
|
|
498
|
+
const nodeSet = new Set(nodes);
|
|
499
|
+
let internalEdges = 0;
|
|
500
|
+
|
|
501
|
+
nodes.forEach(node => {
|
|
502
|
+
adjacency.get(node)?.forEach((_, neighbor) => {
|
|
503
|
+
if (nodeSet.has(neighbor)) internalEdges++;
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const maxEdges = nodes.length * (nodes.length - 1);
|
|
508
|
+
return maxEdges > 0 ? internalEdges / maxEdges : 0;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Find natural boundaries between clusters using MinCut
|
|
513
|
+
*/
|
|
514
|
+
async findBoundaries(
|
|
515
|
+
graph: Graph,
|
|
516
|
+
options: {
|
|
517
|
+
useExistingClusters?: boolean;
|
|
518
|
+
minCutWeight?: number;
|
|
519
|
+
} = {}
|
|
520
|
+
): Promise<BoundaryResult> {
|
|
521
|
+
const { useExistingClusters = true, minCutWeight = 0.1 } = options;
|
|
522
|
+
|
|
523
|
+
// First cluster if needed
|
|
524
|
+
if (!useExistingClusters || !graph.nodes.some(n => n.clusterId !== undefined)) {
|
|
525
|
+
await this.detectCommunities(graph);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Try RuVector's mincut
|
|
529
|
+
await this.initRuvectorGraphs();
|
|
530
|
+
if (this.ruvectorGraphs?.minCutBoundaries) {
|
|
531
|
+
try {
|
|
532
|
+
const vectors = this.graphToVectors(graph);
|
|
533
|
+
const result = await this.ruvectorGraphs.minCutBoundaries(vectors);
|
|
534
|
+
return this.convertBoundaryResult(result, graph);
|
|
535
|
+
} catch (e) {
|
|
536
|
+
console.warn('[GNN-ENGINE] RuVector mincut failed, using fallback');
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Fallback: find cross-cluster edges
|
|
541
|
+
const boundaries: BoundaryResult['boundaries'] = [];
|
|
542
|
+
let totalCutWeight = 0;
|
|
543
|
+
const clusterIds = new Set<number>();
|
|
544
|
+
|
|
545
|
+
graph.edges.forEach(edge => {
|
|
546
|
+
const fromNode = graph.nodes.find(n => n.id === edge.from);
|
|
547
|
+
const toNode = graph.nodes.find(n => n.id === edge.to);
|
|
548
|
+
|
|
549
|
+
if (fromNode?.clusterId !== undefined) clusterIds.add(fromNode.clusterId);
|
|
550
|
+
if (toNode?.clusterId !== undefined) clusterIds.add(toNode.clusterId);
|
|
551
|
+
|
|
552
|
+
const crossCluster = fromNode?.clusterId !== toNode?.clusterId &&
|
|
553
|
+
fromNode?.clusterId !== undefined &&
|
|
554
|
+
toNode?.clusterId !== undefined;
|
|
555
|
+
|
|
556
|
+
if (crossCluster && edge.weight >= minCutWeight) {
|
|
557
|
+
boundaries.push({
|
|
558
|
+
from: edge.from,
|
|
559
|
+
to: edge.to,
|
|
560
|
+
cutWeight: edge.weight,
|
|
561
|
+
crossCluster: true
|
|
562
|
+
});
|
|
563
|
+
totalCutWeight += edge.weight;
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
boundaries.sort((a, b) => b.cutWeight - a.cutWeight);
|
|
568
|
+
|
|
569
|
+
return {
|
|
570
|
+
boundaries,
|
|
571
|
+
totalCutWeight,
|
|
572
|
+
numClusters: clusterIds.size
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Get nodes in a specific cluster
|
|
578
|
+
*/
|
|
579
|
+
getClusterNodes(graph: Graph, clusterId: number): GraphNode[] {
|
|
580
|
+
return graph.nodes.filter(n => n.clusterId === clusterId);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Get edges within a cluster (internal edges)
|
|
585
|
+
*/
|
|
586
|
+
getClusterEdges(graph: Graph, clusterId: number): GraphEdge[] {
|
|
587
|
+
const clusterNodeIds = new Set(
|
|
588
|
+
graph.nodes.filter(n => n.clusterId === clusterId).map(n => n.id)
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
return graph.edges.filter(e =>
|
|
592
|
+
clusterNodeIds.has(e.from) && clusterNodeIds.has(e.to)
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Get edges between clusters (boundary edges)
|
|
598
|
+
*/
|
|
599
|
+
getBoundaryEdges(
|
|
600
|
+
graph: Graph,
|
|
601
|
+
clusterA: number,
|
|
602
|
+
clusterB: number
|
|
603
|
+
): GraphEdge[] {
|
|
604
|
+
const nodesA = new Set(
|
|
605
|
+
graph.nodes.filter(n => n.clusterId === clusterA).map(n => n.id)
|
|
606
|
+
);
|
|
607
|
+
const nodesB = new Set(
|
|
608
|
+
graph.nodes.filter(n => n.clusterId === clusterB).map(n => n.id)
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
return graph.edges.filter(e =>
|
|
612
|
+
(nodesA.has(e.from) && nodesB.has(e.to)) ||
|
|
613
|
+
(nodesB.has(e.from) && nodesA.has(e.to))
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Auto-label clusters based on node types/labels
|
|
619
|
+
*/
|
|
620
|
+
labelClusters(graph: Graph, clusterResult: ClusteringResult): ClusteringResult {
|
|
621
|
+
clusterResult.clusters.forEach(cluster => {
|
|
622
|
+
const nodes = cluster.nodes.map(id => graph.nodes.find(n => n.id === id)!);
|
|
623
|
+
|
|
624
|
+
// Count node types
|
|
625
|
+
const typeCounts = new Map<string, number>();
|
|
626
|
+
nodes.forEach(n => {
|
|
627
|
+
typeCounts.set(n.type, (typeCounts.get(n.type) || 0) + 1);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// Get dominant type
|
|
631
|
+
let dominantType = '';
|
|
632
|
+
let maxCount = 0;
|
|
633
|
+
typeCounts.forEach((count, type) => {
|
|
634
|
+
if (count > maxCount) {
|
|
635
|
+
maxCount = count;
|
|
636
|
+
dominantType = type;
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// Use most common words from labels
|
|
641
|
+
const words = nodes.flatMap(n => n.label.toLowerCase().split(/\s+/));
|
|
642
|
+
const wordCounts = new Map<string, number>();
|
|
643
|
+
words.filter(w => w.length > 3).forEach(w => {
|
|
644
|
+
wordCounts.set(w, (wordCounts.get(w) || 0) + 1);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
const topWords = [...wordCounts.entries()]
|
|
648
|
+
.sort((a, b) => b[1] - a[1])
|
|
649
|
+
.slice(0, 3)
|
|
650
|
+
.map(([word]) => word);
|
|
651
|
+
|
|
652
|
+
cluster.label = `${dominantType}: ${topWords.join(', ')}`;
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
return clusterResult;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ---------------------------------------------------------------------------
|
|
659
|
+
// HELPER METHODS FOR RUVECTOR INTEGRATION
|
|
660
|
+
// ---------------------------------------------------------------------------
|
|
661
|
+
|
|
662
|
+
private graphToVectors(graph: Graph): number[][] {
|
|
663
|
+
// Convert graph to vector representation for RuVector
|
|
664
|
+
return graph.nodes.map(node => {
|
|
665
|
+
if (node.embedding) return node.embedding;
|
|
666
|
+
|
|
667
|
+
// Create simple structural embedding if no embedding exists
|
|
668
|
+
const nodeIndex = graph.nodes.findIndex(n => n.id === node.id);
|
|
669
|
+
const inDegree = graph.edges.filter(e => e.to === node.id).length;
|
|
670
|
+
const outDegree = graph.edges.filter(e => e.from === node.id).length;
|
|
671
|
+
const avgWeight = graph.edges
|
|
672
|
+
.filter(e => e.from === node.id || e.to === node.id)
|
|
673
|
+
.reduce((sum, e) => sum + e.weight, 0) / (inDegree + outDegree || 1);
|
|
674
|
+
|
|
675
|
+
// Simple 8-dimensional structural embedding
|
|
676
|
+
return [
|
|
677
|
+
nodeIndex / graph.nodes.length,
|
|
678
|
+
inDegree / graph.nodes.length,
|
|
679
|
+
outDegree / graph.nodes.length,
|
|
680
|
+
avgWeight,
|
|
681
|
+
node.type === 'decision' ? 1 : 0,
|
|
682
|
+
node.type === 'outcome' ? 1 : 0,
|
|
683
|
+
node.type === 'constraint' ? 1 : 0,
|
|
684
|
+
node.type === 'entity' ? 1 : 0
|
|
685
|
+
];
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
private convertRuvectorResult(result: any, graph: Graph): ClusteringResult {
|
|
690
|
+
const clusters: Cluster[] = result.clusters.map((c: any, i: number) => ({
|
|
691
|
+
id: i,
|
|
692
|
+
nodes: c.nodeIndices.map((idx: number) => graph.nodes[idx].id),
|
|
693
|
+
size: c.nodeIndices.length,
|
|
694
|
+
density: c.density || 0,
|
|
695
|
+
centroid: c.centroid
|
|
696
|
+
}));
|
|
697
|
+
|
|
698
|
+
// Update node cluster IDs
|
|
699
|
+
clusters.forEach(cluster => {
|
|
700
|
+
cluster.nodes.forEach(nodeId => {
|
|
701
|
+
const node = graph.nodes.find(n => n.id === nodeId);
|
|
702
|
+
if (node) node.clusterId = cluster.id;
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
return {
|
|
707
|
+
clusters,
|
|
708
|
+
modularity: result.modularity || 0,
|
|
709
|
+
numClusters: clusters.length,
|
|
710
|
+
algorithm: 'ruvector',
|
|
711
|
+
isolatedNodes: result.isolated || []
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private convertBoundaryResult(result: any, graph: Graph): BoundaryResult {
|
|
716
|
+
return {
|
|
717
|
+
boundaries: result.boundaries.map((b: any) => ({
|
|
718
|
+
from: graph.nodes[b.fromIndex]?.id || b.from,
|
|
719
|
+
to: graph.nodes[b.toIndex]?.id || b.to,
|
|
720
|
+
cutWeight: b.weight,
|
|
721
|
+
crossCluster: true
|
|
722
|
+
})),
|
|
723
|
+
totalCutWeight: result.totalWeight || 0,
|
|
724
|
+
numClusters: result.numClusters || 0
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ---------------------------------------------------------------------------
|
|
729
|
+
// PROPAGATION SIMULATION
|
|
730
|
+
// ---------------------------------------------------------------------------
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Simulate what happens when a node value changes
|
|
734
|
+
*/
|
|
735
|
+
async simulate(
|
|
736
|
+
graph: Graph,
|
|
737
|
+
change: { node: string; newValue: any },
|
|
738
|
+
options: { maxDepth?: number; minWeight?: number; respectClusterBoundaries?: boolean } = {}
|
|
739
|
+
): Promise<SimulationResult> {
|
|
740
|
+
const { maxDepth = 4, minWeight = 0.1, respectClusterBoundaries = false } = options;
|
|
741
|
+
|
|
742
|
+
const node = graph.nodes.find(n => n.id === change.node);
|
|
743
|
+
if (!node) throw new Error(`Node not found: ${change.node}`);
|
|
744
|
+
|
|
745
|
+
const originalValue = node.currentValue;
|
|
746
|
+
const directEffects: PropagationResult[] = [];
|
|
747
|
+
const cascadingEffects: PropagationResult[] = [];
|
|
748
|
+
const visited = new Set<string>();
|
|
749
|
+
const newValues: Record<string, any> = { [change.node]: change.newValue };
|
|
750
|
+
|
|
751
|
+
// BFS propagation
|
|
752
|
+
const queue: { nodeId: string; depth: number; path: string[]; confidence: number }[] = [];
|
|
753
|
+
|
|
754
|
+
// Find direct edges from changed node
|
|
755
|
+
const directEdges = graph.edges.filter(e => e.from === change.node);
|
|
756
|
+
for (const edge of directEdges) {
|
|
757
|
+
if (edge.weight >= minWeight) {
|
|
758
|
+
// NEW: Check cluster boundary if respecting boundaries
|
|
759
|
+
if (respectClusterBoundaries) {
|
|
760
|
+
const targetNode = graph.nodes.find(n => n.id === edge.to);
|
|
761
|
+
if (node.clusterId !== undefined && targetNode?.clusterId !== undefined &&
|
|
762
|
+
node.clusterId !== targetNode.clusterId) {
|
|
763
|
+
// Reduce confidence for cross-cluster propagation
|
|
764
|
+
queue.push({
|
|
765
|
+
nodeId: edge.to,
|
|
766
|
+
depth: 1,
|
|
767
|
+
path: [change.node, edge.to],
|
|
768
|
+
confidence: edge.weight * 0.5 // 50% penalty for crossing boundaries
|
|
769
|
+
});
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
queue.push({
|
|
775
|
+
nodeId: edge.to,
|
|
776
|
+
depth: 1,
|
|
777
|
+
path: [change.node, edge.to],
|
|
778
|
+
confidence: edge.weight
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
while (queue.length > 0) {
|
|
784
|
+
const current = queue.shift()!;
|
|
785
|
+
|
|
786
|
+
if (visited.has(current.nodeId) || current.depth > maxDepth) {
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
visited.add(current.nodeId);
|
|
790
|
+
|
|
791
|
+
const targetNode = graph.nodes.find(n => n.id === current.nodeId);
|
|
792
|
+
if (!targetNode) continue;
|
|
793
|
+
|
|
794
|
+
// Calculate new value based on incoming edges
|
|
795
|
+
const incomingEdges = graph.edges.filter(e => e.to === current.nodeId);
|
|
796
|
+
let newValue = targetNode.currentValue;
|
|
797
|
+
|
|
798
|
+
for (const edge of incomingEdges) {
|
|
799
|
+
if (newValues[edge.from] !== undefined) {
|
|
800
|
+
const transform = this.transforms.get(edge.transform || 'linear');
|
|
801
|
+
if (transform) {
|
|
802
|
+
newValue = transform(newValues[edge.from], edge.weight);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
newValues[current.nodeId] = newValue;
|
|
808
|
+
|
|
809
|
+
const effect: PropagationResult = {
|
|
810
|
+
changedNode: current.nodeId,
|
|
811
|
+
originalValue: targetNode.currentValue,
|
|
812
|
+
newValue: newValue,
|
|
813
|
+
depth: current.depth,
|
|
814
|
+
path: current.path,
|
|
815
|
+
confidence: current.confidence
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
if (current.depth === 1) {
|
|
819
|
+
directEffects.push(effect);
|
|
820
|
+
} else {
|
|
821
|
+
cascadingEffects.push(effect);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Queue downstream nodes
|
|
825
|
+
const outgoingEdges = graph.edges.filter(e => e.from === current.nodeId);
|
|
826
|
+
for (const edge of outgoingEdges) {
|
|
827
|
+
if (!visited.has(edge.to) && edge.weight >= minWeight) {
|
|
828
|
+
let confidenceMultiplier = 1;
|
|
829
|
+
|
|
830
|
+
// Apply cluster boundary penalty
|
|
831
|
+
if (respectClusterBoundaries) {
|
|
832
|
+
const nextNode = graph.nodes.find(n => n.id === edge.to);
|
|
833
|
+
if (targetNode.clusterId !== undefined && nextNode?.clusterId !== undefined &&
|
|
834
|
+
targetNode.clusterId !== nextNode.clusterId) {
|
|
835
|
+
confidenceMultiplier = 0.5;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
queue.push({
|
|
840
|
+
nodeId: edge.to,
|
|
841
|
+
depth: current.depth + 1,
|
|
842
|
+
path: [...current.path, edge.to],
|
|
843
|
+
confidence: current.confidence * edge.weight * confidenceMultiplier
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
return {
|
|
850
|
+
initialChange: { node: change.node, from: originalValue, to: change.newValue },
|
|
851
|
+
directEffects,
|
|
852
|
+
cascadingEffects,
|
|
853
|
+
equilibrium: newValues,
|
|
854
|
+
confidence: this.calculateOverallConfidence(directEffects, cascadingEffects)
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Compare two scenarios
|
|
860
|
+
*/
|
|
861
|
+
async compareScenarios(
|
|
862
|
+
graph: Graph,
|
|
863
|
+
scenarioA: { node: string; newValue: any },
|
|
864
|
+
scenarioB: { node: string; newValue: any }
|
|
865
|
+
): Promise<{
|
|
866
|
+
scenarioA: SimulationResult;
|
|
867
|
+
scenarioB: SimulationResult;
|
|
868
|
+
differences: { node: string; valueA: any; valueB: any; impact: number }[];
|
|
869
|
+
recommendation: 'A' | 'B' | 'equivalent';
|
|
870
|
+
}> {
|
|
871
|
+
const resultA = await this.simulate(graph, scenarioA);
|
|
872
|
+
const resultB = await this.simulate(graph, scenarioB);
|
|
873
|
+
|
|
874
|
+
const allNodes = new Set([
|
|
875
|
+
...Object.keys(resultA.equilibrium),
|
|
876
|
+
...Object.keys(resultB.equilibrium)
|
|
877
|
+
]);
|
|
878
|
+
|
|
879
|
+
const differences: { node: string; valueA: any; valueB: any; impact: number }[] = [];
|
|
880
|
+
|
|
881
|
+
for (const nodeId of allNodes) {
|
|
882
|
+
const valueA = resultA.equilibrium[nodeId];
|
|
883
|
+
const valueB = resultB.equilibrium[nodeId];
|
|
884
|
+
|
|
885
|
+
if (valueA !== valueB) {
|
|
886
|
+
differences.push({
|
|
887
|
+
node: nodeId,
|
|
888
|
+
valueA,
|
|
889
|
+
valueB,
|
|
890
|
+
impact: Math.abs(this.numericValue(valueA) - this.numericValue(valueB))
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
differences.sort((a, b) => b.impact - a.impact);
|
|
896
|
+
|
|
897
|
+
return {
|
|
898
|
+
scenarioA: resultA,
|
|
899
|
+
scenarioB: resultB,
|
|
900
|
+
differences,
|
|
901
|
+
recommendation: resultA.confidence > resultB.confidence ? 'A' :
|
|
902
|
+
resultB.confidence > resultA.confidence ? 'B' : 'equivalent'
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// ---------------------------------------------------------------------------
|
|
907
|
+
// GNN LAYER OPERATIONS (SQL)
|
|
908
|
+
// ---------------------------------------------------------------------------
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Apply GCN layer via SQL
|
|
912
|
+
*/
|
|
913
|
+
async applyGCNLayer(
|
|
914
|
+
nodeFeatures: number[][],
|
|
915
|
+
adjacencyMatrix: number[][],
|
|
916
|
+
weights: number[][]
|
|
917
|
+
): Promise<number[][]> {
|
|
918
|
+
const client = await this.pool.connect();
|
|
919
|
+
try {
|
|
920
|
+
const result = await client.query(`
|
|
921
|
+
SELECT ruvector_gnn_gcn_layer($1, $2, $3) as features
|
|
922
|
+
`, [nodeFeatures, adjacencyMatrix, weights]);
|
|
923
|
+
return result.rows[0].features;
|
|
924
|
+
} finally {
|
|
925
|
+
client.release();
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Apply Graph Attention layer via SQL
|
|
931
|
+
*/
|
|
932
|
+
async applyGATLayer(
|
|
933
|
+
nodeFeatures: number[][],
|
|
934
|
+
adjacencyMatrix: number[][],
|
|
935
|
+
attentionWeights: number[][],
|
|
936
|
+
numHeads: number
|
|
937
|
+
): Promise<number[][]> {
|
|
938
|
+
const client = await this.pool.connect();
|
|
939
|
+
try {
|
|
940
|
+
const result = await client.query(`
|
|
941
|
+
SELECT ruvector_gnn_gat_layer($1, $2, $3, $4) as features
|
|
942
|
+
`, [nodeFeatures, adjacencyMatrix, attentionWeights, numHeads]);
|
|
943
|
+
return result.rows[0].features;
|
|
944
|
+
} finally {
|
|
945
|
+
client.release();
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Message passing via SQL
|
|
951
|
+
*/
|
|
952
|
+
async messagePassing(
|
|
953
|
+
nodeFeatures: number[][],
|
|
954
|
+
edgeIndex: number[][],
|
|
955
|
+
edgeFeatures: number[][],
|
|
956
|
+
aggregation: 'sum' | 'mean' | 'max'
|
|
957
|
+
): Promise<number[][]> {
|
|
958
|
+
const client = await this.pool.connect();
|
|
959
|
+
try {
|
|
960
|
+
const result = await client.query(`
|
|
961
|
+
SELECT ruvector_gnn_message_pass($1, $2, $3, $4) as features
|
|
962
|
+
`, [nodeFeatures, edgeIndex, edgeFeatures, aggregation]);
|
|
963
|
+
return result.rows[0].features;
|
|
964
|
+
} finally {
|
|
965
|
+
client.release();
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// ---------------------------------------------------------------------------
|
|
970
|
+
// TRANSFORMS
|
|
971
|
+
// ---------------------------------------------------------------------------
|
|
972
|
+
|
|
973
|
+
registerTransform(name: string, fn: (value: any, weight: number) => any): void {
|
|
974
|
+
this.transforms.set(name, fn);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
private registerDefaultTransforms(): void {
|
|
978
|
+
// Linear: New value scales proportionally
|
|
979
|
+
this.transforms.set('linear', (value, weight) => {
|
|
980
|
+
if (typeof value === 'number') return value * weight;
|
|
981
|
+
return value;
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// Inverse: Opposite relationship
|
|
985
|
+
this.transforms.set('inverse', (value, weight) => {
|
|
986
|
+
if (typeof value === 'number') return value * (1 - weight);
|
|
987
|
+
return value;
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
// Threshold: Binary effect above threshold
|
|
991
|
+
this.transforms.set('threshold', (value, weight) => {
|
|
992
|
+
if (typeof value === 'number') return value > 0.5 ? weight : 0;
|
|
993
|
+
return value;
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
// Delay: Effect with delay factor
|
|
997
|
+
this.transforms.set('delay', (value, weight) => {
|
|
998
|
+
if (typeof value === 'number') return value * weight * 0.8;
|
|
999
|
+
return value;
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
// Sigmoid: S-curve transformation
|
|
1003
|
+
this.transforms.set('sigmoid', (value, weight) => {
|
|
1004
|
+
if (typeof value === 'number') {
|
|
1005
|
+
const x = value * weight;
|
|
1006
|
+
return 1 / (1 + Math.exp(-x));
|
|
1007
|
+
}
|
|
1008
|
+
return value;
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// ---------------------------------------------------------------------------
|
|
1013
|
+
// HELPERS
|
|
1014
|
+
// ---------------------------------------------------------------------------
|
|
1015
|
+
|
|
1016
|
+
private calculateOverallConfidence(
|
|
1017
|
+
direct: PropagationResult[],
|
|
1018
|
+
cascading: PropagationResult[]
|
|
1019
|
+
): number {
|
|
1020
|
+
const allEffects = [...direct, ...cascading];
|
|
1021
|
+
if (allEffects.length === 0) return 1.0;
|
|
1022
|
+
|
|
1023
|
+
const avgConfidence = allEffects.reduce((sum, e) => sum + e.confidence, 0) / allEffects.length;
|
|
1024
|
+
return avgConfidence;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
private numericValue(value: any): number {
|
|
1028
|
+
if (typeof value === 'number') return value;
|
|
1029
|
+
if (typeof value === 'boolean') return value ? 1 : 0;
|
|
1030
|
+
return 0;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// =============================================================================
|
|
1035
|
+
// SCHEMA FOR GNN TABLES (Updated for v3.0)
|
|
1036
|
+
// =============================================================================
|
|
1037
|
+
|
|
1038
|
+
export const GNN_SCHEMA = `
|
|
1039
|
+
-- GNN Nodes (updated with cluster_id)
|
|
1040
|
+
CREATE TABLE IF NOT EXISTS gnn_nodes (
|
|
1041
|
+
id TEXT PRIMARY KEY,
|
|
1042
|
+
namespace TEXT NOT NULL,
|
|
1043
|
+
type TEXT NOT NULL CHECK (type IN ('decision', 'outcome', 'constraint', 'entity')),
|
|
1044
|
+
label TEXT NOT NULL,
|
|
1045
|
+
current_value JSONB,
|
|
1046
|
+
possible_values JSONB,
|
|
1047
|
+
embedding vector(384),
|
|
1048
|
+
cluster_id INTEGER,
|
|
1049
|
+
metadata JSONB DEFAULT '{}',
|
|
1050
|
+
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
1051
|
+
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
1052
|
+
);
|
|
1053
|
+
|
|
1054
|
+
-- GNN Edges
|
|
1055
|
+
CREATE TABLE IF NOT EXISTS gnn_edges (
|
|
1056
|
+
id TEXT PRIMARY KEY,
|
|
1057
|
+
namespace TEXT NOT NULL,
|
|
1058
|
+
from_node TEXT NOT NULL REFERENCES gnn_nodes(id),
|
|
1059
|
+
to_node TEXT NOT NULL REFERENCES gnn_nodes(id),
|
|
1060
|
+
relationship TEXT NOT NULL CHECK (relationship IN ('affects', 'requires', 'conflicts', 'enables', 'depends_on')),
|
|
1061
|
+
weight REAL NOT NULL CHECK (weight >= 0 AND weight <= 1),
|
|
1062
|
+
direction TEXT DEFAULT 'unidirectional' CHECK (direction IN ('unidirectional', 'bidirectional')),
|
|
1063
|
+
transform TEXT,
|
|
1064
|
+
metadata JSONB DEFAULT '{}',
|
|
1065
|
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
1066
|
+
);
|
|
1067
|
+
|
|
1068
|
+
-- Cluster metadata table (NEW in v3.0)
|
|
1069
|
+
CREATE TABLE IF NOT EXISTS gnn_clusters (
|
|
1070
|
+
id SERIAL PRIMARY KEY,
|
|
1071
|
+
namespace TEXT NOT NULL,
|
|
1072
|
+
cluster_id INTEGER NOT NULL,
|
|
1073
|
+
label TEXT,
|
|
1074
|
+
centroid vector(384),
|
|
1075
|
+
size INTEGER DEFAULT 0,
|
|
1076
|
+
density REAL DEFAULT 0,
|
|
1077
|
+
metadata JSONB DEFAULT '{}',
|
|
1078
|
+
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
1079
|
+
UNIQUE(namespace, cluster_id)
|
|
1080
|
+
);
|
|
1081
|
+
|
|
1082
|
+
-- Indexes
|
|
1083
|
+
CREATE INDEX IF NOT EXISTS gnn_nodes_namespace_idx ON gnn_nodes(namespace);
|
|
1084
|
+
CREATE INDEX IF NOT EXISTS gnn_nodes_cluster_idx ON gnn_nodes(cluster_id);
|
|
1085
|
+
CREATE INDEX IF NOT EXISTS gnn_edges_namespace_idx ON gnn_edges(namespace);
|
|
1086
|
+
CREATE INDEX IF NOT EXISTS gnn_edges_from_idx ON gnn_edges(from_node);
|
|
1087
|
+
CREATE INDEX IF NOT EXISTS gnn_edges_to_idx ON gnn_edges(to_node);
|
|
1088
|
+
CREATE INDEX IF NOT EXISTS gnn_clusters_namespace_idx ON gnn_clusters(namespace);
|
|
1089
|
+
`;
|
|
1090
|
+
|
|
1091
|
+
export default GNNEngine;
|