Files
el/ui/runtime/src/graph.js
T

225 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, {id: string, type: string, name: string, content: any, importance: number, edges: string[]}>} */
this.nodes = new Map();
/** @type {Map<string, {id: string, from: string, to: string, weight: number, relation: string}>} */
this.edges = new Map();
/** @type {Map<string, Array<function>>} */
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<string>} 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<string, number>} */
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);
});
}
}