/** * 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} */ 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} */ export function reachableNodes(graph, seedId, maxDepth = DEFAULT_MAX_DEPTH) { return graph.activate(seedId, maxDepth); }