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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +674 -0
  3. package/SKILL.md +740 -0
  4. package/bin/kb-first.js +123 -0
  5. package/install/init-project.sh +435 -0
  6. package/install/install-global.sh +257 -0
  7. package/install/kb-first-autodetect.sh +108 -0
  8. package/install/kb-first-command.md +80 -0
  9. package/install/kb-first-skill.md +262 -0
  10. package/package.json +87 -0
  11. package/phases/00-assessment.md +529 -0
  12. package/phases/01-storage.md +194 -0
  13. package/phases/01.5-hooks-setup.md +521 -0
  14. package/phases/02-kb-creation.md +413 -0
  15. package/phases/03-persistence.md +125 -0
  16. package/phases/04-visualization.md +170 -0
  17. package/phases/05-integration.md +114 -0
  18. package/phases/06-scaffold.md +130 -0
  19. package/phases/07-build.md +493 -0
  20. package/phases/08-verification.md +597 -0
  21. package/phases/09-security.md +512 -0
  22. package/phases/10-documentation.md +613 -0
  23. package/phases/11-deployment.md +670 -0
  24. package/phases/testing.md +713 -0
  25. package/scripts/1.5-hooks-verify.sh +252 -0
  26. package/scripts/8.1-code-scan.sh +58 -0
  27. package/scripts/8.2-import-check.sh +42 -0
  28. package/scripts/8.3-source-returns.sh +52 -0
  29. package/scripts/8.4-startup-verify.sh +65 -0
  30. package/scripts/8.5-fallback-check.sh +63 -0
  31. package/scripts/8.6-attribution.sh +56 -0
  32. package/scripts/8.7-confidence.sh +56 -0
  33. package/scripts/8.8-gap-logging.sh +70 -0
  34. package/scripts/9-security-audit.sh +202 -0
  35. package/scripts/init-project.sh +395 -0
  36. package/scripts/verify-enforcement.sh +167 -0
  37. package/src/commands/hooks.js +361 -0
  38. package/src/commands/init.js +315 -0
  39. package/src/commands/phase.js +372 -0
  40. package/src/commands/score.js +380 -0
  41. package/src/commands/status.js +193 -0
  42. package/src/commands/verify.js +286 -0
  43. package/src/index.js +56 -0
  44. package/src/mcp-server.js +412 -0
  45. package/templates/attention-router.ts +534 -0
  46. package/templates/code-analysis.ts +683 -0
  47. package/templates/federated-kb-learner.ts +649 -0
  48. package/templates/gnn-engine.ts +1091 -0
  49. package/templates/intentions.md +277 -0
  50. package/templates/kb-client.ts +905 -0
  51. package/templates/schema.sql +303 -0
  52. 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;