/** * Flow simulation engine — pure function, no framework dependencies. * Enforces strict conservation: every dollar is accounted for (in = out). */ import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "./types"; export interface SimulationConfig { tickDivisor: number; } export const DEFAULT_CONFIG: SimulationConfig = { tickDivisor: 5, }; export function computeSufficiencyState(data: FunnelNodeData): SufficiencyState { const ratio = data.currentValue / (data.overflowThreshold || 1); if (ratio >= 1) return "overflowing"; if (ratio >= 0.8) return "approaching"; return "seeking"; } export function computeSystemSufficiency(nodes: FlowNode[]): number { let sum = 0; let count = 0; for (const node of nodes) { if (node.type === "funnel") { const d = node.data as FunnelNodeData; sum += Math.min(1, d.currentValue / (d.overflowThreshold || 1)); count++; } else if (node.type === "outcome") { const d = node.data as OutcomeNodeData; sum += Math.min(1, d.fundingReceived / Math.max(d.fundingTarget, 1)); count++; } } return count > 0 ? sum / count : 0; } /** * Multi-pass inflow rate computation. * Pass 1: source→funnel direct allocations. * Pass 2+: propagate spending drain shares + overflow excess shares downstream. * Loops until convergence (max 20 iterations, delta < 0.001). */ export function computeInflowRates(nodes: FlowNode[]): FlowNode[] { // Pass 1: source → funnel direct allocations const computed = new Map(); for (const n of nodes) { if (n.type === "source") { const d = n.data as SourceNodeData; for (const alloc of d.targetAllocations) { computed.set( alloc.targetId, (computed.get(alloc.targetId) ?? 0) + d.flowRate * (alloc.percentage / 100), ); } } } // Pass 2+: propagate spending + overflow downstream through funnel layers const funnelNodes = nodes.filter((n) => n.type === "funnel").sort((a, b) => a.position.y - b.position.y); for (let iter = 0; iter < 20; iter++) { let delta = 0; for (const fn of funnelNodes) { const d = fn.data as FunnelNodeData; const inflow = computed.get(fn.id) ?? d.inflowRate; if (inflow <= 0) continue; // Spending drain goes downstream const drain = Math.min(d.drainRate, inflow); for (const alloc of d.spendingAllocations) { const share = drain * (alloc.percentage / 100); // Only propagate to other funnels const target = nodes.find((n) => n.id === alloc.targetId); if (target?.type === "funnel") { const prev = computed.get(alloc.targetId) ?? 0; const next = prev + share; if (Math.abs(next - prev) > 0.001) { computed.set(alloc.targetId, next); delta += Math.abs(next - prev); } } } // Overflow excess goes downstream (estimate: inflow - drain when above threshold) const netExcess = Math.max(0, inflow - drain); if (netExcess > 0) { for (const alloc of d.overflowAllocations) { const share = netExcess * (alloc.percentage / 100); const target = nodes.find((n) => n.id === alloc.targetId); if (target?.type === "funnel") { const prev = computed.get(alloc.targetId) ?? 0; const next = prev + share; if (Math.abs(next - prev) > 0.001) { computed.set(alloc.targetId, next); delta += Math.abs(next - prev); } } } } } if (delta < 0.001) break; } return nodes.map((n) => { if (n.type === "funnel" && computed.has(n.id)) { return { ...n, data: { ...n.data, inflowRate: computed.get(n.id)! } }; } return n; }); } /** * Conservation-enforcing tick with convergence loop for circular overflow. * * Wraps funnel processing in up to 3 passes per tick so that upward overflow * (e.g. Growth→Treasury) doesn't suffer a 1-tick delay. */ export function simulateTick( nodes: FlowNode[], config: SimulationConfig = DEFAULT_CONFIG, ): FlowNode[] { const { tickDivisor } = config; const funnelNodes = nodes .filter((n) => n.type === "funnel") .sort((a, b) => a.position.y - b.position.y); const funnelIds = new Set(funnelNodes.map((n) => n.id)); const overflowIncoming = new Map(); const spendingIncoming = new Map(); const updatedFunnels = new Map(); // Initialize funnel data for (const node of funnelNodes) { updatedFunnels.set(node.id, { ...(node.data as FunnelNodeData) }); } // Convergence loop: re-process funnels that receive new overflow after being processed const processed = new Set(); for (let pass = 0; pass < 3; pass++) { const needsProcessing = pass === 0 ? funnelNodes : funnelNodes.filter((n) => { // Only re-process if new overflow arrived after we processed it const incoming = overflowIncoming.get(n.id) ?? 0; return incoming > 0 && processed.has(n.id); }); if (pass > 0 && needsProcessing.length === 0) break; for (const node of needsProcessing) { const data = pass === 0 ? updatedFunnels.get(node.id)! : { ...updatedFunnels.get(node.id)! }; // 1. Inflow: source rate + overflow received from upstream this tick const inflow = (pass === 0 ? data.inflowRate / tickDivisor : 0) + (overflowIncoming.get(node.id) ?? 0); if (pass > 0) { // Clear consumed overflow overflowIncoming.delete(node.id); } let value = data.currentValue + inflow; // 2. Drain: flat rate capped by available funds (only on first pass) let drain = 0; if (pass === 0) { drain = Math.min(data.drainRate / tickDivisor, value); value -= drain; } // 3. Overflow: route excess above threshold to downstream if (value > data.overflowThreshold && data.overflowAllocations.length > 0) { const excess = value - data.overflowThreshold; for (const alloc of data.overflowAllocations) { const share = excess * (alloc.percentage / 100); overflowIncoming.set(alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share); } value = data.overflowThreshold; } // 4. Distribute drain to spending targets (funnel or outcome) if (drain > 0 && data.spendingAllocations.length > 0) { for (const alloc of data.spendingAllocations) { const share = drain * (alloc.percentage / 100); if (funnelIds.has(alloc.targetId)) { // Spending to another funnel: add as overflow incoming for convergence overflowIncoming.set(alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share); } else { spendingIncoming.set(alloc.targetId, (spendingIncoming.get(alloc.targetId) ?? 0) + share); } } } // 5. Clamp data.currentValue = Math.max(0, Math.min(value, data.capacity)); updatedFunnels.set(node.id, data); processed.add(node.id); } } // Process outcomes in Y-order so overflow can cascade const outcomeNodes = nodes .filter((n) => n.type === "outcome") .sort((a, b) => a.position.y - b.position.y); const outcomeOverflowIncoming = new Map(); const updatedOutcomes = new Map(); for (const node of outcomeNodes) { const src = node.data as OutcomeNodeData; const data: OutcomeNodeData = { ...src }; const incoming = (spendingIncoming.get(node.id) ?? 0) + (overflowIncoming.get(node.id) ?? 0) + (outcomeOverflowIncoming.get(node.id) ?? 0); if (incoming > 0) { let newReceived = data.fundingReceived + incoming; // Overflow: if fully funded and has overflow allocations, distribute excess const allocs = data.overflowAllocations; if (allocs && allocs.length > 0 && data.fundingTarget > 0 && newReceived > data.fundingTarget) { const excess = newReceived - data.fundingTarget; for (const alloc of allocs) { const share = excess * (alloc.percentage / 100); outcomeOverflowIncoming.set(alloc.targetId, (outcomeOverflowIncoming.get(alloc.targetId) ?? 0) + share); } newReceived = data.fundingTarget; } // Cap at 105% if no overflow allocations if (!allocs || allocs.length === 0) { newReceived = Math.min( data.fundingTarget > 0 ? data.fundingTarget * 1.05 : Infinity, newReceived, ); } data.fundingReceived = newReceived; if (data.fundingTarget > 0 && data.fundingReceived >= data.fundingTarget && data.status !== "blocked") { data.status = "completed"; } else if (data.fundingReceived > 0 && data.status === "not-started") { data.status = "in-progress"; } } updatedOutcomes.set(node.id, data); } return nodes.map((node) => { if (node.type === "funnel" && updatedFunnels.has(node.id)) { return { ...node, data: updatedFunnels.get(node.id)! }; } if (node.type === "outcome" && updatedOutcomes.has(node.id)) { return { ...node, data: updatedOutcomes.get(node.id)! }; } return node; }); }