/** * 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: 10, }; export function computeSufficiencyState(data: FunnelNodeData): SufficiencyState { return data.currentValue >= data.overflowThreshold ? "overflowing" : "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; } /** * Sync source→funnel allocations into each funnel's inflowRate. * Funnels with no source wired keep their manual inflowRate (backward compat). */ export function computeInflowRates(nodes: FlowNode[]): FlowNode[] { 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), ); } } } 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: for each funnel (Y-order), compute: * 1. inflow = inflowRate / tickDivisor + overflow from upstream * 2. drain = min(drainRate / tickDivisor, currentValue + inflow) * 3. newValue = currentValue + inflow - drain * 4. if newValue > overflowThreshold → route excess to overflow targets * 5. distribute drain to spending targets * 6. clamp to [0, capacity] */ 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 overflowIncoming = new Map(); const spendingIncoming = new Map(); const updatedFunnels = new Map(); for (const node of funnelNodes) { const src = node.data as FunnelNodeData; const data: FunnelNodeData = { ...src }; // 1. Inflow: source rate + overflow received from upstream this tick const inflow = data.inflowRate / tickDivisor + (overflowIncoming.get(node.id) ?? 0); let value = data.currentValue + inflow; // 2. Drain: flat rate capped by available funds const 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 if (drain > 0 && data.spendingAllocations.length > 0) { for (const alloc of data.spendingAllocations) { const share = drain * (alloc.percentage / 100); 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); } // 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; }); }