feat: el-ui — activation-based frontend framework, spreading activation reactivity, graph state

This commit is contained in:
Will Anderson
2026-04-27 19:15:53 -05:00
commit 3bf3c02854
25 changed files with 4642 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
/**
* activation.js — Spreading activation utilities for el-ui.
*
* This module provides standalone activation functions that operate on any
* Graph instance. Useful for implementing custom reactive behavior outside
* of the standard component setState() lifecycle.
*
* The activation model mirrors engram-core/src/activation.rs exactly:
* - Best-first BFS from seed nodes
* - Multiplicative strength: parent_strength × edge.weight × target.importance
* - Winner-take-most: strongest path to each node wins
* - Prune: paths below threshold are cut (models attention filter)
*/
export const PRUNE_THRESHOLD = 0.01;
export const DEFAULT_MAX_DEPTH = 3;
export const DEFAULT_LIMIT = 20;
/**
* Run spreading activation from multiple seed nodes.
*
* @param {import('./graph.js').Graph} graph
* @param {string[]} seeds starting node IDs
* @param {object} opts
* @param {number} [opts.maxDepth=3]
* @param {number} [opts.limit=20]
* @param {number} [opts.pruneThreshold=0.01]
* @returns {Array<{nodeId: string, strength: number, hops: number, node: object}>}
*/
export function spreadActivation(graph, seeds, {
maxDepth = DEFAULT_MAX_DEPTH,
limit = DEFAULT_LIMIT,
pruneThreshold = PRUNE_THRESHOLD,
} = {}) {
/** @type {Map<string, {strength: number, hops: number}>} */
const bestStrength = new Map();
/** @type {Array<{id: string, strength: number, hops: number}>} */
const queue = [];
const seedSet = new Set(seeds);
for (const seed of seeds) {
const node = graph.get(seed);
if (!node) continue;
queue.push({ id: seed, strength: 1.0, hops: 0 });
bestStrength.set(seed, { strength: 1.0, hops: 0 });
}
while (queue.length > 0) {
// Best-first: pop the 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, hops } = queue.splice(bestIdx, 1)[0];
if (hops >= maxDepth) continue;
const node = graph.get(id);
if (!node) continue;
for (const edgeId of node.edges) {
const edge = graph.edges.get(edgeId);
if (!edge) continue;
const target = graph.get(edge.to);
if (!target) continue;
const targetStrength = strength * edge.weight * Math.max(0, target.importance);
if (targetStrength <= pruneThreshold) continue;
const prev = bestStrength.get(edge.to);
if (!prev || targetStrength > prev.strength) {
const nextHops = hops + 1;
bestStrength.set(edge.to, { strength: targetStrength, hops: nextHops });
queue.push({ id: edge.to, strength: targetStrength, hops: nextHops });
}
}
}
// Collect results — exclude seeds, sort by strength
const results = [];
for (const [nodeId, { strength, hops }] of bestStrength) {
if (seedSet.has(nodeId)) continue;
const node = graph.get(nodeId);
if (node) results.push({ nodeId, strength, hops, node });
}
results.sort((a, b) => b.strength - a.strength);
return results.slice(0, limit);
}
/**
* Compute activation strength between two specific nodes.
* Returns 0 if no path exists within maxDepth.
*
* @param {import('./graph.js').Graph} graph
* @param {string} fromId
* @param {string} toId
* @param {number} [maxDepth=3]
* @returns {number}
*/
export function activationStrength(graph, fromId, toId, maxDepth = DEFAULT_MAX_DEPTH) {
const results = spreadActivation(graph, [fromId], { maxDepth });
const found = results.find(r => r.nodeId === toId);
return found ? found.strength : 0;
}
/**
* Find all nodes reachable from a seed within the activation surface.
* Unlike spreadActivation(), this includes the seed itself.
*
* @param {import('./graph.js').Graph} graph
* @param {string} seedId
* @param {number} [maxDepth=3]
* @returns {Set<string>}
*/
export function reachableNodes(graph, seedId, maxDepth = DEFAULT_MAX_DEPTH) {
return graph.activate(seedId, maxDepth);
}