rfunds-online/lib/simulation.ts

254 lines
8.6 KiB
TypeScript

/**
* 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<string, number> {
const nodeMap = new Map(allNodes.map(n => [n.id, n]))
const needs = new Map<string, number>()
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<string, number>()
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<string, number>()
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<string, number>()
const spendingIncoming = new Map<string, number>()
// Store updated funnel data
const updatedFunnels = new Map<string, FunnelNodeData>()
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
})
}