/** * Flow simulation engine — pure function, no React dependencies. * * Replaces the random-noise simulation with actual flow logic: * inflow → overflow distribution → spending drain → outcome accumulation * * Sufficiency layer: funnels can declare a sufficientThreshold and dynamicOverflow. * When dynamicOverflow is true, surplus routes to the most underfunded targets by need-weight. */ import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from './types' export interface SimulationConfig { tickDivisor: number // inflowRate divided by this per tick spendingRateHealthy: number // drain multiplier in healthy zone spendingRateOverflow: number // drain multiplier above maxThreshold spendingRateCritical: number // drain multiplier below minThreshold } export const DEFAULT_CONFIG: SimulationConfig = { tickDivisor: 10, spendingRateHealthy: 0.5, spendingRateOverflow: 0.8, spendingRateCritical: 0.1, } // ─── Sufficiency helpers ──────────────────────────────────── export function computeSufficiencyState(data: FunnelNodeData): SufficiencyState { const threshold = data.sufficientThreshold ?? data.maxThreshold if (data.currentValue >= data.maxCapacity) return 'abundant' if (data.currentValue >= threshold) return 'sufficient' return 'seeking' } /** * Compute need-weights for a set of overflow target IDs. * For funnels: need = max(0, 1 - currentValue / sufficientThreshold) * For outcomes: need = max(0, 1 - fundingReceived / fundingTarget) * Returns a Map of targetId → percentage (normalized to 100). */ export function computeNeedWeights( targetIds: string[], allNodes: FlowNode[], ): Map { const nodeMap = new Map(allNodes.map(n => [n.id, n])) const needs = new Map() for (const tid of targetIds) { const node = nodeMap.get(tid) if (!node) { needs.set(tid, 0); continue } if (node.type === 'funnel') { const d = node.data as FunnelNodeData const threshold = d.sufficientThreshold ?? d.maxThreshold const need = Math.max(0, 1 - d.currentValue / (threshold || 1)) needs.set(tid, need) } else if (node.type === 'outcome') { const d = node.data as OutcomeNodeData const need = Math.max(0, 1 - d.fundingReceived / Math.max(d.fundingTarget, 1)) needs.set(tid, need) } else { needs.set(tid, 0) } } // Normalize to percentages summing to 100 const totalNeed = Array.from(needs.values()).reduce((s, n) => s + n, 0) const weights = new Map() if (totalNeed === 0) { // Equal distribution when all targets are satisfied const equal = targetIds.length > 0 ? 100 / targetIds.length : 0 targetIds.forEach(id => weights.set(id, equal)) } else { needs.forEach((need, id) => { weights.set(id, (need / totalNeed) * 100) }) } return weights } /** * Compute system-wide sufficiency score (0-1). * Averages fill ratios of all funnels and progress ratios of all outcomes. */ 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 const threshold = d.sufficientThreshold ?? d.maxThreshold sum += Math.min(1, d.currentValue / (threshold || 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 } export function simulateTick( nodes: FlowNode[], config: SimulationConfig = DEFAULT_CONFIG, ): FlowNode[] { const { tickDivisor, spendingRateHealthy, spendingRateOverflow, spendingRateCritical } = config // Separate funnels (sorted top-to-bottom) from outcomes const funnelNodes = nodes .filter((n) => n.type === 'funnel') .sort((a, b) => a.position.y - b.position.y) // Source nodes: sum allocations into funnel inflow const sourceInflow = new Map() nodes.forEach((node) => { if (node.type !== 'source') return const data = node.data as SourceNodeData for (const alloc of data.targetAllocations) { const share = (data.flowRate / tickDivisor) * (alloc.percentage / 100) sourceInflow.set(alloc.targetId, (sourceInflow.get(alloc.targetId) ?? 0) + share) } }) // Accumulators for inter-node transfers const overflowIncoming = new Map() const spendingIncoming = new Map() // Store updated funnel data const updatedFunnels = new Map() for (const node of funnelNodes) { const src = node.data as FunnelNodeData const data: FunnelNodeData = { ...src } // 1. Natural inflow + source node inflow let value = data.currentValue + data.inflowRate / tickDivisor + (sourceInflow.get(node.id) ?? 0) // 2. Overflow received from upstream funnels value += overflowIncoming.get(node.id) ?? 0 // 3. Cap at maxCapacity value = Math.min(value, data.maxCapacity) // 4. Distribute overflow when above maxThreshold if (value > data.maxThreshold && data.overflowAllocations.length > 0) { const excess = value - data.maxThreshold if (data.dynamicOverflow) { // Dynamic overflow: route by need-weight instead of fixed percentages const targetIds = data.overflowAllocations.map(a => a.targetId) const needWeights = computeNeedWeights(targetIds, nodes) for (const alloc of data.overflowAllocations) { const weight = needWeights.get(alloc.targetId) ?? 0 const share = excess * (weight / 100) overflowIncoming.set( alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share, ) } } else { // Fixed-percentage overflow (existing behavior) for (const alloc of data.overflowAllocations) { const share = excess * (alloc.percentage / 100) overflowIncoming.set( alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share, ) } } value = data.maxThreshold } // 5. Spending drain (gated by zone) if (value > 0 && data.spendingAllocations.length > 0) { let rateMultiplier: number if (value > data.maxThreshold) { rateMultiplier = spendingRateOverflow } else if (value >= data.minThreshold) { rateMultiplier = spendingRateHealthy } else { rateMultiplier = spendingRateCritical } let drain = (data.inflowRate / tickDivisor) * rateMultiplier drain = Math.min(drain, value) // cannot drain below 0 value -= drain for (const alloc of data.spendingAllocations) { const share = drain * (alloc.percentage / 100) spendingIncoming.set( alloc.targetId, (spendingIncoming.get(alloc.targetId) ?? 0) + share, ) } } data.currentValue = Math.max(0, value) updatedFunnels.set(node.id, data) } // Rebuild node array with updated data return nodes.map((node) => { if (node.type === 'funnel' && updatedFunnels.has(node.id)) { return { ...node, data: updatedFunnels.get(node.id)! } } if (node.type === 'outcome') { const data = node.data as OutcomeNodeData const incoming = spendingIncoming.get(node.id) ?? 0 if (incoming <= 0) return node const newReceived = Math.min( data.fundingTarget > 0 ? data.fundingTarget * 1.05 : Infinity, data.fundingReceived + incoming, ) let newStatus = data.status if (newStatus === 'blocked') { // Don't auto-change blocked status } else if (data.phases && data.phases.length > 0) { // Phase-aware status const anyUnlocked = data.phases.some(p => newReceived >= p.fundingThreshold) const allUnlocked = data.phases.every(p => newReceived >= p.fundingThreshold) const allTasksDone = data.phases.every(p => p.tasks.every(t => t.completed)) if (allUnlocked && allTasksDone) { newStatus = 'completed' } else if (anyUnlocked) { newStatus = 'in-progress' } } else { // Fallback: no phases defined if (data.fundingTarget > 0 && newReceived >= data.fundingTarget) { newStatus = 'completed' } else if (newReceived > 0 && newStatus === 'not-started') { newStatus = 'in-progress' } } return { ...node, data: { ...data, fundingReceived: newReceived, status: newStatus }, } } return node }) }