@terminals-tech/graph
Version:
Language-first event graph system. Extract relationships from text, find patterns, and navigate event chains.
431 lines (430 loc) • 15.4 kB
JavaScript
"use strict";
/**
* GraphProcessor - Scalable graph algorithms for 100K+ events
* Phase 3: Efficient graph operations
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.GraphProcessor = void 0;
class GraphProcessor {
constructor() {
this.adjacencyList = new Map();
this.reverseIndex = new Map();
this.edgeWeights = new Map();
this.nodeData = new Map();
this.pageRankCache = new Map();
this.clusters = new Map();
this.isDirty = false;
// Performance counters
this.nodeCount = 0;
this.edgeCount = 0;
// Idempotency tracking
this.processedEvents = new Set();
this.edgeHashes = new Set();
}
/**
* Add event to graph incrementally (idempotent)
*/
addEvent(event, relations) {
// Check if already processed (idempotent)
if (this.processedEvents.has(event.id)) {
return;
}
// Create snapshot for rollback on error
const snapshot = this.createSnapshot();
try {
// Add node
if (!this.adjacencyList.has(event.id)) {
this.adjacencyList.set(event.id, new Set());
this.nodeData.set(event.id, event);
this.nodeCount++;
}
// Add edges from relations
relations.forEach(rel => {
this.addEdge(event.id, rel.to, rel.weight, rel.type);
});
// Mark as processed
this.processedEvents.add(event.id);
// Mark cache as dirty
this.isDirty = true;
}
catch (error) {
// Rollback on error
this.restoreSnapshot(snapshot);
throw error;
}
}
/**
* Add edge between nodes (idempotent)
*/
addEdge(from, to, weight = 1, type) {
// Create edge hash for idempotency
const edgeHash = `${from}->${to}:${type || 'default'}`;
if (this.edgeHashes.has(edgeHash)) {
// Edge already exists, update weight if higher
const existingWeight = this.edgeWeights.get(from)?.get(to) || 0;
if (weight > existingWeight) {
this.edgeWeights.get(from).set(to, weight);
}
return;
}
// Add to adjacency list
if (!this.adjacencyList.has(from)) {
this.adjacencyList.set(from, new Set());
this.nodeCount++;
}
if (!this.adjacencyList.has(to)) {
this.adjacencyList.set(to, new Set());
this.nodeCount++;
}
this.adjacencyList.get(from).add(to);
// Maintain reverse index for fast incoming edge lookups
if (!this.reverseIndex.has(to)) {
this.reverseIndex.set(to, new Set());
}
this.reverseIndex.get(to).add(from);
// Store edge weight
if (!this.edgeWeights.has(from)) {
this.edgeWeights.set(from, new Map());
}
this.edgeWeights.get(from).set(to, weight);
// Mark edge as added
this.edgeHashes.add(edgeHash);
this.edgeCount++;
this.isDirty = true;
}
/**
* Calculate PageRank scores using power iteration
*/
calculatePageRank(iterations = 20, damping = 0.85) {
if (!this.isDirty && this.pageRankCache.size > 0) {
// Return cached results
return this.formatPageRankResults();
}
const nodes = Array.from(this.adjacencyList.keys());
const n = nodes.length;
if (n === 0)
return [];
const scores = new Map();
const newScores = new Map();
// Initialize scores
nodes.forEach(node => scores.set(node, 1 / n));
// Power iteration
for (let iter = 0; iter < iterations; iter++) {
newScores.clear();
nodes.forEach(node => {
let score = (1 - damping) / n;
// Sum contributions from incoming links
const incoming = this.reverseIndex.get(node) || new Set();
incoming.forEach(source => {
const outDegree = this.adjacencyList.get(source)?.size || 1;
const sourceScore = scores.get(source) || 0;
const edgeWeight = this.edgeWeights.get(source)?.get(node) || 1;
score += damping * sourceScore * edgeWeight / outDegree;
});
newScores.set(node, score);
});
// Copy scores for next iteration
scores.clear();
newScores.forEach((score, node) => scores.set(node, score));
}
// Cache results
this.pageRankCache = scores;
this.isDirty = false;
return this.formatPageRankResults();
}
/**
* Find connected components (clusters)
*/
findClusters() {
this.clusters.clear();
const visited = new Set();
const clusterList = [];
let clusterId = 0;
// Find connected components using BFS
this.adjacencyList.forEach((_, startNode) => {
if (!visited.has(startNode)) {
const cluster = new Set();
const queue = [startNode];
visited.add(startNode);
while (queue.length > 0) {
const node = queue.shift();
cluster.add(node);
this.clusters.set(node, clusterId);
// Add unvisited neighbors (treating graph as undirected for clustering)
const outgoing = this.adjacencyList.get(node) || new Set();
const incoming = this.reverseIndex.get(node) || new Set();
const neighbors = new Set([...outgoing, ...incoming]);
neighbors.forEach(neighbor => {
if (!visited.has(neighbor)) {
visited.add(neighbor);
queue.push(neighbor);
}
});
}
// Calculate cluster density
const density = this.calculateClusterDensity(cluster);
// Find cluster center (node with highest degree within cluster)
const center = this.findClusterCenter(cluster);
clusterList.push({
id: clusterId++,
nodes: cluster,
center,
density
});
}
});
return clusterList.sort((a, b) => b.nodes.size - a.nodes.size);
}
/**
* Find shortest path using BFS
*/
findPath(from, to) {
if (!this.adjacencyList.has(from) || !this.adjacencyList.has(to)) {
return null;
}
const queue = [
{ node: from, path: [from], weight: 0 }
];
const visited = new Set([from]);
while (queue.length > 0) {
const { node, path, weight } = queue.shift();
if (node === to) {
return {
nodes: path,
totalWeight: weight,
length: path.length - 1
};
}
const neighbors = this.adjacencyList.get(node) || new Set();
neighbors.forEach(neighbor => {
if (!visited.has(neighbor)) {
visited.add(neighbor);
const edgeWeight = this.edgeWeights.get(node)?.get(neighbor) || 1;
queue.push({
node: neighbor,
path: [...path, neighbor],
weight: weight + edgeWeight
});
}
});
}
return null;
}
/**
* Find all paths between two nodes (limited depth)
*/
findAllPaths(from, to, maxDepth = 5) {
if (!this.adjacencyList.has(from) || !this.adjacencyList.has(to)) {
return [];
}
const paths = [];
const stack = [
{ node: from, path: [from], weight: 0, visited: new Set([from]) }
];
while (stack.length > 0) {
const { node, path, weight, visited } = stack.pop();
if (path.length > maxDepth)
continue;
if (node === to) {
paths.push({
nodes: path,
totalWeight: weight,
length: path.length - 1
});
continue;
}
const neighbors = this.adjacencyList.get(node) || new Set();
neighbors.forEach(neighbor => {
if (!visited.has(neighbor)) {
const newVisited = new Set(visited);
newVisited.add(neighbor);
const edgeWeight = this.edgeWeights.get(node)?.get(neighbor) || 1;
stack.push({
node: neighbor,
path: [...path, neighbor],
weight: weight + edgeWeight,
visited: newVisited
});
}
});
}
return paths.sort((a, b) => a.totalWeight - b.totalWeight);
}
/**
* Get subgraph around a node
*/
getSubgraph(nodeId, depth = 2) {
if (!this.adjacencyList.has(nodeId)) {
return { nodes: new Set(), edges: [] };
}
const subgraphNodes = new Set();
const subgraphEdges = [];
const queue = [{ node: nodeId, d: 0 }];
const visited = new Set();
while (queue.length > 0) {
const { node, d } = queue.shift();
if (visited.has(node) || d > depth)
continue;
visited.add(node);
subgraphNodes.add(node);
// Add outgoing edges
const neighbors = this.adjacencyList.get(node) || new Set();
neighbors.forEach(neighbor => {
if (d < depth) {
const weight = this.edgeWeights.get(node)?.get(neighbor) || 1;
subgraphEdges.push({
from: node,
to: neighbor,
weight
});
queue.push({ node: neighbor, d: d + 1 });
}
});
// Add incoming edges
const incoming = this.reverseIndex.get(node) || new Set();
incoming.forEach(source => {
if (d < depth && !visited.has(source)) {
queue.push({ node: source, d: d + 1 });
}
});
}
return { nodes: subgraphNodes, edges: subgraphEdges };
}
/**
* Detect cycles in the graph
*/
detectCycles() {
const cycles = [];
const visited = new Set();
const recursionStack = new Set();
const dfs = (node, path) => {
visited.add(node);
recursionStack.add(node);
path.push(node);
const neighbors = this.adjacencyList.get(node) || new Set();
neighbors.forEach(neighbor => {
if (!visited.has(neighbor)) {
dfs(neighbor, [...path]);
}
else if (recursionStack.has(neighbor)) {
// Found a cycle
const cycleStart = path.indexOf(neighbor);
if (cycleStart !== -1) {
cycles.push(path.slice(cycleStart));
}
}
});
recursionStack.delete(node);
};
// Run DFS from each unvisited node
this.adjacencyList.forEach((_, node) => {
if (!visited.has(node)) {
dfs(node, []);
}
});
return cycles;
}
/**
* Calculate graph statistics
*/
getStatistics() {
const degrees = Array.from(this.adjacencyList.values()).map(neighbors => neighbors.size);
const maxDegree = Math.max(...degrees, 0);
const avgDegree = degrees.length > 0 ? degrees.reduce((a, b) => a + b, 0) / degrees.length : 0;
const maxPossibleEdges = this.nodeCount * (this.nodeCount - 1);
const density = maxPossibleEdges > 0 ? this.edgeCount / maxPossibleEdges : 0;
// Check if graph is connected
const clusters = this.findClusters();
const connected = clusters.length === 1;
// Check if graph has cycles
const cycles = this.detectCycles();
const cyclic = cycles.length > 0;
return {
nodes: this.nodeCount,
edges: this.edgeCount,
density,
avgDegree,
maxDegree,
connected,
cyclic
};
}
/**
* Clear the graph
*/
clear() {
this.adjacencyList.clear();
this.reverseIndex.clear();
this.edgeWeights.clear();
this.nodeData.clear();
this.pageRankCache.clear();
this.clusters.clear();
this.processedEvents.clear();
this.edgeHashes.clear();
this.nodeCount = 0;
this.edgeCount = 0;
this.isDirty = false;
}
/**
* Create a snapshot for rollback
*/
createSnapshot() {
return {
nodeCount: this.nodeCount,
edgeCount: this.edgeCount,
processedEvents: new Set(this.processedEvents),
edgeHashes: new Set(this.edgeHashes)
};
}
/**
* Restore from snapshot
*/
restoreSnapshot(snapshot) {
this.nodeCount = snapshot.nodeCount;
this.edgeCount = snapshot.edgeCount;
this.processedEvents = snapshot.processedEvents;
this.edgeHashes = snapshot.edgeHashes;
this.isDirty = true;
}
// Private helper methods
formatPageRankResults() {
const results = Array.from(this.pageRankCache.entries())
.map(([node, score]) => ({ node, score, rank: 0 }))
.sort((a, b) => b.score - a.score);
// Add ranks
results.forEach((result, idx) => {
result.rank = idx + 1;
});
return results;
}
calculateClusterDensity(cluster) {
if (cluster.size <= 1)
return 0;
let internalEdges = 0;
cluster.forEach(node => {
const neighbors = this.adjacencyList.get(node) || new Set();
neighbors.forEach(neighbor => {
if (cluster.has(neighbor)) {
internalEdges++;
}
});
});
const maxPossibleEdges = cluster.size * (cluster.size - 1);
return maxPossibleEdges > 0 ? internalEdges / maxPossibleEdges : 0;
}
findClusterCenter(cluster) {
let maxDegree = 0;
let center;
cluster.forEach(node => {
const outDegree = (this.adjacencyList.get(node) || new Set()).size;
const inDegree = (this.reverseIndex.get(node) || new Set()).size;
const totalDegree = outDegree + inDegree;
if (totalDegree > maxDegree) {
maxDegree = totalDegree;
center = node;
}
});
return center;
}
}
exports.GraphProcessor = GraphProcessor;