feat: el-ui — activation-based frontend framework, spreading activation reactivity, graph state
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user