/** * graph.js — In-browser Engram graph for el-ui state management. * * Every piece of component state is a node in this graph. * Edges encode relationships between state items (e.g., derived values, * related data, component hierarchies). * * Reactivity is driven by spreading activation: when a node is updated, * activation spreads outward through edges, and subscribers on any activated * node are notified. Only subscribers in the activation surface update — * this is the core of el-ui's selective re-rendering. */ export class Graph { constructor() { /** @type {Map} */ this.nodes = new Map(); /** @type {Map} */ this.edges = new Map(); /** @type {Map>} */ this.subscribers = new Map(); } /** * Create a new node and return its ID. * @param {{type: string, name: string, content: any, importance?: number}} opts * @returns {string} node ID */ seed({ type, name, content, importance = 0.5 }) { const id = this._uuid(); this.nodes.set(id, { id, type, name, content, importance, edges: [] }); return id; } /** * Get a node by ID. * @param {string} id * @returns {{id: string, type: string, name: string, content: any, importance: number, edges: string[]} | undefined} */ get(id) { return this.nodes.get(id); } /** * Update node content and trigger spreading activation. * This is the primary mutation point — all state changes go through here. * @param {string} id * @param {any} newContent */ update(id, newContent) { const node = this.nodes.get(id); if (!node) return; node.content = newContent; // Activation boosts importance — recently-changed state nodes // become more salient (same model as Engram salience boost). node.importance = Math.min(1.0, node.importance + 0.1); // Spreading activation from the updated node const activated = this.activate(id); // Notify all subscribers in the activation surface for (const nodeId of activated) { const subs = this.subscribers.get(nodeId); if (subs && subs.length > 0) { const activatedNode = this.nodes.get(nodeId); if (activatedNode) { subs.forEach(cb => cb(activatedNode)); } } } // Always notify direct subscribers on the changed node itself const directSubs = this.subscribers.get(id); if (directSubs && directSubs.length > 0) { directSubs.forEach(cb => cb(node)); } } /** * Spreading activation — BFS from a seed node. * * Faithfully mirrors the Engram spreading activation algorithm: * strength = parent_strength × edge.weight × target.importance * * (In the browser we omit cosine_sim since we have no embedding vectors — * the importance factor serves as the salience filter.) * * @param {string} seedId * @param {number} maxDepth * @param {number} pruneThreshold * @returns {Set} set of activated node IDs (including the seed) */ activate(seedId, maxDepth = 3, pruneThreshold = 0.01) { const result = new Set([seedId]); /** @type {Array<{id: string, strength: number, depth: number}>} */ const queue = [{ id: seedId, strength: 1.0, depth: 0 }]; /** @type {Map} */ const bestStrength = new Map([[seedId, 1.0]]); while (queue.length > 0) { // Best-first: process highest-strength candidate let bestIdx = 0; for (let i = 1; i < queue.length; i++) { if (queue[i].strength > queue[bestIdx].strength) bestIdx = i; } const { id, strength, depth } = queue.splice(bestIdx, 1)[0]; if (depth >= maxDepth) continue; const node = this.nodes.get(id); if (!node) continue; for (const edgeId of node.edges) { const edge = this.edges.get(edgeId); if (!edge) continue; const target = this.nodes.get(edge.to); if (!target) continue; // Activation formula (multiplicative — matches Engram engine) const targetStrength = strength * edge.weight * Math.max(0, target.importance); if (targetStrength <= pruneThreshold) continue; const prevBest = bestStrength.get(edge.to) ?? 0; if (targetStrength > prevBest) { bestStrength.set(edge.to, targetStrength); result.add(edge.to); queue.push({ id: edge.to, strength: targetStrength, depth: depth + 1 }); } } } return result; } /** * Semantic search — find nodes by content similarity. * * In v0.1 this uses simple string matching. In a future version this will * use embedding vectors and cosine similarity (matching the Engram core engine). * * @param {string} query * @param {string|null} nodeType optional filter by node type * @returns {Array<{id: string, type: string, name: string, content: any, importance: number, score: number}>} */ search(query, nodeType = null) { const results = []; const q = query.toLowerCase(); for (const [, node] of this.nodes) { if (nodeType && node.type !== nodeType) continue; const content = String(node.content).toLowerCase(); const nameMatch = node.name.toLowerCase().includes(q); const contentMatch = content.includes(q); if (nameMatch || contentMatch) { // Score: name matches score higher; importance boosts const score = (nameMatch ? 0.6 : 0) + (contentMatch ? 0.4 : 0); results.push({ ...node, score: score * node.importance }); } } return results.sort((a, b) => b.score - a.score); } /** * Subscribe to updates on a specific node (and any activation-reachable neighbors). * Returns an unsubscribe function. * * @param {string} nodeId * @param {function} callback called with the node object when it activates * @returns {function} unsubscribe */ subscribe(nodeId, callback) { if (!this.subscribers.has(nodeId)) { this.subscribers.set(nodeId, []); } this.subscribers.get(nodeId).push(callback); return () => { const subs = this.subscribers.get(nodeId); if (!subs) return; const idx = subs.indexOf(callback); if (idx >= 0) subs.splice(idx, 1); }; } /** * Connect two nodes with a directed edge. * Higher weight = stronger activation path. * * @param {string} fromId * @param {string} toId * @param {{weight?: number, relation?: string}} opts * @returns {string} edge ID */ connect(fromId, toId, { weight = 1.0, relation = 'related' } = {}) { const edgeId = this._uuid(); this.edges.set(edgeId, { id: edgeId, from: fromId, to: toId, weight, relation }); const node = this.nodes.get(fromId); if (node) node.edges.push(edgeId); return edgeId; } /** * Return all nodes as an array (useful for debugging / DevTools). * @returns {Array} */ dump() { return [...this.nodes.values()]; } /** @private */ _uuid() { if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID(); } // Fallback for environments without crypto.randomUUID return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); }); } }