184 lines
5.8 KiB
TypeScript
184 lines
5.8 KiB
TypeScript
/**
|
|
* 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<string, number>();
|
|
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<string, number>();
|
|
const spendingIncoming = new Map<string, number>();
|
|
const updatedFunnels = new Map<string, FunnelNodeData>();
|
|
|
|
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<string, number>();
|
|
const updatedOutcomes = new Map<string, OutcomeNodeData>();
|
|
|
|
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;
|
|
});
|
|
}
|